Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/harnesses/pi/pi-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 时,不承诺实时卡片;公开状态工件仍可用于父历史恢复。
Expand Down
7 changes: 6 additions & 1 deletion packages/adapters/pi/src/pi-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
15 changes: 10 additions & 5 deletions packages/adapters/pi/src/pi-subagent-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
): 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;
Expand Down Expand Up @@ -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<string>,
observer: PiSubagents,
): Promise<void> {
const receipts = new Map<string, string>();
Expand Down Expand Up @@ -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.
Expand Down
81 changes: 80 additions & 1 deletion packages/adapters/pi/test/pi-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand Down
32 changes: 24 additions & 8 deletions packages/adapters/pi/test/pi-subagent-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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: [
{
Expand All @@ -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 });
Expand Down
Loading