| 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 | 442 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 443 | 443 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 444 | 444 | provider: "claudeCode", |
| 445 | resumeCursor: { opaque: "cursor-1" }, | |
| 446 | 445 | approvalPolicy: "on-request", |
| 447 | 446 | sandboxMode: "workspace-write", |
| 448 | 447 | }); |
| 448 | expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty("resumeCursor"); | |
| 449 | 449 | |
| 450 | 450 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 451 | 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 | 245 | return existingSessionId; |
| 246 | 246 | } |
| 247 | 247 | |
| 248 | const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); | |
| 248 | const resumeCursor = providerChanged | |
| 249 | ? undefined | |
| 250 | : yield* resolveResumeCursorForSession(existingSessionId); | |
| 249 | 251 | const restartedSession = yield* startProviderSession({ |
| 250 | 252 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), |
| 251 | 253 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+59| ... | ... | @@ -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 | 343 | it("ignores non-active turn completion when runtime omits thread id", async () => { |
| 285 | 344 | const harness = await createHarness(); |
| 286 | 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 | 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 | 75 | function truncateDetail(value: string, limit = 180): string { |
| 69 | 76 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 70 | 77 | } |
| ... | ... | @@ -345,14 +352,20 @@ const make = Effect.gen(function* () { |
| 345 | 352 | |
| 346 | 353 | const now = event.createdAt; |
| 347 | 354 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; |
| 355 | const scopedSessionProviderThreadId = isSyntheticClaudeThreadId( | |
| 356 | event.provider, | |
| 357 | sessionProviderThreadId, | |
| 358 | ) | |
| 359 | ? null | |
| 360 | : sessionProviderThreadId; | |
| 348 | 361 | const eventProviderThreadId = toProviderThreadId(event.threadId); |
| 349 | 362 | const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); |
| 350 | 363 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| 351 | 364 | |
| 352 | 365 | const matchesThreadScope = |
| 353 | 366 | eventProviderThreadId === null || |
| 354 | sessionProviderThreadId === null || | |
| 355 | sameId(eventProviderThreadId, sessionProviderThreadId); | |
| 367 | scopedSessionProviderThreadId === null || | |
| 368 | sameId(eventProviderThreadId, scopedSessionProviderThreadId); | |
| 356 | 369 | const conflictsWithActiveTurn = |
| 357 | 370 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); |
| 358 | 371 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; |
| ... | ... | @@ -372,8 +385,8 @@ const make = Effect.gen(function* () { |
| 372 | 385 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. |
| 373 | 386 | if ( |
| 374 | 387 | eventProviderThreadId !== null && |
| 375 | sessionProviderThreadId !== null && | |
| 376 | !sameId(eventProviderThreadId, sessionProviderThreadId) | |
| 388 | scopedSessionProviderThreadId !== null && | |
| 389 | !sameId(eventProviderThreadId, scopedSessionProviderThreadId) | |
| 377 | 390 | ) { |
| 378 | 391 | return false; |
| 379 | 392 | } |
| ... | ... | @@ -395,7 +408,7 @@ const make = Effect.gen(function* () { |
| 395 | 408 | return sameId(activeTurnId, eventTurnId); |
| 396 | 409 | } |
| 397 | 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 | 412 | default: |
| 400 | 413 | return true; |
| 401 | 414 | } |
| ... | ... | @@ -415,7 +428,8 @@ const make = Effect.gen(function* () { |
| 415 | 428 | : event.threadId !== undefined |
| 416 | 429 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| 417 | 430 | : null; |
| 418 | const providerThreadId = providerThreadIdFromEvent ?? sessionProviderThreadId ?? null; | |
| 431 | const providerThreadId = | |
| 432 | providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null; | |
| 419 | 433 | const status = |
| 420 | 434 | event.type === "turn.started" |
| 421 | 435 | ? "running" |