@sema-agent/server 7.14.0 → 7.16.0-rc.1

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 (71) hide show
  1. package/MIGRATION.md +16 -1
  2. package/USAGE.md +38 -4
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +10 -0
  5. package/dist/approval-card.d.ts +65 -0
  6. package/dist/approval-card.js +54 -6
  7. package/dist/approval-reconciler.d.ts +17 -1
  8. package/dist/boot/memory-boundary.d.ts +6 -0
  9. package/dist/boot/memory-boundary.js +10 -2
  10. package/dist/boot/resolve-spec.js +31 -4
  11. package/dist/boot/runner-deps.d.ts +41 -2
  12. package/dist/boot/runner-deps.js +43 -0
  13. package/dist/boot/session-faces.js +26 -4
  14. package/dist/boot/shutdown.js +17 -0
  15. package/dist/boot/stores.js +28 -7
  16. package/dist/capabilities/memory-notice.d.ts +13 -9
  17. package/dist/capabilities/memory-notice.js +35 -15
  18. package/dist/config-center/apply-effective.d.ts +14 -0
  19. package/dist/config-center/apply-effective.js +81 -1
  20. package/dist/config-types.d.ts +28 -2
  21. package/dist/config.js +38 -2
  22. package/dist/fleet/fleet-terminal-window.d.ts +12 -0
  23. package/dist/fleet/fleet-terminal-window.js +27 -4
  24. package/dist/http/active-run-conflict.d.ts +41 -1
  25. package/dist/http/active-run-conflict.js +24 -10
  26. package/dist/http/routes/approvals-assistant.d.ts +11 -3
  27. package/dist/http/routes/approvals-assistant.js +97 -20
  28. package/dist/http/routes/capabilities.js +20 -1
  29. package/dist/http/routes/diagnostics.d.ts +18 -0
  30. package/dist/http/routes/diagnostics.js +26 -0
  31. package/dist/http/routes/runs.js +86 -19
  32. package/dist/http/routes/side-query.js +15 -1
  33. package/dist/http/routes/tasks.js +32 -3
  34. package/dist/http/routes/trace-usage.js +38 -37
  35. package/dist/http/server.js +67 -13
  36. package/dist/leader/fanout.d.ts +18 -0
  37. package/dist/leader/fanout.js +34 -1
  38. package/dist/leader/leader.js +9 -5
  39. package/dist/leader/wire.js +16 -5
  40. package/dist/main.js +1 -0
  41. package/dist/memory-scope.d.ts +20 -0
  42. package/dist/memory-scope.js +45 -0
  43. package/dist/model-select.d.ts +43 -1
  44. package/dist/model-select.js +70 -2
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +7 -1
  48. package/dist/parent-watch.d.ts +57 -0
  49. package/dist/parent-watch.js +108 -0
  50. package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
  51. package/dist/plugins/checkpoint-store-sql.js +133 -7
  52. package/dist/plugins/local-checkpoint-store.js +11 -1
  53. package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
  54. package/dist/plugins/memory-embedder-fingerprint.js +280 -0
  55. package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
  56. package/dist/plugins/permission-rule-store-sql.js +1 -7
  57. package/dist/plugins/pg-pool.js +3 -0
  58. package/dist/plugins/store-backend.d.ts +5 -6
  59. package/dist/plugins/store-backend.js +4 -1
  60. package/dist/plugins/store-contracts.d.ts +17 -0
  61. package/dist/plugins/store-contracts.js +33 -0
  62. package/dist/plugins/tidb-pool.js +7 -0
  63. package/dist/plugins/tool-result-store-sql.d.ts +18 -13
  64. package/dist/plugins/tool-result-store-sql.js +50 -29
  65. package/dist/run-local.js +1 -0
  66. package/dist/tool-approval.d.ts +10 -0
  67. package/dist/tool-approval.js +143 -9
  68. package/dist/trace/project.js +8 -0
  69. package/dist/trace/redact.d.ts +14 -1
  70. package/dist/trace/redact.js +14 -2
  71. package/package.json +3 -3
@@ -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";
@@ -14,6 +14,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
14
14
  import { streamSseLog } from "../sse-log.js";
15
15
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
16
16
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
17
+ import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
17
18
  import { normalizeRunEventType } from "../../trace/project.js";
18
19
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
19
20
  import { buildActiveRunConflict } from "../active-run-conflict.js";
@@ -89,7 +90,9 @@ const AskDecisionBodySchema = z
89
90
  decision: z.enum(["approve", "deny"]),
90
91
  /** 仅 approve 臂有意义;deny 带 = 宽收后忽略(与三条 respond 同姿势)。 */
91
92
  updatedInput: z.unknown().optional(),
92
- note: z.string().max(2048).optional(),
93
+ /** 上限走**共享常量**(#229):live 腿的手写 parser 写同一列,两处各写一份 `2048` 字面量 =
94
+ * 同一列上两个口径,而分歧只会在「一条腿收下、另一条腿 400」的那天才暴露。 */
95
+ note: z.string().max(MAX_DECISION_NOTE_CHARS).optional(),
93
96
  idempotencyKey: z
94
97
  .string()
95
98
  .min(1)
@@ -519,7 +522,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
519
522
  // CAS lost — answer by the run's ACTUAL state, never pretend the cancel landed.
520
523
  const now = await rs.getRun(taskId);
521
524
  const st = now?.status;
522
- if (st === "suspended" || st === "needs_review" || st === "running") {
525
+ // 判据取词表属主(合并码重扫):park 是两个词,手抄形对 core 的词表增删两向都没有编译期钉。
526
+ if (st !== undefined && (isParkedRunStatus(st) || st === "running")) {
523
527
  // 文案中性覆盖两因(复验镜头:输家可能是 decide 也可能是 reaper 的 expire——后者无人 resuming)
524
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 });
525
529
  }
@@ -541,7 +545,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
541
545
  if (!claimed) {
542
546
  const now = await rs.getRun(taskId);
543
547
  const st = now?.status;
544
- if (st === "running" || st === "suspended" || st === "needs_review") {
548
+ // 同上:词表属主判据(手抄形绕开 isParkedRunStatus `never` 执法点)
549
+ if (st !== undefined && (st === "running" || isParkedRunStatus(st))) {
545
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 });
546
551
  }
547
552
  else {
@@ -606,15 +611,36 @@ async function handleRunsBody(req, res, url, ctx, miss) {
606
611
  // Durable flag FIRST (only flips a still-running row), abort after: if the run suspended/terminal'd
607
612
  // between the getRun above and here, the flag write affects 0 rows — re-read and answer by the run's
608
613
  // ACTUAL state instead of lying "cancelling" about a run nothing will cancel.
609
- const flagged = await deps.runStore.requestCancel(taskId, run.owner); // owner guard: single-DB defense-in-depth (HTTP gate already passed runOwnerOk)
610
- 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++) {
611
629
  const now = await deps.runStore.getRun(taskId);
612
- if (now?.status === "suspended") {
613
- 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;
614
633
  }
615
- else {
616
- 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;
617
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;
642
+ }
643
+ sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "run already terminal — cancel is a no-op" });
618
644
  return;
619
645
  }
620
646
  // fast path: cancel landed on the running instance → abort now. Label FIRST ([1.207 codex H2]) so the
@@ -625,10 +651,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
625
651
  }
626
652
  sendJson(res, 202, { taskId, status: "cancelling" });
627
653
  }
628
- else if (run.status === "suspended" || run.status === "needs_review") {
654
+ else if (isParkedRunStatus(run.status)) {
629
655
  // [868] settle the pending gate + terminalize + unlock (was a flat 409 dead-end). needs_review rides the
630
656
  // SAME handle (codex M1): a plan-review park holds the claim identically and the old else branch lied
631
- // "already terminal" about it (claim never released).
657
+ // "already terminal" about it (claim never released). 判据同上取属主(扫描P2:两处原地手抄的同一张
658
+ // park 词表已收敛到 `isParkedRunStatus`,再加词由 tsc 逼人表态)。
632
659
  await cancelSuspended();
633
660
  }
634
661
  else {
@@ -787,7 +814,13 @@ async function handleRunsBody(req, res, url, ctx, miss) {
787
814
  };
788
815
  const sendQueueFull = () => sendError(res, 409, "steering.queue_full", parkQueueFull ?? "the parked steering queue on this checkpoint is full");
789
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");
790
- 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" });
791
824
  const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
792
825
  // (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
793
826
  const live = steerableRuns.get(taskId);
@@ -808,9 +841,21 @@ async function handleRunsBody(req, res, url, ctx, miss) {
808
841
  // Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
809
842
  // tick (core already wrote the pending checkpoint) — fall through to the durable park rather than a spurious
810
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);
811
856
  const raced = await tryPark();
812
857
  if (raced === "parked") {
813
- sendParked();
858
+ sendParked(freshBeforePark?.status ?? run.status);
814
859
  return;
815
860
  }
816
861
  if (raced === "queue-full") {
@@ -825,15 +870,18 @@ async function handleRunsBody(req, res, url, ctx, miss) {
825
870
  return;
826
871
  }
827
872
  }
828
- // (b) Durably suspended → park the steer on the pending checkpoint (drained on resume).
829
- 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)) {
830
878
  const outcome = await tryPark();
831
879
  if (outcome === "no-store") {
832
880
  sendError(res, 501, "capability.checkpoint_store_required", "steering a suspended run requires the checkpoint store");
833
881
  return;
834
882
  }
835
883
  if (outcome === "parked") {
836
- sendParked();
884
+ sendParked(run.status);
837
885
  return;
838
886
  }
839
887
  if (outcome === "queue-full") {
@@ -844,7 +892,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
844
892
  sendDuplicateKey();
845
893
  return;
846
894
  }
847
- 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)");
848
897
  return;
849
898
  }
850
899
  // (c) running-elsewhere (cross-replica, fast-follow seam) → not_running.
@@ -1748,6 +1797,18 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1748
1797
  deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve");
1749
1798
  return true;
1750
1799
  };
1800
+ /**
1801
+ * #229(设计稿 233 稿B v2 §2):回决理由的**读面**投影 —— 三个回体共用这一份,行上有才发。
1802
+ *
1803
+ * 🔴 **裁定翻面,原样记账**:本 handler 原先明写「`note` 不回显(以请求者可见权限为界的最窄安全形)」。
1804
+ * 那条最窄形的实测代价是 `decisionNote` 全仓**只写不读** —— 一个零读面的「审计位」对任何按审计面
1805
+ * 接它的消费端都是当场落空。现行裁定:三回体一律 additive 投,**从不发 null / 空键**(缺席 = 这条
1806
+ * 决议没留理由,与 `updatedInputForwarded` 的「从不发 false」同族)。
1807
+ * 读权前提没有放宽:能走到这条口的调用方本来就有权决这只 ask(属主门在本 handler 上游),而 409 支
1808
+ * 回显的**首决**理由与它同支已经在回显的 `decision`/`decidedAtMs`/`actor` 是同一份首决投影 ——
1809
+ * 多这一格不新开任何一条越权读路径。
1810
+ */
1811
+ const decisionNoteEcho = (row) => (row.decisionNote === null ? {} : { decisionNote: row.decisionNote });
1751
1812
  /** 200 回放形:与首决 200 同键集,只差 `updatedInputForwarded`(§12-A 明写回放不承诺字节等同)。
1752
1813
  * 调用前提 = 已过 {@link syncLiveFromDecidedRow}(行合形且活体窗已同步)。 */
1753
1814
  const replayDecided = (row) => {
@@ -1758,6 +1819,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1758
1819
  decision: row.decision,
1759
1820
  decidedAtMs: row.decidedAtMs,
1760
1821
  ...(storedActor ? { actor: storedActor } : {}),
1822
+ ...decisionNoteEcho(row),
1761
1823
  });
1762
1824
  };
1763
1825
  // 终局分派 —— **一律按传进来的这一行**投影,不重读(§12-A:`DecideResult.row` 已是新鲜行;
@@ -1775,11 +1837,13 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1775
1837
  return;
1776
1838
  }
1777
1839
  const first = projectDecisionActor(row.decisionActor);
1778
- // 首决回显三件;`note` **不回显**(以请求者可见权限为界的最窄安全形,稿 §5)
1840
+ // 首决回显四件(#229 起含 `decisionNote`;翻面理由见 {@link decisionNoteEcho})——回显的恒是
1841
+ // **首决**那条理由,不是本次请求带来的那条(本次请求压根没落地)。
1779
1842
  sendError(res, 409, "conflict.ask_decided", "this ask was already decided — the first decision stands and is echoed here", {
1780
1843
  decision: row.decision,
1781
1844
  decidedAtMs: row.decidedAtMs,
1782
1845
  ...(first ? { actor: first } : {}),
1846
+ ...decisionNoteEcho(row),
1783
1847
  });
1784
1848
  return;
1785
1849
  }
@@ -1935,6 +1999,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1935
1999
  decidedAtMs: outcome.row.decidedAtMs,
1936
2000
  actor,
1937
2001
  ...(forwarded ? { updatedInputForwarded: true } : {}),
2002
+ // #229:同上,**按行**投(不按 `body.note`)——赢者行才是这次回决的真相,而 `decideAsk` 的提交与
2003
+ // 回读之间那条行仍可能被版本化收敛改写(同一条「每个入参都取自行」的纪律)。
2004
+ ...decisionNoteEcho(outcome.row),
1938
2005
  });
1939
2006
  return;
1940
2007
  }
@@ -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);
@@ -1,4 +1,3 @@
1
- import { buildToolResultRef } from "@sema-agent/core";
2
1
  import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.js";
3
2
  import { projectArtifacts } from "../../trace/artifacts.js";
4
3
  import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
@@ -235,7 +234,11 @@ async function handleTaskArtifacts(res, runStore, taskId) {
235
234
  * 「坏 query + 已知 ref = 400」而「坏 query + 未知 ref = 404」,状态码之差立刻成了存在性 oracle。
236
235
  * query 的合法性只取决于 query 自身,与调用方看不看得见这个 ref 无关,所以先判是安全的。
237
236
  *
238
- * ② **归属绑定 = 对本 task 的耐久日志做精确成员判定**,不解析 ref、不复制段转义规则。
237
+ * ② **归属绑定 = 店里记的出处(`ownerOf`)与本 run 的 session/task 精确相等**(实现见 ②-a),不解析 ref
238
+ * 不复制段转义规则,**且没有第二条腿**。
239
+ *
240
+ * 下面这一大段是这条判据的**病历**(前缀形 → 本 task 日志作证形 → 出处形),留着是因为「为什么不
241
+ * 照 ref 的字面去判」每隔一阵就被重新问一次;当前生效的判据只有 🟡 段之后的那一条。
239
242
  *
240
243
  * 先说为什么**不是**前缀判定([3321]② 的字面形、[3322] 的指定形):core 的 ref 形是
241
244
  * `tr_<seg(session)>_<seg(toolCallId)>`,而 `_` 在两段里都合法 ⇒ session `team` 的前缀 `tr_team_`
@@ -247,7 +250,7 @@ async function handleTaskArtifacts(res, runStore, taskId) {
247
250
  * 跨租户**拒绝**面(别人注册一个 `<你的 session>_toolu` 就能让你自己的 ref 永远 404)——R2 复审两条
248
251
  * 中标,已撤回该启发式。
249
252
  *
250
- * 改判据:ref **必须由本 task 自己的耐久事件日志作证**。日志里每条 tool_start/tool_end 都带
253
+ * 当年的改判据(**已被 ②-a 取代,见下**):ref **必须由本 task 自己的耐久事件日志作证**。日志里每条 tool_start/tool_end 都带
251
254
  * `toolCallId`(`trace/project.ts` 的 toolStartEventData / toolEndEventData 是唯一铸造点),用 core
252
255
  * 的**同一个** `buildToolResultRef` 把它们铸成 ref 集合,再要求 `ref` 是其中一员 —— 精确相等,
253
256
  * 零解析、零转义规则复制,歧义面结构上不存在(A 的 taskId 只作证 A 自己的 toolCallId)。
@@ -255,10 +258,18 @@ async function handleTaskArtifacts(res, runStore, taskId) {
255
258
  * trace 的 tool-result 块与本判据读的是同一份日志,所以「trace 上看得见 ⇒ 这里读得到」成立;
256
259
  * 同 session 里**别的 task** 的 ref(或日志已过保留窗被逐出的)按 404 同形拒,请到那条 task 上读。
257
260
  *
258
- * 🟢 **两条残余已随 core 5.26.0 #119 销账**(下面原文保留作病历)core 按当年这里写的方向动了刀:
259
- * `put` 带出处落库 + `ownerOf` 读回,读面改用出处判定(实现见 ②-a),于是 (a) 合成 id 铸的 ref 不再
260
- * 读不到、(b) 拼接非单射的越权面结构上消失(ref 铸法本身也换成 `~` 四段单射)。日志重铸只剩**存量
261
- * 兜底**一条腿(无出处的旧行),新写入一律经出处。
261
+ * 🟡 **两条残余的现状(7.14.0 合并窗复扫改口,下面原文保留作病历)**。core 按当年这里写的方向动了刀:
262
+ * `put` 带出处落库 + `ownerOf` 读回,读面改用出处判定(实现见 ②-a)。逐条对账:
263
+ * · (b) 拼接非单射的越权面**已彻底消失** —— ref 铸法换成 `~` 多段,`~` 不在段的合法字符集里。
264
+ * · (a) 合成 id 铸的 ref **只销了一半**:offload / budget / compaction 三个写点的出处只声明
265
+ * session ⇒ 现在读得到;而 `task-registry-monitor` 的 spill(`<stream>_seg<n>`)与
266
+ * `task-registry-agent` 的 spill(`c<cycle>`)在出处里**点名了 taskId,填的是 core TaskRegistry
267
+ * 的 handle id**(`b/w/a/m` 前缀 + 16 hex,见 core `task-registry.js`),与本路由段里的 server
268
+ * run id(uuidv7)是两个命名空间,恒不相等 ⇒ 这两族 ref 在本面**恒 404**(改前也 404,不是回归)。
269
+ * 而它们恰恰是 core 在成品文本里明写「call ReadToolResult with ref …」露给调用方的那批。
270
+ * 真解仍在 core 侧(出处该声明的是**调用方认得的** task 坐标,或干脆只声明 session);本仓不自行
271
+ * 放宽 taskId 那条腿 —— 那是单方面改判定语义,归属判据会与上游各漂一份。
272
+ * 日志重铸那条腿**已删**(见 ②-b:它对任何真实行都不可达,且每次未命中还要付一次全量日志读)。
262
273
  *
263
274
  * ⚠️ 两处**已知残余**,都要 core 侧动刀才能消,写在明处而不是让下一个人自己撞(codex 复审 R3):
264
275
  *
@@ -276,7 +287,7 @@ async function handleTaskArtifacts(res, runStore, taskId) {
276
287
  * 铸出那一枚精确的 toolCallId(provider 铸,本服务不控也不产)。同样只有 core 换成单射编码才真消。
277
288
  * 这里**不留特征化测试**去钉这条通路 —— 把缺陷钉成契约是另一种病。
278
289
  *
279
- * ③ **同形 404**(unknown ref / 未获本 task 日志作证 / 本部署没有 durable store)。任何可区分的响应都是
290
+ * ③ **同形 404**(unknown ref / 出处判不过(含无属主行)/ 本部署没有 durable store)。任何可区分的响应都是
280
291
  * 缺陷:前两者之差会告诉调用方「这枚 ref 存在,只是不是你的」;第三者若回 501 则会把部署形告诉一个
281
292
  * 连自己的 ref 都读不到的 caller。文案恒定且**不回显 ref**(回显=把输入原样反射进错误面)。
282
293
  * 残余面(诚实写明):响应字节同形,**时序**并不同形 —— 三条臂到达的后端调用数不同。要抹平时序得让
@@ -322,10 +333,12 @@ ref, query) {
322
333
  return notFound();
323
334
  // ②-a 出处判定(#119,core 5.26.0 —— 上面 (a)/(b) 两条残余的**正解到货**)。
324
335
  // 店里现在自己记着「这枚 ref 属于谁」(`put` 的第三参落库,`ownerOf` 读回),读面拿**自己已知的字段**
325
- // 比对即可:零解析、零段转义规则复制,合成 id 铸的 ref 与拼接歧义两条残余同时消失。
336
+ // 比对即可:零解析、零段转义规则复制,拼接歧义那条残余彻底消失,合成 id 那条**消了一半**
337
+ // (monitor/agent spill 两族的出处点名的 taskId 是 core handle id,与本函数收到的 server run id 不同
338
+ // 命名空间 ⇒ 恒判假;详见头注 🟡 段)。
326
339
  //
327
- // 🔴 为什么必须改而不是"锦上添花":5.26.0 起 offload ref 是四段单射形(内容坐标进 ref),服务端手里
328
- // 没有内容 ⇒ 下面那条重铸判据**重铸不出来** ⇒ 每一枚真 offload ref 都会 404。读面整死。
340
+ // 🔴 为什么必须改而不是"锦上添花":5.26.0 起 offload ref 是多段单射形(内容坐标进 ref),服务端手里
341
+ // 没有内容 ⇒ 原来那条重铸判据**重铸不出来** ⇒ 每一枚真 offload ref 都会 404。读面整死。
329
342
  //
330
343
  // ⚠️ 辖域(有意的口径变化,写在明处):出处的粒度由 core 定 —— offload/budget/projection 三个写点只声明
331
344
  // session,把 taskId 钉在那里会把「一个 session 的两条 task 共用一枚 ref」这个**设计内**的共享形变成拒绝
@@ -334,32 +347,20 @@ ref, query) {
334
347
  // 之前已过,session 的属主就是这个 caller),换来的是这条读面在 5.26.0 上还活着。
335
348
  const owner = store?.ownerOf ? await store.ownerOf(ref) : undefined;
336
349
  const ownedByThisCaller = owner !== undefined && owner.sessionId === run.sessionId && (owner.taskId === undefined || owner.taskId === taskId);
337
- // ②-b 无出处兜底:**没声明属主**的行按老判据走 —— task 耐久日志作证过的 toolCallId,经 core 同一个
338
- // 铸造点变成 ref 再精确相等。unowned 本身不是授权(core:读面对 unowned 一律 fail-closed),日志作证才是。
339
- //
340
- // ⚠️ 这条**不是**「读得到升级前的存量行」(codex 复审 medium,已核真并改口):5.26.0 的
341
- // `buildToolResultRef` 铸的是 `~` 分隔形,重铸不出旧引擎的 `tr_<sid>_<call>`,而按旧规则手写一个 legacy
342
- // 编码器 = 复制上游规则(本文件头注自己禁掉的那件事)。真正的处置在别处 —— 本批要求**删表重建**
343
- // `tool_result`(见 CHANGELOG),所以升级后库里根本没有旧行。这一支覆盖的是「5.26.0 引擎写下、但写点
344
- // 没声明出处」的行(core 自家的 offload 写点都带出处,所以现实里几乎为空),留着是纵深不是承诺。
345
- // 成本口径:整份日志一读一扫,与同域的 `/turns`、`/artifacts` 同款;有出处时**根本不读日志**。
346
- if (!ownedByThisCaller) {
347
- if (owner !== undefined)
348
- return notFound(); // 出处在,但不是这位 —— 不给第二次机会(日志重铸绕不过出处)
349
- const events = await runStore.getEvents(taskId, 0);
350
- let attested = false;
351
- for (const ev of events) {
352
- // 取键姿势与同域的 `trace/artifacts.ts` 逐字同款(同一份日志、同一个字段,读法不该有第二种)。
353
- const { toolCallId: id } = (ev.data ?? {});
354
- // 逐条比对而不是先建全集:命中即停,长日志上不必把整份集合物化。
355
- if (typeof id === "string" && id.length > 0 && buildToolResultRef(run.sessionId, id) === ref) {
356
- attested = true;
357
- break;
358
- }
359
- }
360
- if (!attested)
361
- return notFound();
362
- }
350
+ // ②-b **没有**第二条腿:出处判不过就是 404(unowned 行也在内 —— core 的口径就是「读面对 unowned
351
+ // fail-closed」)。这里曾有一条「用本 task 耐久日志作证过的 toolCallId `buildToolResultRef` 重铸再
352
+ // 精确相等」的兜底,7.14.0 合并窗复扫判定为**结构性死支**并整条删除,证据链写在明处:
353
+ // · 重铸只喂得起两个参数(服务端手里没有内容)⇒ 铸出的是**两段**形 `tr_<sid>~<callId>`;
354
+ // · core 5.27.0 的**每一个**落库写点铸的都是 ≥3 段(offload/budget/compaction 三段带内容摘要、
355
+ // detail 卸载四段、monitor/agent spill 三段带 `<stream>_seg<n>`/`c<cycle>`),而 `~` 不在段的合法
356
+ // 字符集里 **段数不同的 ref 恒不相等** ⇒ 该判据对库里任何真实行都不可能成立;
357
+ // · 旧引擎(≤5.25.0)的 `tr_<sid>_<call>` 同样重铸不出来(分隔符换了),而按旧规则手写一个 legacy
358
+ // 编码器 = 复制上游规则(本文件头注自己禁掉的那件事);存量行的真处置是**删表重建**(见 CHANGELOG)。
359
+ // 删的另一半理由是**代价**:走到这条腿的前提是「出处缺席」,而任意乱写的未知 ref 恰好也落在这里,
360
+ // 于是每个必然 404 的请求都要付一次整份耐久事件日志读(已鉴权调用方可用随机 ref 放大后端读),
361
+ // 换不到任何放行。行为面只收窄不放宽:能放行的集合原本就是空集。
362
+ if (!ownedByThisCaller)
363
+ return notFound();
363
364
  if (!store)
364
365
  return notFound(); // durable store 缺席:404 同形,不 501(不泄露部署形)
365
366
  const slice = await store.get(ref, { ...(offset !== undefined ? { offset } : {}), ...(limit !== undefined ? { limit } : {}) });
@@ -3,7 +3,7 @@ import { once } from "node:events";
3
3
  import { createHash } from "node:crypto";
4
4
  import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, RETIRED_TOOL_NAMES, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs } from "@sema-agent/core";
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
- import { matchCatalogModel } from "../model-select.js";
6
+ import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
8
8
  import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../security.js";
9
9
  import { verifyDirectDoorProof } from "../principal-jwt.js";
@@ -117,6 +117,13 @@ const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024; // a single inline image's base6
117
117
  // name is never mounted, so keeping it here would let the pre-check claim satisfiability for a tool that
118
118
  // cannot exist (same derivation hygiene as hands-lane.ts HANDS_BAND_TOOL_NAMES).
119
119
  const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS).filter((n) => !RETIRED_TOOL_NAMES.has(n));
120
+ /** #233 / A-002.8 —— 未知模型 400 里 `available` 列表的**名单过滤**:不给用户广告他点不了的模型。
121
+ * 名单缺席/空 ⇒ 与本件之前逐字同集(全目录去 `default` 别名);名单在场 ⇒ 只留名单准的那些。
122
+ * 判定单源 `isModelAllowlisted`(与三个执法点同一只),所以"列出来的"与"点了不被拒的"恒为同一批。 */
123
+ function mentionableNames(config, catalog) {
124
+ const allowlist = config.atModelAllowlist ?? [];
125
+ return Object.keys(catalog).filter((n) => n !== "default" && isModelAllowlisted(n, catalog, allowlist));
126
+ }
120
127
  /** [1245] codex-3/4 — the REOPEN-class resume failures: core's runtask reopened the checkpoint (store.reopen,
121
128
  * dist-read) BEFORE returning this failed result, so the park is STILL PENDING and decidable. Everything
122
129
  * downstream must treat these as a re-park, never terminal: the durable event log writes `suspended` (not
@@ -125,6 +132,37 @@ const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS).filter((n) => !RETIRED_TO
125
132
  function isCheckpointReopenedFailure(r) {
126
133
  return r.status === "failed" && (r.errorCode === "resume.env_failed" || r.errorCode === "resume.tool_unavailable");
127
134
  }
135
+ /**
136
+ * 一条 verify/cascade 腿的结果**是不是嵌套 durable park**,以及 park 落在哪个词上(扫描P2 + codex 复审
137
+ * round1 [high] 一,2026-08-12 亲读 core 验真)。`undefined` = 不是 park(照常走终局)。
138
+ *
139
+ * 🔴 判据是 **errorCode**,不是 `verification.unverifiedReason` —— 后者**不覆盖修复轮**:
140
+ * · `verify.js:201/208`(runWithVerification / resumeWithVerification 的**立即** park)铸的 outcome 带
141
+ * `unverifiedReason: status === "needs_review" ? "needs_review" : "suspended"`;
142
+ * · 但 `verify.js:191`(verifier 判 FAIL 后的**修复轮** `runTask` 又停在门上)返回的是
143
+ * `{ ...mapNestedSuspend(current), verification: outcome }`,那个 `outcome` 是**上一轮验证器的
144
+ * FAIL 判决** —— `verdict:"FAIL"` 的臂根本不填 `unverifiedReason`(`verify.js:166` 只在
145
+ * no_verdict / PARTIAL 两种情况填)。于是「按 reason 判 park」对修复轮恒为假,而结果里
146
+ * checkpointToken 还活着 ⇒ 写 `done` + setTerminal ⇒ 释放 task_active 锁 + 孤儿化那张卡。
147
+ *
148
+ * `mapNestedSuspend`(core `agents/suspend-guard.js`)是**每一条**嵌套 park 的必经点,它无条件盖这两个
149
+ * 码之一,并且码本身就带 park 的词 —— 所以它同时是「是不是 park」和「是哪个 park」的单一真源。
150
+ * 两个字面量在 core 的 index 里没有导出,只能照字面锚(与 `runs.ts` / `resource-suspend.ts` 顶注同姿势)。
151
+ *
152
+ * ⚠️ 边界:`checkpoint.reopen_failed` 与 `resume.*` 两类 reopen 失败**不在**这里(它们不是 gate 再停,
153
+ * 由 {@link isCheckpointReopenedFailure} + 店内复核那条腿处置);`verifier_paused` 也不在(那是验证器
154
+ * 自己的会话与凭据,本 run 的活已经做完)。
155
+ */
156
+ function nestedParkStatusOf(r) {
157
+ switch (r.errorCode) {
158
+ case "unexpected.needs_review": // core UNEXPECTED_NEEDS_REVIEW —— 内层 status 是 needs_review 的 park
159
+ return "needs_review";
160
+ case "unexpected.suspended": // core UNEXPECTED_SUSPENDED —— 内层 status 是 suspended 的 park
161
+ return "suspended";
162
+ default:
163
+ return undefined;
164
+ }
165
+ }
128
166
  /** The TRUSTED end-user principal for an owner-gated / per-tenant request. On a direct-door worker the
129
167
  * trusted-header path is OFF (x-agent-principal is client-spoofable), so identity comes from the crypto-VERIFIED
130
168
  * principal JWT (`x-approval-principal-token`, principal-only proof — no cnf.bnd action binding); otherwise the
@@ -1191,7 +1229,7 @@ export function createHttpServer(rawDeps) {
1191
1229
  const bare = deps.config.models ?? {}; // 防御:极简 stub deps 可无 models 域(真 loadConfig 恒有)
1192
1230
  const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
1193
1231
  if (matchCatalogModel(body.model, catalog) === undefined) {
1194
- sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: Object.keys(catalog).filter((n) => n !== "default") });
1232
+ sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
1195
1233
  return null;
1196
1234
  }
1197
1235
  }
@@ -1207,7 +1245,7 @@ export function createHttpServer(rawDeps) {
1207
1245
  const bare = deps.config.models ?? {};
1208
1246
  const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
1209
1247
  if (matchCatalogModel(body.compactionModel, catalog) === undefined) {
1210
- sendError(res, 400, "request.unknown_reference", `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: Object.keys(catalog).filter((n) => n !== "default") });
1248
+ sendError(res, 400, "request.unknown_reference", `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
1211
1249
  return null;
1212
1250
  }
1213
1251
  }
@@ -1384,9 +1422,15 @@ export function createHttpServer(rawDeps) {
1384
1422
  if (!isApprovalGateKind(decideGateKind)) { // A-002.1 单一属主
1385
1423
  // [2400] HITL-6(clay 裁 2026-08-03,不做兼容):补机读指路——与 [2255]① 的 409 材料同形
1386
1424
  // (pendingGate{kind, decidePath},resumeEntryForGate 单源表),客户端凭 decidePath 直达对的
1387
- // resume 入口,不再解析人话文案。taskId 源=cp.sourceTaskId;缺席 ⇒ 只带 kind,诚实不铸假路径。
1388
- const gnPath = decideGateKind && cp.sourceTaskId
1389
- ? resumeEntryForGate(decideGateKind, { sessionId, taskId: cp.sourceTaskId })
1425
+ // resume 入口,不再解析人话文案。taskId 源=账本活行(getActiveTaskId,与 active-run-conflict.ts
1426
+ // 409 材料同源)——cp.sourceTaskId 是 core 契约里 issuing worker 的 **session id**(ECHO-ONLY
1427
+ // triage,checkpoint-store.d.ts),拿它当 run taskId 铸路径=打出去必 404([3683]-1 同病族,
1428
+ // 扫描 P1 修)。账本无活行 ⇒ 只带 kind,诚实不铸假路径。
1429
+ const activeForPath = decideGateKind
1430
+ ? await deps.runStore?.getActiveTaskId(sessionId).catch(() => undefined)
1431
+ : undefined;
1432
+ const gnPath = decideGateKind && activeForPath
1433
+ ? resumeEntryForGate(decideGateKind, { sessionId, taskId: activeForPath })
1390
1434
  : null;
1391
1435
  return {
1392
1436
  status: 409,
@@ -1811,11 +1855,20 @@ export function createHttpServer(rawDeps) {
1811
1855
  const vr = await resumeWithVerification(legRunner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
1812
1856
  let safe = stripCheckpointToken(vr);
1813
1857
  // 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
1814
- // verification.unverifiedReason:"suspended" with the checkpoint token still live. Treat it exactly like
1815
- // the stream path's suspended branch — write a "suspended" event and return status:"suspended" so the
1816
- // outer block calls setSuspended (keeps the session lock + resumable row). Driving it terminal would
1817
- // release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
1818
- const reSuspended = vr.verification?.unverifiedReason === "suspended";
1858
+ // verification.unverifiedReason with the checkpoint token still live. Treat it exactly like
1859
+ // the stream path's park branches — write the matching park event and return the park status so the
1860
+ // outer block calls setSuspended/setNeedsReview (keeps the session lock + resumable row). Driving it
1861
+ // terminal would release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
1862
+ //
1863
+ // 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族),而且判据必须取 **errorCode** 而不是
1864
+ // `verification.unverifiedReason` —— 完整理由(含修复轮那条 reason 恒缺席的腿)见
1865
+ // {@link nestedParkStatusOf} 的顶注,它是这条判据的唯一属主。只认 `unverifiedReason === "suspended"`
1866
+ // 会漏掉两类真 park:plan_review / dry_run 的 `needs_review` 再停,以及 verifier 判 FAIL 后修复轮
1867
+ // 的**任一**再停 —— 两类都会被写成 `done` + setTerminal:锁被释放、活卡成孤儿。
1868
+ // ⚠️ `verifier_paused` 不进这个集合:那是**验证器自己**的会话/凭据(见 core `VerificationOutcome
1869
+ // .verifierPause`),本 run 的活已经做完,与「本 run 再停在自己的门上」是两件事。
1870
+ const reParkStatus = nestedParkStatusOf(vr);
1871
+ const reSuspended = reParkStatus !== undefined;
1819
1872
  // [1245] codex-4: reopen-class twin of the stream path (store-confirmed, codex-5) — the checkpoint
1820
1873
  // is pending again, so the durable log must record a park, never `done` (the outer row drive
1821
1874
  // re-parks via checkpointReopened; the response keeps status:"failed"+retriable, unlike reSuspended
@@ -1834,7 +1887,8 @@ export function createHttpServer(rawDeps) {
1834
1887
  await appendPromptManifest(ap, deps.promptManifests, taskId); // [998]②: the verified-resume leg has no stream — persist the re-fired manifest before its terminal/suspend event
1835
1888
  if (reSuspended || vrReopened) {
1836
1889
  await appendModelUsageDelta(ap, deps.modelUsage, taskId); // E8: persist pre-re-suspend per-model usage
1837
- await ap("suspended", { gate: vr.checkpointGate ?? null, ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
1890
+ // reopen 腿恒落 `suspended`(它不是 gate 再停,是同一张卡被重开);reSuspended 腿按真 park 词落名。
1891
+ await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
1838
1892
  }
1839
1893
  else {
1840
1894
  // E8: this verified-resume leg is the run's final done → flush the last delta + SUM model_usage into stats.modelUsage
@@ -1848,7 +1902,7 @@ export function createHttpServer(rawDeps) {
1848
1902
  }
1849
1903
  }
1850
1904
  if (reSuspended)
1851
- return { ...safe, status: "suspended" };
1905
+ return { ...safe, status: reParkStatus };
1852
1906
  // Count the gate verdict only when the verifier actually ran (a re-suspend never reached it).
1853
1907
  if (vr.verification?.verdict)
1854
1908
  deps.metrics?.inc("verifications_total", { verdict: vr.verification.verdict });