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

@@ -0,0 +1,63 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// C1-B Agent 与账户遗留收尾:
// - agent_run.await_wake:父会话挂起等待子会话(agent.await / agent.spawn{await:true})时登记的唤醒信号
// {engineRunId, fact};子会话落定时按它写 fact + 发引擎信号,父会话恢复后清空。
// - admin_recovery_code:TOTP 备用恢复码(一次性,argon2id 哈希存库,used_at 非空即已用)。
// - admin_passkey:WebAuthn Passkey(COSE 公钥、签名计数器、传输方式、名称),可多把。
// - webauthn_challenge:注册 / 登录挑战(一次性、5 分钟有效;条件更新消费,防重放)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table agent_run add column await_wake jsonb;
create table admin_recovery_code (
id uuid primary key default gen_random_uuid(),
admin_user_id uuid not null references admin_user (id) on delete cascade,
code_hash text not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create index admin_recovery_code_unused_idx on admin_recovery_code (admin_user_id) where used_at is null;
${entityTable(
"admin_passkey",
`
admin_user_id uuid not null references admin_user (id) on delete cascade,
credential_id text not null,
public_key bytea not null,
counter bigint not null default 0 check (counter >= 0),
transports jsonb not null default '[]'::jsonb,
device_type text,
backed_up boolean not null default false,
name text not null,
last_used_at timestamptz,
constraint admin_passkey_credential_key unique (credential_id)`,
)}
create table webauthn_challenge (
id uuid primary key default gen_random_uuid(),
purpose text not null ${oneOf("purpose", ["register", "login"])},
challenge text not null,
admin_user_id uuid references admin_user (id) on delete cascade,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create index webauthn_challenge_expires_idx on webauthn_challenge (expires_at);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`${dropTables(["webauthn_challenge", "admin_passkey", "admin_recovery_code"])}
alter table agent_run drop column if exists await_wake;`,
);
}