@sema-agent/server 7.15.0 → 7.16.0-rc.2

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 (61) hide show
  1. package/USAGE.md +36 -2
  2. package/dist/approval-card.d.ts +65 -0
  3. package/dist/approval-card.js +54 -6
  4. package/dist/approval-reconciler.d.ts +17 -1
  5. package/dist/boot/resolve-spec.js +31 -4
  6. package/dist/boot/runner-deps.d.ts +41 -2
  7. package/dist/boot/runner-deps.js +43 -0
  8. package/dist/boot/session-faces.js +26 -4
  9. package/dist/boot/shutdown.js +17 -0
  10. package/dist/boot/stores.js +20 -0
  11. package/dist/config-center/apply-effective.d.ts +14 -0
  12. package/dist/config-center/apply-effective.js +81 -1
  13. package/dist/config-types.d.ts +28 -2
  14. package/dist/config.js +71 -37
  15. package/dist/fleet/fleet-terminal-window.d.ts +12 -0
  16. package/dist/fleet/fleet-terminal-window.js +27 -4
  17. package/dist/http/active-run-conflict.d.ts +41 -1
  18. package/dist/http/active-run-conflict.js +24 -10
  19. package/dist/http/routes/approvals-assistant.d.ts +11 -3
  20. package/dist/http/routes/approvals-assistant.js +97 -20
  21. package/dist/http/routes/capabilities.js +8 -1
  22. package/dist/http/routes/diagnostics.d.ts +18 -0
  23. package/dist/http/routes/diagnostics.js +26 -0
  24. package/dist/http/routes/runs.js +63 -17
  25. package/dist/http/routes/side-query.js +15 -1
  26. package/dist/http/routes/tasks.js +32 -3
  27. package/dist/http/server.js +67 -13
  28. package/dist/leader/fanout.d.ts +18 -0
  29. package/dist/leader/fanout.js +34 -1
  30. package/dist/leader/leader.js +9 -5
  31. package/dist/leader/wire.js +16 -5
  32. package/dist/main.js +1 -0
  33. package/dist/model-select.d.ts +43 -1
  34. package/dist/model-select.js +70 -2
  35. package/dist/observability/fail-open.d.ts +8 -0
  36. package/dist/observability/fail-open.js +8 -0
  37. package/dist/observability/metrics.js +3 -0
  38. package/dist/parent-watch.d.ts +57 -0
  39. package/dist/parent-watch.js +108 -0
  40. package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
  41. package/dist/plugins/checkpoint-store-sql.js +133 -7
  42. package/dist/plugins/local-checkpoint-store.js +11 -1
  43. package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
  44. package/dist/plugins/memory-embedder-fingerprint.js +280 -0
  45. package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
  46. package/dist/plugins/permission-rule-store-sql.js +1 -7
  47. package/dist/plugins/pg-pool.js +3 -0
  48. package/dist/plugins/store-backend.d.ts +5 -6
  49. package/dist/plugins/store-backend.js +4 -1
  50. package/dist/plugins/store-contracts.d.ts +17 -0
  51. package/dist/plugins/store-contracts.js +33 -0
  52. package/dist/plugins/tidb-pool.js +7 -0
  53. package/dist/plugins/tool-result-store-sql.d.ts +18 -13
  54. package/dist/plugins/tool-result-store-sql.js +50 -29
  55. package/dist/run-local.js +1 -0
  56. package/dist/tool-approval.d.ts +9 -0
  57. package/dist/tool-approval.js +74 -3
  58. package/dist/trace/project.js +8 -0
  59. package/dist/trace/redact.d.ts +14 -1
  60. package/dist/trace/redact.js +14 -2
  61. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
1
2
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
2
3
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
3
4
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
@@ -6,6 +7,7 @@ import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行
6
7
  import { sleep } from "../sse-log.js";
7
8
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
8
9
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
10
+ import { governanceOriginOf } from "../active-run-conflict.js";
9
11
  // design/80 seam #2 (assistant-scheduler): graceful preempt (durable yield) + resource_limit resume of one task.
10
12
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
11
13
  export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
@@ -61,16 +63,17 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
61
63
  const scope = operator
62
64
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
63
65
  : (principal ?? "__none__");
64
- await streamApprovals(req, res, cs, scope);
66
+ await streamApprovals(req, res, cs, scope, deps.config);
65
67
  return;
66
68
  }
67
69
  if (req.method === "GET" && url === "/v1/approvals") {
68
70
  const scope = operator
69
71
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined) // operator: all (or ?owner)
70
72
  : (principal ?? "__none__"); // non-operator: only its own scope (never others' pending)
71
- // #209 件4:行整只上 wire(键集契约不变),但 `riskDescriptor.shadowedRule` 这一格先脱敏 ——
72
- // 理由与 `/v1/approvals/stream` 共用同一个投影函数,见 `redactPendingDisclosures` 顶注。
73
- sendJson(res, 200, { pending: (await cs.listPending(scope)).map(redactPendingDisclosures) });
73
+ // #209 件4 + [3684]②:行整只上 wire(键集契约照旧「整只透传、不按键投影」),但两格由投影函数
74
+ // 负责——`riskDescriptor.shadowedRule` 脱敏、`governanceForced` 归因。与 `/v1/approvals/stream`
75
+ // **共用同一个** `projectPendingForWire`(两条读面各投各的必漂,见其顶注)
76
+ sendJson(res, 200, { pending: (await cs.listPending(scope)).map((r) => projectPendingForWire(r, deps.config)) });
74
77
  return;
75
78
  }
76
79
  // exemptions surface — the UI's "本会话不再询问" state (list) + revoke. Same authz shape as
@@ -214,7 +217,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
214
217
  // so a running/suspended-only filter DROPPED exactly the HITL-gated tasks §3 promises to surface "needs-
215
218
  // attention first" — the operator could never discover the very plan_review tasks §4c says to resolve.
216
219
  // Same class as the inbox leak (handler vs contract drift). needs_review is a non-terminal park → include it.
217
- .filter((r) => r.status === "running" || r.status === "suspended" || r.status === "needs_review")
220
+ // 判据取词表属主(合并码重扫):park 两个词由 `isParkedRunStatus` 穷举,手抄形对 core 加词无编译期钉。
221
+ .filter((r) => r.status === "running" || isParkedRunStatus(r.status))
218
222
  .map((r) => {
219
223
  const s = byGate.get(r.sessionId);
220
224
  const gate = s ? { kind: s.gateKind, severity: s.severity ?? null, spentMicroUsd: s.spentMicroUsd ?? null, deadline: s.deadline ?? null } : null;
@@ -313,17 +317,43 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
313
317
  }
314
318
  // Durable flag FIRST (only flips a still-running row), abort after — if the run suspended/terminal'd in
315
319
  // the getRun→here window the flag affects 0 rows, so re-read and answer by the ACTUAL state.
316
- const flagged = await deps.runStore.requestPreempt(taskId, run.owner); // owner guard: single-DB defense-in-depth (owner-gate already enforced above)
317
- if (!flagged) {
320
+ let flagged = await deps.runStore.requestPreempt(taskId, run.owner); // owner guard: single-DB defense-in-depth (owner-gate already enforced above)
321
+ // 🔴 抢跑输了(旗打空 0 行) 按 run 的**真实**状态**三向**重判(重扫二轮红先修;与 `runs.ts` 取消腿
322
+ // 的孪生臂 `CANCEL_RECLASSIFY_ATTEMPTS` 那一段同源同形)。旧形无论重读到什么都发 202 no-op,于是
323
+ // **同一条 park 行**:不经这个竞态窗时回 409 `conflict.already_suspended`(前置检查那一支,`403a841`),
324
+ // 经过它时回 202「task no longer running」。按状态码分支的调度器对同一个状态拿到两种答案,而契约文
325
+ // §4d 把它写成了绝对规则(parked ⇒ 409 / genuinely terminal ⇒ 202),只成文了其中一种。
326
+ // · **park**(判据取词表属主 `isParkedRunStatus`,穷举 switch + never 臂)⇒ 409,`status` 带**行的真词**;
327
+ // · **running**(park→running 二次跃迁真实存在:并发 approve 的恢复腿把行翻回来)⇒ **重挂旗**,
328
+ // 有界重试;行活过来正是该重挂旗的时刻,不是该撒谎的时刻;
329
+ // · 真终局 ⇒ 照旧幂等 202 no-op(带真实状态)。
330
+ // 有界(park↔running 反复跳时不许把请求钉在这):次数用尽仍 running ⇒ 如实 409 让调用方重试,
331
+ // 码与取消腿的同名情形共用(同一件事:并发决议赢了,它的恢复腿正在跑)。
332
+ const PREEMPT_RECLASSIFY_ATTEMPTS = 3;
333
+ for (let attempt = 0; !flagged; attempt++) {
318
334
  const now = await deps.runStore.getRun(taskId);
335
+ if (now !== undefined && isParkedRunStatus(now.status)) {
336
+ sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: now.status });
337
+ return;
338
+ }
339
+ if (now?.status === "running" && attempt < PREEMPT_RECLASSIFY_ATTEMPTS) {
340
+ flagged = await deps.runStore.requestPreempt(taskId, run.owner); // 行又活了 ⇒ 重挂 durable 旗(赢了就走下面的快路)
341
+ continue;
342
+ }
343
+ if (now?.status === "running") {
344
+ sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry preempt if it is still active", { taskId, status: now.status });
345
+ return;
346
+ }
319
347
  sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "task no longer running — preempt is a no-op" });
320
348
  return;
321
349
  }
322
350
  preemptableRuns.get(taskId)?.abort(); // fast path: preempt landed on the running instance → suspend now
323
351
  sendJson(res, 202, { taskId, status: "preempting", note: "graceful durable yield — the task suspends at the next clean turn boundary if preempt-eligible; resume via POST /v1/assistant/tasks/:id/resume" });
324
352
  }
325
- else if (run.status === "suspended") {
326
- sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: "suspended" });
353
+ else if (isParkedRunStatus(run.status)) {
354
+ // 扫描P2 顺带件:park 是两个词(isParkedRunStatus 属主)——needs_review(plan_review/dry-run )
355
+ // 此前掉进 else 被谎报「already terminal — no-op」(无状态破坏,纯指路错)。extras.status 带真词。
356
+ sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: run.status });
327
357
  }
328
358
  else {
329
359
  sendJson(res, 202, { taskId, status: run.status, note: "task already terminal — preempt is a no-op" });
@@ -687,6 +717,30 @@ function redactPendingDisclosures(row) {
687
717
  return row;
688
718
  return { ...row, riskDescriptor: { ...rd, shadowedRule: redactSecrets(rd.shadowedRule) } };
689
719
  }
720
+ /**
721
+ * 🔴 [3683]-2 / [3684]②:两条 durable 读面(`GET /v1/approvals` + `/v1/approvals/stream`)的**唯一**
722
+ * 投影 —— 脱敏(#209 件4)+ 出身归因(#220 的另一半)一次做完。
723
+ *
724
+ * 出身格的病灶如实记:`governanceForced` 此前只在 409 体上算(`buildActiveRunConflict`),而运维队列
725
+ * 的行不算 —— 同一道 park 门,客户端从 409 看得见「这是运维治理层下的」,从队列看不见。它**不是**一个
726
+ * 落库缺口:判据的两半(行上 `riskDescriptor.shellGateDoctrine` + 本部署的治理姿态)在这条读面上都够得着,
727
+ * 缺的只是**没有人算**。所以补法 = 让两面共用 `governanceOriginOf` 这一个属主(在读面算的已知失真、
728
+ * 以及「根治要 core 在 mint 写进行里」的登记,逐字在 `runtime-governance.ts` 那个函数的顶注,不复述)。
729
+ *
730
+ * 🔴 `true` 才写键,恒不写 `false` —— 与活卡帧 / 409 体 / `card_json` 同一条纪律:缺席 = 「没有治理来源
731
+ * 的证据」,不是「这不是治理门」。
732
+ *
733
+ * ⚠️ **`delegation` 刻意不在这里**(同批如实登记,禁编造键):park 行上**没有**这条信息 —— core 的
734
+ * `Checkpoint` / `PendingAction` / `CheckpointGate` 三个面都不带 `{parentToolCallId, depth, agentName}`
735
+ * (`Checkpoint.state.delegationProvenance` 是 design/180 的内容安全聚合 `{version, coverageStartTurn,
736
+ * sawExternal, incomplete}`,与 ask 的出处链是两件事)。同步腿的这一格来自 `AskRequest.delegation`,
737
+ * 那条路 park 时不随行。⇒ 无供给,诚实缺席;要它得先有 core 侧的属主面。
738
+ */
739
+ function projectPendingForWire(row, governance) {
740
+ const redacted = redactPendingDisclosures(row);
741
+ const governanceForced = governanceOriginOf({ riskDescriptor: row.riskDescriptor ?? undefined }, governance);
742
+ return governanceForced ? { ...redacted, governanceForced } : redacted;
743
+ }
690
744
  /**
691
745
  * GET /v1/approvals/stream (design/80 native push): SSE — pushes pending-approval deltas so the portal
692
746
  * SUBSCRIBES ONCE instead of polling GET /v1/approvals every ~10s (better UX: near-real-time + no client poll
@@ -694,8 +748,12 @@ function redactPendingDisclosures(row) {
694
748
  * for a decided/expired/gone one. Cross-replica BY CONSTRUCTION — the poll reads the SHARED checkpoint table,
695
749
  * so a suspend on ANY replica is seen by an operator streaming on a DIFFERENT replica. A transient DB blip
696
750
  * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
697
- * Poll-granularity caveat: a pending that resolves AND re-suspends on the same (session,toolCallId) within one
698
- * interval shows no delta acceptable (the live snapshot is always eventually correct; no decision is missed).
751
+ * Poll-granularity caveat (**narrowed** by [3684]②): the delta now re-emits a `pending` frame whenever a key's
752
+ * WIRE PROJECTION changes (upsert), not only when the key enters so a same-key re-suspend within one interval
753
+ * IS visible as long as anything on the row differs. What still shows no delta is the strictly degenerate case:
754
+ * a resolve + re-suspend on the same (session,toolCallId) whose projection is BYTE-IDENTICAL — indistinguishable
755
+ * by construction, and harmless (the card the operator sees is the card that is pending). Pins: the two halves of
756
+ * `test/wire-pairing-approvals-stream.test.ts` S3b (changed ⇒ re-emit with the NEW payload; unchanged ⇒ silence).
699
757
  *
700
758
  * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
701
759
  * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
@@ -704,7 +762,10 @@ function redactPendingDisclosures(row) {
704
762
  * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
705
763
  * 演化,不是现在的缺陷。
706
764
  */
707
- export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_STREAM_POLL_MS) {
765
+ export async function streamApprovals(req, res, cs, scope,
766
+ /** [3684]②:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席,与 best-effort 同方向)。**位置在
767
+ * `pollMs` 之前**是刻意的:`pollMs` 只有测试传,而本参数是每个生产调用点都必须给的那一个。 */
768
+ governance, pollMs = APPROVALS_STREAM_POLL_MS) {
708
769
  // JSON-encode the (sessionId, toolCallId) pair so distinct pendings can NEVER collide into one Map key,
709
770
  // regardless of what a caller-supplied sessionId contains (any single-char delimiter — space OR even NUL — is
710
771
  // injectable by an adversarial id, masking/wrong-removing an approval card; adversarial finding). JSON escaping
@@ -720,13 +781,25 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
720
781
  const start = Date.now();
721
782
  let lastBeat = Date.now();
722
783
  let prev = new Map();
784
+ /**
785
+ * 🔴 [3684]② / codex 对抗复审 [medium](验真后修):**上一拍每行的 wire 投影字节**。
786
+ *
787
+ * 旧 delta 只判「键进/键出」——对一条内容**不变**的 park 行那是对的,而 `governanceForced` 是**派生位**,
788
+ * 它随 `config.autonomy`(config-center 的 `apply-effective` 就地热改)在**同一条行还挂着**的时候翻面。
789
+ * 只判键的话,常驻订阅的 portal 会一直渲着旧徽标(最长到 15 分钟 cap 重连),而同一时刻
790
+ * `GET /v1/approvals` 已经给出另一个答案 —— 两条本该同源的读面当场分家。
791
+ *
792
+ * ⇒ 判据改成**投影字节变了就重发**。`pending` 帧对同一 (sessionId, toolCallId) 键因此是 **upsert**
793
+ * 语义(消费端本来就按这个键建表);重复发一帧是无害的(幂等覆盖),漏发一帧是错的徽标。
794
+ */
795
+ let prevFrames = new Map();
723
796
  let first = true;
724
797
  while (!closed) {
725
798
  let pending;
726
799
  try {
727
- // #209 件4:与 `GET /v1/approvals` **同一个**投影函数 —— 两条 durable 读面各洗各的就会漂
728
- // (SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
729
- pending = (await cs.listPending(scope)).map(redactPendingDisclosures);
800
+ // #209 件4 + [3684]②:与 `GET /v1/approvals` **同一个**投影函数(脱敏 + 出身归因)—— 两条 durable
801
+ // 读面各投各的就会漂(SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
802
+ pending = (await cs.listPending(scope)).map((r) => projectPendingForWire(r, governance));
730
803
  }
731
804
  catch {
732
805
  // a transient TiDB blip must NOT kill the subscription — heartbeat + retry next tick (fail-soft)
@@ -736,22 +809,26 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
736
809
  continue;
737
810
  }
738
811
  const cur = new Map(pending.map((p) => [keyOf(p), p]));
812
+ // 一行只序列化一次:既是要发的帧体,也是与上一拍比对的判据(两处各算一遍必然漂)。
813
+ const curFrames = new Map([...cur].map(([k, p]) => [k, JSON.stringify({ type: "pending", ...p })]));
739
814
  if (first) {
740
815
  // initial snapshot so a freshly-subscribed portal renders the current queue immediately (no first-poll gap)
741
- for (const p of pending)
742
- res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`);
816
+ for (const frame of curFrames.values())
817
+ res.write(`event: pending\ndata: ${frame}\n\n`);
743
818
  res.write(`event: synced\ndata: ${JSON.stringify({ type: "synced", count: pending.length })}\n\n`);
744
819
  first = false;
745
820
  }
746
821
  else {
747
- for (const [k, p] of cur)
748
- if (!prev.has(k))
749
- res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`); // a new suspend
822
+ // 新 park **或**同一行的投影内容变了( `prevFrames` 顶注:派生位会在行不变时翻面)⇒ upsert 一帧
823
+ for (const [k, frame] of curFrames)
824
+ if (prevFrames.get(k) !== frame)
825
+ res.write(`event: pending\ndata: ${frame}\n\n`);
750
826
  for (const [k, p] of prev)
751
827
  if (!cur.has(k))
752
828
  res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`); // decided/expired/gone
753
829
  }
754
830
  prev = cur;
831
+ prevFrames = curFrames;
755
832
  if (Date.now() - start > SSE_MAX_STREAM_MS) {
756
833
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
757
834
  break;
@@ -1,4 +1,5 @@
1
- import { DEFAULT_EFFORT_LEVELS } from "@sema-agent/core";
1
+ import { DEFAULT_EFFORT_LEVELS, expandTiers } from "@sema-agent/core";
2
+ import { isModelAllowlisted } from "../../model-select.js";
2
3
  import { cwdHonored } from "../../task-cwd.js";
3
4
  import { mcpInjectionHonored } from "../../task-mcp.js";
4
5
  import { sendJson, sendError } from "../send.js";
@@ -394,6 +395,11 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
394
395
  // today = env-derived config.models; later the sema registry (sema-registry). The internal
395
396
  // `default` alias is hidden (it's a fallback ref, not a user-pickable model).
396
397
  if (req.method === "GET" && url === "/v1/models") {
398
+ // #233 / A-002.8 读面:名单**在场时**每行补 `atMentionable`(名单缺席/空 ⇒ 整个键不发 —— 缺省形的
399
+ // 字节逐字不变,消费端"没有这个键"= 这台没开名单治理,而不是"这行不能点")。判定与三个执法点同源
400
+ // (isModelAllowlisted + 增广目录):壳面板上灰掉的行与服务端真会拒的行永远是同一批。
401
+ const atCatalog = expandTiers(deps.config.models, deps.config.tiers ?? {}) ?? deps.config.models;
402
+ const atAllowlist = deps.config.atModelAllowlist ?? [];
397
403
  const models = Object.entries(deps.config.models)
398
404
  .filter(([name]) => name !== "default")
399
405
  .map(([name, m]) => ({
@@ -414,6 +420,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
414
420
  // model (effort doesn't apply). The accept-set on a request is broader (any core ThinkingLevel) — this is
415
421
  // the advertised picker default, not a hard allow-list.
416
422
  ...(m.reasoning ? { supportedEffortLevels: [...DEFAULT_EFFORT_LEVELS] } : {}),
423
+ ...(atAllowlist.length > 0 ? { atMentionable: isModelAllowlisted(name, atCatalog, atAllowlist) } : {}),
417
424
  }));
418
425
  // [865]③ id/name 撕裂消解:`default` 历史上是 **id 形**(壳面板行 value 却是 name 形,壳侧只能打补丁
419
426
  // 拼回)。additive 双给:`default` 保持 id 形字节不动(既有消费者),新 `defaultModel = { id, name }`
@@ -80,5 +80,23 @@ export declare function buildStaticWiringAudit(facts: StaticWiringFacts): Static
80
80
  export declare function assertStaticWiringConsistent(facts: StaticWiringFacts, logger: {
81
81
  warn: (msg: string, meta?: Record<string, unknown>) => void;
82
82
  } | undefined): StaticWiringAudit;
83
+ /**
84
+ * [3399]③ clay 裁 (b)(2026-08-12,core [3648]④ + cli [3649]① 双背书):**零凭证 turnkey 形**的
85
+ * loopback 来源门判据(单一属主,端点与测试同吃)。
86
+ *
87
+ * 只在「operator 名单空 ∧ 非多租户 ∧ 全局 service-credential 门也不在场(没配 SERVICE_AUTH_TOKEN /
88
+ * AUTH_TOKENS)」的部署形上判——那正是全局门整个不设、诊断端点此前无认证可读的形。诊断面泄的是装配
89
+ * 拓扑(store 接线/能力位/治理姿态),对误暴露公网端口的 turnkey 机器构成侦察面。(b) 对本机自用零摩擦
90
+ * (壳缺省钉 BIND_HOST=127.0.0.1,local curl 照常),对远程侦察 fail-closed;配了 token 的部署远程读
91
+ * 走认证(全局门),不进本臂。
92
+ *
93
+ * 判源=TCP 对端地址(`req.socket.remoteAddress`),**非任何头**——XFF 类头在无反代的 turnkey 形上本就
94
+ * 是伪造面;有反代的部署必然配 token(反代后 remoteAddress 恒 loopback,门自然放行,语义仍对:该形的
95
+ * 边界由反代+token 承担)。`remoteAddress` 缺席(socket 已断等边缘)⇒ 判拒(fail-closed,安全轴方向)。
96
+ */
97
+ export declare function zeroCredentialLoopbackDenied(remoteAddress: string | undefined, config: {
98
+ authToken?: string;
99
+ authTokens?: Record<string, string>;
100
+ }): boolean;
83
101
  export declare function handleDiagnostics(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
84
102
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -91,6 +91,28 @@ export function assertStaticWiringConsistent(facts, logger) {
91
91
  }
92
92
  return audit;
93
93
  }
94
+ /**
95
+ * [3399]③ clay 裁 (b)(2026-08-12,core [3648]④ + cli [3649]① 双背书):**零凭证 turnkey 形**的
96
+ * loopback 来源门判据(单一属主,端点与测试同吃)。
97
+ *
98
+ * 只在「operator 名单空 ∧ 非多租户 ∧ 全局 service-credential 门也不在场(没配 SERVICE_AUTH_TOKEN /
99
+ * AUTH_TOKENS)」的部署形上判——那正是全局门整个不设、诊断端点此前无认证可读的形。诊断面泄的是装配
100
+ * 拓扑(store 接线/能力位/治理姿态),对误暴露公网端口的 turnkey 机器构成侦察面。(b) 对本机自用零摩擦
101
+ * (壳缺省钉 BIND_HOST=127.0.0.1,local curl 照常),对远程侦察 fail-closed;配了 token 的部署远程读
102
+ * 走认证(全局门),不进本臂。
103
+ *
104
+ * 判源=TCP 对端地址(`req.socket.remoteAddress`),**非任何头**——XFF 类头在无反代的 turnkey 形上本就
105
+ * 是伪造面;有反代的部署必然配 token(反代后 remoteAddress 恒 loopback,门自然放行,语义仍对:该形的
106
+ * 边界由反代+token 承担)。`remoteAddress` 缺席(socket 已断等边缘)⇒ 判拒(fail-closed,安全轴方向)。
107
+ */
108
+ export function zeroCredentialLoopbackDenied(remoteAddress, config) {
109
+ const anyServiceAuth = Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
110
+ if (anyServiceAuth)
111
+ return false; // 凭证在场 ⇒ 边界归全局 service-credential 门,本判不参与
112
+ const addr = remoteAddress ?? "";
113
+ const isLoopback = addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
114
+ return !isLoopback;
115
+ }
94
116
  export async function handleDiagnostics(req, res, url, ctx) {
95
117
  const miss = { fell: false };
96
118
  await handleDiagnosticsBody(req, res, url, ctx, miss);
@@ -122,6 +144,10 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
122
144
  return;
123
145
  }
124
146
  }
147
+ else if (zeroCredentialLoopbackDenied(req.socket.remoteAddress, deps.config)) {
148
+ sendError(res, 403, "auth.loopback_only", "wiring diagnostics on a zero-credential deployment are readable from loopback only — configure SERVICE_AUTH_TOKEN to read remotely");
149
+ return;
150
+ }
125
151
  // 静态半场是 **composition root 在 boot 算的**(与拒启自检同一份产物,不可能与它不同)。缺席 ⇒ 这个
126
152
  // 进程不是由 main.ts 装起来的(测试夹具形),此面诚实地不存在 —— 落到全局 404,而不是回一个空壳 200。
127
153
  if (!deps.staticWiring) {
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
3
- import { isTerminalRunStatus } from "../../plugins/store-contracts.js";
3
+ import { isTerminalRunStatus, isParkedRunStatus } from "../../plugins/store-contracts.js";
4
4
  import { redactSecrets } from "../../trace/redact.js";
5
5
  import { HttpError, verifiedPrincipal, isUuidV7, encodeCheckpointScope } from "../../security.js";
6
6
  import { runInBackground } from "../../runs.js";
@@ -522,7 +522,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
522
522
  // CAS lost — answer by the run's ACTUAL state, never pretend the cancel landed.
523
523
  const now = await rs.getRun(taskId);
524
524
  const st = now?.status;
525
- if (st === "suspended" || st === "needs_review" || st === "running") {
525
+ // 判据取词表属主(合并码重扫):park 是两个词,手抄形对 core 的词表增删两向都没有编译期钉。
526
+ if (st !== undefined && (isParkedRunStatus(st) || st === "running")) {
526
527
  // 文案中性覆盖两因(复验镜头:输家可能是 decide 也可能是 reaper 的 expire——后者无人 resuming)
527
528
  sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
528
529
  }
@@ -544,7 +545,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
544
545
  if (!claimed) {
545
546
  const now = await rs.getRun(taskId);
546
547
  const st = now?.status;
547
- if (st === "running" || st === "suspended" || st === "needs_review") {
548
+ // 同上:词表属主判据(手抄形绕开 isParkedRunStatus `never` 执法点)
549
+ if (st !== undefined && (st === "running" || isParkedRunStatus(st))) {
548
550
  sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
549
551
  }
550
552
  else {
@@ -609,15 +611,36 @@ async function handleRunsBody(req, res, url, ctx, miss) {
609
611
  // Durable flag FIRST (only flips a still-running row), abort after: if the run suspended/terminal'd
610
612
  // between the getRun above and here, the flag write affects 0 rows — re-read and answer by the run's
611
613
  // ACTUAL state instead of lying "cancelling" about a run nothing will cancel.
612
- const flagged = await deps.runStore.requestCancel(taskId, run.owner); // owner guard: single-DB defense-in-depth (HTTP gate already passed runOwnerOk)
613
- if (!flagged) {
614
+ let flagged = await deps.runStore.requestCancel(taskId, run.owner); // owner guard: single-DB defense-in-depth (HTTP gate already passed runOwnerOk)
615
+ // 抢跑输了(旗打空 0 行) 按 run 的**真实**状态重判。三向,而且 `running` 那一向必须**重挂旗**
616
+ // 而不是回答:
617
+ // · **park**(🔴 判据取词表属主 `store-contracts.ts` 的穷举 `isParkedRunStatus`,扫描P2):原地手抄的
618
+ // `=== "suspended"` 漏掉 `needs_review`(plan_review / dry-run 门的 park 落名),一条抢跑停到计划
619
+ // 评审上的 run 会掉进 no-op 臂被谎报终局,而行还活着、`task_active` claim 一根手指都没动 ——
620
+ // [868] 会话锁死指纹的同族第二例(直连分支早已认两个词,codex M1;这条孪生臂当时漏了)。
621
+ // · **running**(codex 复审 round1 [high] 二,验真):`requestCancel` 只对 running 行生效,而
622
+ // park→running 的**二次跃迁**真实存在——并发 approve 的 `markResuming` 会把行从 park 翻回
623
+ // running。旧码只处理「重读到 park」,重读到 running 掉进 no-op 臂:旗没挂上、腿没 abort,
624
+ // **被批准的活继续跑**,而调用方以为取消是空操作。行活过来正是该重挂旗的时刻,不是该撒谎的时刻。
625
+ // · 真终局 ⇒ 照旧幂等 no-op(带真实状态)。
626
+ // 有界(park↔running 反复跳时不许把请求钉在这):次数用尽仍 running ⇒ 如实 409 让调用方重试。
627
+ const CANCEL_RECLASSIFY_ATTEMPTS = 3;
628
+ for (let attempt = 0; !flagged; attempt++) {
614
629
  const now = await deps.runStore.getRun(taskId);
615
- if (now?.status === "suspended") {
616
- await cancelSuspended(); // [868] lost race INTO suspended — same recovery handle as the direct branch
630
+ if (now !== undefined && isParkedRunStatus(now.status)) {
631
+ await cancelSuspended(); // [868] lost race INTO a park — same recovery handle as the direct branch
632
+ return;
617
633
  }
618
- else {
619
- sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "run already terminal cancel is a no-op" });
634
+ if (now?.status === "running" && attempt < CANCEL_RECLASSIFY_ATTEMPTS) {
635
+ flagged = await deps.runStore.requestCancel(taskId, run.owner); // 行又活了 重挂 durable 旗(赢了就走下面的快路)
636
+ continue;
637
+ }
638
+ if (now?.status === "running") {
639
+ // 文案与 cancelSuspended 的 CAS 输臂同源(同一件事:并发决议赢了,它的恢复腿正在跑)。
640
+ sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: now.status });
641
+ return;
620
642
  }
643
+ sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "run already terminal — cancel is a no-op" });
621
644
  return;
622
645
  }
623
646
  // fast path: cancel landed on the running instance → abort now. Label FIRST ([1.207 codex H2]) so the
@@ -628,10 +651,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
628
651
  }
629
652
  sendJson(res, 202, { taskId, status: "cancelling" });
630
653
  }
631
- else if (run.status === "suspended" || run.status === "needs_review") {
654
+ else if (isParkedRunStatus(run.status)) {
632
655
  // [868] settle the pending gate + terminalize + unlock (was a flat 409 dead-end). needs_review rides the
633
656
  // SAME handle (codex M1): a plan-review park holds the claim identically and the old else branch lied
634
- // "already terminal" about it (claim never released).
657
+ // "already terminal" about it (claim never released). 判据同上取属主(扫描P2:两处原地手抄的同一张
658
+ // park 词表已收敛到 `isParkedRunStatus`,再加词由 tsc 逼人表态)。
635
659
  await cancelSuspended();
636
660
  }
637
661
  else {
@@ -790,7 +814,13 @@ async function handleRunsBody(req, res, url, ctx, miss) {
790
814
  };
791
815
  const sendQueueFull = () => sendError(res, 409, "steering.queue_full", parkQueueFull ?? "the parked steering queue on this checkpoint is full");
792
816
  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");
793
- const sendParked = () => sendJson(res, 202, { taskId, status: "suspended", delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
817
+ /**
818
+ * 202 park 回执。`status` 是**入参**而不是字面量(合并码重扫):park 是两个词,`403a841` 把 (b) 支的
819
+ * 判据拓成 `isParkedRunStatus` 之后 `needs_review` 行首次走进这条支,而回执还硬报 `"suspended"` ⇒ 同一
820
+ * 时刻 `GET /v1/runs/:id` 报 `needs_review`,按回执刷新本地行的消费端把「待评审」渲成「已挂起」。
821
+ * 姊妹位早就读真词(preempt 的 `extras.status`、本文件终局 cancel 与 wake-park 两处回执)。
822
+ */
823
+ const sendParked = (status) => sendJson(res, 202, { taskId, status, delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
794
824
  const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
795
825
  // (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
796
826
  const live = steerableRuns.get(taskId);
@@ -811,9 +841,21 @@ async function handleRunsBody(req, res, url, ctx, miss) {
811
841
  // Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
812
842
  // tick (core already wrote the pending checkpoint) — fall through to the durable park rather than a spurious
813
843
  // 409 (the live→suspended transition window). Only a genuinely-terminal run → not_running.
844
+ // 🔴 回执的 status 要报「投递时刻 server 看到的行状态」,而手里那份快照是**进 handler 时**读的
845
+ // (这条腿上多半还是 `running`,park 发生在它之后)。所以在这里重读一次 —— 且**必须在 park 之前**
846
+ // (codex 复审二/三轮合并处置):
847
+ // · 提交**后**再读 = 给一个已成功的写加提交后依赖。库降级时那次读可以永远挂着(`.catch` 只接
848
+ // 拒绝、接不住不返回),客户端拿不到 202 就重试,而不带 `Idempotency-Key` 的重试会**再追加一条**
849
+ // 同样的指令(队列只有 3 个位子)——把「回执更准」换成「指令重复入队」,方向反了。
850
+ // · 提交**前**读则没有这个代价:此刻还没有任何东西入队,读挂了 = 这次请求超时,与 park 之前任何
851
+ // 一步超时同形,重试语义不变。
852
+ // 读不到(理论不可达:行必在)⇒ 退回快照,绝不硬编码一个 park 词。残余如实说:行的翻面与 core
853
+ // 写 checkpoint 不在同一步,所以这一腿仍可能读到 `running` ——判别键是 `delivery`(SDK/openapi 逐字
854
+ // 「branch on `delivery`, not the status」),不是 status。
855
+ const freshBeforePark = await deps.runStore.getRun(taskId).catch(() => undefined);
814
856
  const raced = await tryPark();
815
857
  if (raced === "parked") {
816
- sendParked();
858
+ sendParked(freshBeforePark?.status ?? run.status);
817
859
  return;
818
860
  }
819
861
  if (raced === "queue-full") {
@@ -828,15 +870,18 @@ async function handleRunsBody(req, res, url, ctx, miss) {
828
870
  return;
829
871
  }
830
872
  }
831
- // (b) Durably suspended → park the steer on the pending checkpoint (drained on resume).
832
- if (run.status === "suspended") {
873
+ // (b) Durably PARKED → park the steer on the pending checkpoint (drained on resume). 判据取词表属主
874
+ // isParkedRunStatus(扫描P2 顺带件):只认 suspended 会把 needs_review park 漏到 (d) 终局支——消息
875
+ // 虽也 park 上同一张卡,但回执是 parked_for_wake+「run already ended,用 /wake 送」,指路错人
876
+ // (真相是本支的「injected when the run resumes」)。
877
+ if (isParkedRunStatus(run.status)) {
833
878
  const outcome = await tryPark();
834
879
  if (outcome === "no-store") {
835
880
  sendError(res, 501, "capability.checkpoint_store_required", "steering a suspended run requires the checkpoint store");
836
881
  return;
837
882
  }
838
883
  if (outcome === "parked") {
839
- sendParked();
884
+ sendParked(run.status);
840
885
  return;
841
886
  }
842
887
  if (outcome === "queue-full") {
@@ -847,7 +892,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
847
892
  sendDuplicateKey();
848
893
  return;
849
894
  }
850
- sendNotRunning("run is no longer suspended (resolved or expired)");
895
+ // 文案按 park **两个词**中性化(旧句只说 suspended,对一条 needs_review park 说的是别人的事)
896
+ sendNotRunning("run is no longer parked on a pending decision (resolved or expired)");
851
897
  return;
852
898
  }
853
899
  // (c) running-elsewhere (cross-replica, fast-follow seam) → not_running.
@@ -1,5 +1,6 @@
1
- import { isThinkingLevel } from "@sema-agent/core";
1
+ import { expandTiers, isThinkingLevel } from "@sema-agent/core";
2
2
  import { HttpError } from "../../security.js";
3
+ import { isModelAllowlisted } from "../../model-select.js";
3
4
  import { cacheFamilyOfMirror } from "../../budget.js";
4
5
  import { redactSecrets } from "../../trace/redact.js";
5
6
  import { sendJson, sendError, httpErrorCode, msg } from "../send.js";
@@ -135,6 +136,19 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
135
136
  sendError(res, 400, "request.field_invalid", `invalid modelRole (expected one of: ${Object.keys(MODEL_ROLE_TABLE).join(", ")}) — a role only resolves a model when 'model' is absent`);
136
137
  return;
137
138
  }
139
+ // #233 / A-002.8 第三执法点 —— side-query 的 `body.model` 是**计费提交面**上的用户逐 turn 选择
140
+ // 通道(直透传给 core 的路由),与任务面 `body.model` 同名单同拒形。归一走同一只
141
+ // `isModelAllowlisted`(增广目录:档位词/CC 别名/id 与 name 指同一只模型时不许绕门)。
142
+ // 不受限的是 `modelRole`(operator 的角色表选路,不是用户点的模型)——两者的辖域线与任务面一致。
143
+ // 本路由无 resume 腿(一次性问答),故只有 fresh 拒这一支。
144
+ if (typeof body.model === "string" && body.model.length > 0) {
145
+ const bare = deps.config.models ?? {};
146
+ const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
147
+ if (!isModelAllowlisted(body.model, catalog, deps.config.atModelAllowlist)) {
148
+ sendError(res, 400, "request.model_not_allowed", `model is not in this deployment's @-mention allowlist: "${body.model.slice(0, 120)}" — the allowlist is configured by the operator (config-center models.atModelAllowlist)`);
149
+ return;
150
+ }
151
+ }
138
152
  if (ac.signal.aborted)
139
153
  return; // client already gone — don't start the brain call at all
140
154
  const spec = {
@@ -20,6 +20,12 @@ import { failOpenTagForDroppedFrame, recordFailOpen } from "../../observability/
20
20
  * 前缀 `id: <eventId>` 行(SSE 游标=账本 eventId,壳/SDK 拿它锚进账本或断线换道 events tail 续读);
21
21
  * 无 eventId 的帧**不发行**,EventSource 沿用上一游标 —— 绝不合成占位 id(那会把不可锚的位置伪装成可锚)。
22
22
  * durable 腿的 `id:` 行在 sse-log.ts(同一契约的另半);行为钉=test/live-sse-cursor.test.ts(规则式全流判据)。 */
23
+ /** #241([3731] 裁定 A):断连转 park 的有界宽限——ask 已被强转 park 路由后,给 core 从「收到
24
+ * "unavailable"」走到「铸 checkpoint + done(suspended)」的时间。到点仍没收到 done ⇒ 照旧 abort,
25
+ * 交断连支既有的 findPendingTokenBySession 竞态卫兵兜底(checkpoint 已落盘则仍 park)。
26
+ * 取值:core 的 park 是本地 IO(checkpoint 写盘/写库)+一次 done 帧,秒级富余;不设旋钮——它不是
27
+ * 行为面选择,只是竞态兜底的等待上限(设长了唯一代价是极端卡死时 abort 晚到)。 */
28
+ const DISCONNECT_PARK_GRACE_MS = 10_000;
23
29
  function sseData(res, payload) {
24
30
  const anchor = payload.eventId;
25
31
  const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
@@ -161,6 +167,8 @@ async function handleTasksBody(req, res, url, ctx, miss) {
161
167
  // 🔴 stoppedBy 归因的注册点在**下面**(createRun 认领成功之后),不在这里 —— #168 件3,见那里的注。
162
168
  let closed = false;
163
169
  let detachLogged = false; // [854]①b:detach 断连 info 只打一条(req/res 两个 close listener 都可能进来)
170
+ // #241([3731] 裁定 A):断连转 park 的宽限表——非空 = 已触发过 ask 转 park,断连支不再二次进臂。
171
+ let disconnectParkGrace;
164
172
  const onDisconnect = () => {
165
173
  // [854]①b:detach opt-in 的断连分支 —— 不置 closed(for-await 继续消费到 done,终态走既有
166
174
  // setTerminal 路径落 durable 账本)、不 abort(run 不杀)。断连后的 res.write 落在已 destroy 的
@@ -180,6 +188,26 @@ async function handleTasksBody(req, res, url, ctx, miss) {
180
188
  }
181
189
  return;
182
190
  }
191
+ // #241([3731] 裁定 A「park 应跨壳死存活」,[3730] core 零改动承运;cli [3729] L3 真机复现=本臂
192
+ // 缺席时的行为):断连瞬间本 run 有**流内未决 ask** 且 park 设施在场 ⇒ 不立即 abort——先把 ask 按
193
+ // 窗到期同路强转 park(coordinator 交 `"unavailable"` ⇒ core 走 durable park ⇒ done(suspended)),
194
+ // 给 core 一个**有界宽限**走完 park:宽限内 done 到 ⇒ 循环自然收束、走上面的 setSuspended 臂
195
+ // (行 park、卡存活、resume 重呈闭环);宽限用尽(park 卡在半路)⇒ 照旧 closed+abort,下面既有的
196
+ // findPendingTokenBySession 竞态卫兵接手(checkpoint 已落盘则仍 park)。真算力中断(无未决 ask)
197
+ // 语义**不变**:立即 abort——那是这个断连臂存在的原始理由(dropped relay 不烧 token)。
198
+ if (deps.checkpointStore && earlyDurableTid && disconnectParkGrace === undefined) {
199
+ const parked = deps.toolApproval?.parkOpenAsksForTask(earlyDurableTid) ?? 0;
200
+ if (parked > 0) {
201
+ res.on("error", () => undefined); // 断连后循环继续消费,死 socket 上的 write 错误按 detach 同形吞
202
+ deps.logger?.info("stream_disconnect_ask_parked", { taskId: earlyDurableTid, sessionId: prepared.spec.sessionId, asks: parked, graceMs: DISCONNECT_PARK_GRACE_MS });
203
+ disconnectParkGrace = setTimeout(() => {
204
+ closed = true;
205
+ ac.abort();
206
+ }, DISCONNECT_PARK_GRACE_MS);
207
+ disconnectParkGrace.unref?.();
208
+ return;
209
+ }
210
+ }
183
211
  closed = true;
184
212
  ac.abort();
185
213
  };
@@ -1054,7 +1082,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1054
1082
  if (finalResult?.status === "suspended" && deps.checkpointStore)
1055
1083
  await deps.runStore.setSuspended(durableTaskId);
1056
1084
  else if (finalResult?.status === "needs_review" && deps.checkpointStore)
1057
- await deps.runStore.setNeedsReview(durableTaskId); // D-B: park a review pause, keep the lock (resumable via /plan_review)
1085
+ await deps.runStore.setNeedsReview(durableTaskId); // D-B: park a review pause, keep the lock (a plan_review GATE resumes via /plan_review; a needs_review gate = dry-run review has no resolution endpoint today — [3683]-1, don't copy this comment as "needs_review → /plan_review")
1058
1086
  else if (finalResult)
1059
1087
  await deps.runStore.setTerminal(durableTaskId, finalResult.status, finalResult, finalResult.errorMessage ?? null);
1060
1088
  else {
@@ -1345,8 +1373,9 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1345
1373
  return { status: 200, body: { taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "suspended" } };
1346
1374
  }
1347
1375
  // design/80 D-B: a review pause (plan_review / dry_run_review) on the sync path — PARK it like a suspend
1348
- // (keep task_active) + return a pollable taskId so the /v1/assistant/tasks/:id/plan_review resume can
1349
- // claim it. NEVER the capability token (parity with the suspended branch above).
1376
+ // (keep task_active) + return a pollable taskId. For a plan_review GATE the /v1/assistant/tasks/:id/plan_review
1377
+ // resume claims it; a needs_review gate (dry-run review) has no resolution endpoint today ([3683]-1).
1378
+ // NEVER the capability token (parity with the suspended branch above).
1350
1379
  if (result.status === "needs_review" && deps.checkpointStore && deps.runStore && prepared.spec.sessionId) {
1351
1380
  if (durableTaskId)
1352
1381
  await deps.runStore.setNeedsReview(durableTaskId);