diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e5d19e25a..222d24370 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -69,6 +69,9 @@ - Fixed omp plugin install failing with cloning errors for legacy Pi extensions whose tool schemas use legacy-typebox builders. - Fixed omp update aborting with chmod ENOENT when concurrent update runs overlapped by using unique download temporary paths. - Fixed the browser tool executable probe launching the user's installed GUI Chromium on Windows: the `--version` version probe from ecb22957 was Linux-scoped but ran for every platform candidate, so on Windows it could hand off to a running `chrome.exe`, open a normal browser window, then reject the candidate and fall back to cached Chrome for Testing. The probe is now confined to Linux ([#8445](https://github.com/can1357/oh-my-pi/issues/8445)). +### 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 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(), @@ -393,9 +408,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 }); @@ -581,7 +597,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); @@ -597,7 +613,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(