fix(agent-core-v2): seed the activity view's lastTurn from the persisted turn.ended record (#2648)
Some checks are pending
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(agent-core-v2): seed the activity view's lastTurn from the persisted turn.ended record

A cold-resumed agent seeded its activity view only from live loop/task
state, so the last turn's outcome was lost on a server restart: sessions
came back with no lastTurnReason, and clients could not surface a
previously failed turn (e.g. a provider 429 that killed the turn before
the restart).

The loop already persists the terminal turn.ended record (reason, error,
durationMs); fold the latest one into the TurnModel as lastEnded and have
AgentActivityView.seedFromLoop adopt it when no turn is active, so the
session work aggregate (and everything built on it) reflects the last
turn's outcome again after a cold start.

* fix(agent-core-v2): seed lastTurn on wire restore and add a changeset

Review follow-up: the agent scope (and with it this view) is constructed
before wire.restore() replays the journal, so a constructor-time read of
TurnModel.lastEnded always saw the initial state on a cold resume. Move
the wire-backed seed behind the onDidRestore hook (constructor seed kept
for views built after a restore), and drop the inline comments in favor
of the file header per the package comment convention.

* docs(agent-core-v2): trim the activityView header to role and collaborators

Review follow-up: the previous revision narrated the restore-hook
mechanics in the header; the package convention keeps headers at the
module's external role plus collaborators, so drop the implementation
narrative.

* fix(agent-core-v2): keep TurnModel.lastEnded across clock advances

Review follow-up: advanceTurnClock built a fresh state object without
spreading, so a new prompt or a queued cancel silently dropped the stored
last-ended outcome even though no new turn had ended — after a restart the
activity view would again find nothing to seed. Spread the prior state and
cover the prompt/queued-cancel/replace cycle with a model-level test.

* fix(agent-core-v2): clear the stored turn outcome once a newer turn starts

Review follow-up: with the clock advances preserving lastEnded, a prompt
persisted without its turn ever starting would leave the previous turn's
outcome to be seeded after a restart, reporting a stale result for a turn
that never ended. The loop-event fold now drops lastEnded as soon as a
newer turn's events land, while prompts and queued cancels keep it.

* docs(agent-core-v2): keep the turnOps header at the domain role

Review follow-up: the lastEnded keep/clear mechanics read as
implementation narrative in the header; the convention there is role and
collaborators only.
This commit is contained in:
qer 2026-08-06 01:07:22 +08:00 committed by GitHub
parent 68ba740ebf
commit d1ded01b7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 161 additions and 22 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Restore how the last turn ended (completed, cancelled, or failed) when a session is resumed after a server restart, so clients can still surface a previously failed turn instead of the session looking silently stopped.

View file

@ -6,14 +6,16 @@
* events drive the live phase/stream/retry detail, permission approval events
* drive the pending-approval list, while task and full-compaction events drive
* the background-work slice. The view seeds once from `IAgentLoopService`,
* `IAgentTaskService`, and `IAgentFullCompactionService` (reads, never writes)
* and otherwise holds only derived state, so it can be discarded and rebuilt
* at any time. The mutable view state (`lifecycle`, `turn`, `lastTurn`,
* `background`, `current`) is registered into `agentState`
* (`IAgentStateService`) and read/written through it; the event-bus
* subscription handles stay mechanism held by the `Disposable` base, and
* `MutableTurn`'s in-place-mutated Maps stay instance fields of that
* per-turn class. Bound at Agent scope.
* `IAgentTaskService`, and `IAgentFullCompactionService`, and recovers the
* last turn's outcome from the wire `TurnModel` through `IWireService`, so
* a cold-resumed agent still reports how its previous turn ended (reads,
* never writes). Otherwise the view holds only derived state, so it can be
* discarded and rebuilt at any time. The mutable view state (`lifecycle`,
* `turn`, `lastTurn`, `background`, `current`) is registered into
* `agentState` (`IAgentStateService`) and read/written through it; the
* event-bus subscription handles stay mechanism held by the `Disposable`
* base, and `MutableTurn`'s in-place-mutated Maps stay instance fields of
* that per-turn class. Bound at Agent scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
@ -21,12 +23,14 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
import { defineState } from '#/_base/state/stateRegistry';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentLoopService } from '#/agent/loop/loop';
import { TurnModel } from '#/agent/loop/turnOps';
import { IAgentStateService } from '#/agent/state/agentState';
import { IAgentTaskService } from '#/agent/task/task';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { TurnEndReason } from '#/agent/loop/turnEvents';
import { IWireService } from '#/wire/wire';
import type {
ActivityLastTurnState,
@ -74,6 +78,7 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
@IAgentTaskService private readonly tasks: IAgentTaskService,
@IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService,
@IAgentStateService private readonly states: IAgentStateService,
@IWireService private readonly wire: IWireService,
) {
super();
this.states.register(activityViewLifecycleKey);
@ -84,6 +89,12 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
this.seedFromLoop();
this.seedFromTasks();
this.seedFromFullCompaction();
this._register(
this.wire.hooks.onDidRestore.register('activityView', async (_ctx, next) => {
this.seedLastTurnFromWire();
await next();
}),
);
this._register(this.eventBus.subscribe('turn.started', (e) => this.onTurnStarted(e.turnId, e.origin)));
this._register(this.eventBus.subscribe('turn.step.started', (e) => this.onStepStarted(e.step)));
@ -220,8 +231,24 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
private seedFromLoop(): void {
const status = this.loop.status();
if (status.state !== 'running' || status.activeTurnId === undefined) return;
this.turn = new MutableTurn(status.activeTurnId, USER_PROMPT_ORIGIN);
if (status.state === 'running' && status.activeTurnId !== undefined) {
this.turn = new MutableTurn(status.activeTurnId, USER_PROMPT_ORIGIN);
this.publish();
return;
}
this.seedLastTurnFromWire();
}
private seedLastTurnFromWire(): void {
if (this.turn !== undefined || this.lastTurn !== undefined) return;
const lastEnded = this.wire.getModel(TurnModel).lastEnded;
if (lastEnded === undefined) return;
this.lastTurn = {
turnId: lastEnded.turnId,
reason: lastEnded.reason,
durationMs: lastEnded.durationMs,
at: Date.now(),
};
this.publish();
}

View file

@ -4,9 +4,9 @@
*
* Owns the next available turn id, including cancelled queued reservations and
* legacy loop-event observations. Also persists the terminal `turn.ended`
* record (reason / error / durationMs) so downstream history rebuilds can
* recover how a turn ended; the record carries no engine-restorable state, so
* its `apply` is a no-op. Consumed by the Agent-scope `loopService`; the
* record (reason / error / durationMs) so downstream history rebuilds and
* cold-resumed read models (e.g. the activity view) can recover how the last
* turn ended. Consumed by the Agent-scope `loopService`; the
* `interruptionReminder` domain projects `turn.cancel` into its own model.
*/
@ -20,6 +20,11 @@ import type { PromptOrigin } from '#/agent/contextMemory/types';
export interface TurnModelState {
readonly nextTurnId: number;
readonly cancelledTurnIds: readonly number[];
readonly lastEnded?: {
readonly turnId: number;
readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked';
readonly durationMs?: number;
};
}
export const TurnModel = defineModel<TurnModelState>(
@ -33,9 +38,13 @@ export const TurnModel = defineModel<TurnModelState>(
}
const turnId = Number.parseInt(event.turnId, 10);
return Number.isInteger(turnId) && turnId >= state.nextTurnId
? advanceTurnClock(state, turnId + 1)
: state;
if (!Number.isInteger(turnId)) return state;
let next = state;
if (turnId >= state.nextTurnId) next = advanceTurnClock(state, turnId + 1);
if (next.lastEnded !== undefined && turnId > next.lastEnded.turnId) {
next = { ...next, lastEnded: undefined };
}
return next;
},
},
},
@ -85,7 +94,10 @@ export const endTurn = TurnModel.defineOp('turn.ended', {
error: z.custom<KimiErrorPayload>().optional(),
durationMs: z.number().optional(),
}),
apply: (s) => s,
apply: (s, { turnId, reason, durationMs }) => ({
...s,
lastEnded: { turnId, reason, durationMs },
}),
});
function advanceTurnClock(
@ -98,6 +110,7 @@ function advanceTurnClock(
);
while (pendingCancellations.delete(nextTurnId)) nextTurnId += 1;
return {
...state,
nextTurnId,
cancelledTurnIds: [...pendingCancellations].toSorted((a, b) => a - b),
};

View file

@ -17,10 +17,10 @@ import { IAgentTaskService } from '#/agent/task/task';
import type { AgentTaskInfo } from '#/agent/task/types';
import { AgentActivityView } from '#/agent/activityView/activityViewService';
import { IAgentActivityView, type AgentActivityState } from '#/agent/activityView/activityView';
import {
IAgentFullCompactionService,
type FullCompactionTask,
} from '#/agent/fullCompaction/fullCompaction';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import type { FullCompactionTask } from '#/agent/fullCompaction/fullCompaction';
import { TurnModel, type TurnModelState } from '#/agent/loop/turnOps';
import { IWireService } from '#/wire/wire';
class FakeBus {
private readonly byType = new Map<string, Array<(e: DomainEvent) => void>>();
@ -64,16 +64,38 @@ let disposables: DisposableStore;
function harness(
seedTasks: readonly AgentTaskInfo[] = [],
compacting: FullCompactionTask | null = null,
lastEnded?: TurnModelState['lastEnded'],
) {
const bus = new FakeBus();
const loop = {
status: () => ({ state: 'idle', pendingTurnIds: [], hasPendingRequests: false }),
} as unknown as IAgentLoopService;
const tasks = { list: () => seedTasks } as unknown as IAgentTaskService;
const wireState: { lastEnded?: TurnModelState['lastEnded'] } = { lastEnded };
const restoreHooks: Array<() => Promise<void>> = [];
const wire = {
getModel: (model: unknown) =>
model === TurnModel
? { nextTurnId: 1, cancelledTurnIds: [], lastEnded: wireState.lastEnded }
: undefined,
hooks: {
onDidRestore: {
register: (_id: string, fn: (ctx: undefined, next: () => Promise<void>) => Promise<void>) => {
restoreHooks.push(async () => fn(undefined, async () => {}));
return { dispose: () => {} };
},
},
},
} as unknown as IWireService;
const restore = async (ended: TurnModelState['lastEnded']): Promise<void> => {
wireState.lastEnded = ended;
for (const hook of restoreHooks) await hook();
};
const ix = disposables.add(new TestInstantiationService());
ix.stub(IEventBus, bus as unknown as IEventBus);
ix.stub(IAgentLoopService, loop);
ix.stub(IAgentTaskService, tasks);
ix.stub(IWireService, wire);
ix.set(IAgentStateService, new AgentStateService());
ix.stub(IAgentFullCompactionService, {
_serviceBrand: undefined,
@ -85,7 +107,7 @@ function harness(
bus.published
.filter((e) => e.type === 'agent.activity.updated')
.map((e) => e as unknown as AgentActivityState);
return { bus, view, updates };
return { bus, view, updates, restore };
}
describe('AgentActivityView', () => {
@ -119,6 +141,30 @@ describe('AgentActivityView', () => {
expect(view.state().background).toEqual([{ kind: 'process', id: 'bash-9', since: 100 }]);
});
it('seeds lastTurn from the wire TurnModel when the view is built after restore', () => {
const { view } = harness([], null, { turnId: 7, reason: 'failed', durationMs: 1234 });
expect(view.state().lastTurn).toMatchObject({ turnId: 7, reason: 'failed', durationMs: 1234 });
});
it('seeds lastTurn when the wire restore lands after construction (cold resume ordering)', async () => {
const { view, restore } = harness();
expect(view.state().lastTurn).toBeUndefined();
await restore({ turnId: 7, reason: 'failed', durationMs: 1234 });
expect(view.state().lastTurn).toMatchObject({ turnId: 7, reason: 'failed', durationMs: 1234 });
});
it('does not overwrite a live lastTurn when the restore hook runs', async () => {
const { bus, view, restore } = harness([], null, { turnId: 7, reason: 'failed' });
bus.publish({ type: 'turn.ended', turnId: 9, reason: 'completed' });
await restore({ turnId: 7, reason: 'failed' });
expect(view.state().lastTurn).toMatchObject({ turnId: 9, reason: 'completed' });
});
it('leaves lastTurn empty when the wire has no ended turn', () => {
const { view } = harness();
expect(view.state().lastTurn).toBeUndefined();
});
it('folds full compaction into the background slice', () => {
const { bus, view } = harness();

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { MODEL_CROSS_REDUCERS } from '#/wire/model';
import { TurnModel, cancelTurn, endTurn, promptTurn } from '#/agent/loop/turnOps';
function foldLoopEvent(s: import('#/agent/loop/turnOps').TurnModelState, turnId: string) {
const entries = MODEL_CROSS_REDUCERS.get('context.append_loop_event') ?? [];
const entry = entries.find((e) => e.model === TurnModel);
if (entry === undefined) throw new Error('turn model cross-reducer not registered');
return entry.reducer(s, { event: { type: 'step.begin', turnId } }) as typeof s;
}
describe('TurnModel lastEnded', () => {
it('keeps the stored outcome across prompts and queued cancels', () => {
let s = TurnModel.initial();
s = promptTurn.apply(s, { input: [], origin: { kind: 'user' } });
s = endTurn.apply(s, { turnId: 0, reason: 'failed', durationMs: 10 });
expect(s.lastEnded).toMatchObject({ turnId: 0, reason: 'failed' });
s = promptTurn.apply(s, { input: [], origin: { kind: 'user' } });
expect(s.lastEnded?.reason).toBe('failed');
s = cancelTurn.apply(s, { turnId: 1, target: 'queued' });
expect(s.lastEnded?.reason).toBe('failed');
s = endTurn.apply(s, { turnId: 1, reason: 'completed' });
expect(s.lastEnded).toMatchObject({ turnId: 1, reason: 'completed' });
});
it('clears the stored outcome once a newer turn starts producing', () => {
let s = TurnModel.initial();
s = promptTurn.apply(s, { input: [], origin: { kind: 'user' } });
s = endTurn.apply(s, { turnId: 0, reason: 'failed' });
s = promptTurn.apply(s, { input: [], origin: { kind: 'user' } });
s = foldLoopEvent(s, '1');
expect(s.lastEnded).toBeUndefined();
});
it('keeps the stored outcome on the same turns own events', () => {
let s = TurnModel.initial();
s = promptTurn.apply(s, { input: [], origin: { kind: 'user' } });
s = foldLoopEvent(s, '0');
s = endTurn.apply(s, { turnId: 0, reason: 'completed' });
s = foldLoopEvent(s, '0');
expect(s.lastEnded?.reason).toBe('completed');
});
it('starts without a stored outcome', () => {
expect(TurnModel.initial().lastEnded).toBeUndefined();
});
});