From 41dfc2cd9e51212459f76204645d7e994a753f98 Mon Sep 17 00:00:00 2001 From: Md Tahsin Rahman Date: Fri, 14 Aug 2026 01:34:34 +0800 Subject: [PATCH] fix(hub): skip mid-spawn stubs in persisted scan SessionManager.open writes title+session before createAgentSession claims the id. Agent Hub parked that stub, so the spawn CAS failed with "already owned by another session generation" and the row could not be revived (no session_init). --- packages/coding-agent/CHANGELOG.md | 4 + .../src/registry/persisted-agents.ts | 14 +++ .../test/agent-hub-activate.test.ts | 28 ++++-- .../registry/persisted-mid-spawn-stub.test.ts | 85 +++++++++++++++++++ .../test/task/executor-soft-budget.test.ts | 32 ++++++- 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 14334fddd..10a28bbcf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Agent Hub parking a mid-spawn child JSONL (title + session header only) so `task` then fails with `already owned by another session generation` and the row cannot be revived. + ## [17.3.0] - 2026-08-13 ### Breaking Changes diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 7f6043e3e..e8f591a9e 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -25,6 +25,8 @@ interface PersistedAgentMetadata { createdAt?: number; lastActivity?: number; history?: AgentHistorySummary; + /** True when the file is only a SessionManager header (no session_init, no messages). */ + incomplete?: boolean; } interface PersistedTranscript { @@ -244,6 +246,8 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise Promise) { const agents = new AgentRegistry(); agents.register({ @@ -123,7 +138,7 @@ describe("Agent hub Enter activation", () => { const sessionFile = path.join(tempDir.path(), "main.jsonl"); const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write(workerSessionFile, persistedChildJsonl("worker")); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ settings: Settings.isolated(), @@ -172,8 +187,8 @@ describe("Agent hub Enter activation", () => { const parentSessionFile = path.join(tempDir.path(), "main", "Parent.jsonl"); const childSessionFile = path.join(tempDir.path(), "main", "Parent", "Child.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(parentSessionFile, ""); - await Bun.write(childSessionFile, ""); + await Bun.write(parentSessionFile, persistedChildJsonl("parent")); + await Bun.write(childSessionFile, persistedChildJsonl("child")); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ settings: Settings.isolated(), @@ -391,9 +406,10 @@ describe("Agent hub Enter activation", () => { if (!sourceSessionFile) throw new Error("Expected source session file"); const sourceArtifacts = sourceSessionFile.slice(0, -6); await fs.mkdir(sourceArtifacts, { recursive: true }); - for (const id of ["ActiveVibe", "KilledVibe", "OrdinaryTask"]) { + for (const id of ["ActiveVibe", "KilledVibe"]) { await fs.writeFile(path.join(sourceArtifacts, `${id}.jsonl`), "persisted child"); } + await fs.writeFile(path.join(sourceArtifacts, "OrdinaryTask.jsonl"), persistedChildJsonl("OrdinaryTask")); const fork = await manager.fork(); if (!fork) throw new Error("Expected persisted fork"); await fs.cp(sourceArtifacts, fork.newSessionFile.slice(0, -6), { recursive: true }); @@ -579,7 +595,7 @@ describe("Agent hub double-← gating", () => { const sessionFile = path.join(tempDir.path(), "main.jsonl"); const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write(workerSessionFile, persistedChildJsonl("worker")); const agents = new AgentRegistry(); const { controller, shown, shownReady } = setup(agents, sessionFile); @@ -595,7 +611,7 @@ describe("Agent hub double-← gating", () => { using tempDir = TempDir.createSync("@omp-agent-hub-explicit-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), persistedChildJsonl("worker")); const agents = new AgentRegistry(); const { controller, shown, overlayOptions } = setup(agents, sessionFile); diff --git a/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts b/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts new file mode 100644 index 000000000..051f0b249 --- /dev/null +++ b/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { registerPersistedSubagents } from "@oh-my-pi/pi-coding-agent/registry/persisted-agents"; +import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +function sessionHeader(id: string): string { + return JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id, + timestamp: "2026-08-13T17:14:48.125Z", + cwd: "/tmp", + }); +} + +async function registerFrom(dir: string): Promise { + const registry = new AgentRegistry(); + await registerPersistedSubagents(registry, path.join(dir, "main.jsonl")); + return registry; +} + +describe("registerPersistedSubagents mid-spawn stubs", () => { + it("does not park a child that only has the SessionManager header", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-stub-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Adversary.jsonl"), + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-13T17:14:48.125Z", pad: " " })}\n${sessionHeader("adversary")}\n`, + ); + + const registry = await registerFrom(dir); + expect(registry.get("Adversary")).toBeUndefined(); + }); + + it("still parks a finished child that recorded session_init", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-init-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Worker.jsonl"), + [ + sessionHeader("worker"), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + systemPrompt: "review", + task: "review the diff", + tools: ["read"], + agent: "adversarial-reviewer", + }), + ].join("\n") + "\n", + ); + + const registry = await registerFrom(dir); + expect(registry.get("Worker")?.status).toBe("parked"); + expect(registry.get("Worker")?.sessionFile).toBe(path.join(dir, "main", "Worker.jsonl")); + }); + + it("still parks a legacy child that has messages but no session_init", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-legacy-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Legacy.jsonl"), + [ + sessionHeader("legacy"), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + message: { role: "user", content: "hello", timestamp: 1 }, + }), + ].join("\n") + "\n", + ); + + const registry = await registerFrom(dir); + expect(registry.get("Legacy")?.status).toBe("parked"); + }); +}); diff --git a/packages/coding-agent/test/task/executor-soft-budget.test.ts b/packages/coding-agent/test/task/executor-soft-budget.test.ts index d0c778899..7a2214823 100644 --- a/packages/coding-agent/test/task/executor-soft-budget.test.ts +++ b/packages/coding-agent/test/task/executor-soft-budget.test.ts @@ -352,7 +352,21 @@ describe("runSubprocess soft request budget", () => { const rootSessionFile = `${tempDir.path()}/main.jsonl`; const workerSessionFile = `${tempDir.path()}/main/${id}.jsonl`; await Bun.write(rootSessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-13T17:14:48.000Z", cwd: "/tmp" }), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:48.000Z", + systemPrompt: "system", + task: "work", + tools: ["read"], + }), + ].join("\n"), + ); const controller = new AbortController(); // abort #1 = budget soft-stop (abortSent still false); abort #2 = // budget hard-abort's abortActiveSession (abortReason already "budget"). @@ -394,7 +408,21 @@ describe("runSubprocess soft request budget", () => { const rootSessionFile = `${tempDir.path()}/main.jsonl`; const workerSessionFile = `${tempDir.path()}/main/${id}.jsonl`; await Bun.write(rootSessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-13T17:14:48.000Z", cwd: "/tmp" }), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:48.000Z", + systemPrompt: "system", + task: "work", + tools: ["read"], + }), + ].join("\n"), + ); const promptStarted = Promise.withResolvers(); const promptStopped = Promise.withResolvers(); const handle = createMockSession(