Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/session-meta-updated-at.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep session list order stable when renaming, archiving, restoring, or forking a session.
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 @@ -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<Record<string, /* AgentMeta — packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts */ {
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface SessionSummary {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
/** Archive time (epoch ms); absent for sessions archived before the field
* existed — callers fall back to `updatedAt` for display. */
Comment thread
liruifengv marked this conversation as resolved.
readonly archivedAt?: number;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function buildSessionSummary(fields: {
createdAt: number;
updatedAt: number;
archived: boolean;
archivedAt?: number;
custom?: Record<string, unknown>;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}): SessionSummary {
Expand All @@ -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,
};
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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']),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface SessionWireFields {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly archivedAt?: number;
readonly custom?: Record<string, unknown>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class SessionLegacyService implements ISessionLegacyService {
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
archived: meta.archived,
archivedAt: meta.archivedAt,
custom: meta.custom,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, AgentMeta>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -124,7 +124,11 @@ export class SessionMetadata extends Service implements ISessionMetadata {
): Promise<void> {
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 =
Comment thread
liruifengv marked this conversation as resolved.
Outdated
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;
Expand All @@ -135,20 +139,26 @@ export class SessionMetadata extends Service implements ISessionMetadata {
}

async setTitle(title: string): Promise<void> {
await this.update({ title, isCustomTitle: true });
await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false });
}

async setArchived(archived: boolean): Promise<void> {
await this.update({ archived });
await this.update(
archived ? { archived: true, archivedAt: Date.now() } : { archived: false, archivedAt: undefined },
{ touchUpdatedAt: false },
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
);
}

async registerAgent(agentId: string, meta: AgentMeta): Promise<void> {
return this.enqueueUpdate(async () => {
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 });
});
}

Expand All @@ -173,6 +183,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,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -428,6 +428,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
sessionId: string,
opts?: ResumeSessionOptions,
): Promise<ISessionScopeHandle | undefined> {
// 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);
Expand Down Expand Up @@ -525,11 +528,33 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}

const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`;

await this.duplicateCronTasks(sourceId, targetId);
Comment thread
liruifengv marked this conversation as resolved.
Outdated

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 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(),
Comment thread
liruifengv marked this conversation as resolved.
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
Expand All @@ -538,17 +563,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,57 @@ 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('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 });

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', {
Expand Down Expand Up @@ -296,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',
Expand All @@ -313,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 () => {
Expand Down
Loading
Loading