Skip to content

Commit 133808a

Browse files
committed
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.
1 parent 0753f13 commit 133808a

3 files changed

Lines changed: 89 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix a resumed session showing a stale "manually stopped" or failed state when its last turn had actually completed.

‎packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
129129
this.write(undefined, { touchUpdatedAt: false });
130130
return;
131131
}
132+
const replayed = replayedOutcome(lastEnded.reason);
133+
if (replayed !== undefined && replayed !== this.lastPersisted) {
134+
this.write(replayed, { touchUpdatedAt: false, turnId: lastEnded.turnId });
135+
return;
136+
}
132137
if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId;
133138
}
134139

@@ -164,3 +169,9 @@ registerScopedService(
164169
ScopeActivation.OnScopeCreated,
165170
'sessionActivity',
166171
);
172+
173+
function replayedOutcome(reason: TurnEnded['reason']): SessionTurnOutcome | undefined {
174+
if (reason === 'completed') return 'completed';
175+
if (reason === 'failed' || reason === 'blocked') return 'failed';
176+
return undefined;
177+
}

‎packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,79 @@ describe('SessionOutcomeMirror (Session scope)', () => {
371371
expect(writes).toEqual([]);
372372
});
373373

374+
const restoredSession = (
375+
id: string,
376+
persisted: SessionMeta['lastTurnReason'],
377+
lastEnded: FakeAgentLifecycle['lastEnded'],
378+
) => {
379+
const scope = host.child(LifecycleScope.Session, id, [
380+
stubPair(ISessionMetadata, {
381+
read: async () => ({ lastTurnReason: persisted }) as SessionMeta,
382+
update: async (
383+
patch: { lastTurnReason?: SessionMeta['lastTurnReason'] },
384+
uopts?: { touchUpdatedAt?: boolean },
385+
) => {
386+
writes.push(patch.lastTurnReason);
387+
touches.push(uopts?.touchUpdatedAt !== false);
388+
},
389+
} as unknown as ISessionMetadata),
390+
]);
391+
const scopeLifecycle = scope.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle;
392+
scope.accessor.get(ISessionOutcomeMirror);
393+
scopeLifecycle.addMain();
394+
scopeLifecycle.lastEnded = lastEnded;
395+
return scopeLifecycle;
396+
};
397+
398+
it('replaces a stale persisted outcome with the replayed completed turn', async () => {
399+
const restored = restoredSession('session-restored-completed', 'cancelled', {
400+
turnId: 3,
401+
reason: 'completed',
402+
durationMs: 5,
403+
});
404+
await tick();
405+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
406+
expect(writes).toEqual(['completed']);
407+
expect(touches).toEqual([false]);
408+
});
409+
410+
it('maps a replayed blocked turn onto the persisted failed outcome', async () => {
411+
const restored = restoredSession('session-restored-blocked', 'completed', {
412+
turnId: 3,
413+
reason: 'blocked',
414+
durationMs: 5,
415+
});
416+
await tick();
417+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
418+
expect(writes).toEqual(['failed']);
419+
expect(touches).toEqual([false]);
420+
});
421+
422+
it('keeps the persisted outcome when the replayed turn was cancelled', async () => {
423+
const restored = restoredSession('session-restored-cancelled', 'completed', {
424+
turnId: 3,
425+
reason: 'cancelled',
426+
durationMs: 5,
427+
});
428+
await tick();
429+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
430+
expect(writes).toEqual([]);
431+
});
432+
433+
it('tracks the replayed turn for the undo range after a restore reconcile', async () => {
434+
const restored = restoredSession('session-restored-undo', 'cancelled', {
435+
turnId: 3,
436+
reason: 'completed',
437+
durationMs: 5,
438+
});
439+
await tick();
440+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
441+
restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 4 }));
442+
expect(writes).toEqual(['completed']);
443+
restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 3 }));
444+
expect(writes).toEqual(['completed', undefined]);
445+
});
446+
374447
it('reattaches when the main agent is disposed and recreated', async () => {
375448
lifecycle.addMain();
376449
await tick();

0 commit comments

Comments
 (0)