feat(agent): agent.spawn{await:true} suspends too; children wake the parent via fact
agent.await and agent.spawn{await:true} now return immediately when
children are still running: the session closes, runAgent registers a
wake signal in agent_run.await_wake and waits with waitForFact without
holding an io/agent worker slot. A child that settles (terminal or
waiting) signals the parent's registered fact, so the parent resumes at
once instead of polling; the suspended call replays and the child
report becomes the tool result. agent.spawn is registered replay-safe;
a suspended spawn is recorded as failed in the ledger so the replay
re-runs it against the same child.
Migration 0037 also adds the admin_recovery_code, admin_passkey and
webauthn_challenge tables used by the account work.
E11 asserts the parent is suspended with no run held by any worker
while it waits, and covers spawn{await:true} end to end.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f9b30b7e72
commit
5c6f483420
8 files changed
+382
-28
No files matched your search
@@ -87,6 +87,39 @@ const abortParentScript: FakeScript = {
|
||||
finalText: "不应走到这里",
|
||||
};
|
||||
|
||||
const SPAWN_AWAIT_GOAL = "派生并等待:检查《沙丘》原声版本";
|
||||
const SPAWN_AWAIT_CHILD = "子目标:检查《沙丘》原声版本";
|
||||
const spawnAwaitScript: FakeScript = {
|
||||
name: "e11-spawn-await",
|
||||
match: SPAWN_AWAIT_GOAL,
|
||||
steps: [
|
||||
{
|
||||
toolCalls: [
|
||||
{
|
||||
capability: "agent.spawn",
|
||||
input: {
|
||||
goal: SPAWN_AWAIT_CHILD,
|
||||
capabilityAllowlist: READONLY,
|
||||
mode: "plan_only",
|
||||
await: true,
|
||||
timeoutSec: 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
finalText: (results) => {
|
||||
const r = lastOk(results, "agent.spawn")?.result;
|
||||
return `子会话 ${r?.status ?? "无结果"}:${r?.summary ?? ""}`;
|
||||
},
|
||||
};
|
||||
const spawnAwaitChild: FakeScript = {
|
||||
name: "e11-spawn-await-child",
|
||||
match: SPAWN_AWAIT_CHILD,
|
||||
steps: [{ toolCalls: [{ capability: "inventory.query", input: { query: "沙丘", limit: 1 } }] }],
|
||||
finalText: "《沙丘》原声版本已在库",
|
||||
};
|
||||
|
||||
const slowChild: FakeScript = {
|
||||
name: "e11-slow",
|
||||
match: /^子目标:慢任务/,
|
||||
@@ -98,7 +131,16 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
let e: E2E;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e11", { fakeScripts: [parentScript, duneScript, oppieScript, abortParentScript, slowChild] });
|
||||
e = await createE2E("e11", { fakeScripts: [
|
||||
parentScript,
|
||||
duneScript,
|
||||
oppieScript,
|
||||
abortParentScript,
|
||||
slowChild,
|
||||
spawnAwaitScript,
|
||||
spawnAwaitChild,
|
||||
],
|
||||
});
|
||||
await e.settings({ pi: { subagents: { enabled: true, maxConcurrent: 1, maxDepth: 1 } } });
|
||||
}, 120_000);
|
||||
|
||||
@@ -106,6 +148,31 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
await e?.close();
|
||||
}, 60_000);
|
||||
|
||||
/** 父会话的引擎 run(task.engine_run_id) */
|
||||
const engineRunOf = async (runId: string) => {
|
||||
const t = await e.db
|
||||
.selectFrom("task")
|
||||
.select("engine_run_id")
|
||||
.where("subject_type", "=", "agent_run")
|
||||
.where("subject_id", "=", runId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return t.engine_run_id as string;
|
||||
};
|
||||
/** 等到:父会话已挂起(登记了唤醒信号)且没有任何 run 被 worker 持有(io / agent 槽位全部空闲) */
|
||||
const suspendedAndIdle = (parentId: string) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
const p = await runRow(e, parentId);
|
||||
if (!p.await_wake) return null;
|
||||
const held = (await e.booted.ctx.engine.list({ status: "running", limit: 200 })).filter(
|
||||
(r) => r.status === "running",
|
||||
);
|
||||
return held.length === 0 ? { wake: p.await_wake as Any } : null;
|
||||
},
|
||||
30_000,
|
||||
`父会话 ${parentId} 挂起且 worker 槽位空闲`,
|
||||
);
|
||||
|
||||
const settle = (runId: string, statuses: string[], ms = 90_000) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
@@ -334,6 +401,10 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
"父会话派生两个子会话并开始等待",
|
||||
);
|
||||
expect(kids.every((k) => k.status === "queued")).toBe(true);
|
||||
// agent.await 挂起:父会话不占 worker(引擎 run 未被持有),子会话在等名额也不占 → io / agent 槽位空闲
|
||||
const idle = await suspendedAndIdle(parentId);
|
||||
expect(idle.wake.engineRunId).toBe(await engineRunOf(parentId));
|
||||
expect((await e.booted.ctx.engine.get(idle.wake.engineRunId))?.status).toBe("waiting");
|
||||
|
||||
const ab = await e.api("POST", `/agent/runs/${parentId}/abort`, { reason: "e11 中止" });
|
||||
expect(ab.status).toBe(200);
|
||||
@@ -349,4 +420,90 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
expect(b.cancel_requested_at).not.toBeNull();
|
||||
await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", blocker.id).execute();
|
||||
}, 120_000);
|
||||
it("agent.spawn{await:true} 挂起:等待期间 io/agent 槽位空闲,子会话落定写 fact 唤醒父会话,子汇报作为工具结果续跑", async () => {
|
||||
await e.worker?.stop();
|
||||
const r = await e.api("POST", "/agent/runs", {
|
||||
goal: SPAWN_AWAIT_GOAL,
|
||||
mode: "plan_only",
|
||||
capabilityAllowlist: [...READONLY, "agent.spawn", "agent.await"],
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
const parentId = r.body.data.id as string;
|
||||
// 占住唯一名额:真实子会话排队,父会话必须挂起等待
|
||||
const blocker = await e.db
|
||||
.insertInto("agent_run")
|
||||
.values({
|
||||
trigger: "subagent",
|
||||
goal: "占名额",
|
||||
status: "running",
|
||||
mode: "plan_only",
|
||||
scope: JSON.stringify({ capabilities: READONLY }),
|
||||
budget: JSON.stringify({}),
|
||||
parent_run_id: parentId,
|
||||
depth: 1,
|
||||
started_at: new Date(),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await e.startWorker();
|
||||
|
||||
const idle = await suspendedAndIdle(parentId);
|
||||
const engineRunId = await engineRunOf(parentId);
|
||||
expect(idle.wake).toEqual({ engineRunId, fact: "subagent-await-1" });
|
||||
expect((await e.booted.ctx.engine.get(engineRunId))?.status).toBe("waiting");
|
||||
const child = await e.db
|
||||
.selectFrom("agent_run")
|
||||
.selectAll()
|
||||
.where("parent_run_id", "=", parentId)
|
||||
.where("id", "<>", blocker.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(child.status).toBe("queued");
|
||||
// 挂起的调用没有把"未结束"的结果交给模型:工具调用只有开始、没有结束
|
||||
const before = await runEvents(e, parentId);
|
||||
expect(before.some((x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "agent.spawn")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 放出名额 → 子会话跑完 → 写 fact 唤醒父会话(不用等兜底轮询)
|
||||
await e.db
|
||||
.updateTable("agent_run")
|
||||
.set({ status: "succeeded", finished_at: new Date() })
|
||||
.where("id", "=", blocker.id)
|
||||
.execute();
|
||||
const doneChild = await settle(child.id, ["succeeded", "failed", "cancelled", "budget_exceeded"]);
|
||||
expect(doneChild.status).toBe("succeeded");
|
||||
const parent = await settle(parentId, ["succeeded", "failed", "cancelled", "budget_exceeded"], 30_000);
|
||||
expect(parent.status).toBe("succeeded");
|
||||
expect(parent.await_wake).toBeNull();
|
||||
const fact = await e.db
|
||||
.selectFrom("tasks_fact")
|
||||
.select(["name", "payload"])
|
||||
.where("run_id", "=", engineRunId)
|
||||
.where("name", "=", "subagent-await-1")
|
||||
.execute();
|
||||
expect(fact.map((f) => (f.payload as Any).childRunId)).toContain(child.id);
|
||||
|
||||
const events = await runEvents(e, parentId);
|
||||
const spawnEnd = events.find(
|
||||
(x) => x.type === "tool_call_end" && (x.payload as Any).capabilityId === "agent.spawn",
|
||||
)!;
|
||||
expect(spawnEnd).toBeTruthy();
|
||||
expect((spawnEnd.payload as Any).isError).toBe(false);
|
||||
expect((spawnEnd.payload as Any).recovery).toBe(true);
|
||||
expect(events.filter((x) => x.type === "run_started").map((x) => (x.payload as Any).resumed)).toEqual([
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
const done = events.find((x) => x.type === "run_completed")!;
|
||||
expect((done.payload as Any).finalText).toContain("子会话 succeeded");
|
||||
expect((done.payload as Any).finalText).toContain("《沙丘》原声版本已在库");
|
||||
// 只派生了一个子会话(重放命中同一个 dedupe_key)
|
||||
const kids = await e.db
|
||||
.selectFrom("agent_run")
|
||||
.select("id")
|
||||
.where("parent_run_id", "=", parentId)
|
||||
.where("id", "<>", blocker.id)
|
||||
.execute();
|
||||
expect(kids).toHaveLength(1);
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in new issue
Block a user