From 133808a61996cd5a02d51e5d8a9eff3ef26af1e8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 26 Aug 2026 20:53:12 -0400 Subject: [PATCH] fix(agent-core-v2): adopt the replayed turn outcome on restore A session whose persisted outcome went stale (a write that never landed before shutdown) kept that outcome after restore whenever the replayed wire still had an ended turn, because the restore reconcile only cleared the outcome when no ended turn existed. Adopt the replayed outcome when it is unambiguous: completed stays completed, failed and blocked map to failed. A replayed cancellation is left alone, since the wire cannot tell a user stop from a programmatic abort and only user stops are persisted. --- .changeset/restore-reconciles-turn-outcome.md | 5 ++ .../sessionOutcomeMirrorService.ts | 11 +++ .../sessionOutcomeMirror.test.ts | 73 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .changeset/restore-reconciles-turn-outcome.md diff --git a/.changeset/restore-reconciles-turn-outcome.md b/.changeset/restore-reconciles-turn-outcome.md new file mode 100644 index 000000000..304f9079f --- /dev/null +++ b/.changeset/restore-reconciles-turn-outcome.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix a resumed session showing a stale "manually stopped" or failed state when its last turn had actually completed. diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts index f229e32cc..5ecaee168 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -129,6 +129,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM this.write(undefined, { touchUpdatedAt: false }); return; } + const replayed = replayedOutcome(lastEnded.reason); + if (replayed !== undefined && replayed !== this.lastPersisted) { + this.write(replayed, { touchUpdatedAt: false, turnId: lastEnded.turnId }); + return; + } if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId; } @@ -164,3 +169,9 @@ registerScopedService( ScopeActivation.OnScopeCreated, 'sessionActivity', ); + +function replayedOutcome(reason: TurnEnded['reason']): SessionTurnOutcome | undefined { + if (reason === 'completed') return 'completed'; + if (reason === 'failed' || reason === 'blocked') return 'failed'; + return undefined; +} diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts index 5698ef8b2..c6d66b4ed 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts @@ -371,6 +371,79 @@ describe('SessionOutcomeMirror (Session scope)', () => { expect(writes).toEqual([]); }); + const restoredSession = ( + id: string, + persisted: SessionMeta['lastTurnReason'], + lastEnded: FakeAgentLifecycle['lastEnded'], + ) => { + const scope = host.child(LifecycleScope.Session, id, [ + stubPair(ISessionMetadata, { + read: async () => ({ lastTurnReason: persisted }) as SessionMeta, + update: async ( + patch: { lastTurnReason?: SessionMeta['lastTurnReason'] }, + uopts?: { touchUpdatedAt?: boolean }, + ) => { + writes.push(patch.lastTurnReason); + touches.push(uopts?.touchUpdatedAt !== false); + }, + } as unknown as ISessionMetadata), + ]); + const scopeLifecycle = scope.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle; + scope.accessor.get(ISessionOutcomeMirror); + scopeLifecycle.addMain(); + scopeLifecycle.lastEnded = lastEnded; + return scopeLifecycle; + }; + + it('replaces a stale persisted outcome with the replayed completed turn', async () => { + const restored = restoredSession('session-restored-completed', 'cancelled', { + turnId: 3, + reason: 'completed', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual(['completed']); + expect(touches).toEqual([false]); + }); + + it('maps a replayed blocked turn onto the persisted failed outcome', async () => { + const restored = restoredSession('session-restored-blocked', 'completed', { + turnId: 3, + reason: 'blocked', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual(['failed']); + expect(touches).toEqual([false]); + }); + + it('keeps the persisted outcome when the replayed turn was cancelled', async () => { + const restored = restoredSession('session-restored-cancelled', 'completed', { + turnId: 3, + reason: 'cancelled', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual([]); + }); + + it('tracks the replayed turn for the undo range after a restore reconcile', async () => { + const restored = restoredSession('session-restored-undo', 'cancelled', { + turnId: 3, + reason: 'completed', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 4 })); + expect(writes).toEqual(['completed']); + restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 3 })); + expect(writes).toEqual(['completed', undefined]); + }); + it('reattaches when the main agent is disposed and recreated', async () => { lifecycle.addMain(); await tick();