| author | |
| committer | |
| log | 92f1a4aa3ca9cb11f10e3ab97d08970f443d22aa |
| tree | 7dcf0b18f9a8c219faf7cdaac2597808a4ab70ca |
| parent | 11f929c3975ae71ea9f1c6360c9b5c28f50f2961 |
- Do not pass `resumeCursor` when restarting a session after changing providers
- Treat synthetic Claude thread IDs (`claude-thread-*`) as unscoped during runtime ingestion
- Add tests covering provider-switch restart behavior and Claude turn lifecycle acceptance4 files changed, 83 insertions(+), 8 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1-1| ... | @@ -442,10 +442,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -442,10 +442,10 @@ describe("ProviderCommandReactor", () => { |
| 442 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | 442 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 443 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | 443 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 444 | provider: "claudeCode", | 444 | provider: "claudeCode", |
| 445 | resumeCursor: { opaque: "cursor-1" }, | ||
| 446 | approvalPolicy: "on-request", | 445 | approvalPolicy: "on-request", |
| 447 | sandboxMode: "workspace-write", | 446 | sandboxMode: "workspace-write", |
| 448 | }); | 447 | }); |
| 448 | expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty("resumeCursor"); | ||
| 449 | 449 | ||
| 450 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 450 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 451 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 451 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3-1| ... | @@ -245,7 +245,9 @@ const make = Effect.gen(function* () { | ... | @@ -245,7 +245,9 @@ const make = Effect.gen(function* () { |
| 245 | return existingSessionId; | 245 | return existingSessionId; |
| 246 | } | 246 | } |
| 247 | 247 | ||
| 248 | const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); | 248 | const resumeCursor = providerChanged |
| 249 | ? undefined | ||
| 250 | : yield* resolveResumeCursorForSession(existingSessionId); | ||
| 249 | const restartedSession = yield* startProviderSession({ | 251 | const restartedSession = yield* startProviderSession({ |
| 250 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), | 252 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), |
| 251 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), | 253 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+59| ... | @@ -281,6 +281,65 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -281,6 +281,65 @@ describe("ProviderRuntimeIngestion", () => { |
| 281 | ); | 281 | ); |
| 282 | }); | 282 | }); |
| 283 | 283 | ||
| 284 | it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { | ||
| 285 | const harness = await createHarness(); | ||
| 286 | const seededAt = new Date().toISOString(); | ||
| 287 | |||
| 288 | await Effect.runPromise( | ||
| 289 | harness.engine.dispatch({ | ||
| 290 | type: "thread.session.set", | ||
| 291 | commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), | ||
| 292 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 293 | session: { | ||
| 294 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 295 | status: "ready", | ||
| 296 | providerName: "claudeCode", | ||
| 297 | providerSessionId: asSessionId("sess-1"), | ||
| 298 | providerThreadId: asProviderThreadId("claude-thread-placeholder"), | ||
| 299 | approvalPolicy: "on-request", | ||
| 300 | sandboxMode: "workspace-write", | ||
| 301 | activeTurnId: null, | ||
| 302 | updatedAt: seededAt, | ||
| 303 | lastError: null, | ||
| 304 | }, | ||
| 305 | createdAt: seededAt, | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | |||
| 309 | harness.emit({ | ||
| 310 | type: "turn.started", | ||
| 311 | eventId: asEventId("evt-turn-started-claude-placeholder"), | ||
| 312 | provider: "claudeCode", | ||
| 313 | sessionId: asSessionId("sess-1"), | ||
| 314 | createdAt: new Date().toISOString(), | ||
| 315 | threadId: asProviderThreadId("provider-thread-real"), | ||
| 316 | turnId: asProviderTurnId("turn-claude-placeholder"), | ||
| 317 | }); | ||
| 318 | |||
| 319 | await waitForThread( | ||
| 320 | harness.engine, | ||
| 321 | (thread) => | ||
| 322 | thread.session?.status === "running" && | ||
| 323 | thread.session?.activeTurnId === "turn-claude-placeholder", | ||
| 324 | ); | ||
| 325 | |||
| 326 | harness.emit({ | ||
| 327 | type: "turn.completed", | ||
| 328 | eventId: asEventId("evt-turn-completed-claude-placeholder"), | ||
| 329 | provider: "claudeCode", | ||
| 330 | sessionId: asSessionId("sess-1"), | ||
| 331 | createdAt: new Date().toISOString(), | ||
| 332 | threadId: asProviderThreadId("provider-thread-real"), | ||
| 333 | turnId: asProviderTurnId("turn-claude-placeholder"), | ||
| 334 | status: "completed", | ||
| 335 | }); | ||
| 336 | |||
| 337 | await waitForThread( | ||
| 338 | harness.engine, | ||
| 339 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | ||
| 340 | ); | ||
| 341 | }); | ||
| 342 | |||
| 284 | it("ignores non-active turn completion when runtime omits thread id", async () => { | 343 | it("ignores non-active turn completion when runtime omits thread id", async () => { |
| 285 | const harness = await createHarness(); | 344 | const harness = await createHarness(); |
| 286 | const now = new Date().toISOString(); | 345 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+20-6| ... | @@ -65,6 +65,13 @@ function sameId(left: string | null | undefined, right: string | null | undefine | ... | @@ -65,6 +65,13 @@ function sameId(left: string | null | undefined, right: string | null | undefine |
| 65 | return left === right; | 65 | return left === right; |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | function isSyntheticClaudeThreadId( | ||
| 69 | provider: ProviderRuntimeEvent["provider"], | ||
| 70 | threadId: ProviderThreadId | null, | ||
| 71 | ): boolean { | ||
| 72 | return provider === "claudeCode" && threadId !== null && threadId.startsWith("claude-thread-"); | ||
| 73 | } | ||
| 74 | |||
| 68 | function truncateDetail(value: string, limit = 180): string { | 75 | function truncateDetail(value: string, limit = 180): string { |
| 69 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; | 76 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 70 | } | 77 | } |
| ... | @@ -345,14 +352,20 @@ const make = Effect.gen(function* () { | ... | @@ -345,14 +352,20 @@ const make = Effect.gen(function* () { |
| 345 | 352 | ||
| 346 | const now = event.createdAt; | 353 | const now = event.createdAt; |
| 347 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; | 354 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; |
| 355 | const scopedSessionProviderThreadId = isSyntheticClaudeThreadId( | ||
| 356 | event.provider, | ||
| 357 | sessionProviderThreadId, | ||
| 358 | ) | ||
| 359 | ? null | ||
| 360 | : sessionProviderThreadId; | ||
| 348 | const eventProviderThreadId = toProviderThreadId(event.threadId); | 361 | const eventProviderThreadId = toProviderThreadId(event.threadId); |
| 349 | const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); | 362 | const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); |
| 350 | const activeTurnId = thread.session?.activeTurnId ?? null; | 363 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| 351 | 364 | ||
| 352 | const matchesThreadScope = | 365 | const matchesThreadScope = |
| 353 | eventProviderThreadId === null || | 366 | eventProviderThreadId === null || |
| 354 | sessionProviderThreadId === null || | 367 | scopedSessionProviderThreadId === null || |
| 355 | sameId(eventProviderThreadId, sessionProviderThreadId); | 368 | sameId(eventProviderThreadId, scopedSessionProviderThreadId); |
| 356 | const conflictsWithActiveTurn = | 369 | const conflictsWithActiveTurn = |
| 357 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); | 370 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); |
| 358 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; | 371 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; |
| ... | @@ -372,8 +385,8 @@ const make = Effect.gen(function* () { | ... | @@ -372,8 +385,8 @@ const make = Effect.gen(function* () { |
| 372 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. | 385 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. |
| 373 | if ( | 386 | if ( |
| 374 | eventProviderThreadId !== null && | 387 | eventProviderThreadId !== null && |
| 375 | sessionProviderThreadId !== null && | 388 | scopedSessionProviderThreadId !== null && |
| 376 | !sameId(eventProviderThreadId, sessionProviderThreadId) | 389 | !sameId(eventProviderThreadId, scopedSessionProviderThreadId) |
| 377 | ) { | 390 | ) { |
| 378 | return false; | 391 | return false; |
| 379 | } | 392 | } |
| ... | @@ -395,7 +408,7 @@ const make = Effect.gen(function* () { | ... | @@ -395,7 +408,7 @@ const make = Effect.gen(function* () { |
| 395 | return sameId(activeTurnId, eventTurnId); | 408 | return sameId(activeTurnId, eventTurnId); |
| 396 | } | 409 | } |
| 397 | // Without an active turn, only accept completion when no thread mismatch signal exists. | 410 | // Without an active turn, only accept completion when no thread mismatch signal exists. |
| 398 | return eventProviderThreadId === null || sessionProviderThreadId === null; | 411 | return eventProviderThreadId === null || scopedSessionProviderThreadId === null; |
| 399 | default: | 412 | default: |
| 400 | return true; | 413 | return true; |
| 401 | } | 414 | } |
| ... | @@ -415,7 +428,8 @@ const make = Effect.gen(function* () { | ... | @@ -415,7 +428,8 @@ const make = Effect.gen(function* () { |
| 415 | : event.threadId !== undefined | 428 | : event.threadId !== undefined |
| 416 | ? ProviderThreadId.makeUnsafe(event.threadId) | 429 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| 417 | : null; | 430 | : null; |
| 418 | const providerThreadId = providerThreadIdFromEvent ?? sessionProviderThreadId ?? null; | 431 | const providerThreadId = |
| 432 | providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null; | ||
| 419 | const status = | 433 | const status = |
| 420 | event.type === "turn.started" | 434 | event.type === "turn.started" |
| 421 | ? "running" | 435 | ? "running" |