authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 17:10:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log92f1a4aa3ca9cb11f10e3ab97d08970f443d22aa
tree7dcf0b18f9a8c219faf7cdaac2597808a4ab70ca
parent11f929c3975ae71ea9f1c6360c9b5c28f50f2961

Ignore stale resume cursors and Claude placeholder thread IDs

- 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 acceptance

4 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");
449449
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 }
247247
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 });
283283
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}
6767
68function isSyntheticClaudeThreadId(
69 provider: ProviderRuntimeEvent["provider"],
70 threadId: ProviderThreadId | null,
71): boolean {
72 return provider === "claudeCode" && threadId !== null && threadId.startsWith("claude-thread-");
73}
74
68function truncateDetail(value: string, limit = 180): string {75function 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* () {
345352
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;
351364
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 !== undefined428 : 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"