/** * 件3d `/decide` 消费 parked 后台子代——server 半场辅件(设计稿:sema-internal * server/docs/DECIDE-PARKED-DESIGN.md;黑板 [ref];core 裁定 [ref]/[ref])。 * * 本模块先落判别器:`POST /v1/approvals/:sessionId/decide` 路由在既有 session→token 映射拿到 * pending checkpoint 后,用它判定该 cp 是否属于一个 parked 后台子代——命中则走 claim → * 裸 `ToolSpec.execute`(ctx.reviveClaim)赎回腿,不中则零回归走既有任务级 resumeCheckpoint 腿。 * 判别必须 parked-first:legacy 腿的 `resumeStream` 会绕开 bg registry(无 consumeParkedFlip、 * 行永 parked、生命周期分叉)。 */ import type { HostDecision } from "./host-decision.js"; import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, ResumeOutcome, TaskRegistry, ToolSpec, WorkflowRun, WorkflowRunStore } from "@sema-agent/core"; import { type WorkflowRunParkKey, type WorkflowAgentSessionIndex } from "./orchestration/workflow-agent-session-index.js"; export interface ParkedAgentMatch { handle: string; row: BackgroundAgentRecord; } /** * S-185 车CM —— **park 的出身判别式**(全仓唯一的那一个)。 * * 一条 pending checkpoint 落到 `/decide` 时,它可能来自三种出身:①一只 parked 的**后台委派子代** * ([ref] 的第一条腿)、②一只 parked 的 **workflow 子代**(core 7.10.0 [ref] 开的第二条车道)、③一条 * 普通的**任务级** suspend(legacy 腿)。三个消费点 —— decide 路由、SLA deny-sweep 的静默跳过臂、 * 将来的读面 —— 此前只认得出 ①,于是 ② 一律落到 ③ 上拿一个**响亮但错因**的 409 * `conflict.resume_context_unavailable`(getCtx(子会话) 恒 null),而 sweep 那条静默跳过臂对它整个失效。 * * 修法**不是**再写一条 if 链,而是把「出身」抽成这一个判别式:它读两个源(bg 行店 / workflow 索引), * 三个消费点都读它。加第三条车道时只改这里一处 —— 这条纪律就是本函数存在的全部理由。 * * 两条臂共享同一条硬判据:**赎回键逐字相等**(bg 行的 `parkedCheckpointToken` / wa* 行的同名位 ⟺ * `cp.token`)。行 REOPENED 后在**新** token 下 re-park,旧 cp 的 decide 不得匹配到它;判别不出一律 * 诚实 miss(落 legacy 腿,它对已消失/不属于它的 pending cp 有自己的 404/409 处置)——「宁可不命中, * 绝不错配」与收敛器那条铁则同源。 * * 顺序 = bg 先、workflow 后:bg 臂是既有行为,零回归优先;两臂的键域(bg 行的 sessionId / wa* 行的 * sessionId)结构上互斥(一个子会话不会同时是两种子代),所以顺序只是确定性,不是优先级语义。 */ export type ParkOrigin = { kind: "background"; handle: string; row: BackgroundAgentRecord; } /** `park` 是**权威条目**(从 run 记录的真源 `parks[]` 里按赎回键复核出来的那一条);身份位一律从它读 * —— 索引行是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */ | { kind: "workflow"; runId: string; run: WorkflowRun; park: WorkflowRunParkKey; } /** * S-252 —— **这条记录的 park 真源在本车道读不出来**(第四种出身,不是第四条车道)。 * * 索引行还在、run 行也还在,但记录上没有一份读得出来的 `parks[]`:旧引擎(core < 7.17.0)写下的存量行 * 根本没有这一位,或者某一条条目的形坏了。而这一位是**引擎自己**的 park 责任表,也是本车道把调用方的 * checkpoint join 到某一条 park 的**单一真源** ⇒ 这条决定**结构上**送不出去:答 200「已交给宿主的 * 下一跑」就是一张兑付不了的回执。 * 此前这一格与「索引落后一拍 / 这条 run 已不再负责它」两种**正常** miss 完全同形,于是落 legacy 腿拿 * 一个错因 409。判别式据真源的**可读性**把这一格分出来,三个消费点各自响亮处置(decide 拒 + 不驱动 * 宿主续跑;SLA sweep 照旧静默跳过——它同样复核不了)。 * * 🔴 **7.78.0 / core 7.18.0([ref]):本臂的「为什么拒」没变,「拒了之后干什么」变了。** * 旧注(与旧拒文)断言 core 的 resume 准入对 `!Array.isArray(prior.parks)` 一律整条拒,并据此告诉运维 * 「这条 run 恢复不了,开一条新的」。core 7.18.0 把那条准入拆成**三臂**([ref]「走 a″」),该断言 * 自此为假:①记录带 `parks` ⇒ 那张表独自作数(不变);②**`parks` 缺席 ⇒ 候选从 journal 的 `parked` 臂 * 与记录自己 `parked` 腿行的 `parkedCheckpointToken` 取并集派生**,非空即准入(每个派生候选仍逐个 * `checkpointStore.get(token)` 验真);③缺席且并集为空 ⇒ 仍拒(`parks_unreadable`,条件收窄到这一格)。 * ⇒ 存量记录**多数可以被 resume 救回来**,而旧拒文让人去开新 run = 把一条救得回来的 run 劝弃。 * 本车道**仍然拒**(core 逐字:那种记录的赎回键是在准入期派生的、**从记录上读不出来**;本腿要么拒、 * 要么就得在服务端重算 core 的三臂 —— 后者正是这次统一掉的双读),但恢复动作改成真话:**去 resume * 这条 run**。准入一旦走完,core 会把 `parks` 写上记录([ref]),下一次 `/decide` 就走正常的 workflow 臂。 */ | { kind: "workflow_parks_unreadable"; runId: string; run: WorkflowRun; }; /** 判别式的两个源。各自可缺席(部署没接就是没接)—— 缺席 ⇒ 那条臂不判,不是失败。 */ export interface ParkOriginSources { agentStore?: BackgroundAgentStore; /** S-185:workflow 出身的**有界** join 索引(契约见 orchestration/workflow-agent-session-index.ts)。 */ workflowIndex?: WorkflowAgentSessionIndex; /** 索引只回答「去哪条 run 找」,条目本体(含赎回键)仍以 run 记录的 `parks[]` 为准 ⇒ 两件必须成对在场。 */ workflowRunStore?: Pick; warn?: (event: string, fields: Record) => void; } export declare function findParkOrigin(sources: ParkOriginSources, cp: { token: string; scope: string; sessionId: string; }): Promise; /** * pending checkpoint → parked bg 行匹配。 * * 键链(core 1.390 事实):park 时 cp 落 `sourceTaskId: sessionId`(子会话 id,**不是** bg handle), * 而 bg 行同样存该子会话 id(`row.sessionId`)与绑定 token(`row.parkedCheckpointToken`)。 * `listByScope` 只回 summary(含 handle/sessionId、不含 token),故两步:summary.sessionId 初筛 → * `get(handle, scope)` 全行复核 token。 * * token 复核是硬门,不是优化:行 REOPENED 后可在**新** token 下 re-park,旧 cp 的 decide 不得 * 匹配到它(后续 `claimParkedAgent` 的 CAS/binding_broken 是兜底,不是第一道门)。同理,list 与 * get 之间行被 stop/decide 抢走(离开 parked)时诚实 miss——调用方拿 undefined 走 legacy 腿, * 该腿对已消失的 pending cp 有自己的 404/409 处置。 */ export declare function findParkedAgentForCheckpoint(agentStore: BackgroundAgentStore, cp: { token: string; scope: string; sessionId: string; }): Promise; export interface ParkedDecideDeps { agentStore: BackgroundAgentStore; checkpointStore: Pick; /** 与 reaper/scenarios 同一个 registry(claim/rollback 的 CAS 与 in-memory handle 收敛在同处)。 */ registry: Pick; /** boot 期从生产装配(buildScenarios)取的裸 Agent ToolSpec——与 spawn 用同一装配路径, * subRunner/bg opts/checkpointStore 同实例天然成立([ref] 设计稿 §4)。 */ reviveTool: ToolSpec; /** revive args 的 `subagent_type` 成员性判定集([ref] Q1 修正:非 roster 成员必须省略,否则 * unknown subagent_type 拒;字面比对 general-purpose 在 roster 撞名时会错省)。缺省空集=恒省略。 */ knownAgentTypes?: ReadonlySet; /** [ref]/[ref] 跨进程父约束重供席:按部署配置**重建的同语义**父策略链工厂(host 任务的 toolPolicy * 解析槽一条,单层链;exempt 探针锚= row.rootSessionId,与 remember grant 锚同键)。core 1.396 起 * `parkedResume.inheritedGate` 席位把它透传进 resume 的官方重供通道——不供 = 重启后带 * requiresParentConstraint 的 checkpoint 恒被 pre-CAS 门拒(retryable 假话,永不可赎回)。链长形状 * 校验在 core(fail-closed,供错链长照拒)。 * * ⚠️ **async**([ref]):重建条目的第三位决议链元数据 `autoModeArmed` 与 `durableMandate` 的 * `forceDurableGate` 项都要按**本行的 principal** 现解 entitlement(`RunnerDeps.runtimeCapsResolver` * 本身就是 sync-or-async 的座),理由全在 `boot/parked-revive-gate.ts`。本腿一直就在 async 里, * 「席位必须同步」是那个文件此前自设的约束、不是结构事实。 */ rebuildInheritedGate?: (row: BackgroundAgentRecord, identity: { principal: string | undefined; }) => Promise; warn?: (event: string, fields: Record) => void; } export interface ParkedDecideRequest { token: string; scope: string; sessionId: string; /** cp.pendingAction 原样(outcome 铸造的 persisted 回落源,与 legacy 腿 D-1 姿势逐字同形)。 */ pendingAction: unknown; decision: "approve" | "deny"; /** [ref] 件6③ / S-136(core 7.6.0 S6-A):**这次结算的事实**,由调用方陈述(必填,理由与 legacy 腿 * `resumeCheckpoint` 的同名参数逐字同源 —— 本函数分辨不出谁在叫它,漏报要是编译错而不是假出处)。 * 🔴 宿主报**事实**(谁结束的 + 通道自报的归属),core 铸**词**:`human_allowed` / `human_refused` / * `park_sla_expired` 由 core 在 resume 入口铸出来,写进行的 `resolvedOutcome.gateOutcome` 与那次调用的 * `tool_end.gate`。server 一个结算词都不再拼(core 对宿主自铸的词是 pre-CAS 响亮拒)。 * 今天唯一的调用方是 `server.ts` 的人为 `/decide`(那道门写着 `req !== undefined`,而内部 D-D SLA * deny-sweep 恰是 req 缺席的那条腿,它对 parked 行另有静默跳过臂)⇒ 恒 `decidedBy:"person"`。 */ hostDecision: HostDecision; reason?: string; /** D-1 decision-binding echo(操作员在 GET 看到的 boundCallId/boundInputHash 原样透传;server * 绝不重算,core resume 侧做 fail-closed 等值校验——与 legacy 腿同姿势)。 */ binding?: { boundCallId?: string; boundInputHash?: string; updatedInput?: unknown; }; /** body 带了 `remember:"session"` 时路由传入的 grant 执行器([ref] 候裁② 的 server 侧修,经两轮 * 行为探针实证):豁免必须锚在**行的 root/host 会话 id**——部署侧三个豁免探针(policy 层/fs-write * 门/workflow 门)的闭包统一捕获 host 会话键且签名不携身份(1.393 seam 亲读:`sourceTaskId` 只在 * onAsk 层的 AskRequest,durable 车道无 onAsk),grant 落子代会话则键永不相交(G-F4 原案);落 * root 会话则宿主键闭包立即命中,复活子代的后续同工具 ask 被短路(探针 v2 实证),语义与同步车道/ * 宿主任务的「本会话不再询问」一致(= host 会话树全域)。行无 root 锚(病态形)⇒ claim 前诚实 400。 */ grantRemember?: (rootSessionId: string) => Promise; /** [ref](core 5.7.0,F6 翻案):AskUserQuestion 的 approve 携操作员答案——原样透传进 * `ResumeOutcome.answer`(形状校验在路由层 isQuestionAnswer;header 逐字匹配等语义校验在 core, * server 不预判)。core 拒绝形:同步腿 409 invalid_outcome field:"answer",parked 腿经 drive 失败 * 面 422 parked_resume.startup_failed。 */ answer?: QuestionAnswer; /** claim CAS 赢了(≈ legacy 腿的 markResuming 赢)之后、execute 之前触发——remember 豁免 grant * 的挂点(调用方自吞错,绝不抛落赎回)。claim 败/判别 miss 恒不触发。 */ onDecideCommitted?: () => Promise; } export interface ParkedDecideResult { status: number; body: Record; } /** * 两条 park 车道(bg 赎回 / workflow 驱动)共用的 **`ResumeOutcome` 铸点**(S-185 车CM 单点化)。 * * 判据一字未改,只是从 bg 腿里提出来:两条腿结算的是**同一种门**(core gateMatch 的 `policy_ask`), * 各铸一份必然漂移,而漂的是「人到底批了什么」这条审计事实。 * * · D-1 绑定:操作员在 GET 看到的 `boundCallId`/`boundInputHash` 原样透传,server **绝不重算**; * 缺席时回落到行上 persisted 的那两位(core resume 侧做 fail-closed 等值校验)。 * · `reason` 走**存在性**判定(`!== undefined`),不是真值判定 —— 本铸点不折叠,原样上送。 * 🔴 但**别把这条读成「空串与缺席在结算上是两件事」**(7.19.0 / [ref] 之前那句话是真的,之后不是): * 上送域的属主是引擎的单铸点,它把空文本折成缺席 ⇒ 到了消费端,`reason:""`、不传 `reason`、 * 与一条裸拒**同形**。本腿仍不在这里折,理由是「源头修复禁下游旁路」(下游再折一次就是同一语义的 * 第二个写者),不是因为两者可分辨。真正按「空串 ≠ 缺席」办事的只有两个**别的**域:验签载荷 * (approval-hmac.ts 的 `env.reason ?? null` —— 线上原值,折一刀就恒 401)与幂等回放的**身份**判定 * (`decisionReasonOf`,属主是本仓)。三域各有属主,逐域的射程写在 `decisionReasonOf` 的顶注里。 * · `hostDecision` = 结算**事实**逐字上 outcome;`gateOutcome` 一格 server **永不**填(core 对宿主 * 自铸的结算词是 pre-CAS 响亮拒)。 * · `answer` 原样透传([ref]:header 逐字匹配等语义校验全在 core,server 不预判)。 */ export declare function buildParkedResumeOutcome(req: ParkedDecideRequest): ResumeOutcome; /** * S-185 车CM —— **第三条车道的计划**(纯函数;驱动本身归 resume 家族的那条腿,见下方「为什么不自建驱动」)。 * * ## 这条车道为什么长这样(亲读 core 实现后的结论,不是从类型面推的) * 任务书原案是「server 直接 `startWorkflow({resumeFromRunId, parkedResume})`」。亲读安装的 core * `dist/orchestration/run-workflow-tool.js` 与 `dist/core/runner/prepare-caps-and-workflow.js` 证伪了它: * · `startWorkflow(runner, fn, opts)` 的 `fn` 是**脚本函数**,由 `scriptRunner.run({scriptSource, primitives, * scriptArgs})` 现编 —— server 手上只有持久化的 `WorkflowRun` 行,没有脚本源,也没有那一整套按 host * 任务折出来的 governance/model/limits;自己拼一份 = 在 workflow 启动语义上多一个写者(本仓宪法明令禁止)。 * · core 给部署方留的**唯一**通道是 `RunInternals.workflowParkedResume`(d.ts 逐字:「A host that decided a * workflow child's parked checkpoint **launches the run that re-invokes** `Workflow({resumeFromRunId})` * with the decision here」),由 `prepare-caps-and-workflow` 折进 Workflow 工具的 `parkedResume` dep, * 再进 `RunWorkflowOptions.parkedResume`。core 自己给模型的 park 回执也是这句:「once the approval is * decided, the deployment resumes this run (resumeFromRunId: …)」。 * ⇒ 第三条车道 = **把决定挂在宿主会话的下一跑上**,而那一跑必须走 resume 家族**既有**的那条腿 * (`notify-wake.ts` 的成文纪律:wake 会烧模型,所以复用 `ctx.legs.resumeWake`,「而不是在本域自建第二条 * 驱动路径」)。本函数因此只出**计划**:宿主会话 + 要注入的消息 + 要随 internals 走的那一条决定。 * * ## 诚实边界(登记,不假装没有) * · **模型中介**:决定送达宿主之后,真正的赎回发生在宿主再调一次 `Workflow({resumeFromRunId})` 时。 * core 的 park 回执逐字告诉模型这件事,但它不是一条确定性的机器腿。宿主没照做 ⇒ 卡仍 pending、 * 人可再决(**不会**静默吃掉批准)。 * · **`inheritedGate` 不供**:那是「跨进程重供 checkpoint 的父约束」的席位,而 wa* 行不带父约束链; * 同进程时 core 的内存 registry 自动重供,跨进程且该 cp 带 `requiresParentConstraint` 时 core 响亮拒 * (`resume.parent_constraint_missing`)⇒ 该 ordinal 再 park 一次,不会 fail-open。 */ /** * 🔴 拒臂就是**一条 `ParkedDecideResult`**(7.69.0 合并重扫 c2/c7)。此前它把 `status`/`body` 平铺在 * 判别对象上(`{ok:false, status, body}`),于是本文件里同时存在两种「HTTP 错误结果」的写法,而 * `test/error-code-catalog-live.test.ts` 的目录抽取器只认这一族既有的 `{ status: 4xx, body: {…} }` 形 * (它的臂③ 顶注逐字点名 parked-decide)——两条新码因此在 wire 上发得出、附录 A 查不到、机器门还看不见。 * 收法是让**返回形回到同一种**(本文件只留一个 HTTP 结果类型),不是给扫描器加第九条臂: * 后者会让「换个包装就隐形」这条病继续存在,只是多一条特判去追它。 */ export type WorkflowParkedDecidePlan = { ok: true; hostSessionId: string; runId: string; message: string; outcome: ResumeOutcome; } | { ok: false; deny: ParkedDecideResult; }; /** * S-252 —— **park 真源读不出来的那条记录的响亮拒**(与上面那族拒臂同一种返回形)。 * * 为什么是拒而不是「尽力而为地投递一次」:记录上的 `parks[]` 是本车道把调用方的 checkpoint join 到某一条 * park 的**单一真源**。读不出来 ⇒ 这条决定结构上送不出去,答 200「已交给宿主的下一跑」就是一张兑付不了的 * 回执:卡还 pending、人以为决完了。⇒ 拒,并把真话说出来;卡仍 pending,什么都没有被伪造。 * * 🔴 **7.78.0 / core 7.18.0([ref]):真话变了一句,拒没变。** 亲读安装树 7.18.0 的三臂准入(判别式顶注逐字 * 记着):`parks` 缺席时 core **从 journal 的 `parked` 臂 + 记录自己 `parked` 腿行的 `parkedCheckpointToken` * 派生候选**并逐个向 checkpoint 店验真,非空即准入 —— 所以「这条 run 恢复不了」自 7.18.0 起对**多数**存量 * 记录是假的,照旧文答就是劝人弃掉一条救得回来的 run。本腿仍拒的理由是另一条(core 逐字):那种记录的赎回键 * 是**在准入期派生的、从记录上读不出来**,服务端要么拒、要么重算 core 的三臂(被这次统一掉的双读)。 * ⇒ 恢复动作 = **去 resume 这条 run**(`Workflow({resumeFromRunId})`):准入走完 core 会把 `parks` 写上 * 记录([ref]),下一次 `/decide` 走正常 workflow 臂。若引擎那一步也拒(journal 与腿行都给不出候选, * core 的第三臂),那才是真的恢复不了。 * * 这条臂的受害集 = **存量持久数据**(core < 7.17.0 的引擎写下、升级后还 parked 着的 run 记录)。硬 breaking * 三句里持久数据那一句要的就是这个形:响亮拒,不做静默双读(读 `agents[]` 补一份身份正是被退役的双算)。 * * 体上只带 `runId`(那是操作员唯一用得上的坐标;真源都读不出来,任何计数都是编的)。 * * 🔴 **S-376(词域门,7.80.1):拒文逐字改,status / errorCode / 体键零变。** 旧句把本注里的内部机件整段 * 搬上了 wire(「the parks the run owns」「when it parks」「join your checkpoint to a park」「its own parked * legs」)—— 那些名字调用方一个都拿不到。新句把承重的四件事一件不少地留着(不投递 / 卡仍 pending 什么都没 * 伪造 / 恢复动作 `Workflow({resumeFromRunId})` / 只有引擎也拒才是真恢复不了)。 * ⚠️ **新句刻意不提 `checkpointToken`**(codex 对抗复审 r1 指出、亲核属实):契约 D-1 逐字写着它是 * server-INTERNAL、不上 `/v1/approvals` 记录、合规 SDK 绝不发送 —— 让调用方去找一个它既没发也拿不到的 * 字段是错指路。新句说的是「无法确认这项待决审批属于这条 run」。判据与冻结钉见 * `test/api-error-text-lexicon.test.ts`。 */ export declare function denyWorkflowParksUnreadable(origin: Extract): ParkedDecideResult; export declare function planWorkflowParkedDecide(origin: Extract, req: ParkedDecideRequest): WorkflowParkedDecidePlan; /** * `/decide` parked 赎回腿([ref] 件3d server 半场;[ref]/[ref]/[ref] core 裁定形)。 * * 调用方先经 {@link findParkOrigin} 判出 `kind:"background"` 再进来(S-185 车CM:判别只做一次); * 返回的结果对象 = 赎回腿已受理/已拒,调用方直接回 HTTP。 * * 链:`claimParkedAgent`(access = 行镜像双轴 * `{scope: row.scope, owner: row.owner}`,[ref] Q3)→ outcome 铸造(D-1 echo/persisted 回落) * → 裸 `reviveTool.execute(args, ctx.reviveClaim)`。execute 一旦发起 drive,rollback 全在 core 内 * (driveParkedResume);server 只在「claim 成功但 drive 从未发起」窗口补 `rollbackParkedClaim` * ([ref] Q2:isError 入参级早退 + throw 两形,已核幂等/双滚=无害 lost)。 * * 200 是**受理**语义(drive 是 core 内 fire-and-forget):失败经行/notification 面 + REOPENED * re-park 诚实可见,操作员可再决。 */ export declare function decideParkedAgent(deps: ParkedDecideDeps, req: ParkedDecideRequest, match: ParkedAgentMatch): Promise; //# sourceMappingURL=parked-decide.d.ts.map