From 7fe71f963dd6c7d1bad38a115cb48996da7dbfad Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 18:30:15 +0800 Subject: [PATCH 1/8] feat(agent-core-v2): keep session updatedAt stable across meta management writes Rename, archive/restore, and fork no longer bump a session's updatedAt, so recency-sorted session lists stop reshuffling on management actions: - setTitle/setArchived pass touchUpdatedAt: false; an explicit patch.updatedAt always wins (fork inherits the source's recency, so a fork lands next to the source instead of floating to the top) - new SessionMeta.archivedAt records the archive moment (cleared on restore) and is surfaced through the session index, the v1/v2 session routes (archived_at), and the klient contract, so the archived list keeps an accurate archive time without relying on the updatedAt bump --- .changeset/session-meta-updated-at.md | 5 +++ .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../src/app/sessionIndex/sessionIndex.ts | 3 ++ .../app/sessionIndex/sessionIndexSource.ts | 4 ++ .../src/app/sessionLegacy/sessionLegacy.ts | 1 + .../app/sessionLegacy/sessionLegacyService.ts | 1 + .../sessionMetadata/sessionMetadata.ts | 4 ++ .../sessionMetadata/sessionMetadataService.ts | 14 +++++-- .../sessionLifecycleService.ts | 5 +++ .../sessionMetadata/sessionMetadata.test.ts | 38 +++++++++++++++++++ .../sessionLifecycle/sessionLifecycle.test.ts | 20 ++++++++++ packages/kap-server/src/protocol/session.ts | 3 ++ packages/kap-server/src/routes/sessions.ts | 5 +++ packages/kap-server/src/routes/v2/sessions.ts | 4 ++ packages/kap-server/test/v2Sessions.test.ts | 1 + .../klient/src/contract/global/sessions.ts | 1 + .../klient/src/contract/session/metadata.ts | 3 ++ packages/protocol/src/session.ts | 3 ++ 18 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 .changeset/session-meta-updated-at.md diff --git a/.changeset/session-meta-updated-at.md b/.changeset/session-meta-updated-at.md new file mode 100644 index 0000000000..3f3698e112 --- /dev/null +++ b/.changeset/session-meta-updated-at.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep session list order stable when renaming, archiving, restoring, or forking a session. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index bdc9795519..9c3c18472b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -454,6 +454,7 @@ export interface SessionStateSnapshot { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 9bd1d4f6fa..cd75ef1767 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -66,6 +66,7 @@ export function buildSessionSummary(fields: { createdAt: number; updatedAt: number; archived: boolean; + archivedAt?: number; custom?: Record; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }): SessionSummary { @@ -78,6 +79,7 @@ export function buildSessionSummary(fields: { createdAt: fields.createdAt, updatedAt: fields.updatedAt, archived: fields.archived, + archivedAt: fields.archivedAt, custom: fields.custom, lastTurnReason: fields.lastTurnReason, }; @@ -108,6 +110,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { a.createdAt === b.createdAt && a.updatedAt === b.updatedAt && a.archived === b.archived && + a.archivedAt === b.archivedAt && a.lastTurnReason === b.lastTurnReason && JSON.stringify(a.custom) === JSON.stringify(b.custom) ); @@ -159,6 +162,7 @@ export async function readSessionSummary( createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), archived: meta['archived'] === true, + archivedAt: meta['archivedAt'] === undefined ? undefined : parseTime(meta['archivedAt']), custom, lastTurnReason: parseTurnOutcome(meta['lastTurnReason']), }); diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 1a6cffccda..ecf83bfe2e 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -28,6 +28,7 @@ export interface SessionWireFields { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly custom?: Record; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 1ec77c346e..b4353decfa 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -95,6 +95,7 @@ export class SessionLegacyService implements ISessionLegacyService { createdAt: meta.createdAt, updatedAt: meta.updatedAt, archived: meta.archived, + archivedAt: meta.archivedAt, custom: meta.custom, }; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 11c75c7ff7..10c0229057 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -33,6 +33,10 @@ export interface SessionMeta { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + /** When the session was archived (epoch ms); cleared on restore. Management + * writes (title / archived) never touch `updatedAt` — it tracks content + * activity only, so listings stay put on rename/archive/restore. */ + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly>; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 70505926ec..5ceed85976 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -124,7 +124,11 @@ export class SessionMetadata extends Service implements ISessionMetadata { ): Promise { await this.ready; if (this.disposed) return; - const updatedAt = opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now(); + // An explicit patch.updatedAt always wins (fork inherits the source's + // recency); otherwise management writes (touchUpdatedAt: false — rename, + // archive/restore) keep the current value so listings don't reorder. + const updatedAt = + patch.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; @@ -135,11 +139,14 @@ export class SessionMetadata extends Service implements ISessionMetadata { } async setTitle(title: string): Promise { - await this.update({ title, isCustomTitle: true }); + await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false }); } async setArchived(archived: boolean): Promise { - await this.update({ archived }); + await this.update( + archived ? { archived: true, archivedAt: Date.now() } : { archived: false, archivedAt: undefined }, + { touchUpdatedAt: false }, + ); } async registerAgent(agentId: string, meta: AgentMeta): Promise { @@ -173,6 +180,7 @@ export class SessionMetadata extends Service implements ISessionMetadata { createdAt: this.data.createdAt, updatedAt: this.data.updatedAt, archived: this.data.archived === true, + archivedAt: this.data.archivedAt, custom: this.data.custom, lastTurnReason: this.data.lastTurnReason, }), diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 2eecd65f38..feb39b457c 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -530,6 +530,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, forkedFrom: sourceId, archived: false, + // The fork is a copy of the source's content as of now, not fresh + // activity: inherit the source's recency so the fork lands next to it + // instead of floating to the top of the list. (createdAt stays now — + // that's the creation fact.) + updatedAt: sourceMeta?.updatedAt ?? Date.now(), lastPrompt: sourceMeta?.lastPrompt, // The fork continues the source's conversation, so it inherits the // last turn's outcome too — otherwise a restart would drop a failure diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 574eec1520..cd8a80a132 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -97,6 +97,44 @@ describe('SessionMetadata', () => { expect(await meta.read()).toMatchObject({ title: 't', archived: true }); }); + it('setTitle keeps updatedAt (rename must not reorder listings)', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + await meta.setTitle('renamed'); + + const next = await meta.read(); + expect(next.title).toBe('renamed'); + expect(next.updatedAt).toBe(before); + }); + + it('setArchived records archivedAt without touching updatedAt; restore clears it', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + + await meta.setArchived(true); + const archived = await meta.read(); + expect(archived.archived).toBe(true); + expect(archived.archivedAt).toBeGreaterThan(before); + expect(archived.updatedAt).toBe(before); + + await meta.setArchived(false); + const restored = await meta.read(); + expect(restored.archived).toBe(false); + expect(restored.archivedAt).toBeUndefined(); + expect(restored.updatedAt).toBe(before); + }); + + it('an explicit patch.updatedAt always wins (fork inherits the source recency)', async () => { + const meta = ix.get(ISessionMetadata); + await meta.update({ title: 'fork', updatedAt: 1234 }); + + const next = await meta.read(); + expect(next.title).toBe('fork'); + expect(next.updatedAt).toBe(1234); + }); + it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index a848c9521b..57c7cb588b 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -1524,6 +1524,26 @@ describe('SessionLifecycleService', () => { expect(forkUpdate?.lastTurnReason).toBe('failed'); }); + it('fork inherits the source session\'s updatedAt (a copy is not fresh activity)', async () => { + const updates: { readonly updatedAt?: unknown }[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + read: () => + Promise.resolve({ updatedAt: 9876, agents: {} } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }; + const svc = await build([stubPair(ISessionMetadata, metaStub)]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + const forkUpdate = updates.find((u) => 'forkedFrom' in u); + expect(forkUpdate?.updatedAt).toBe(9876); + }); + it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = await build([ diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index 9fa454244a..0a43a759dc 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -54,6 +54,9 @@ export const sessionSchema = z.object({ /** Outcome of the main agent's most recent turn. */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + /** When the session was archived (ISO 8601); absent for sessions archived + * before the field existed — clients fall back to `updated_at`. */ + archived_at: isoDateTimeSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(), diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e6cdd0c435..5fc4235e2c 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -1108,6 +1108,7 @@ export interface SessionWireFields { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly custom?: Record; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } @@ -1123,6 +1124,10 @@ export function toWireSession( title: fields.title ?? '', created_at: new Date(fields.createdAt).toISOString(), updated_at: new Date(fields.updatedAt).toISOString(), + // Archive moment; sessions archived before the field existed report + // nothing (clients fall back to updated_at for display). + archived_at: + fields.archivedAt === undefined ? undefined : new Date(fields.archivedAt).toISOString(), busy: facts.busy, main_turn_active: facts.mainTurnActive, pending_interaction: facts.pendingInteraction, diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index a88b63a318..2ac8590b85 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -159,6 +159,9 @@ const v2SessionSchema = z.object({ created_at: z.number().int(), updated_at: z.number().int(), archived: z.boolean(), + /** Unix ms; null when absent (never archived, or archived before the + * field existed — clients fall back to updated_at for display). */ + archived_at: z.number().int().nullable(), }), activity: z.object({ status: v2ActivityStatusSchema }), git: v2GitDomainSchema.optional(), @@ -492,6 +495,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): created_at: summary.createdAt, updated_at: summary.updatedAt, archived: summary.archived, + archived_at: summary.archivedAt ?? null, }, activity: { status: mapActivityStatus(factsOf(summary.id), summary.lastTurnReason) }, git: diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 9938d3be32..cd1e2ebebe 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -234,6 +234,7 @@ describe('server /api/v2/sessions', () => { created_at: 3_000, updated_at: 5_000, archived: false, + archived_at: null, }); // Stubbed sessions are cold → always idle. expect(first.activity).toEqual({ status: 'idle' }); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 06ac78a390..9a25323aac 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -17,6 +17,7 @@ export const sessionSummarySchema = z.object({ createdAt: z.number(), updatedAt: z.number(), archived: z.boolean(), + archivedAt: z.number().optional(), custom: z.record(z.string(), z.unknown()).optional(), lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), }); diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index 71764ed563..e72d6642ce 100644 --- a/packages/klient/src/contract/session/metadata.ts +++ b/packages/klient/src/contract/session/metadata.ts @@ -27,6 +27,7 @@ export const sessionMetaSchema = z.object({ createdAt: z.number(), updatedAt: z.number(), archived: z.boolean(), + archivedAt: z.number().optional(), cwd: z.string().optional(), forkedFrom: z.string().optional(), agents: z.record(z.string(), agentMetaSchema).optional(), @@ -42,6 +43,7 @@ export const sessionMetaPatchSchema = z.object({ lastPrompt: z.string().optional(), updatedAt: z.number().optional(), archived: z.boolean().optional(), + archivedAt: z.number().optional(), cwd: z.string().optional(), forkedFrom: z.string().optional(), agents: z.record(z.string(), agentMetaSchema).optional(), @@ -59,6 +61,7 @@ export const sessionMetaKeySchema = z.enum([ 'createdAt', 'updatedAt', 'archived', + 'archivedAt', 'cwd', 'forkedFrom', 'agents', diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index 8d10164e8c..4417ba39b4 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -102,6 +102,9 @@ export const sessionSchema = z.object({ * reason is cancelled/failed). */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + /** When the session was archived (ISO 8601); absent for sessions archived + * before the field existed — clients fall back to `updated_at`. */ + archived_at: isoDateTimeSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(), From 52f5a432951eb85bb3aa84e90affb61023c06d6b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 19:18:55 +0800 Subject: [PATCH 2/8] fix(agent-core-v2): normalize a legacy ISO-string updatedAt when forking a cold session A cold legacy/v1 state.json read from disk can still carry an ISO-string updatedAt; passing it through as the fork's explicit patch.updatedAt would persist a string into the v2 metadata. Normalize with toEpochMs (falling back to now when absent/unparseable). --- .../sessionLifecycleService.ts | 7 +++--- .../sessionLifecycle/sessionLifecycle.test.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index feb39b457c..aafaf014f8 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -115,7 +115,7 @@ import { type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { drainSessionMetadataWrites } from '#/session/sessionMetadata/sessionMetadataService'; +import { drainSessionMetadataWrites, toEpochMs } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { IWireService } from '#/wire/wire'; @@ -533,8 +533,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec // The fork is a copy of the source's content as of now, not fresh // activity: inherit the source's recency so the fork lands next to it // instead of floating to the top of the list. (createdAt stays now — - // that's the creation fact.) - updatedAt: sourceMeta?.updatedAt ?? Date.now(), + // that's the creation fact.) A cold legacy source read from disk can + // still carry an ISO-string updatedAt — normalize to epoch ms. + updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), lastPrompt: sourceMeta?.lastPrompt, // The fork continues the source's conversation, so it inherits the // last turn's outcome too — otherwise a restart would drop a failure diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 57c7cb588b..56aa08d217 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -1544,6 +1544,28 @@ describe('SessionLifecycleService', () => { expect(forkUpdate?.updatedAt).toBe(9876); }); + it('fork normalizes a legacy ISO-string updatedAt from a cold source to epoch ms', async () => { + const updates: { readonly updatedAt?: unknown }[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + // A cold legacy/v1 document read from disk can still carry an ISO + // string — the fork must not persist it as the v2 updatedAt. + read: () => + Promise.resolve({ updatedAt: '2026-08-10T23:00:00.000Z', agents: {} } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }; + const svc = await build([stubPair(ISessionMetadata, metaStub)]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + const forkUpdate = updates.find((u) => 'forkedFrom' in u); + expect(forkUpdate?.updatedAt).toBe(Date.parse('2026-08-10T23:00:00.000Z')); + }); + it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = await build([ From 84ff77647c97d95e213144a564de8aacd8fb5437 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 21:08:07 +0800 Subject: [PATCH 3/8] fix(agent-core-v2): write fork metadata after agent recreation Registering each copied agent during fork is an ordinary metadata write that bumps updatedAt, which overwrote the inherited source recency and still floated normal forks (sessions with agents) to the top. Move the fork's metadata update after the agent recreation loop so the inherited updatedAt is the final write. --- .../sessionLifecycleService.ts | 26 +++++----- .../sessionLifecycle/sessionLifecycle.test.ts | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index aafaf014f8..9ed10272ba 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -525,6 +525,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; + + await this.duplicateCronTasks(sourceId, targetId); + + for (const agentId of agentIds) { + const sourceAgent = sourceAgents[agentId]!; + await target.accessor.get(IAgentLifecycleService).create({ + agentId, + forkedFrom: sourceAgent.forkedFrom, + labels: labelsFromAgentMeta(sourceAgent), + }); + } + + // Write the fork metadata AFTER the agent recreation loop: registering + // each copied agent performs an ordinary metadata update that bumps + // updatedAt, so the recency restore must come last to survive. await targetMeta.update({ title, isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, @@ -544,17 +559,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - await this.duplicateCronTasks(sourceId, targetId); - - for (const agentId of agentIds) { - const sourceAgent = sourceAgents[agentId]!; - await target.accessor.get(IAgentLifecycleService).create({ - agentId, - forkedFrom: sourceAgent.forkedFrom, - labels: labelsFromAgentMeta(sourceAgent), - }); - } - await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 56aa08d217..51c2110a35 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -1566,6 +1566,54 @@ describe('SessionLifecycleService', () => { expect(forkUpdate?.updatedAt).toBe(Date.parse('2026-08-10T23:00:00.000Z')); }); + it('fork writes the inherited updatedAt AFTER agent registration (registering bumps it)', async () => { + const calls: string[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + read: () => + Promise.resolve({ + updatedAt: 9876, + agents: { main: { type: 'main', homedir: '/tmp/h' } }, + } as never), + update: (patch) => { + calls.push('forkedFrom' in patch ? 'update:fork' : 'update:other'); + return Promise.resolve(); + }, + registerAgent: () => { + calls.push('registerAgent'); + return Promise.resolve(); + }, + }; + const agentHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(ISessionMetadata, metaStub), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + create: async () => { + // Mirror the real doCreate: recreation registers the agent, which + // is an ordinary metadata write (it bumps updatedAt). + await metaStub.registerAgent('main', {}); + return agentHandle; + }, + }), + ]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(calls).toContain('registerAgent'); + expect(calls.indexOf('update:fork')).toBeGreaterThan(calls.lastIndexOf('registerAgent')); + }); + it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = await build([ From c365f9a098cae6f7c611d6f88aa11510291b4526 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 21:43:19 +0800 Subject: [PATCH 4/8] fix(agent-core-v2): preserve persisted recency when restoring a cold session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume creates the main agent for a cold session that has no persisted agents.main entry (e.g. an empty session), and that registration bumps updatedAt — so unarchiving an empty session still floated it to the top. Capture the index summary's updatedAt before resume and re-apply it in the restore write (archived:false, archivedAt cleared, explicit updatedAt wins over the bump). --- .../sessionLifecycleService.ts | 11 +++- .../sessionLifecycle/sessionLifecycle.test.ts | 56 +++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 9ed10272ba..fa9ebd7ac6 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -428,9 +428,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sessionId: string, opts?: ResumeSessionOptions, ): Promise { + // Capture the persisted recency BEFORE resume: a cold session without a + // persisted main agent gets one created during resume, and that + // registration is an ordinary metadata write that bumps updatedAt — + // without restoring it here, unarchiving an empty session floats it to + // the top of recency-sorted listings. + const summary = await this.index.get(sessionId); const handle = await this.resume(sessionId, opts); if (handle === undefined) return undefined; - await handle.accessor.get(ISessionMetadata).setArchived(false); + await handle.accessor.get(ISessionMetadata).update( + { archived: false, archivedAt: undefined, updatedAt: summary?.updatedAt }, + { touchUpdatedAt: false }, + ); return handle; } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 51c2110a35..d5626fdc30 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -905,14 +905,14 @@ describe('SessionLifecycleService', () => { }); it('restore clears the archived flag when the session exists on disk', async () => { - let archived: boolean | undefined; + let patch: Record | undefined; const svc = await build([ stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), stubPair(ISessionMetadata, { ...metadataStub(), - setArchived: (value: boolean) => { - archived = value; + update: (p: Record) => { + patch = p; return Promise.resolve(); }, }), @@ -921,7 +921,55 @@ describe('SessionLifecycleService', () => { const restored = await svc.restore('s1'); expect(restored?.id).toBe('s1'); - expect(archived).toBe(false); + expect(patch).toMatchObject({ archived: false, archivedAt: undefined }); + }); + + it('restore preserves the persisted recency when resume (re)creates the main agent', async () => { + // A cold empty session has no persisted agents.main: resume creates it, + // and that registration is an ordinary metadata write that bumps + // updatedAt. The restore's final write must re-apply the persisted value. + const updates: Record[] = []; + const metaStub: ISessionMetadata = { + ...metadataStub(), + update: (p) => { + updates.push(p as Record); + return Promise.resolve(); + }, + }; + const agentHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), + stubPair(ISessionMetadata, metaStub), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + create: async () => { + // Mirror the real doCreate: registering the created agent writes + // metadata (which bumps updatedAt to now). + await metaStub.registerAgent('main', {}); + return agentHandle; + }, + }), + ]); + + const restored = await svc.restore('s1'); + + expect(restored?.id).toBe('s1'); + // The summary stub persists updatedAt = 1; the restore's final write + // re-applies it over the registration's bump. + expect(updates.at(-1)).toMatchObject({ + archived: false, + archivedAt: undefined, + updatedAt: 1, + }); }); describe('delete', () => { From 1a4b897396ee77823626bf486a2a150a89337ac6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 22:12:00 +0800 Subject: [PATCH 5/8] fix(agent-core-v2): make agent registration non-touching for recency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering an agent is a structural write, not content activity — but it went through an ordinary metadata update that bumped updatedAt. That reordered recency-sorted listings whenever materialization created an agent: resume of a cold session without a persisted agents.main (so archive-via-resume and restore of empty sessions still floated), and runtime subagent registration mid-turn. registerAgent now passes touchUpdatedAt: false; restore goes back to the plain unarchive write and no longer needs the capture/reapply workaround. --- .../sessionMetadata/sessionMetadataService.ts | 13 +++--- .../sessionLifecycleService.ts | 21 ++++----- .../sessionMetadata/sessionMetadata.test.ts | 18 +++++++- .../sessionLifecycle/sessionLifecycle.test.ts | 44 +++++++------------ 4 files changed, 49 insertions(+), 47 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 5ceed85976..8a00e0f537 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,10 +10,10 @@ * document always carries the `agents` / `custom` maps — seeded at creation, * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). Re-registering an agent whose metadata is unchanged is - * a no-op (no write, no mirror, no event), so resuming a session — which - * re-registers its agents as they materialize — never bumps `updatedAt` and - * never reorders session listings. Bound at Session scope. + * session listings). Agent registration is a structural write, not content + * activity: it never bumps `updatedAt` — neither when resume materializes a + * cold session's agents, nor when a runtime subagent registers mid-turn (the + * turn's own submit/end moments carry recency). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is recorded into the App-scoped @@ -154,8 +154,11 @@ export class SessionMetadata extends Service implements ISessionMetadata { await this.ready; const existing = this.data.agents?.[agentId]; if (existing !== undefined && agentMetaEquals(existing, meta)) return; + // The agents map is structural metadata, not content activity: resume + // materializing a cold session's main agent (and runtime subagent + // registration) must never reorder recency-sorted listings. const agents = { ...this.data.agents, [agentId]: meta }; - await this.applyUpdate({ agents }); + await this.applyUpdate({ agents }, { touchUpdatedAt: false }); }); } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index fa9ebd7ac6..4b12fbc4f8 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -428,18 +428,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sessionId: string, opts?: ResumeSessionOptions, ): Promise { - // Capture the persisted recency BEFORE resume: a cold session without a - // persisted main agent gets one created during resume, and that - // registration is an ordinary metadata write that bumps updatedAt — - // without restoring it here, unarchiving an empty session floats it to - // the top of recency-sorted listings. - const summary = await this.index.get(sessionId); + // agent registration during resume is non-touching (see + // SessionMetadata.registerAgent), so the persisted recency survives + // materializing a cold session's main agent. const handle = await this.resume(sessionId, opts); if (handle === undefined) return undefined; - await handle.accessor.get(ISessionMetadata).update( - { archived: false, archivedAt: undefined, updatedAt: summary?.updatedAt }, - { touchUpdatedAt: false }, - ); + await handle.accessor.get(ISessionMetadata).setArchived(false); return handle; } @@ -546,9 +540,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - // Write the fork metadata AFTER the agent recreation loop: registering - // each copied agent performs an ordinary metadata update that bumps - // updatedAt, so the recency restore must come last to survive. + // Write the fork metadata AFTER the agent recreation loop so the + // fork's meta settles once, after all structural writes (agent + // registration is non-touching, but keeping the recency restore last + // makes the ordering robust against future structural writes). await targetMeta.update({ title, isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index cd8a80a132..c4c199f114 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -126,6 +126,19 @@ describe('SessionMetadata', () => { expect(restored.updatedAt).toBe(before); }); + it('registerAgent never bumps updatedAt — the agents map is structural', async () => { + const meta = ix.get(ISessionMetadata); + const before = (await meta.read()).updatedAt; + await new Promise((r) => setTimeout(r, 2)); + // A genuinely NEW agent (resume materializing a cold session's main + // agent, or a runtime subagent) is not content activity. + await meta.registerAgent('main', { homedir: '/tmp/h', type: 'main' }); + + const next = await meta.read(); + expect(next.agents?.['main']?.homedir).toBe('/tmp/h'); + expect(next.updatedAt).toBe(before); + }); + it('an explicit patch.updatedAt always wins (fork inherits the source recency)', async () => { const meta = ix.get(ISessionMetadata); await meta.update({ title: 'fork', updatedAt: 1234 }); @@ -334,7 +347,7 @@ describe('SessionMetadata', () => { expect((await meta.read()).updatedAt).toBe(1700000000000); }); - it('updates when re-registering with changed fields', async () => { + it('updates changed fields on re-registration without bumping updatedAt', async () => { const meta = ix.get(ISessionMetadata); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', @@ -351,7 +364,8 @@ describe('SessionMetadata', () => { const next = await meta.read(); expect(next.agents?.['main']?.labels).toEqual({ swarmItem: 'src/a.ts' }); - expect(next.updatedAt).toBeGreaterThan(before); + // Agent registration is structural, not content activity — listings stay. + expect(next.updatedAt).toBe(before); }); it('records the fresh summary into the session index mirror on update', async () => { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index d5626fdc30..d8810934ac 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -905,14 +905,14 @@ describe('SessionLifecycleService', () => { }); it('restore clears the archived flag when the session exists on disk', async () => { - let patch: Record | undefined; + let archived: boolean | undefined; const svc = await build([ stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), stubPair(ISessionMetadata, { ...metadataStub(), - update: (p: Record) => { - patch = p; + setArchived: (value: boolean) => { + archived = value; return Promise.resolve(); }, }), @@ -921,21 +921,13 @@ describe('SessionLifecycleService', () => { const restored = await svc.restore('s1'); expect(restored?.id).toBe('s1'); - expect(patch).toMatchObject({ archived: false, archivedAt: undefined }); + expect(archived).toBe(false); }); - it('restore preserves the persisted recency when resume (re)creates the main agent', async () => { - // A cold empty session has no persisted agents.main: resume creates it, - // and that registration is an ordinary metadata write that bumps - // updatedAt. The restore's final write must re-apply the persisted value. - const updates: Record[] = []; - const metaStub: ISessionMetadata = { - ...metadataStub(), - update: (p) => { - updates.push(p as Record); - return Promise.resolve(); - }, - }; + it('restore re-creates the main agent for a cold empty session, then unarchives', async () => { + // The registration itself is non-touching (pinned at the SessionMetadata + // level), so restore only needs the plain unarchive write. + const calls: string[] = []; const agentHandle = { id: 'main', kind: LifecycleScope.Agent, @@ -948,13 +940,17 @@ describe('SessionLifecycleService', () => { } as unknown as IAgentScopeHandle; const svc = await build([ stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), - stubPair(ISessionMetadata, metaStub), + stubPair(ISessionMetadata, { + ...metadataStub(), + setArchived: (value: boolean) => { + calls.push(`setArchived:${value}`); + return Promise.resolve(); + }, + }), stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), create: async () => { - // Mirror the real doCreate: registering the created agent writes - // metadata (which bumps updatedAt to now). - await metaStub.registerAgent('main', {}); + calls.push('create'); return agentHandle; }, }), @@ -963,13 +959,7 @@ describe('SessionLifecycleService', () => { const restored = await svc.restore('s1'); expect(restored?.id).toBe('s1'); - // The summary stub persists updatedAt = 1; the restore's final write - // re-applies it over the registration's bump. - expect(updates.at(-1)).toMatchObject({ - archived: false, - archivedAt: undefined, - updatedAt: 1, - }); + expect(calls).toEqual(['create', 'setArchived:false']); }); describe('delete', () => { From c0444fa425a044c7bb7228b03d35a3317fd6a8e1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:07:53 +0800 Subject: [PATCH 6/8] fix(agent-core-v2): duplicate cron tasks only after the fork metadata is durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the metadata write moved after agent recreation, cron duplication ran before it — a rejected metadata update left cloned cron records pointing at a fork whose directory the catch block just removed. Keep cron duplication after the durable metadata write. --- .../workspace/sessionLifecycle/sessionLifecycleService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 4b12fbc4f8..596a1c690a 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -529,8 +529,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; - await this.duplicateCronTasks(sourceId, targetId); - for (const agentId of agentIds) { const sourceAgent = sourceAgents[agentId]!; await target.accessor.get(IAgentLifecycleService).create({ @@ -563,6 +561,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); + // Cron duplication stays after the durable metadata write: if any step + // above rejects, the catch removes the target dir and no cloned cron + // records are left pointing at a fork that never materialized. + await this.duplicateCronTasks(sourceId, targetId); + await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, From 00bbd6e7b7ce0f4fcf7fdc5b2462c20cecc65d61 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:42:33 +0800 Subject: [PATCH 7/8] style(agent-core-v2): fold new invariants into module headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package convention keeps comments in the top-of-file block only — move the touchUpdatedAt precedence, non-touching registration, and fork ordering notes out of statement-level positions into the respective module headers. --- .../sessionMetadata/sessionMetadata.ts | 6 ++---- .../sessionMetadata/sessionMetadataService.ts | 18 ++++++++--------- .../sessionLifecycleService.ts | 20 +++++-------------- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 10c0229057..e7af38ce33 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -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, the latest main turn's terminal outcome). + * archived flag and the archive moment `archivedAt` — set on archive, cleared + * on restore — 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 @@ -33,9 +34,6 @@ export interface SessionMeta { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; - /** When the session was archived (epoch ms); cleared on restore. Management - * writes (title / archived) never touch `updatedAt` — it tracks content - * activity only, so listings stay put on rename/archive/restore. */ readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 8a00e0f537..8cd2b79a99 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,10 +10,14 @@ * document always carries the `agents` / `custom` maps — seeded at creation, * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). Agent registration is a structural write, not content - * activity: it never bumps `updatedAt` — neither when resume materializes a - * cold session's agents, nor when a runtime subagent registers mid-turn (the - * turn's own submit/end moments carry recency). Bound at Session scope. + * session listings). `updatedAt` tracks content activity only: management + * writes (rename via `setTitle`, archive/restore via `setArchived`) keep the + * persisted value through `touchUpdatedAt: false`, an explicit + * `patch.updatedAt` always wins (fork restores the source's recency), and + * agent registration is a structural write that never touches it — neither + * when resume materializes a cold session's agents, nor when a runtime + * subagent registers mid-turn (the turn's own submit/end moments carry + * recency). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is recorded into the App-scoped @@ -124,9 +128,6 @@ export class SessionMetadata extends Service implements ISessionMetadata { ): Promise { await this.ready; if (this.disposed) return; - // An explicit patch.updatedAt always wins (fork inherits the source's - // recency); otherwise management writes (touchUpdatedAt: false — rename, - // archive/restore) keep the current value so listings don't reorder. const updatedAt = patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now()); this.data = { ...this.data, ...patch, updatedAt }; @@ -154,9 +155,6 @@ export class SessionMetadata extends Service implements ISessionMetadata { await this.ready; const existing = this.data.agents?.[agentId]; if (existing !== undefined && agentMetaEquals(existing, meta)) return; - // The agents map is structural metadata, not content activity: resume - // materializing a cold session's main agent (and runtime subagent - // registration) must never reorder recency-sorted listings. const agents = { ...this.data.agents, [agentId]: meta }; await this.applyUpdate({ agents }, { touchUpdatedAt: false }); }); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 596a1c690a..2d4f322283 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -38,6 +38,11 @@ * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent; fork is * confined to this handler (source and target share the workspace bucket). + * Fork restores the source's recency onto the target: the metadata write + * carries an explicit `updatedAt` and runs after agent recreation as the + * fork's final metadata write (agent registration is non-touching), ahead + * of cron duplication, so a mid-fork failure never leaves cloned cron + * records behind. * On * materialize, the agent-profile loaders' `ready` is awaited * before the handle is published — agent-file discovery is local- @@ -428,9 +433,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sessionId: string, opts?: ResumeSessionOptions, ): Promise { - // agent registration during resume is non-touching (see - // SessionMetadata.registerAgent), so the persisted recency survives - // materializing a cold session's main agent. const handle = await this.resume(sessionId, opts); if (handle === undefined) return undefined; await handle.accessor.get(ISessionMetadata).setArchived(false); @@ -538,20 +540,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - // Write the fork metadata AFTER the agent recreation loop so the - // fork's meta settles once, after all structural writes (agent - // registration is non-touching, but keeping the recency restore last - // makes the ordering robust against future structural writes). await targetMeta.update({ title, isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, forkedFrom: sourceId, archived: false, - // The fork is a copy of the source's content as of now, not fresh - // activity: inherit the source's recency so the fork lands next to it - // instead of floating to the top of the list. (createdAt stays now — - // that's the creation fact.) A cold legacy source read from disk can - // still carry an ISO-string updatedAt — normalize to epoch ms. updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), lastPrompt: sourceMeta?.lastPrompt, // The fork continues the source's conversation, so it inherits the @@ -561,9 +554,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - // Cron duplication stays after the durable metadata write: if any step - // above rejects, the catch removes the target dir and no cloned cron - // records are left pointing at a fork that never materialized. await this.duplicateCronTasks(sourceId, targetId); await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); From 3c845a18437134a22953f26803df6edd5b34d1a1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 13:35:41 +0800 Subject: [PATCH 8/8] chore: scope the changeset to agent-core-v2 --- .changeset/session-meta-updated-at.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/session-meta-updated-at.md b/.changeset/session-meta-updated-at.md index 3f3698e112..2b5303cdfb 100644 --- a/.changeset/session-meta-updated-at.md +++ b/.changeset/session-meta-updated-at.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core-v2": patch --- -Keep session list order stable when renaming, archiving, restoring, or forking a session. +Keep session updatedAt stable across metadata management writes: rename and archive/restore no longer bump it, fork inherits the source session's recency, and agent registration is non-touching; add SessionMeta.archivedAt (set on archive, cleared on restore) and surface it as archived_at through the session index and the v1/v2 session routes.