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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Persiste
|
||||
let createdAt: number | undefined;
|
||||
let activity: string | undefined;
|
||||
let history: AgentHistorySummary = {};
|
||||
let hasSessionInit = false;
|
||||
let hasConversation = false;
|
||||
try {
|
||||
await visitEntriesFromFileStream(
|
||||
sessionFile,
|
||||
@@ -264,7 +268,12 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise<Persiste
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (record.type === "message" || record.type === "custom_message") {
|
||||
hasConversation = true;
|
||||
return;
|
||||
}
|
||||
if (record.type !== "session_init") return;
|
||||
hasSessionInit = true;
|
||||
createdAt ??= timestampOf(record.timestamp);
|
||||
if (typeof record.task === "string") activity = summarizePersistedTask(record.task);
|
||||
const inferred = typeof record.systemPrompt === "string" ? inferBundledAgent(record.systemPrompt) : {};
|
||||
@@ -290,6 +299,7 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise<Persiste
|
||||
activity,
|
||||
createdAt: createdAt ?? file?.birthtimeMs,
|
||||
lastActivity: file?.mtimeMs,
|
||||
incomplete: !hasSessionInit && !hasConversation,
|
||||
history: {
|
||||
...history,
|
||||
...(hasOutput ? { outputPath } : {}),
|
||||
@@ -425,6 +435,10 @@ async function registerPersistedSubagentsFromDir(
|
||||
if (!registry.get(id)) {
|
||||
const metadata = await readPersistedAgentMetadata(sessionFile);
|
||||
if (!shouldContinue()) return;
|
||||
// SessionManager.open writes title+session before createAgentSession
|
||||
// claims the id. Parking that stub makes the spawn's expectedAgentRef:null
|
||||
// CAS fail with "already owned by another session generation".
|
||||
if (metadata.incomplete && !tombstoned) continue;
|
||||
registry.register({
|
||||
id,
|
||||
displayName: id,
|
||||
|
||||
@@ -23,6 +23,21 @@ import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
const AGENT_ID = "Worker";
|
||||
const TEST_CWD = path.resolve("agent-hub-cwd");
|
||||
|
||||
function persistedChildJsonl(id: string): string {
|
||||
return [
|
||||
JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-07-30T01:13:37.835Z", cwd: TEST_CWD }),
|
||||
JSON.stringify({
|
||||
type: "session_init",
|
||||
id: "init",
|
||||
parentId: null,
|
||||
timestamp: "2026-07-30T01:13:37.835Z",
|
||||
systemPrompt: "system",
|
||||
task: "work",
|
||||
tools: ["read"],
|
||||
}),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function makeHub(focusAgent: (id: string) => Promise<void>) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<AgentRegistry> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<void>();
|
||||
const promptStopped = Promise.withResolvers<void>();
|
||||
const handle = createMockSession(
|
||||
|
||||
Reference in New Issue
Block a user