From fa7e8a425fb534ef9eb6cd7e870018897f912b47 Mon Sep 17 00:00:00 2001 From: kunkun9527 Date: Sat, 24 Oct 2026 14:10:24 +0200 Subject: [PATCH 1/2] fix(pi): restore subagent cards owned by the parent's session file --- docs/harnesses/pi/pi-subagents.md | 2 +- packages/adapters/pi/src/pi-adapter.ts | 7 +++- .../adapters/pi/src/pi-subagent-history.ts | 15 ++++++--- .../pi/test/pi-subagent-history.test.ts | 32 ++++++++++++++----- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/harnesses/pi/pi-subagents.md b/docs/harnesses/pi/pi-subagents.md index 9b306c048..7fcb3d441 100644 --- a/docs/harnesses/pi/pi-subagents.md +++ b/docs/harnesses/pi/pi-subagents.md @@ -29,7 +29,7 @@ Pi 的 Fork 只使用精确可表达的位置:下一轮是用户消息时切 ## 历史和限制 -- 父历史中的成功异步启动结果指向插件公开的 `status.json`。恢复时仅接受 lifecycle artifact v3,要求 run ID 和父 Session ID 精确匹配;每次最多读取最近 128 个 run,每文件最多 1 MiB,不跟随状态文件本身的符号链接。 +- 父历史中的成功异步启动结果指向插件公开的 `status.json`。恢复时仅接受 lifecycle artifact v3,要求 run ID 精确匹配,且 artifact 的 `sessionId` 精确等于父 Session 的 Session ID 或会话文件路径(插件在 Session 有会话文件时记录文件路径);每次最多读取最近 128 个 run,每文件最多 1 MiB,不跟随状态文件本身的符号链接。 - 状态快照可能截断。缺失的 run/child 不等于结束、停止或删除;已观察到的状态保留。磁盘历史恢复目前投影直接 steps;嵌套单任务依赖实时状态快照。嵌套 workflow 的内部 steps 暂不生成链接,避免将其 ID 错配到外层 run。 - 任务工件被清理、格式不支持或归属不匹配时,不伪造历史卡片。已持久化子 Thread 的读取仍交给原生检查协议,可能返回 `stale` / `not_found`。 - 实时发现依赖插件发出异步状态 widget。关闭该 widget 时,不承诺实时卡片;公开状态工件仍可用于父历史恢复。 diff --git a/packages/adapters/pi/src/pi-adapter.ts b/packages/adapters/pi/src/pi-adapter.ts index d7f466568..6df58120e 100644 --- a/packages/adapters/pi/src/pi-adapter.ts +++ b/packages/adapters/pi/src/pi-adapter.ts @@ -555,7 +555,12 @@ class PiHarnessSession implements HarnessSession { try { const transport = await this.#ensureTransport(); const history = await transport.getEntries(); - await restorePiSubagents(history, transport.state.sessionId, this.#subagents); + const { sessionId, sessionFile } = transport.state; + await restorePiSubagents( + history, + new Set(sessionFile ? [sessionId, sessionFile] : [sessionId]), + this.#subagents, + ); return { ok: true, value: { diff --git a/packages/adapters/pi/src/pi-subagent-history.ts b/packages/adapters/pi/src/pi-subagent-history.ts index 33b8c4f61..6f7acf97d 100644 --- a/packages/adapters/pi/src/pi-subagent-history.ts +++ b/packages/adapters/pi/src/pi-subagent-history.ts @@ -13,17 +13,22 @@ function state(value: unknown): unknown { return value === "pending" ? "queued" : value === "completed" ? "complete" : value; } -/** Project only documented lifecycle v3 artifacts; do not import the plugin's private runtime. */ +/** + * Project only documented lifecycle v3 artifacts; do not import the plugin's private runtime. + * pi-subagents records the parent's session file as `sessionId` when the Session has one and its + * Session ID otherwise, so `owners` holds both identities of the parent Session. + */ export function piSubagentArtifact( value: unknown, runId: string, - parentSessionId: string, + owners: ReadonlySet, ): PiSubagentNode[] | null { if ( !record(value) || value.lifecycleArtifactVersion !== 3 || value.runId !== runId || - value.sessionId !== parentSessionId || + typeof value.sessionId !== "string" || + !owners.has(value.sessionId) || !["single", "parallel", "chain", "workflow"].includes(String(value.mode)) ) return null; @@ -66,7 +71,7 @@ export function piSubagentArtifact( /** Restored cards use parent-owned native receipts, never arbitrary filesystem paths supplied by the UI. */ export async function restorePiSubagents( history: PiSessionHistory, - parentSessionId: string, + owners: ReadonlySet, observer: PiSubagents, ): Promise { const receipts = new Map(); @@ -104,7 +109,7 @@ export async function restorePiSubagents( const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); if (bytesRead > 1024 * 1024) continue; const value: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")); - const runs = piSubagentArtifact(value, id, parentSessionId); + const runs = piSubagentArtifact(value, id, owners); if (runs) observer.restore(runs); } catch { // Artifact retention and status publication races are not parent Session failures. diff --git a/packages/adapters/pi/test/pi-subagent-history.test.ts b/packages/adapters/pi/test/pi-subagent-history.test.ts index 0095f2b62..7eb3a825e 100644 --- a/packages/adapters/pi/test/pi-subagent-history.test.ts +++ b/packages/adapters/pi/test/pi-subagent-history.test.ts @@ -18,19 +18,35 @@ const status = { describe("Pi subagent history restoration", () => { it("accepts only known artifact versions owned by the exact parent Session", () => { - expect(piSubagentArtifact(status, "run-1", "parent")?.[0]?.children).toEqual([ + const parent = new Set(["parent"]); + expect(piSubagentArtifact(status, "run-1", parent)?.[0]?.children).toEqual([ { id: "review", kind: "step", label: "reviewer", state: "complete" }, ]); - expect(piSubagentArtifact(status, "run-1", "foreign")).toBeNull(); - expect(piSubagentArtifact(status, "wrong-run", "parent")).toBeNull(); + expect(piSubagentArtifact(status, "run-1", new Set(["foreign"]))).toBeNull(); + expect(piSubagentArtifact(status, "wrong-run", parent)).toBeNull(); expect( - piSubagentArtifact({ ...status, lifecycleArtifactVersion: 4 }, "run-1", "parent"), + piSubagentArtifact({ ...status, lifecycleArtifactVersion: 4 }, "run-1", parent), ).toBeNull(); expect( piSubagentArtifact( { ...status, steps: [{ ...status.steps[0], status: "future" }] }, "run-1", - "parent", + parent, + ), + ).toBeNull(); + expect(piSubagentArtifact({ ...status, sessionId: undefined }, "run-1", parent)).toBeNull(); + }); + + it("accepts the parent's session file, which pi-subagents records as the owner", () => { + const sessionFile = path.join(os.tmpdir(), "parent.jsonl"); + const owned = { ...status, sessionId: sessionFile }; + expect(piSubagentArtifact(owned, "run-1", new Set(["parent", sessionFile]))).not.toBeNull(); + expect(piSubagentArtifact(owned, "run-1", new Set(["parent"]))).toBeNull(); + expect( + piSubagentArtifact( + owned, + "run-1", + new Set(["parent", path.join(os.tmpdir(), "other.jsonl")]), ), ).toBeNull(); }); @@ -53,9 +69,9 @@ describe("Pi subagent history restoration", () => { }; const emit = vi.fn(); const observer = new PiSubagents(emit); - await restorePiSubagents(history, "foreign", observer); + await restorePiSubagents(history, new Set(["foreign"]), observer); expect(observer.project("subagent", result, hostItemIdSchema.parse("item"))).toBeNull(); - await restorePiSubagents(history, "parent", observer); + await restorePiSubagents(history, new Set(["parent"]), observer); expect(observer.project("subagent", result, hostItemIdSchema.parse("item"))).toMatchObject({ subagents: [ { @@ -67,7 +83,7 @@ describe("Pi subagent history restoration", () => { expect(emit).not.toHaveBeenCalled(); await rm(path.join(asyncDir, "status.json")); await expect( - restorePiSubagents(history, "parent", new PiSubagents(emit)), + restorePiSubagents(history, new Set(["parent"]), new PiSubagents(emit)), ).resolves.toBeUndefined(); } finally { await rm(dir, { recursive: true, force: true }); From cb90080b879c49bfc47a0c01d45b54406d1ee6ec Mon Sep 17 00:00:00 2001 From: kunkun9527 Date: Sat, 24 Oct 2026 14:35:29 +0200 Subject: [PATCH 2/2] test(pi): cover session-file owners through readSnapshot --- packages/adapters/pi/test/pi-adapter.test.ts | 81 +++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/adapters/pi/test/pi-adapter.test.ts b/packages/adapters/pi/test/pi-adapter.test.ts index d41100a9e..2375daca6 100644 --- a/packages/adapters/pi/test/pi-adapter.test.ts +++ b/packages/adapters/pi/test/pi-adapter.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -601,6 +601,85 @@ describe("Pi HarnessAdapter Session", () => { await adapter.close(); }); + it("restores child cards whose status artifact records the parent's session file", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "pi-adapter-subagent-")); + const { adapter, transports } = fixture(); + try { + const asyncDir = path.join(dir, "run-1"); + await mkdir(asyncDir); + await writeFile( + path.join(asyncDir, "status.json"), + JSON.stringify({ + lifecycleArtifactVersion: 3, + runId: "run-1", + sessionId: "/synthetic/pi-session.jsonl", + mode: "single", + state: "complete", + steps: [{ agent: "reviewer", status: "completed" }], + }), + ); + const session = await openSession(adapter); + await session.readSnapshot(); + const transport = transports[0]; + if (!transport) throw new Error("Missing transport"); + transport.history = { + leafId: "result", + entries: [ + { + id: "user", + parentId: null, + type: "message", + message: { role: "user", content: [{ type: "text", text: "review" }] }, + }, + { + id: "assistant", + parentId: "user", + type: "message", + message: { + role: "assistant", + stopReason: "toolUse", + content: [ + { + type: "toolCall", + id: "call", + name: "subagent", + arguments: { agent: "reviewer", task: "review", async: true }, + }, + ], + }, + }, + { + id: "result", + parentId: "assistant", + type: "message", + message: { + role: "toolResult", + toolCallId: "call", + toolName: "subagent", + isError: false, + content: [{ type: "text", text: "launched" }], + details: { mode: "single", asyncId: "run-1", asyncDir }, + }, + }, + ], + }; + const snapshot = await session.readSnapshot(); + expect(snapshot).toMatchObject({ ok: true }); + if (!snapshot.ok) throw new Error(snapshot.error.message); + expect(snapshot.value.turns.flatMap((turn) => turn.items)).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "subagentDelegation", + subagents: [expect.objectContaining({ status: "completed" })], + }), + }), + ); + } finally { + await adapter.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + it("reads a live parent's child without opening another Pi process and rejects a different cwd", async () => { const { adapter, transports } = fixture(); const session = await openSession(adapter);