diff --git a/packages/coding-agent/.changes/legacy-rlm-parser.md b/packages/coding-agent/.changes/legacy-rlm-parser.md new file mode 100644 index 000000000..15cf1b793 --- /dev/null +++ b/packages/coding-agent/.changes/legacy-rlm-parser.md @@ -0,0 +1 @@ +- Fixed passive RLM child metadata recovery from legacy registries without a session directory. diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 66bfe736f..e5f4b5e51 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -9,7 +9,7 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { createConnection, createServer, type Server, type Socket } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { type Api, getLogger, type Model } from "@earendil-works/pi-ai"; @@ -193,9 +193,11 @@ import { import { MutationDrainLatch } from "./mutation-drain-latch.js"; import { createRlmLedgerRegistrySeedSource, + type LegacyRlmSubagentRegistryEntry, type RlmLedgerDeleteReason, type RlmLedgerEdge, RlmSpawnLedger, + readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -403,17 +405,6 @@ interface PassiveRlmSubagentEntry { createdAt: number; } -/** - * Legacy per-parent `rlm-subagents.jsonl` entry shape, exactly as the daemon - * wrote it before the spawn ledger became topology authority. Read-only: - * registries are consumed only as the ledger seed source and as fallback - * hydration metadata for pre-ledger children without a display file. - */ -interface LegacyRlmSubagentRegistryEntry extends PassiveRlmSubagentEntry { - type: "rlm_subagent"; - updatedAt: string; -} - /** Spread-ready optional metadata fields shared by display files and legacy registry entries. */ function rlmSubagentMetadataFields(source: { rlmMaxDepth?: number; @@ -957,50 +948,14 @@ export class AgentDaemon { return join(getSessionArtifactPathForFile(parentSessionFile, parentSessionId), RLM_SUBAGENT_REGISTRY_FILE); } - private async readLegacyRlmSubagentRegistry( + private readLegacyRlmSubagentRegistry( path: string, throwOnReadError = false, ): Promise { - let lines: string[]; - try { - lines = (await readFile(path, "utf8")).split(/\r?\n/); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - this.log(`failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`); - if (throwOnReadError) { - throw error; - } - } - return []; - } - const latest = new Map(); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const entry = JSON.parse(trimmed) as Partial; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionDir !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) || - (entry.rlmMaxDepth !== undefined && (!Number.isSafeInteger(entry.rlmMaxDepth) || entry.rlmMaxDepth < 0)) - ) { - continue; - } - latest.set(entry.childId, entry as LegacyRlmSubagentRegistryEntry); - } catch (error) { - this.log( - `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistryFile(path, { + throwOnReadError, + log: (message) => this.log(message), + }); } /** diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 0ab9ee9e2..d25767847 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -103,22 +103,76 @@ export interface RlmLedgerSeedRegistryEntry { status: "running" | "completed" | "deleted"; } +export interface LegacyRlmSubagentRegistryEntry extends RlmLedgerSeedRegistryEntry { + type: "rlm_subagent"; + sessionDir: string; + parentSessionId: string; + parentSessionFile?: string; + rlmMaxDepth?: number; + rlmParentNodeId?: string; + prompt?: string; + spawnCode?: string; + model?: { provider: string; modelId: string }; + createdAt: number; + updatedAt: string; +} + export interface RlmLedgerSeedSource { - /** - * Tolerant last-writer-wins registry read for a parent session file, using - * the daemon's existing registry conventions. Must never throw for a - * missing registry; other failures may throw (seeding degrades to empty). - */ readRegistryForSessionFile(sessionFile: string): Promise; } -/** - * Default seed source: derive the per-parent registry path from the session - * file's header id (a bounded first-line read, no full transcript parse) and - * read it with the same tolerant last-writer-wins semantics the daemon's - * passive-hydration reader uses (malformed lines ignored, unknown fields - * accepted, absent depths allowed). - */ +export async function readLegacyRlmSubagentRegistry( + path: string, + options: { throwOnReadError?: boolean; log?: (message: string) => void } = {}, +): Promise { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + options.log?.( + `failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`, + ); + if (options.throwOnReadError) throw error; + } + return []; + } + const latest = new Map(); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed) as Partial; + if ( + entry.type !== "rlm_subagent" || + typeof entry.childId !== "string" || + typeof entry.sessionName !== "string" || + typeof entry.sessionFile !== "string" || + (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || + (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) + ) { + continue; + } + latest.set(entry.childId, { + ...entry, + sessionDir: typeof entry.sessionDir === "string" ? entry.sessionDir : dirname(entry.sessionFile), + // rlmMaxDepth is optional hydration metadata the ledger seeder never + // reads; a damaged value must not discard the child's topology edge, + // so it is dropped instead of rejecting the whole entry. + rlmMaxDepth: + entry.rlmMaxDepth !== undefined && Number.isSafeInteger(entry.rlmMaxDepth) && entry.rlmMaxDepth >= 0 + ? entry.rlmMaxDepth + : undefined, + } as LegacyRlmSubagentRegistryEntry); + } catch (error) { + options.log?.( + `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return [...latest.values()]; +} + export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return { readRegistryForSessionFile: async (sessionFile) => { @@ -133,49 +187,9 @@ export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return []; } if (!headerId) return []; - const registryPath = join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"); - let contents: string; - try { - contents = await readFile(registryPath, "utf8"); - } catch { - return []; - } - const latest = new Map(); - for (const line of contents.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed) as { - type?: unknown; - childId?: unknown; - sessionName?: unknown; - sessionFile?: unknown; - rlmDepth?: unknown; - status?: unknown; - }; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && - (!Number.isSafeInteger(entry.rlmDepth) || (entry.rlmDepth as number) < 0)) - ) { - continue; - } - latest.set(entry.childId, { - childId: entry.childId, - sessionName: entry.sessionName, - sessionFile: entry.sessionFile, - ...(typeof entry.rlmDepth === "number" ? { rlmDepth: entry.rlmDepth } : {}), - status: entry.status, - }); - } catch { - // Malformed registry history is ignored, matching the daemon reader. - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistry( + join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"), + ); }, }; } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 7e9373cb0..7ae54b7cc 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -908,18 +908,19 @@ describe("daemon mode helpers", () => { const registryPath = join(fixture.parentArtifactDir, "rlm-subagents.jsonl"); const entry = JSON.parse(readFileSync(registryPath, "utf8")) as Record; entry.status = "running"; + delete entry.sessionDir; writeFileSync(registryPath, `${JSON.stringify(entry)}\n`); const internals = fixture.daemon as unknown as { createRuntime(command: Extract): Promise; - listPassiveRlmSubagents(): Promise>; + listPassiveRlmSubagents(): Promise>; createAgentMessageController( getCurrentState: () => ActiveSessionState | undefined, ): AgentSessionMessageController; }; const parentState = await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); - expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry.childId)).toContain( - fixture.childId, + expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry)).toContainEqual( + expect.objectContaining({ childId: fixture.childId, status: "running" }), ); await expect(internals.createAgentMessageController(() => parentState).roster?.()).resolves.toMatchObject({ entries: [expect.objectContaining({ relationship: "child", name: "renamed-worker" })], diff --git a/packages/coding-agent/test/rlm-ledger.test.ts b/packages/coding-agent/test/rlm-ledger.test.ts index 18af973e1..9932bfbfd 100644 --- a/packages/coding-agent/test/rlm-ledger.test.ts +++ b/packages/coding-agent/test/rlm-ledger.test.ts @@ -45,6 +45,7 @@ import { RLM_LEDGER_MAX_RECORDS, type RlmLedgerDeleteReason, RlmSpawnLedger, + readLegacyRlmSubagentRegistry, rlmLedgerPath, } from "../src/modes/daemon/rlm-ledger.js"; @@ -781,6 +782,40 @@ describe("rlm spawn ledger daemon wiring", () => { } }); + it("keeps a registry entry whose optional rlmMaxDepth is damaged, dropping only the field", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-damaged-maxdepth-")); + try { + const registryPath = join(tempDir, "rlm-subagents.jsonl"); + const entry = (childId: string, overrides: Record) => ({ + type: "rlm_subagent", + childId, + sessionName: `${childId}-worker`, + sessionDir: join(tempDir, childId), + sessionFile: join(tempDir, childId, "session.jsonl"), + status: "completed", + rlmDepth: 1, + rlmMaxDepth: 4, + createdAt: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }); + writeFileSync( + registryPath, + `${[ + JSON.stringify(entry("sub-11111111", {})), + JSON.stringify(entry("sub-22222222", { rlmMaxDepth: "corrupt" })), + ].join("\n")}\n`, + ); + + const entries = await readLegacyRlmSubagentRegistry(registryPath); + expect(entries.map((item) => item.childId).sort()).toEqual(["sub-11111111", "sub-22222222"]); + expect(entries.find((item) => item.childId === "sub-11111111")?.rlmMaxDepth).toBe(4); + expect(entries.find((item) => item.childId === "sub-22222222")?.rlmMaxDepth).toBeUndefined(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("leaves no ledger file behind an interrupted seed and re-seeds completely", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-seed-crash-")); try {