@sema-agent/server 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +26 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +174 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +39 -4
  14. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  15. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  16. package/dist/boot/execution-env.js +11 -1
  17. package/dist/boot/lexical-path-env.d.ts +10 -0
  18. package/dist/boot/lexical-path-env.js +88 -0
  19. package/dist/boot/reapers.d.ts +34 -0
  20. package/dist/boot/reapers.js +198 -23
  21. package/dist/boot/resolve-spec.js +97 -33
  22. package/dist/capabilities/center-prompts.js +4 -1
  23. package/dist/capabilities/oa-tools.d.ts +15 -0
  24. package/dist/capabilities/oa-tools.js +54 -0
  25. package/dist/config-types.d.ts +68 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.js +138 -2
  28. package/dist/elicitation.d.ts +4 -0
  29. package/dist/elicitation.js +7 -3
  30. package/dist/finance/cost-taxonomy.d.ts +34 -0
  31. package/dist/finance/cost-taxonomy.js +26 -0
  32. package/dist/hooks/hook-runner.js +32 -0
  33. package/dist/http/routes/capabilities.js +14 -0
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +140 -0
  36. package/dist/http/routes/runs.d.ts +1 -0
  37. package/dist/http/routes/runs.js +548 -16
  38. package/dist/http/routes/tasks.js +175 -12
  39. package/dist/http/server.d.ts +6 -1
  40. package/dist/http/server.js +120 -4
  41. package/dist/http/sse-log.d.ts +51 -0
  42. package/dist/http/sse-log.js +64 -0
  43. package/dist/http/wire-types.d.ts +20 -5
  44. package/dist/main.js +35 -4
  45. package/dist/observability/fail-open.d.ts +98 -0
  46. package/dist/observability/fail-open.js +216 -0
  47. package/dist/observability/prompt-manifest.d.ts +13 -0
  48. package/dist/observability/prompt-manifest.js +8 -0
  49. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  50. package/dist/plugins/approval-ask-store-memory.js +299 -0
  51. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  52. package/dist/plugins/approval-ask-store-sql.js +705 -0
  53. package/dist/plugins/approval-store-sql.d.ts +116 -0
  54. package/dist/plugins/approval-store-sql.js +151 -0
  55. package/dist/plugins/background-agent-store-sql.js +20 -1
  56. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  57. package/dist/plugins/checkpoint-store-sql.js +297 -16
  58. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  59. package/dist/plugins/file-workflow-journal-store.js +12 -0
  60. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  61. package/dist/plugins/local-checkpoint-store.js +4 -0
  62. package/dist/plugins/pg-approval-store.d.ts +9 -0
  63. package/dist/plugins/pg-approval-store.js +9 -0
  64. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  65. package/dist/plugins/pg-breaker-state.js +8 -0
  66. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  67. package/dist/plugins/pg-checkpoint-store.js +10 -0
  68. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  69. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  70. package/dist/plugins/pg-image-bake.d.ts +12 -0
  71. package/dist/plugins/pg-image-bake.js +11 -0
  72. package/dist/plugins/pg-image-index.d.ts +12 -0
  73. package/dist/plugins/pg-image-index.js +11 -0
  74. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  75. package/dist/plugins/pg-outcome-ledger.js +11 -0
  76. package/dist/plugins/pg-pool.js +11 -0
  77. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  78. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  79. package/dist/plugins/pg-run-store.d.ts +9 -0
  80. package/dist/plugins/pg-run-store.js +9 -0
  81. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  82. package/dist/plugins/pg-session-policy-store.js +7 -0
  83. package/dist/plugins/pg-session-store.d.ts +12 -0
  84. package/dist/plugins/pg-session-store.js +12 -0
  85. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  86. package/dist/plugins/pg-tool-result-store.js +9 -0
  87. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  88. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  89. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  90. package/dist/plugins/pg-workflow-run-store.js +9 -0
  91. package/dist/plugins/store-backend.d.ts +18 -0
  92. package/dist/plugins/store-backend.js +10 -0
  93. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  94. package/dist/plugins/tidb-approval-store.js +8 -0
  95. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  96. package/dist/plugins/tidb-breaker-state.js +7 -0
  97. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  98. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  99. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  100. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  101. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  102. package/dist/plugins/tidb-image-bake.js +11 -0
  103. package/dist/plugins/tidb-image-index.d.ts +12 -0
  104. package/dist/plugins/tidb-image-index.js +11 -0
  105. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  106. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  107. package/dist/plugins/tidb-pool.js +27 -4
  108. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  109. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  110. package/dist/plugins/tidb-run-store.d.ts +10 -0
  111. package/dist/plugins/tidb-run-store.js +9 -0
  112. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  113. package/dist/plugins/tidb-session-policy-store.js +7 -0
  114. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  115. package/dist/plugins/tidb-tool-result-store.js +10 -0
  116. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  117. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  118. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  119. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  120. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  121. package/dist/plugins/workflow-journal-limits.js +12 -0
  122. package/dist/question.d.ts +21 -14
  123. package/dist/question.js +83 -34
  124. package/dist/runs.d.ts +16 -1
  125. package/dist/runs.js +61 -3
  126. package/dist/runtime-caps-resolver.d.ts +7 -1
  127. package/dist/runtime-caps-resolver.js +65 -3
  128. package/dist/sema-registry.d.ts +41 -0
  129. package/dist/sema-registry.js +40 -0
  130. package/dist/spec-fields.d.ts +4 -0
  131. package/dist/spec-fields.js +6 -0
  132. package/dist/task-settings.d.ts +36 -15
  133. package/dist/task-settings.js +19 -5
  134. package/dist/tool-approval.d.ts +296 -3
  135. package/dist/tool-approval.js +1074 -50
  136. package/dist/trace/core-keyset-guard.d.ts +2 -2
  137. package/dist/trace/ledger-sink.js +14 -1
  138. package/dist/trace/project.d.ts +90 -0
  139. package/dist/trace/project.js +188 -0
  140. package/package.json +5 -4
@@ -1,4 +1,6 @@
1
+ import { z } from "zod";
1
2
  import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
3
+ import { redactSecrets } from "../../trace/redact.js";
2
4
  import { HttpError, verifiedPrincipal, isUuidV7, encodeCheckpointScope } from "../../security.js";
3
5
  import { runInBackground } from "../../runs.js";
4
6
  import { redactSteerIn, STEER_IN_MAX_CHARS, STEER_IN_MAX_REQUEST_CHARS } from "../../orchestration/workflow-agent-steer.js";
@@ -6,8 +8,11 @@ import { defaultSubagentTailBus } from "../../fleet/subagent-tail-bus.js";
6
8
  import { fleetRunPublisher, fleetRunLabels } from "../../fleet/fleet-bus.js";
7
9
  import { composeSupervisorCost, infraCost, infraUsageFromEvents, hasInfraPricing } from "../../observability/cost-taxonomy.js";
8
10
  import { cascadeConfig, runMeta } from "../run-meta.js";
11
+ import { createHash } from "node:crypto";
9
12
  import { scopedIdempotencyKey } from "../idempotency.js";
10
13
  import { streamSseLog } from "../sse-log.js";
14
+ import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
15
+ import { resolveStreamApprovalGate } from "../../tool-approval.js";
11
16
  import { normalizeRunEventType } from "../../trace/project.js";
12
17
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
13
18
  import { buildActiveRunConflict } from "../active-run-conflict.js";
@@ -36,6 +41,68 @@ export const ELICIT_RESPOND_RE = /^\/v1\/elicitations\/([^/]+)\/respond$/;
36
41
  // §4④: the shell answers the agent's AskUserQuestion here. Same live-only + same-replica shape as elicit.
37
42
  export const QUESTION_RESPOND_RE = /^\/v1\/questions\/([^/]+)\/respond$/;
38
43
  export const TOOL_APPROVAL_RESPOND_RE = /^\/v1\/tool-approvals\/([^/]+)\/respond$/;
44
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
45
+ // #151 车4(design/172 §3.2;稿 = design/151-172-stream-approval/151-car4-design.md,§12 折叠节为准)
46
+ // —— 流内审批卡的 **durable 回决口**。HITL 族第四条,但与上面三条 respond 的辖域正相反:
47
+ // · 三条 respond = LIVE-ONLY + 同副本(解一个内存里的悬挂 promise;跨副本/窗后一律 404);
48
+ // · 本条 = 打 `approval_asks` 行的 **CAS**(任何副本都能受理),本副本活体窗只是「顺带同步终局」。
49
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
50
+ export const TASK_ASK_DECISION_RE = /^\/v1\/tasks\/([^/]+)\/asks\/([^/]+)\/decision$/;
51
+ /** 425 的轮询提示秒数(头与体成对,§12-C F37)。 */
52
+ const ASK_PARKING_RETRY_AFTER_SEC = 2;
53
+ /** `decision_actor` 的展示文本上限(171 ActorAssertion 的 `label` 帽,§2)。 */
54
+ const ASK_ACTOR_LABEL_MAX = 256;
55
+ /**
56
+ * 171 `ActorAssertion` 的 server 侧对齐形(**读**面 schema)。
57
+ *
58
+ * 🔴 属主与依赖(稿 §12-D 的施工偏离,如实记账):§12-D 裁「schema 落 `@sema-agent/registry-core`」,
59
+ * 而该包在本仓的 deps 上钉的是 `^0.16.0`(树上实际解析 0.16.0),`ActorAssertionWire` 要 0.17.1 才有 ——
60
+ * 抬依赖属于 package.json 面,不在本车的施工辖域(并行车共树)。本车按「用本地类型对齐 wire 形」落地:
61
+ * 键名/取值域逐字照 171(`{id, label?, verified?}`),**加** `via`(§12-D F7 的审计缺口补丁)。
62
+ * 依赖抬到 ≥0.17.1 的那一车应把本 schema 换成 registry-core 的同源导出(键集已对齐,零 wire 变更)。
63
+ *
64
+ * 读面刻意宽:行是别的进程/别的版本写下的,`verified`/`via` 缺席不是错误(缺席 = 那一版没记),
65
+ * 形真的不认 ⇒ 调用点省略 `actor` 字段而不是 500(§12-D F12)。
66
+ *
67
+ * 🔴 **不加 `.strict()`**(codex 交叉复审 round2 抓获,真 finding,红先验过):写面的 body schema 该严
68
+ * (未知键 = 调用方写错了,当场告诉他);**读**面严则是自伤 —— 未来某版给 actor 加一个附加字段,这里
69
+ * 一 `.strict()` 就把**整份** actor 判为不认、连 `id`/`verified` 都一起丢,而那正是本 schema 存在的
70
+ * 意义。zod 默认剥未知键:已知字段照常投影,附加字段静默丢弃(响应键集因此仍是封闭的)。
71
+ */
72
+ const AskDecisionActorSchema = z.object({
73
+ id: z.string().min(1).max(ASK_ACTOR_LABEL_MAX),
74
+ label: z.string().max(ASK_ACTOR_LABEL_MAX).optional(),
75
+ verified: z.boolean().optional(),
76
+ via: z.enum(["owner", "operator"]).optional(),
77
+ });
78
+ /**
79
+ * 回决 body(稿 §3 + §12-F)。`.strict()` = 未知键当场 400,不静默吞。
80
+ *
81
+ * 🔴 `actor` 只收 `label`:`id`/`verified` 恒由 server 铸(171 §5.1 —— **actor 永不参与权限判定**,
82
+ * 它是审计投影,不是凭证)。调用方给的 id 若被采信,任何持有本 ask 读权的调用方都能把回决记在别人名下。
83
+ * 🔴 `idempotencyKey` 的形与店门口的 `assertIdempotencyKeyShape` 同规(1–255 且无首尾空白)——
84
+ * 端点先拒 ⇒ 坏形键永远走不到店(店那道门是最后一道,不是唯一一道)。
85
+ */
86
+ const AskDecisionBodySchema = z
87
+ .object({
88
+ decision: z.enum(["approve", "deny"]),
89
+ /** 仅 approve 臂有意义;deny 带 = 宽收后忽略(与三条 respond 同姿势)。 */
90
+ updatedInput: z.unknown().optional(),
91
+ note: z.string().max(2048).optional(),
92
+ idempotencyKey: z
93
+ .string()
94
+ .min(1)
95
+ .max(255)
96
+ .refine((k) => k.trim() === k, { message: "idempotency key must not have leading/trailing whitespace" })
97
+ .optional(),
98
+ actor: z.object({ label: z.string().max(ASK_ACTOR_LABEL_MAX) }).strict().optional(),
99
+ })
100
+ .strict();
101
+ /** 行上的 `decision_actor`(裸 JSON)投成回显形;形不认 ⇒ undefined(调用点省略该键,不 500)。 */
102
+ function projectDecisionActor(raw) {
103
+ const parsed = AskDecisionActorSchema.safeParse(raw);
104
+ return parsed.success ? parsed.data : undefined;
105
+ }
39
106
  // C2 (core 1.219): steer a still-running Task SUB-AGENT of a run. `:target` = the child's
40
107
  // parentToolCallId (spawn-time identity, unique) or its agentName (display name; 409 when ambiguous).
41
108
  export const RUN_SUBAGENT_STEER_RE = /^\/v1\/runs\/([^/]+)\/subagents\/([^/]+)\/steer$/;
@@ -77,8 +144,36 @@ async function subagentReadGate(req, res, deps, runId) {
77
144
  }
78
145
  return { run, trusted };
79
146
  }
80
- async function streamRunEvents(req, res, runStore, taskId, staleMs) {
147
+ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
81
148
  await streamSseLog(req, res, {
149
+ // #151 车3 §5.1:durable tail 腿的开流重放 —— 本 run 的未决审批卡按 taskId 读回并投成
150
+ // `approval_request` preamble 帧(时序=416 判定 + sseHeaders **之后**,见 sse-log.ts 的 hook 顶注)。
151
+ //
152
+ // 三条判据都在这几行里:
153
+ // · **零写**:只 `listPendingByTask`,不 CAS、不改 expires_at_ms(投影见 buildReplayFrame 顶注);
154
+ // · **坏行不炸开流**:形不合的 `card_json` 行跳过 + 一次 warn(不是每行一条,免得一张坏表刷屏);
155
+ // · **读面帽**:超 `replayMax` 只投最新 N + 一次 warn。
156
+ //
157
+ // 🔴 开关谓词是**必须**的(codex 复审第二轮 high,2026-08-06):原按「关的时候表里本就没行 ⇒
158
+ // 无需分支」落地,但这个前提在**回滚**下不成立 —— 开着协议的副本(或更新版本)已经落下的
159
+ // `STREAM_PENDING` 行在关掉开关后**仍在表里**,无谓词的重放腿会把它们照常投上 wire,
160
+ // 「默认关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
161
+ // 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
162
+ preamble: async (signal) => {
163
+ if (!deps.config.streamApproval.enabled)
164
+ return [];
165
+ const store = deps.backend?.approvalAsk();
166
+ if (!store)
167
+ return []; // env-only worker(无 durable backend)= 无 ask 账 ⇒ 无对账基准,诚实缺席
168
+ const rows = await store.listPendingByTask(taskId, signal);
169
+ const { frames, skipped, dropped } = buildApprovalPreamble(rows, Date.now(), deps.config.streamApproval.replayMax);
170
+ if (skipped > 0)
171
+ deps.logger?.warn?.("approval_preamble_row_skipped", { taskId, skipped, face: "run.events" });
172
+ if (dropped > 0)
173
+ deps.logger?.warn?.("approval_preamble_capped", { taskId, dropped, replayMax: deps.config.streamApproval.replayMax, face: "run.events" });
174
+ return buildApprovalPreambleSseFrames(frames);
175
+ },
176
+ onPreambleFailure: (reason, err) => deps.logger?.warn?.("approval_preamble_failed", { taskId, reason, face: "run.events", error: err instanceof Error ? err.message : String(err ?? "") }),
82
177
  statusOf: async (id) => {
83
178
  const run = await runStore.getRun(id);
84
179
  return run ? { status: run.status, updatedAt: run.updatedAt } : undefined;
@@ -240,7 +335,24 @@ async function handleRunsBody(req, res, url, ctx, miss) {
240
335
  deps.subagentSteerRegistry, // C2 (core 1.219): register sync-delegation steer handles under this run's taskId
241
336
  (m, x) => deps.logger?.info?.(m, x), // emit-target diagnostics for the bg leg's inbox drain
242
337
  deps.sendUserFile, // SendUserFile 切片2:file_link 帧走本腿 durable append
243
- deps.promptManifests);
338
+ deps.promptManifests, // [998]②: drained into a durable prompt_assembled event by this leg
339
+ // #151 车3 刀 3b:bg 腿的审批 ALS(此前这条腿无 ctx ⇒ 每只 ask 恒 "unavailable" ⇒ 恒 park)。
340
+ // 协议上场判据在**路由层**求值(单一谓词),执行腿只消费布尔——runs.ts 是纯执行面,不读 backend/config。
341
+ deps.toolApproval
342
+ ? {
343
+ coordinator: deps.toolApproval,
344
+ streamApprovalOn: resolveStreamApprovalGate({
345
+ toolApprovalEnabled: true,
346
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
347
+ backend: deps.backend,
348
+ parkFacility: deps.checkpointStore !== undefined,
349
+ }).active,
350
+ // 两个窗旋钮随传:窗=0 的三源判定要在**本腿开始执行的时刻**做(那是 runInBackground 内部),
351
+ // 路由层只负责把配置递过去 —— runs.ts 是纯执行面,不读 config。
352
+ windowMs: deps.config.streamApproval?.windowMs ?? 0,
353
+ windowMarginMs: deps.config.streamAskWindowMarginMs,
354
+ }
355
+ : undefined);
244
356
  return { status: 202, body: { taskId, sessionId, status: "running" } };
245
357
  }, (r) => r.status === 202); // don't cache a transient 409 (session-active, billed nothing) — let a retry re-run (council)
246
358
  sendJson(res, resp.status, resp.body);
@@ -274,7 +386,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
274
386
  if (!runSessionAcceptOk(req, res, run, runMatch[2] ? "run.events" : "run.poll"))
275
387
  return; // [1499] accept-phase
276
388
  if (runMatch[2]) {
277
- await streamRunEvents(req, res, deps.runStore, taskId, deps.config.runStaleSec * 1000);
389
+ await streamRunEvents(req, res, deps, deps.runStore, taskId, deps.config.runStaleSec * 1000);
278
390
  }
279
391
  else {
280
392
  const stale = run.status === "running" && Date.now() - new Date(run.updatedAt).getTime() > deps.config.runStaleSec * 1000;
@@ -571,14 +683,25 @@ async function handleRunsBody(req, res, url, ctx, miss) {
571
683
  }
572
684
  const text = body.text;
573
685
  const priority = body.priority;
574
- // Mint a server-side messageId so the shell can reference this steer (its drop handle once the core queue seam
575
- // lands). Honest + stable now even while `priority` is a no-op forward-draft.
576
- const messageId = uuidv7();
686
+ // 🔴 core 5.14.0(#147)之后**必须**把调用方的 Idempotency-Key 接到 core `inputId`
687
+ // (codex 复审 2026-08-06 F4,验真后采纳)。队列化前,重发一条 steer last-writer-wins 无害;
688
+ // 队列化后,「202 丢在路上、客户端照常重试」会**再追加一条**同样的操作员指令,而队列只有
689
+ // MAX_PENDING_STEER_ENTRIES(=3)个位子 —— 三次重试就把它填满,之后合法转向一律 409 queue_full。
690
+ // 这条危害是本批的队列改动**引入**的,所以修它属于本批,不是顺手加功能。
691
+ // 形:与 `POST /v1/runs` 同一条 scoped 口径(source+verified principal+原 key),再 sha256 收成定长
692
+ // 不透明串 —— core 对 inputId 只限「非空 ≤128 且非保留词」,而 scoped 原串含 \x1f 分隔符且长度无界。
693
+ // 缺 header ⇒ 完全按旧行为(core 自铸 uuidv7),字节零变化。
694
+ const steerIdemRaw = headerStr(req.headers["idempotency-key"]);
695
+ const steerScoped = steerIdemRaw ? scopedIdempotencyKey(steerIdemRaw, source, principal) : undefined;
696
+ const steerInputId = steerScoped ? `idem-${createHash("sha256").update(steerScoped).digest("hex").slice(0, 32)}` : undefined;
697
+ // messageId:带 key 时**回同一个** id(重试拿到逐字相同的回执,与「这是同一条指令」一致);
698
+ // 不带 key 时保持历史的 uuidv7 现铸(壳的引用句柄,drop 动词候 core seam)。
699
+ const messageId = steerInputId ?? uuidv7();
577
700
  // trusted = explicit operator (NOT bare isOperator — its empty-list "true-for-all" would let any caller send a
578
701
  // system-authority steer). A normal owner's steer reaches the model as a plain user message (trusted:false).
579
702
  const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
580
703
  try {
581
- validatePendingSteer({ text, trusted }); // throws CheckpointError(steering.invalid_content) on break-out / control chars
704
+ validatePendingSteer({ text, trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) }); // throws CheckpointError(steering.invalid_content) on break-out / control chars / bad inputId
582
705
  }
583
706
  catch (e) {
584
707
  if (e instanceof CheckpointError && e.code === "steering.invalid_content") {
@@ -605,6 +728,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
605
728
  // row to "suspended"), and CASes on `status='pending'`, so this lands iff the run is genuinely parked. Read the
606
729
  // checkpoint's ACTUAL scope ("_" for a no-principal run) then its token; a missing scope/token/CAS-loss means
607
730
  // the checkpoint resolved/expired/never-existed → "no-checkpoint". "no-store" = no checkpoint store wired.
731
+ // core 5.14.0(#147)队列化后新出的第四态:park 的队列**满了**。core 的界是 fail-loud 的 ——
732
+ // 越界抛 `steering.queue_full` 而不是驱逐一条已被接受的指令(驱逐 = 把队列本来要消灭的静默丢失
733
+ // 原样请回来)。所以这条必须有自己的 wire 码,不能折进 `no-checkpoint`(那会谎称「已不再挂起」)。
734
+ // 另一个新码 `steering.duplicate_input_id` 自本批起**可达**:调用方带 `Idempotency-Key` 时它被接成
735
+ // core 的 `inputId`(见下),同 key 配不同正文即撞键 ⇒ 409。不带 key 的调用方永远碰不到它。
736
+ let parkQueueFull;
737
+ let parkDuplicateKey;
738
+ /** 每个 durable park 调用点的同一份入参(带上幂等键才让 core 的 inputId 去重真正可达)。 */
739
+ const steerInput = () => ({ text, trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
608
740
  const tryPark = async () => {
609
741
  const cs = deps.checkpointStore;
610
742
  if (!cs)
@@ -615,8 +747,25 @@ async function handleRunsBody(req, res, url, ctx, miss) {
615
747
  const token = await cs.findPendingTokenBySession(run.sessionId, scope);
616
748
  if (!token)
617
749
  return "no-checkpoint";
618
- return (await cs.setPendingSteer(token, scope, { text, trusted })) ? "parked" : "no-checkpoint";
750
+ try {
751
+ return (await cs.setPendingSteer(token, scope, steerInput())) ? "parked" : "no-checkpoint";
752
+ }
753
+ catch (e) {
754
+ if (e instanceof CheckpointError && e.code === "steering.queue_full") {
755
+ parkQueueFull = e.message;
756
+ return "queue-full";
757
+ }
758
+ // 同一个 Idempotency-Key 配**不同**正文 —— core 拒绝(吞掉它就等于把队列要消灭的静默丢失请回来)。
759
+ // 与 `conflict.ask_idempotency` 同族语义:换一个 key,别复用。
760
+ if (e instanceof CheckpointError && e.code === "steering.duplicate_input_id") {
761
+ parkDuplicateKey = e.message;
762
+ return "duplicate-key";
763
+ }
764
+ throw e;
765
+ }
619
766
  };
767
+ const sendQueueFull = () => sendError(res, 409, "steering.queue_full", parkQueueFull ?? "the parked steering queue on this checkpoint is full");
768
+ const sendDuplicateKey = () => sendError(res, 409, "steering.duplicate_input_id", parkDuplicateKey ?? "this Idempotency-Key is already parked with different steering content — reissue with a fresh key");
620
769
  const sendParked = () => sendJson(res, 202, { taskId, status: "suspended", delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
621
770
  const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
622
771
  // (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
@@ -638,10 +787,19 @@ async function handleRunsBody(req, res, url, ctx, miss) {
638
787
  // Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
639
788
  // tick (core already wrote the pending checkpoint) — fall through to the durable park rather than a spurious
640
789
  // 409 (the live→suspended transition window). Only a genuinely-terminal run → not_running.
641
- if (await tryPark() === "parked") {
790
+ const raced = await tryPark();
791
+ if (raced === "parked") {
642
792
  sendParked();
643
793
  return;
644
794
  }
795
+ if (raced === "queue-full") {
796
+ sendQueueFull();
797
+ return;
798
+ }
799
+ if (raced === "duplicate-key") {
800
+ sendDuplicateKey();
801
+ return;
802
+ }
645
803
  sendNotRunning("run just finished — no longer accepting steers");
646
804
  return;
647
805
  }
@@ -657,6 +815,14 @@ async function handleRunsBody(req, res, url, ctx, miss) {
657
815
  sendParked();
658
816
  return;
659
817
  }
818
+ if (outcome === "queue-full") {
819
+ sendQueueFull();
820
+ return;
821
+ }
822
+ if (outcome === "duplicate-key") {
823
+ sendDuplicateKey();
824
+ return;
825
+ }
660
826
  sendNotRunning("run is no longer suspended (resolved or expired)");
661
827
  return;
662
828
  }
@@ -682,13 +848,43 @@ async function handleRunsBody(req, res, url, ctx, miss) {
682
848
  }
683
849
  // per-session 串行(复审 HIGH:并发双终态 steer 的 tryPark→put 窗口双铸两个 pending park)。
684
850
  const prior = wakeParkMints.get(run.sessionId) ?? Promise.resolve();
685
- const mintResult = { parked: false };
851
+ const mintResult = { parked: false, queueFull: false, duplicateKey: false };
852
+ // wake park 腿的 queue_full:新铸的 task_done checkpoint 队列必空,所以只可能来自「锁内重查撞到
853
+ // 既有 pending」或「跨副本赢家」两条既有 park 上——两处都要如实报满,不许折成「不接受转向」。
854
+ const parkOnce = async (t, sc) => {
855
+ try {
856
+ if (await deps.checkpointStore.setPendingSteer(t, sc, steerInput()))
857
+ mintResult.parked = true;
858
+ }
859
+ catch (e) {
860
+ if (e instanceof CheckpointError && e.code === "steering.queue_full") {
861
+ parkQueueFull = e.message;
862
+ mintResult.queueFull = true;
863
+ return;
864
+ }
865
+ if (e instanceof CheckpointError && e.code === "steering.duplicate_input_id") {
866
+ parkDuplicateKey = e.message;
867
+ mintResult.duplicateKey = true;
868
+ return;
869
+ }
870
+ throw e;
871
+ }
872
+ };
686
873
  const job = prior.then(async () => {
687
874
  const cs = deps.checkpointStore;
688
- if (await tryPark() === "parked") {
875
+ const reprobe = await tryPark();
876
+ if (reprobe === "parked") {
689
877
  mintResult.parked = true;
690
878
  return;
691
879
  } // 锁内重查:前一位刚铸完 → 直接 park 上去
880
+ if (reprobe === "queue-full") {
881
+ mintResult.queueFull = true;
882
+ return;
883
+ }
884
+ if (reprobe === "duplicate-key") {
885
+ mintResult.duplicateKey = true;
886
+ return;
887
+ }
692
888
  const leafId = await Promise.resolve(deps.sessionStorage.getLeafId(run.sessionId)).catch(() => undefined);
693
889
  if (leafId === undefined || leafId === null)
694
890
  return;
@@ -711,12 +907,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
711
907
  const winner = await cs.findPendingTokenBySession(run.sessionId).catch(() => null);
712
908
  if (winner !== null && winner !== wakeToken) {
713
909
  await cs.expire(wakeToken, cpScope).catch(() => undefined);
714
- if (await cs.setPendingSteer(winner, cpScope, { text, trusted }))
715
- mintResult.parked = true;
910
+ await parkOnce(winner, cpScope);
716
911
  return;
717
912
  }
718
- if (await cs.setPendingSteer(wakeToken, cpScope, { text, trusted }))
719
- mintResult.parked = true;
913
+ await parkOnce(wakeToken, cpScope);
720
914
  });
721
915
  const wrapped = job.catch(() => undefined);
722
916
  wakeParkMints.set(run.sessionId, wrapped);
@@ -728,6 +922,14 @@ async function handleRunsBody(req, res, url, ctx, miss) {
728
922
  if (wakeParkMints.get(run.sessionId) === wrapped)
729
923
  wakeParkMints.delete(run.sessionId);
730
924
  }
925
+ if (mintResult.queueFull) {
926
+ sendQueueFull();
927
+ return;
928
+ }
929
+ if (mintResult.duplicateKey) {
930
+ sendDuplicateKey();
931
+ return;
932
+ }
731
933
  if (mintResult.parked) {
732
934
  sendJson(res, 202, { taskId, status: run.status, delivery: "parked_for_wake", messageId, ...(priority ? { priority } : {}), note: "run already ended — steer parked on a task_done checkpoint; deliver it with POST /v1/sessions/:id/wake" });
733
935
  return;
@@ -1381,10 +1583,340 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1381
1583
  sendError(res, 400, "request.invalid_json", "invalid JSON body");
1382
1584
  return;
1383
1585
  }
1384
- const { status, body: respBody } = deps.toolApproval.respond(id, principal, body);
1586
+ // #151 车2:respond() 的返回型是 union(store 缺席时同步、在场时 Promise——src/tool-approval.ts 顶注);
1587
+ // await 对两者都是恒等/透传操作,这里统一 await 以兼容两条路径。
1588
+ const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body);
1385
1589
  sendJson(res, status, respBody);
1386
1590
  return;
1387
1591
  }
1592
+ // #151 车4:POST /v1/tasks/:taskId/asks/:askId/decision —— durable 回决(辖域对比见 TASK_ASK_DECISION_RE 顶注)。
1593
+ const askDecisionMatch = req.method === "POST" ? TASK_ASK_DECISION_RE.exec(url) : null;
1594
+ if (askDecisionMatch) {
1595
+ if (rateLimited(req, res))
1596
+ return; // 打的是一行 CAS,不新开模型工作 ⇒ 不过配额门(与三条 respond 同判)
1597
+ // 协议关 / 无 durable backend ⇒ 根本没有可 CAS 的账本。501 fail-loud:静默 404 会把「本部署没接这个
1598
+ // 面」伪装成「你的 ask 不存在」,而这两件事的处置完全相反(改部署 vs 别重试)。
1599
+ //
1600
+ // 🔴 **上场判据 = 单一谓词**(车3 刀 3b 收口)。车4 落地时这里是一份局部的
1601
+ // `enabled ∧ backend ∧ kind !== "local"` 临时执行面,注释里写死了交接条件:「等能力面 `streamApproval`
1602
+ // 那一车,把 `kind === "local"` 换成消费**那一个**谓词并同步删掉本段 —— 两份判据长期并存必然漂」。
1603
+ // 兑现即此:`resolveStreamApprovalGate` 现在同时服务协调器注入(boot/coordinators.ts)、
1604
+ // `/v1/capabilities` 的 `streamApproval` 格与本口的 501 门。判据由三项扩到五项(多了 `toolApproval`
1605
+ // 在场与 **park 设施**在场,§8.4)——本口因此更严:能力面说 false 的部署,这里恒 501,不会出现
1606
+ // 「能力面诚实说没有、端点却收下一批注定无处安放的决议」。
1607
+ const gate = resolveStreamApprovalGate({
1608
+ toolApprovalEnabled: Boolean(deps.toolApproval),
1609
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
1610
+ backend: deps.backend,
1611
+ parkFacility: deps.checkpointStore !== undefined,
1612
+ });
1613
+ const askStore = gate.active ? gate.askStore : undefined;
1614
+ if (!askStore) {
1615
+ sendError(res, 501, "feature.approval_ask_disabled", "the durable stream-approval ask ledger is not enabled on this worker (needs STREAM_APPROVAL_ENABLED plus a DURABLE store backend — the local lane keeps asks in memory and would lose accepted decisions on restart)");
1616
+ return;
1617
+ }
1618
+ // §12-F 前置链(逐字照三条 respond 的先例):rateLimited → principal 门 → readJson → safeParse。
1619
+ // 身份恒取 gatedPrincipal(已验证的那份),**绝不**取可伪造的头 —— 它同时是属主门与 actor 铸造的唯一源。
1620
+ const principal = gatedPrincipal(req, deps.config);
1621
+ if (deps.config.requirePrincipal && principal === undefined) {
1622
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
1623
+ return;
1624
+ }
1625
+ let rawBody;
1626
+ try {
1627
+ rawBody = await readJson(req);
1628
+ }
1629
+ catch {
1630
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
1631
+ return;
1632
+ }
1633
+ const parsedBody = AskDecisionBodySchema.safeParse(rawBody);
1634
+ if (!parsedBody.success) {
1635
+ sendError(res, 400, "request.body_shape", "invalid ask decision body — expected decision=approve|deny plus optional updatedInput / note / idempotencyKey / actor.label");
1636
+ return;
1637
+ }
1638
+ const body = parsedBody.data;
1639
+ // `updatedInput` 的**在场**判定读原始体:`z.unknown().optional()` 解出来的 undefined 分不清
1640
+ // 「没给这个键」与「给了 undefined」,而 forwarded 位的语义是前者。
1641
+ const hasUpdatedInput = typeof rawBody === "object" && rawBody !== null && "updatedInput" in rawBody;
1642
+ const taskId = askDecisionMatch[1];
1643
+ const askId = askDecisionMatch[2];
1644
+ // 404 三处同文同码(行不存在 / taskId 不属 / 属主门不过)—— 不给存在性谕示是这条门的全部意义,
1645
+ // 三条臂只要文案分岔就等于把「它存在但不是你的」写在 wire 上(decide 面 parity)。
1646
+ const askNotFound = () => sendError(res, 404, "not_found.ask", "no such approval ask under this task (never existed, belongs to another task, or is not yours)");
1647
+ const row0 = await askStore.getAsk(askId);
1648
+ if (!row0 || row0.taskId !== taskId) {
1649
+ askNotFound();
1650
+ return;
1651
+ }
1652
+ // §2 v1 判据 = 行 owner 一致 ∨ 显式 operator。`owner === null`(auth-off 单用户)恒过。
1653
+ // 172 的 `task.approve` 独立动词候 center runtimeCaps 有位后再升级,v1 不 block(稿 §2)。
1654
+ const ownerOk = row0.owner === null || (principal !== undefined && row0.owner === principal);
1655
+ const operatorOk = explicitOperatorOk(principal, deps.config.operatorPrincipals);
1656
+ if (!ownerOk && !operatorOk) {
1657
+ askNotFound();
1658
+ return;
1659
+ }
1660
+ // CSRF:本仓无 cookie 面(身份恒来自头/凭证,浏览器不会自动附带)⇒ 不适用(§12-D F8 显式收案)。
1661
+ const actor = {
1662
+ id: principal ?? "_", // auth-off 哨兵,与 durable scope 的 "_" 同字
1663
+ ...(body.actor ? { label: redactSecrets(body.actor.label).slice(0, ASK_ACTOR_LABEL_MAX) } : {}),
1664
+ verified: principal !== undefined, // auth-off 下无「验证」可言,如实 false
1665
+ via: ownerOk ? "owner" : "operator",
1666
+ };
1667
+ // 回放形与首决 200 形同键集,只差 `updatedInputForwarded`(它是本副本活体状态的函数,回放不承诺
1668
+ // 字节等同 —— §12-A 明写)。`actor` 从行上读出、safeParse 不过就省略(F12),不 500。
1669
+ /**
1670
+ * 🔴 见到一条 DECIDED 行 ⇒ **先把本副本活体窗同步到它**,再谈这次请求该拿 200 还是 409
1671
+ * (codex 交叉复审 round4 [high] + round5 [high],两轮真 finding,均红先验过)。
1672
+ *
1673
+ * round4 抓的是回放臂:提交歧义窗自己许诺的处置(带同一把键重试)会走 200 回放,而那只活体卡
1674
+ * 从没被结算过 —— run 一直等到窗到期,端点的头号承诺(「窗内回决 run 不停」)在它自己写下的恢复
1675
+ * 路径上不成立。round5 抓的是同一个洞的另一半:**无幂等键**的那次歧义之后,重试落的是 409 臂,
1676
+ * 那条臂当时同样不结算 —— 同一个 run 照样挂死;跨副本回决的本地输者也是这条臂。
1677
+ *
1678
+ * 收敛后的判据一句话:**响应分类不许决定本地是否结算**。行上有权威决议这件事,与「这次请求算
1679
+ * 重试还是算冲突」完全正交 —— 前者是事实,后者只是这次调用的礼节。
1680
+ *
1681
+ * 三条纪律:①用**行上的**决议,不是这次请求带的(权威是首决,B-2 已钉「异决重试回放首决」);
1682
+ * ②**不**转发这次请求的 `updatedInput`(它没有与幂等键一起持久绑定,转发等于让重试悄悄改写首决
1683
+ * 的载荷);③因此回放恒**省略** `updatedInputForwarded`。协调器缺席/查无条目都是正常(跨副本、
1684
+ * 窗已关),`notifyExternalDecision` 对已结算条目天然幂等(其顶注),故重复调用无害。
1685
+ *
1686
+ * 返回值 = 这行是否是一条**可当权威人决用**的 DECIDED 行。三个合取项(codex round5 [medium] +
1687
+ * round6 [high],两轮各补一半),缺任一项都**既不结算也不作答**,而是记不变量 + 500:
1688
+ * ① `decision ∈ {approve, deny}` —— 列可空且通用 `transitionAsk` 不强制 decision 载荷,DECIDED
1689
+ * 而无决议是可表示的坏行;把它当 200/409 交出去等于把持久层损坏包装成协议答案(契约是二值)。
1690
+ * ② `provisional === false` —— `AskRow.provisional` 的语义(§3.0 对账三约束②)就是「这条终态还可能
1691
+ * 被 `resolveProvisional` 版本化收敛掉」。**未定稿的终态不是终态**:照它 `notifyExternalDecision(true)`
1692
+ * 会在持久行还可能被补偿成 VOID 之前就放行一次不可逆工具调用 —— 结算是单向门,不可回收。
1693
+ * ③ `decidedAtMs !== null` —— 它是响应体与回放形的必备字段,也是「这条决议真的被 decideAsk 落过」
1694
+ * 的印记;为空说明这行不是走回决腿铸出来的。
1695
+ * 三者今天都没有在树上的写者会产生(人决恒走 `decideAsk`,它三项齐备),所以这是**fail-closed 的
1696
+ * 结构性守卫**,不是对某个已知 bug 的补丁 —— 判据是「可表示即须拒」,因为代价不对称:错拒 = 一次
1697
+ * 500,错认 = 一次不可逆放行。
1698
+ */
1699
+ // 判据本身(纯守卫,不结算)—— **三个入口共用一份**:CAS 赢者、CAS 输者重读、幂等预查命中。
1700
+ // 🔴 三入口必须同守(codex round7 [high],真 finding,红先验过):此前只守了后两个,而 `decideAsk`
1701
+ // 的 CAS 谓词只看 `state='STREAM_PENDING'`、既不谓词也不清 `provisional` —— 一条 provisional 的
1702
+ // STREAM_PENDING 行赢下 CAS 就成了 provisional 的 DECIDED 行,赢者臂照样放行。守卫贴在「见到一条
1703
+ // DECIDED 行」这件事上,不贴在「怎么见到的」上,才没有第四个入口能绕过去。
1704
+ // 🔴 `state === "DECIDED"` 是第四个合取项(codex round10 [high]):赢者臂是唯一不先判 state 就调本
1705
+ // 谓词的入口,而 `decideAsk` 提交与回读之间那条行可能已被 `resolveProvisional` 版本化收敛走
1706
+ // (例如 provisional 的 DECIDED 被收敛成 VOID 却**留着** decision/decidedAtMs 元数据)——只验元数据
1707
+ // 会让那条已经不是 DECIDED 的行照样放行。谓词名叫「这条 DECIDED 行权威吗」,那就把 DECIDED 本身
1708
+ // 也验了,四个入口才真的同守。
1709
+ const decidedRowIsAuthoritative = (row) => {
1710
+ if (row.state !== "DECIDED" || row.decision !== "approve" && row.decision !== "deny" || row.provisional || row.decidedAtMs === null) {
1711
+ deps.logger?.error?.("ask_decision_row_not_authoritative", {
1712
+ taskId,
1713
+ askId,
1714
+ state: row.state,
1715
+ decision: row.decision,
1716
+ provisional: row.provisional,
1717
+ decidedAtMs: row.decidedAtMs,
1718
+ });
1719
+ sendError(res, 500, "internal.error", "the stored ask row is DECIDED but is not a usable human decision (missing approve/deny, still provisional, or missing decidedAtMs) — no answer can be given for it");
1720
+ return false;
1721
+ }
1722
+ return true;
1723
+ };
1724
+ const syncLiveFromDecidedRow = (row) => {
1725
+ if (!decidedRowIsAuthoritative(row))
1726
+ return false;
1727
+ deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve");
1728
+ return true;
1729
+ };
1730
+ /** 200 回放形:与首决 200 同键集,只差 `updatedInputForwarded`(§12-A 明写回放不承诺字节等同)。
1731
+ * 调用前提 = 已过 {@link syncLiveFromDecidedRow}(行合形且活体窗已同步)。 */
1732
+ const replayDecided = (row) => {
1733
+ const storedActor = projectDecisionActor(row.decisionActor);
1734
+ sendJson(res, 200, {
1735
+ askId: row.askId,
1736
+ taskId: row.taskId,
1737
+ decision: row.decision,
1738
+ decidedAtMs: row.decidedAtMs,
1739
+ ...(storedActor ? { actor: storedActor } : {}),
1740
+ });
1741
+ };
1742
+ // 终局分派 —— **一律按传进来的这一行**投影,不重读(§12-A:`DecideResult.row` 已是新鲜行;
1743
+ // 补一次确认读只会引入第三个时点,让响应描述的行与 CAS 判定的行再次分家)。
1744
+ const sendTerminal = (row) => {
1745
+ switch (row.state) {
1746
+ case "DECIDED": {
1747
+ // 结算**先于**响应分类(见 syncLiveFromDecidedRow 顶注:200 与 409 两臂对本地活体窗是同一件事)。
1748
+ if (!syncLiveFromDecidedRow(row))
1749
+ return; // 坏行 ⇒ 已答 500
1750
+ // §12-A 顺序钉:**先**比幂等键。同 key 的两个并发请求都会 miss 上面的预查(那时谁都还没落键),
1751
+ // 输者重读时若不先比键,会把一次合法重试错报成 409。
1752
+ if (body.idempotencyKey !== undefined && row.idempotencyKey === body.idempotencyKey) {
1753
+ replayDecided(row);
1754
+ return;
1755
+ }
1756
+ const first = projectDecisionActor(row.decisionActor);
1757
+ // 首决回显三件;`note` **不回显**(以请求者可见权限为界的最窄安全形,稿 §5)。
1758
+ sendError(res, 409, "conflict.ask_decided", "this ask was already decided — the first decision stands and is echoed here", {
1759
+ decision: row.decision,
1760
+ decidedAtMs: row.decidedAtMs,
1761
+ ...(first ? { actor: first } : {}),
1762
+ });
1763
+ return;
1764
+ }
1765
+ case "PARKED":
1766
+ // §12-C:三件坐标,**不含 gateToken** —— checkpointToken 是从不外发的 resume 凭证
1767
+ // (approval-hmac 的既定不变量);壳按 sessionId 走现行 durable gate 流程。
1768
+ sendError(res, 410, "gone.ask_parked", "the in-stream window closed and this ask was parked onto the durable approval gate — decide it through that gate for the session echoed here", {
1769
+ sessionId: row.sessionId,
1770
+ gateBoundCallId: row.gateBoundCallId,
1771
+ gateBoundInputHash: row.gateBoundInputHash,
1772
+ });
1773
+ return;
1774
+ case "DENIED":
1775
+ case "VOID":
1776
+ sendError(res, 410, "gone.ask", "this ask reached a terminal state without a human decision (routing failure or batch abort) and can no longer be decided", {
1777
+ state: row.state,
1778
+ ...(row.deniedReason !== null ? { deniedReason: row.deniedReason } : {}),
1779
+ });
1780
+ return;
1781
+ case "PARKING":
1782
+ // §12-C F44:PARKING 体**不含** gate 坐标(bindBatch 才写,此刻尚未存在)——只给轮询位置。
1783
+ res.setHeader("retry-after", String(ASK_PARKING_RETRY_AFTER_SEC));
1784
+ sendError(res, 425, "parking.ask_retry", "this ask is mid-park — retrying tells you where it finally landed; a retry does NOT queue this decision for acceptance", {
1785
+ retryAfterSec: ASK_PARKING_RETRY_AFTER_SEC,
1786
+ });
1787
+ return;
1788
+ default:
1789
+ // STREAM_PENDING 出现在这里 = 状态机转移表无回边却回去了(不变量破)。fail-loud 500 且
1790
+ // **禁重入 CAS**(§12-A F20):重入等于在一个已知不可信的状态上再赌一次。
1791
+ deps.logger?.error?.("ask_decision_state_invariant", { taskId, askId, state: row.state });
1792
+ sendError(res, 500, "internal.error", "ask decision hit an impossible row state (a CAS loser re-read a still-pending row) — no decision was recorded");
1793
+ return;
1794
+ }
1795
+ };
1796
+ // 🔴 **观察到即验权 + 即同步**(codex 交叉复审 round8 [high],真 finding,红先验过)。
1797
+ //
1798
+ // 这是 round4/round5/round8 三轮同一族缺口的收口形。前两轮把结算分别补进了 200 回放臂和 409 冲突臂,
1799
+ // 但**跨 ask 撞键**那条臂在它们之前就 return 了:row0 明明已经是 DECIDED(这次请求已经看见了权威
1800
+ // 决议),却因为幂等键属于另一只 ask 而直接 409 走人 —— 本副本那只活体卡照样挂到窗到期。幂等预查
1801
+ // 本身抛错也同理(冒到外层 500,同样没同步)。
1802
+ //
1803
+ // 根治不是再补第三处调用,而是把这一步提到**观察到这行的那一刻**:凡这次请求手里有一条 DECIDED 行,
1804
+ // 先验它是不是权威决议、再把本地活体窗同步到它,**之后**才谈这次请求该拿 200 / 409 / 500。于是
1805
+ // 「响应分类不许决定本地是否结算」成为结构性成立,而不是靠每条臂各自记得调一次。
1806
+ // (下游 `sendTerminal` 的 DECIDED 臂仍会再调一次:`notifyExternalDecision` 对已结算条目幂等,
1807
+ // 重复调用是无操作 —— 宁可重复,不可遗漏。)
1808
+ if (row0.state === "DECIDED" && !syncLiveFromDecidedRow(row0))
1809
+ return;
1810
+ // ①幂等回放预查(§5-4 + §12-A):命中同 askId 且已 DECIDED ⇒ 直接回放,**不打第二次 CAS**;
1811
+ // 命中另一 askId ⇒ 409(跨 ask 回放会掩盖调用方的键管理缺陷,且 172「禁复用旧同意」排除该解,F23)。
1812
+ if (body.idempotencyKey !== undefined) {
1813
+ const hit = await askStore.getByIdempotencyKey(taskId, body.idempotencyKey);
1814
+ if (hit && hit.askId !== askId) {
1815
+ sendError(res, 409, "conflict.ask_idempotency", "this idempotency key is already bound to a different ask under this task — reuse of a key across asks is refused", {
1816
+ idempotencyKey: body.idempotencyKey,
1817
+ });
1818
+ return;
1819
+ }
1820
+ if (hit && hit.state === "DECIDED") {
1821
+ if (!syncLiveFromDecidedRow(hit))
1822
+ return; // 同上:先同步活体窗,坏行 ⇒ 500
1823
+ replayDecided(hit);
1824
+ return;
1825
+ }
1826
+ }
1827
+ if (row0.state !== "STREAM_PENDING") {
1828
+ sendTerminal(row0);
1829
+ return;
1830
+ }
1831
+ // 先读后分派是**优化**不是判权:CAS 仍是唯一权威,输者按它带回的新鲜行分派。
1832
+ //
1833
+ // 🔴 提交歧义窗(§12-A 的「行缺失 ⇒ 500 fail-loud + 日志注明」条款;codex 交叉复审 round2 抓获本
1834
+ // 实现漏了它):`SqlApprovalAskStore.decideAsk` 是「事务提交 → **另起**一次独立 getAsk 读」的形态
1835
+ // (approval-ask-store-sql.ts,commit() 之后那一行)。那次读若失败/读不到行,它抛 —— 而**决议此刻
1836
+ // 可能已经持久了**。裸让它冒到外层兜底 catch 会给调用方一个不带任何线索的 500:他既不知道该不该重
1837
+ // 试,不带幂等键重试还会撞 409(首决已在)。这里把它接住,fail-loud 但**把处置写进文案与日志**:
1838
+ // 带**同一个** idempotencyKey 重试 ⇒ 若决议真已落地,幂等回放路径会 200 把它原样交回。
1839
+ let outcome;
1840
+ try {
1841
+ outcome = await askStore.decideAsk(askId, row0.batchId, {
1842
+ decision: body.decision,
1843
+ decisionActor: actor,
1844
+ decisionNote: body.note ?? null,
1845
+ idempotencyKey: body.idempotencyKey ?? null,
1846
+ });
1847
+ }
1848
+ catch (err) {
1849
+ deps.logger?.error?.("ask_decision_commit_ambiguous", {
1850
+ taskId,
1851
+ askId,
1852
+ hadIdempotencyKey: body.idempotencyKey !== undefined,
1853
+ error: err instanceof Error ? err.message : String(err ?? ""),
1854
+ });
1855
+ // 🔴 处置**按幂等键在场与否分岔**(codex round3 抓获,真 finding):无键的请求根本没有「同一把键」
1856
+ // 可重试,而一次盲重试撞上的是已决行(409),把「可能已批准」错读成「批准冲突」。无键臂如实说
1857
+ // 它做不到,并指出下次怎么做得到 —— 承诺一个不存在的处置比不给处置更坏(过期声明族)。
1858
+ if (body.idempotencyKey !== undefined) {
1859
+ sendError(res, 500, "internal.error", "the ask decision store failed around commit — the decision MAY already be persisted; retry with the same idempotencyKey to replay it instead of colliding with it");
1860
+ }
1861
+ else {
1862
+ sendError(res, 500, "internal.error", "the ask decision store failed around commit — the decision MAY already be persisted, and this request carried no idempotencyKey, so a blind retry cannot be told apart from a first attempt; re-read the ask state before retrying, and send an idempotencyKey next time so retries can replay");
1863
+ }
1864
+ return;
1865
+ }
1866
+ if (!outcome.ok) {
1867
+ // 🔴 同一条纪律施于**输者带回的行**(codex 交叉复审 round9 [high],真 finding,红先验过):
1868
+ // `idempotency_conflict` 臂原先直接 409、把 `outcome.row` 丢掉,而店契约允许该臂带回目标行,
1869
+ // SQL twin 更是在**回滚之后**才读它 —— 那一刻它完全可能已经是别人决出来的 DECIDED。于是又一条
1870
+ // 「已经看见权威决议却不结算」的臂(活体窗挂到窗到期),外加「非权威行被撞键 409 掩盖」。
1871
+ // 放在 reason 分派**之前** ⇒ 三个 reason 一视同仁,不靠每条臂各自记得。至此本 handler 能拿到
1872
+ // AskRow 的**全部四个来源**(row0 / 幂等预查命中 / CAS 赢者行 / CAS 输者行)都在守卫下游。
1873
+ if (outcome.row?.state === "DECIDED" && !syncLiveFromDecidedRow(outcome.row))
1874
+ return;
1875
+ if (outcome.reason === "idempotency_conflict") {
1876
+ sendError(res, 409, "conflict.ask_idempotency", "this idempotency key is already bound to a different ask under this task — reuse of a key across asks is refused", {
1877
+ idempotencyKey: body.idempotencyKey,
1878
+ });
1879
+ return;
1880
+ }
1881
+ if (outcome.row === null) {
1882
+ askNotFound(); // 并发删(deleteByTask)——行没了,如实 404
1883
+ return;
1884
+ }
1885
+ sendTerminal(outcome.row);
1886
+ return;
1887
+ }
1888
+ // 赢下 CAS **不等于**这行就能放行(codex round7 [high]):`decideAsk` 的谓词只看 `state`,不看也不清
1889
+ // `provisional` —— 赢者臂同样要过那一份权威守卫,否则一条 provisional 行会从这个入口绕过去(结算是
1890
+ // 单向门,而 `resolveProvisional` 事后还可能把它补偿成 VOID)。守卫在**结算之前**。
1891
+ if (!decidedRowIsAuthoritative(outcome.row))
1892
+ return;
1893
+ // 🔴 结算的**每一个入参都取自行**,不取自请求(codex round10 [high]):赢者臂原先用 `askId` 与
1894
+ // `body.decision` 结算 —— 那是本 handler 最后一处「按请求而非按行」的地方。提交与回读之间那条行
1895
+ // 可能已被版本化收敛改写,此时按请求结算 = 放行一个与持久真相相反的决议(结算不可回收)。
1896
+ if (outcome.row.decision !== body.decision) {
1897
+ // 行与本次请求分岔:本地仍按**行**收敛(活体窗恒随持久真相走,哪怕代价是拒),HTTP 侧 fail-loud
1898
+ // —— 调用方提交的那个决议不是最终生效的那个,让他重读而不是以为自己批了。
1899
+ deps.logger?.error?.("ask_decision_row_diverged", { taskId, askId, requested: body.decision, stored: outcome.row.decision });
1900
+ deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve");
1901
+ sendError(res, 500, "internal.error", "the ask row was rewritten between commit and read-back — the decision that stands is not the one this request submitted; re-read the ask before acting on it");
1902
+ return;
1903
+ }
1904
+ // 端点已是唯一权威。协调器缺席(TOOL_APPROVAL_ENABLED 关而 askStore 在场)⇒ 联动步
1905
+ // optional 跳过(§12-F F31):没有活体窗要同步,持久终局已经落定。
1906
+ const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", hasUpdatedInput ? body.updatedInput : undefined).settled ?? 0;
1907
+ // `updatedInputForwarded` 只在**真结算**时发 `true`,其余一律**省略**(从不发 false,照 finishRespond
1908
+ // 先例,§12-F)。它是「本副本活体状态」的函数,故幂等回放不承诺字节等同(§12-A)。
1909
+ const forwarded = outcome.row.decision === "approve" && hasUpdatedInput && settled > 0;
1910
+ sendJson(res, 200, {
1911
+ askId: outcome.row.askId,
1912
+ taskId: outcome.row.taskId,
1913
+ decision: outcome.row.decision,
1914
+ decidedAtMs: outcome.row.decidedAtMs,
1915
+ actor,
1916
+ ...(forwarded ? { updatedInputForwarded: true } : {}),
1917
+ });
1918
+ return;
1919
+ }
1388
1920
  miss.fell = true;
1389
1921
  }
1390
1922
  //# sourceMappingURL=runs.js.map