Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a9c7f74
feat(agent-core-v2): persist the last turn outcome into session metad…
wbxl2000 Aug 5, 2026
a90c83c
fix(klient): carry lastTurnReason/lastTurnOutcome in the validated co…
wbxl2000 Aug 5, 2026
b7e2c36
fix(agent-core-v2): persist user-cancelled outcomes, never teardown a…
wbxl2000 Aug 5, 2026
5e2561b
fix(kap-server): only fall back to the persisted outcome for cold ses…
wbxl2000 Aug 5, 2026
a6efba8
docs(agent-core-v2): keep the outcome-recorder header at role level
wbxl2000 Aug 5, 2026
c442515
fix(agent-core-v2): settle turn outcomes on turn start and drain meta…
wbxl2000 Aug 5, 2026
241c41f
fix(agent-core-v2): track the metadata dispose flag locally
wbxl2000 Aug 5, 2026
532037c
fix(kap-server): drain session metadata writes before the mirror and …
wbxl2000 Aug 5, 2026
21559fa
fix(agent-core-v2): reattach the recorder when the main agent is recr…
wbxl2000 Aug 5, 2026
5ef0c87
test(agent-core-v2): resolve the recorder through the scoped DI harness
wbxl2000 Aug 5, 2026
c62d190
fix(agent-core-v2): unbreak CI — iterable Promise.all and the debug c…
wbxl2000 Aug 5, 2026
345c761
fix(kap-server): surface persisted failures on the v2 session status
wbxl2000 Aug 6, 2026
e83a171
refactor(agent-core-v2): name the persisted field lastTurnReason
wbxl2000 Aug 6, 2026
c9ba5a1
fix(agent-core-v2): drain pending metadata writes before session tear…
wbxl2000 Aug 6, 2026
69b35ae
fix(node-sdk): carry lastTurnReason through the SDK session summary
wbxl2000 Aug 6, 2026
f4dc3bb
fix(node-sdk): populate lastTurnReason on live SDK summaries
wbxl2000 Aug 6, 2026
a50d776
fix(agent-core-v2): carry the last turn outcome across session forks
wbxl2000 Aug 6, 2026
3902d01
fix(agent-core-v2): settle pending outcome writes before reading a fo…
wbxl2000 Aug 6, 2026
6dc33a1
fix(agent-core-v2): backfill restored outcomes into the session metadata
wbxl2000 Aug 6, 2026
87f0568
fix(agent-core-v2): never backfill restored cancellations
wbxl2000 Aug 6, 2026
6d24140
refactor(agent-core-v2): rename the outcome recorder to outcome mirror
wbxl2000 Aug 6, 2026
f02041c
fix(agent-core-v2): backfill without bumping recency; header-only com…
wbxl2000 Aug 6, 2026
c3c4507
fix(node-sdk): read the live outcome for resumed session summaries
wbxl2000 Aug 6, 2026
977e533
fix(agent-core-v2): confine the outcome backfill to pure resumes
wbxl2000 Aug 6, 2026
109cfed
fix(agent-core-v2): drain the session-index mirror before session tea…
wbxl2000 Aug 6, 2026
883ba17
docs(agent-core-v2): fold the metadata drain contract into the file h…
wbxl2000 Aug 6, 2026
a904ce3
chore: include the SDK package in the changeset; fold the drain note …
wbxl2000 Aug 6, 2026
faf73c7
fix(agent-core-v2): backfill restored cancellations too, quietly
wbxl2000 Aug 6, 2026
19634c7
fix(node-sdk): prefer the live outcome over the index in SDK listings
wbxl2000 Aug 6, 2026
8c511e6
fix(node-sdk): never read the metadata outcome for a live session
wbxl2000 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cold-session-last-turn-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
Comment thread
wbxl2000 marked this conversation as resolved.
---

Session listings keep how the last turn ended (completed, cancelled, or failed) across server restarts, so clients can mark previously failed sessions before they are opened.
1 change: 1 addition & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export interface SessionStateSnapshot {
readonly swarmItem?: string;
}>>;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
} | undefined;
// src/session/sessionSkillCatalog/skillCatalogService.ts
'sessionSkillCatalog.contributions': Map<string, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface SessionSummary {
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
Comment thread
wbxl2000 marked this conversation as resolved.
Comment thread
wbxl2000 marked this conversation as resolved.
}

export interface SessionListQuery {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export function parseTime(value: unknown): number {
return 0;
}

export function parseTurnOutcome(value: unknown): 'completed' | 'cancelled' | 'failed' | undefined {
return value === 'completed' || value === 'cancelled' || value === 'failed' ? value : undefined;
}

export function recoverCwd(meta: Record<string, unknown>): string | undefined {
if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd'];
if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) {
Expand All @@ -63,6 +67,7 @@ export function buildSessionSummary(fields: {
updatedAt: number;
archived: boolean;
custom?: Record<string, unknown>;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}): SessionSummary {
return {
id: fields.id,
Expand All @@ -74,6 +79,7 @@ export function buildSessionSummary(fields: {
updatedAt: fields.updatedAt,
archived: fields.archived,
custom: fields.custom,
lastTurnReason: fields.lastTurnReason,
};
}

Expand Down Expand Up @@ -102,6 +108,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean {
a.createdAt === b.createdAt &&
a.updatedAt === b.updatedAt &&
a.archived === b.archived &&
a.lastTurnReason === b.lastTurnReason &&
JSON.stringify(a.custom) === JSON.stringify(b.custom)
);
}
Expand Down Expand Up @@ -153,6 +160,7 @@ export async function readSessionSummary(
updatedAt: parseTime(meta['updatedAt']),
archived: meta['archived'] === true,
custom,
lastTurnReason: parseTurnOutcome(meta['lastTurnReason']),
});
}

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export * from '#/session/sessionMetadata/sessionMetadata';
export * from '#/session/sessionMetadata/sessionMetadataService';
export * from '#/session/sessionActivity/sessionActivity';
export * from '#/session/sessionActivity/sessionActivityService';
export * from '#/session/sessionActivity/sessionOutcomeMirror';
export * from '#/session/sessionActivity/sessionOutcomeMirrorService';
export * from '#/session/sessionToolPolicy/sessionToolPolicy';
export * from '#/session/sessionToolPolicy/sessionToolPolicyService';
export * from '#/app/config/config';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* `sessionActivity` domain — `ISessionOutcomeMirror` contract: persist the
* latest main-turn outcome into durable session metadata.
*
* The activity aggregate's `lastTurnReason` is live fold state, rebuilt per
* process; this mirror is the write side that lands terminal outcomes in
* the session's metadata document, so the session index (and therefore cold
* listings after a restart) keep reporting them. Session-scoped — one
* instance per session.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export interface ISessionOutcomeMirror {
readonly _serviceBrand: undefined;
}

export const ISessionOutcomeMirror: ServiceIdentifier<ISessionOutcomeMirror> =
createDecorator<ISessionOutcomeMirror>('sessionOutcomeMirror');
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* `sessionActivity` domain — `ISessionOutcomeMirror` implementation.
*
* Persists the main agent's terminal turn outcomes through `ISessionMetadata`
* (observed via `agentLifecycle` and the main agent's `eventBus`), so the
* session index keeps reporting them across restarts. Persisted on turn end
* (completed/failed, or a user's stop), cleared when a new turn starts, and
* backfilled from a cold resume's restored outcome — backfills never bump
* `updatedAt`, and programmatic aborts (including scope-teardown cancels)
* are deliberately never persisted, and backfills only apply to a pure
* resume (no turn started in this process — a live turn end owns its write,
* recency bump included). Writes are deduped against the last value this
* process persisted. Bound at Session scope.
*/

import { Disposable, DisposableStore } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IEventBus } from '#/app/event/eventBus';
import {
IAgentLifecycleService,
MAIN_AGENT_ID,
} from '#/session/agentLifecycle/agentLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';

import type { SessionTurnOutcome } from './sessionActivity';
import { ISessionOutcomeMirror } from './sessionOutcomeMirror';

export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeMirror {
declare readonly _serviceBrand: undefined;

private lastPersisted: SessionTurnOutcome | undefined;
private adopted = false;
private turnStartedHere = false;
private mainSubscription: DisposableStore | undefined;

constructor(
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@ISessionMetadata private readonly metadata: ISessionMetadata,
) {
super();
void this.metadata
.read()
.then((meta) => {
if (!this.adopted) this.lastPersisted = meta.lastTurnReason;
})
.catch(() => {});
this.attachMain();
this._register(this.agents.onDidCreate((handle) => {
if (handle.id === MAIN_AGENT_ID) this.attachMain();
}));
this._register(this.agents.onDidDispose((agentId) => {
if (agentId !== MAIN_AGENT_ID) return;
this.mainSubscription?.dispose();
this.mainSubscription = undefined;
}));
this._register({
dispose: () => {
this.mainSubscription?.dispose();
this.mainSubscription = undefined;
},
});
}

private attachMain(): void {
if (this.mainSubscription !== undefined) return;
Comment thread
wbxl2000 marked this conversation as resolved.
const bus = this.agents.get(MAIN_AGENT_ID)?.accessor.get(IEventBus) as IEventBus | undefined;
if (bus === undefined) return;
const subscription = new DisposableStore();
this.mainSubscription = subscription;
subscription.add(
bus.subscribe('turn.ended', (event) => {
Comment thread
wbxl2000 marked this conversation as resolved.
if (event.type !== 'turn.ended') return;
const reason = (event as { reason?: unknown }).reason;
const interruptReason = (event as { interruptReason?: unknown }).interruptReason;
if (reason === 'completed') {
this.write('completed');
return;
}
if (reason === 'failed' || reason === 'blocked') {
this.write('failed');
return;
}
if (reason === 'cancelled' && interruptReason === 'user_cancelled') {
this.write('cancelled');
}
}),
);
subscription.add(
bus.subscribe('turn.started', () => {
this.turnStartedHere = true;
this.write(undefined);
}),
);
subscription.add(
bus.subscribe('agent.activity.updated', (event) => {
if (this.turnStartedHere) return;
if (this.lastPersisted !== undefined) return;
const lastTurn = (event as { lastTurn?: { reason?: unknown } }).lastTurn;
const reason = lastTurn?.reason;
if (reason === 'completed') this.write('completed', { touchUpdatedAt: false });
else if (reason === 'failed' || reason === 'blocked') {
this.write('failed', { touchUpdatedAt: false });
Comment thread
wbxl2000 marked this conversation as resolved.
Outdated
Comment thread
wbxl2000 marked this conversation as resolved.
Outdated
}
}),
);
}

private write(
outcome: SessionTurnOutcome | undefined,
opts?: { readonly touchUpdatedAt?: boolean },
): void {
if (outcome === this.lastPersisted) return;
this.adopted = true;
const previous = this.lastPersisted;
this.lastPersisted = outcome;
Comment thread
wbxl2000 marked this conversation as resolved.
void this.metadata.update({ lastTurnReason: outcome }, opts).catch(() => {
if (this.lastPersisted === outcome) this.lastPersisted = previous;
});
}
}

registerScopedService(
LifecycleScope.Session,
ISessionOutcomeMirror,
SessionOutcomeMirror,
ScopeActivation.OnScopeCreated,
'sessionActivity',
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*
* Defines the `SessionMeta` model and the `ISessionMetadata` used by upper
* layers to read and update the session's durable metadata (title, timestamps,
* archived flag, fork provenance). Owns the in-memory copy, persists it as a
* archived flag, fork provenance, the latest main turn's terminal outcome).
* Owns the in-memory copy, persists it as a
* single atomic document through `storage`, and notifies changes via
* `onDidChangeMetadata`. Session-scoped — one instance per session. The initial
* document is materialized when the session is created.
Expand Down Expand Up @@ -36,6 +37,7 @@ export interface SessionMeta {
readonly forkedFrom?: string;
readonly agents?: Readonly<Record<string, AgentMeta>>;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}

export type SessionMetaPatch = Partial<Omit<SessionMeta, 'id' | 'createdAt'>>;
Expand All @@ -50,7 +52,7 @@ export interface ISessionMetadata {
readonly ready: Promise<void>;
readonly onDidChangeMetadata: Event<SessionMetadataChangedEvent>;
read(): Promise<SessionMeta>;
update(patch: SessionMetaPatch): Promise<void>;
update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise<void>;
setTitle(title: string): Promise<void>;
setArchived(archived: boolean): Promise<void>;
registerAgent(agentId: string, meta: AgentMeta): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
* derived store (no mirror flush, no query-store lock). First-time creation in
* `load()` records too — a new session must appear in listings immediately
* (the mirror's pending queue feeds the index's read-your-writes merge);
* loading an *existing* document (session resume) stays silent.
* loading an *existing* document (session resume) stays silent. Queued writes
* are tracked in a module-level pending set, drained through
* `drainSessionMetadataWrites()` by hosts before the sessions root may be
* torn down (the query-store/mirror drain pattern).
*/

import { Disposable } from '#/_base/di/lifecycle';
Expand All @@ -48,13 +51,21 @@ import {

const META_KEY = 'state.json';

const pendingWrites = new Set<Promise<void>>();

export async function drainSessionMetadataWrites(): Promise<void> {
await Promise.all(pendingWrites);
}

export const sessionMetadataDataKey = defineState<SessionMeta | undefined>(
'sessionMetadata.data',
() => undefined,
);

export class SessionMetadata extends Disposable implements ISessionMetadata {
declare readonly _serviceBrand: undefined;

private disposed = false;
readonly ready: Promise<void>;
readonly onDidChangeMetadata: Event<SessionMetadataChangedEvent>;

Expand All @@ -72,6 +83,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
@ISessionIndexMirror private readonly mirror: ISessionIndexMirror,
) {
super();
this._register({
dispose: () => {
this.disposed = true;
},
});
this.states.register(sessionMetadataDataKey);
this.scope = ctx.metaScope;
this.onDidChangeMetadata = this._onDidChangeMetadata.event;
Expand All @@ -91,14 +107,26 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
return this.data;
}

async update(patch: SessionMetaPatch): Promise<void> {
return this.enqueueUpdate(() => this.applyUpdate(patch));
async update(
patch: SessionMetaPatch,
opts?: { readonly touchUpdatedAt?: boolean },
): Promise<void> {
return this.enqueueUpdate(() => this.applyUpdate(patch, opts));
}

private async applyUpdate(patch: SessionMetaPatch): Promise<void> {
private async applyUpdate(
patch: SessionMetaPatch,
opts?: { readonly touchUpdatedAt?: boolean },
): Promise<void> {
await this.ready;
this.data = { ...this.data, ...patch, updatedAt: Date.now() };
// The update queue outlives its callers: a patch queued before scope
// disposal must not write (or recreate files) once the session is being
// torn down.
Comment thread
wbxl2000 marked this conversation as resolved.
Outdated
if (this.disposed) return;
const updatedAt = opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now();
this.data = { ...this.data, ...patch, updatedAt };
await this.store.set(this.scope, META_KEY, this.data);
if (this.disposed) return;
this.mirrorToReadModel();
this._onDidChangeMetadata.fire({
changed: Object.keys(patch) as (keyof SessionMeta)[],
Expand All @@ -125,7 +153,10 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {

private enqueueUpdate(work: () => Promise<void>): Promise<void> {
const run = this.updateQueue.then(work, work);
this.updateQueue = run.catch(() => {});
const tracked = run.catch(() => {});
this.updateQueue = tracked;
pendingWrites.add(tracked);
void tracked.finally(() => pendingWrites.delete(tracked));
return run;
}

Expand All @@ -141,6 +172,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
updatedAt: this.data.updatedAt,
archived: this.data.archived === true,
custom: this.data.custom,
lastTurnReason: this.data.lastTurnReason,
}),
);
}
Expand Down
Loading
Loading