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:
rootandClaude Opus 5.5 committed 2026-09-28 20:21:52 +08:00
1 parent f9b30b7e72
commit 5c6f483420
8 files changed
+382 -28

No files matched your search

+13 -6
View File
@@ -17,14 +17,17 @@ import {
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { finalize } from "./service.ts";
import { awaitReady, catchUpChildren, claimSubagentSlot } from "./subagents.ts";
import { awaitReady, catchUpChildren, claimSubagentSlot, setAwaitWake } from "./subagents.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
const id = z.string().uuid();
/** agent.await 挂起时的唤醒轮询间隔与单个会话最多挂起次数 */
const AWAIT_POLL_SEC = 2;
/**
* agent.await / agent.spawn{await:true} 挂起时的兜底轮询间隔(正常由子会话落定时的 wakeParent 信号立即唤醒;
* 信号在等待开始前送达而丢失时靠它兜底)与单个会话最多挂起次数
*/
const AWAIT_POLL_SEC = 10;
const MAX_AWAIT_SUSPENDS = 100;
const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const;
@@ -77,8 +80,8 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
res = { status: "failed", reason: "error", budget: null, message: "等待子会话名额超时", finalText: "" };
else {
res = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal));
// agent.await 挂起:会话已关闭,waitForFact 等子会话落定 / 超时 / 取消(不占 worker、不占模型),
// 再重开会话 resume(挂起的 agent.await 重放一次拿到结果)
// agent.await / agent.spawn{await:true} 挂起:会话已关闭;登记唤醒信号后 waitForFact 等子会话落定 / 超时 / 取消
// (不占 worker 槽位、不调用模型),子会话落定时写 fact 唤醒;再重开会话 resume(挂起的调用重放一次拿到结果)
const resumedAwaits: string[] = [];
for (let n = 1; res.status === "suspended" && res.suspension; n++) {
const sus = res.suspension;
@@ -87,11 +90,15 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
break;
}
const waitMs = Math.max(0, Date.parse(sus.deadline) - Date.now());
await t.step.waitForFact<true>(`subagent-await-${n}`, {
const fact = `subagent-await-${n}`;
const engineRunId = t.runId;
await t.step.run(`${fact}-arm`, () => setAwaitWake(e.ctx, runId, { engineRunId, fact }));
await t.step.waitForFact<true>(fact, {
check: () => awaitReady(e.ctx, runId, sus),
timeout: `${AWAIT_POLL_SEC}s`,
maxRounds: Math.max(1, Math.ceil(waitMs / 1000 / AWAIT_POLL_SEC) + 1),
});
await t.step.run(`${fact}-disarm`, () => setAwaitWake(e.ctx, runId, null));
resumedAwaits.push(sus.invocationId);
const resumed = [...resumedAwaits];
res = await t.step.run(`drive-${n}`, () =>