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/agent-core-v2": patch
---

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.
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 @@ -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
Expand Down Expand Up @@ -33,6 +34,7 @@ export interface SessionMeta {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
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,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). 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). `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
Expand Down Expand Up @@ -124,7 +128,8 @@ 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();
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;
Expand All @@ -135,11 +140,14 @@ 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> {
Expand All @@ -148,7 +156,7 @@ export class SessionMetadata extends Service implements ISessionMetadata {
const existing = this.data.agents?.[agentId];
if (existing !== undefined && agentMetaEquals(existing, meta)) return;
const agents = { ...this.data.agents, [agentId]: meta };
await this.applyUpdate({ agents });
await this.applyUpdate({ agents }, { touchUpdatedAt: false });
});
}

Expand All @@ -173,6 +181,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 @@ -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-
Expand Down Expand Up @@ -115,7 +120,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 @@ -525,11 +530,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}

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

for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
}

await targetMeta.update({
title,
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
forkedFrom: sourceId,
archived: false,
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 @@ -540,15 +556,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec

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