Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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/v2-legacy-session-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core-v2": patch
---

Write v1-compatible session index entries when the v2 engine creates or forks sessions, so both engine generations discover each other's sessions on the same data directory.
74 changes: 74 additions & 0 deletions packages/agent-core-v2/src/app/sessionIndex/legacySessionIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* `sessionIndex` domain (L2) — v1 legacy session index file format.
*
* Single definition of the byte format of `<homeDir>/session_index.jsonl`,
* shared by the v2 writer (`sessionLifecycle` appends one line per
* create/fork) and the v2 readers (`FileSessionIndex` point lookups,
* `workspaceRegistry` one-shot rebuild). The file is a v1 interop artifact:
* v2's own source of truth is the `<sessionsDir>/<workspaceId>/<sessionId>/`
* directory tree; this file exists so a v1 CLI sharing the same homeDir can
* discover v2-created sessions, and so v2 can locate sessions the v1 way.
*
* Format (identical to v1 `packages/agent-core/src/session/store/session-index.ts`):
* append-only JSONL, one `{sessionId, sessionDir, workDir}` object per line;
* later lines override earlier ones for the same sessionId; `workDir` is
* informational only and never authoritative on read.
*/

import { basename, dirname, isAbsolute, relative, resolve } from 'pathe';

/** Scope of the legacy index file: the homeDir root (join skips empty segments). */
export const LEGACY_SESSION_INDEX_SCOPE = '';
export const LEGACY_SESSION_INDEX_KEY = 'session_index.jsonl';

export interface LegacySessionIndexEntry {
readonly sessionId: string;
readonly sessionDir: string;
readonly workDir: string;
}

/**
* Tolerantly parse one index line. Returns `undefined` for blank-ish garbage,
* non-JSON text, and entries with non-string fields — same acceptance as v1's
* `parseIndexLine`, so a corrupt line never breaks a whole read.
*/
export function parseLegacySessionIndexLine(line: string): LegacySessionIndexEntry | undefined {
try {
const parsed = JSON.parse(line) as unknown;
if (typeof parsed !== 'object' || parsed === null) return undefined;
const entry = parsed as Partial<LegacySessionIndexEntry>;
if (
typeof entry.sessionId !== 'string' ||
typeof entry.sessionDir !== 'string' ||
typeof entry.workDir !== 'string'
) {
return undefined;
}
return {
sessionId: entry.sessionId,
sessionDir: entry.sessionDir,
workDir: entry.workDir,
};
} catch {
return undefined;
}
}

/**
* Validate a parsed entry against v1's read-side rules and derive the
* workspaceId from the sessionDir layout. Returns `undefined` for entries v1
* would skip: a non-absolute `sessionDir`, one outside `sessionsDir`, or one
* whose basename does not match `sessionId`. The workspaceId is the name of
* the bucket directory the session lives in (`<sessionsDir>/<workspaceId>/<sessionId>`).
*/
export function validateLegacySessionIndexEntry(
entry: LegacySessionIndexEntry,
sessionsDir: string,
): { sessionDir: string; workspaceId: string } | undefined {
if (!isAbsolute(entry.sessionDir)) return undefined;
const sessionDir = resolve(entry.sessionDir);
const rel = relative(resolve(sessionsDir), sessionDir);
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return undefined;
if (basename(sessionDir) !== entry.sessionId) return undefined;
return { sessionDir, workspaceId: basename(dirname(sessionDir)) };
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
* session ids are enumerated via `IFileSystemStorageService.list`, and each session's
* metadata document is read via `IAtomicDocumentStore` to build its summary.
*
* Point lookups (`get`) first consult v1's legacy `<homeDir>/session_index.jsonl`
* (format defined in `./legacySessionIndex`) to locate a session's workspace in
* O(1), falling back to the directory scan when the file has no line for the id
* or the line is stale. Listings never trust the file: the tree stays
* authoritative.
*
* The session metadata document lives at `<sessionDir>/state.json`, a layout
* shared by v1 and v2; the `version` field distinguishes them (`2` = v2,
* epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also
Expand Down Expand Up @@ -48,12 +54,20 @@ import {
type SessionListQuery,
type SessionSummary,
} from './sessionIndex';
import {
LEGACY_SESSION_INDEX_KEY,
LEGACY_SESSION_INDEX_SCOPE,
parseLegacySessionIndexLine,
validateLegacySessionIndexEntry,
} from './legacySessionIndex';

const META_SCOPE = 'session-meta';
const META_KEY = 'state.json';
const SESSION_COLLECTION = 'session';
const READ_MODEL_FLAG = 'persistence_minidb_readmodel';

const textDecoder = new TextDecoder();

/** Accept both v2 (epoch ms number) and v1 (ISO string) timestamps. */
function parseTime(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
Expand Down Expand Up @@ -194,7 +208,14 @@ export class FileSessionIndex implements ISessionIndex {
private async getFromReadModel(id: string): Promise<SessionSummary | undefined> {
const cached = await this.queryStore.get<SessionSummary>(SESSION_COLLECTION, id);
if (cached !== undefined) return cached;
// Cold miss: locate the session on disk, then read + backfill.
// Cold miss: locate the session on disk, then read + backfill. Prefer the
// legacy index (O(1)) over the per-workspace scan; a stale line falls
// through to the scan.
const located = (await this.readLegacyIndex()).get(id);
if (located !== undefined) {
const summary = await this.getCachedSummary(located, id);
if (summary !== undefined) return summary;
}
for (const workspaceId of await this.listWorkspaceIds()) {
if (!(await this.hasSession(workspaceId, id))) continue;
return this.getCachedSummary(workspaceId, id);
Expand Down Expand Up @@ -275,6 +296,13 @@ export class FileSessionIndex implements ISessionIndex {
}

private async getLegacy(id: string): Promise<SessionSummary | undefined> {
// Prefer the legacy index (O(1)) over the per-workspace scan; a stale line
// (deleted dir / unreadable metadata) falls through to the scan.
const located = (await this.readLegacyIndex()).get(id);
if (located !== undefined) {
const summary = await this.readSummary(located, id);
if (summary !== undefined) return summary;
}
for (const workspaceId of await this.listWorkspaceIds()) {
if (!(await this.hasSession(workspaceId, id))) continue;
const summary = await this.readSummary(workspaceId, id);
Expand All @@ -283,6 +311,29 @@ export class FileSessionIndex implements ISessionIndex {
return undefined;
}

/**
* Read v1's global session index (`<homeDir>/session_index.jsonl`) as a
* sessionId → workspaceId locator. Entries are validated with v1's rules
* (absolute sessionDir inside `sessionsDir`, basename === sessionId) and
* later lines override earlier ones. Returns an empty map when the file is
* missing or unreadable.
*/
private async readLegacyIndex(): Promise<Map<string, string>> {
const result = new Map<string, string>();
const bytes = await this.storage.read(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back when the legacy index read fails

When <homeDir>/session_index.jsonl is present but unreadable (for example EACCES or a directory at that path), FileStorageService.read throws storage.io_failed; because this new read is outside a try/catch, get() / list({ sessionId }) now fail before the existing directory scan can run. v1's index reader and the helper comment both treat unreadable index files as empty, so catch read failures here and return the empty map to keep sessions discoverable from the authoritative directory tree.

Useful? React with 👍 / 👎.

if (bytes === undefined) return result;
for (const line of textDecoder.decode(bytes).split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed === '') continue;
const entry = parseLegacySessionIndexLine(trimmed);
if (entry === undefined) continue;
const located = validateLegacySessionIndexEntry(entry, this.bootstrap.sessionsDir);
if (located === undefined) continue;
result.set(entry.sessionId, located.workspaceId);
}
return result;
}

private async countActiveLegacy(workspaceId: string): Promise<number> {
let count = 0;
for (const sessionId of await this.listSessionIds(workspaceId)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
ISessionIndex,
PARENT_SESSION_ID_KEY,
} from '#/app/sessionIndex/sessionIndex';
import {
LEGACY_SESSION_INDEX_KEY,
LEGACY_SESSION_INDEX_SCOPE,
} from '#/app/sessionIndex/legacySessionIndex';
import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
import { ITelemetryService } from '#/app/telemetry/telemetry';
Expand Down Expand Up @@ -82,6 +86,12 @@ import {
type MaterializeSessionOptions = Omit<CreateSessionOptions, 'sessionId'> & {
readonly sessionId: string;
readonly workspaceId?: string;
/**
* Append a v1-compatible `session_index.jsonl` line once the session is
* materialized. Set for brand-new sessions (create / fork target); resume
* must not re-append (v1 only writes on create/fork).
*/
readonly legacyIndex?: boolean;
};

export class SessionLifecycleService extends Disposable implements ISessionLifecycleService {
Expand Down Expand Up @@ -126,7 +136,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec

async create(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
const sessionId = opts.sessionId ?? createSessionId();
const handle = await this.materializeSession({ ...opts, sessionId });
const handle = await this.materializeSession({ ...opts, sessionId, legacyIndex: true });
await this.appendSessionIndexEntry(sessionId, opts.workDir);
if (this.config.get<boolean>(DEFAULT_PLAN_MODE_SECTION) === true) {
const main = await ensureMainAgent(handle);
Expand Down Expand Up @@ -193,6 +203,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
void handle.accessor.get(ISessionSkillCatalog).ready;
await handle.accessor.get(IAgentLifecycleService).ensureMcpReady();
handle.accessor.get(ISessionExternalHooksService);
if (opts.legacyIndex === true) {
// Project the new session into v1's global index so a v1 CLI sharing
// this homeDir can discover it. `AppendLogStore` framing is
// byte-identical to v1's `JSON.stringify(entry) + '\n'`; the explicit
// flush makes the line durable before create/fork returns. Placed at
// the materialization point (not fork completion) because v2 does not
Comment on lines +207 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move inline implementation notes into the file header

packages/agent-core-v2/AGENTS.md says comments must live “solely in the top-of-file /** */ block” and “never beside functions, methods, or statements.” This newly added method-level rationale violates that package rule (as do the other new inline source comments in this patch), so move the explanation into the module header or remove it.

Useful? React with 👍 / 👎.

// roll back a failed fork's directory — the tree is the index, so the
// file line matches on-disk reality.
this.appendLogStore.append(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY, {
sessionId: opts.sessionId,
sessionDir,
workDir: opts.workDir,
});
await this.appendLogStore.flush();
}
return handle;
}

Expand Down Expand Up @@ -389,6 +414,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const target = await this.materializeSession({
sessionId: targetId,
workDir: workspace.root,
legacyIndex: true,
});
const targetCtx = target.accessor.get(ISessionContext);
const targetMeta = target.accessor.get(ISessionMetadata);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,20 @@ import { basename, isAbsolute } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import {
LEGACY_SESSION_INDEX_KEY,
LEGACY_SESSION_INDEX_SCOPE,
parseLegacySessionIndexLine,
} from '#/app/sessionIndex/legacySessionIndex';
import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IFileSystemStorageService } from '#/persistence/interface/storage';

import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry';
import { IWorkspacePersistence } from './workspacePersistence';

// Legacy v1 session index, read only for the one-shot rebuild. Empty scope
// resolves to `<homeDir>/<key>` (join skips empty segments).
const SESSION_INDEX_SCOPE = '';
const SESSION_INDEX_KEY = 'session_index.jsonl';

const textDecoder = new TextDecoder();

interface SessionIndexLine {
readonly sessionId: string;
readonly sessionDir: string;
readonly workDir: string;
}

export class WorkspaceRegistryService implements IWorkspaceRegistry {
declare readonly _serviceBrand: undefined;

Expand Down Expand Up @@ -146,13 +140,13 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {

private async rebuildFromSessionIndex(): Promise<Map<string, Workspace>> {
const result = new Map<string, Workspace>();
const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY);
const bytes = await this.storage.read(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY);
if (bytes === undefined) return result;
const now = Date.now();
for (const line of textDecoder.decode(bytes).split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed === '') continue;
const entry = parseSessionIndexLine(trimmed);
const entry = parseLegacySessionIndexLine(trimmed);
if (entry === undefined) continue;
if (!isAbsolute(entry.workDir)) continue;
const id = encodeWorkDirKey(entry.workDir);
Expand All @@ -178,28 +172,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
}
}

function parseSessionIndexLine(line: string): SessionIndexLine | undefined {
try {
const parsed = JSON.parse(line) as unknown;
if (typeof parsed !== 'object' || parsed === null) return undefined;
const entry = parsed as Partial<SessionIndexLine>;
if (
typeof entry.sessionId !== 'string' ||
typeof entry.sessionDir !== 'string' ||
typeof entry.workDir !== 'string'
) {
return undefined;
}
return {
sessionId: entry.sessionId,
sessionDir: entry.sessionDir,
workDir: entry.workDir,
};
} catch {
return undefined;
}
}

/**
* Collapse registered workspaces that share a `root`. The persisted catalog
* (v1-compatible `workspaces.json`) can hold legacy entries whose id was
Expand Down
Loading
Loading