@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.
- package/MIGRATION.md +16 -1
- package/USAGE.md +38 -4
- package/dist/approval-ask-machine.d.ts +10 -0
- package/dist/approval-ask-machine.js +10 -0
- package/dist/approval-card.d.ts +65 -0
- package/dist/approval-card.js +54 -6
- package/dist/approval-reconciler.d.ts +17 -1
- package/dist/boot/memory-boundary.d.ts +6 -0
- package/dist/boot/memory-boundary.js +10 -2
- package/dist/boot/resolve-spec.js +31 -4
- package/dist/boot/runner-deps.d.ts +41 -2
- package/dist/boot/runner-deps.js +43 -0
- package/dist/boot/session-faces.js +26 -4
- package/dist/boot/shutdown.js +17 -0
- package/dist/boot/stores.js +28 -7
- package/dist/capabilities/memory-notice.d.ts +13 -9
- package/dist/capabilities/memory-notice.js +35 -15
- package/dist/config-center/apply-effective.d.ts +14 -0
- package/dist/config-center/apply-effective.js +81 -1
- package/dist/config-types.d.ts +28 -2
- package/dist/config.js +38 -2
- package/dist/fleet/fleet-terminal-window.d.ts +12 -0
- package/dist/fleet/fleet-terminal-window.js +27 -4
- package/dist/http/active-run-conflict.d.ts +41 -1
- package/dist/http/active-run-conflict.js +24 -10
- package/dist/http/routes/approvals-assistant.d.ts +11 -3
- package/dist/http/routes/approvals-assistant.js +97 -20
- package/dist/http/routes/capabilities.js +20 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +86 -19
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- package/dist/http/routes/trace-usage.js +38 -37
- package/dist/http/server.js +67 -13
- package/dist/leader/fanout.d.ts +18 -0
- package/dist/leader/fanout.js +34 -1
- package/dist/leader/leader.js +9 -5
- package/dist/leader/wire.js +16 -5
- package/dist/main.js +1 -0
- package/dist/memory-scope.d.ts +20 -0
- package/dist/memory-scope.js +45 -0
- package/dist/model-select.d.ts +43 -1
- package/dist/model-select.js +70 -2
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +7 -1
- package/dist/parent-watch.d.ts +57 -0
- package/dist/parent-watch.js +108 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
- package/dist/plugins/checkpoint-store-sql.js +133 -7
- package/dist/plugins/local-checkpoint-store.js +11 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
- package/dist/plugins/memory-embedder-fingerprint.js +280 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
- package/dist/plugins/permission-rule-store-sql.js +1 -7
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/store-backend.d.ts +5 -6
- package/dist/plugins/store-backend.js +4 -1
- package/dist/plugins/store-contracts.d.ts +17 -0
- package/dist/plugins/store-contracts.js +33 -0
- package/dist/plugins/tidb-pool.js +7 -0
- package/dist/plugins/tool-result-store-sql.d.ts +18 -13
- package/dist/plugins/tool-result-store-sql.js +50 -29
- package/dist/run-local.js +1 -0
- package/dist/tool-approval.d.ts +10 -0
- package/dist/tool-approval.js +143 -9
- package/dist/trace/project.js +8 -0
- package/dist/trace/redact.d.ts +14 -1
- package/dist/trace/redact.js +14 -2
- package/package.json +3 -3
package/dist/http/routes/runs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
|
613
|
-
await cancelSuspended(); // [868] lost race INTO
|
|
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
|
-
|
|
616
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
829
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
1349
|
-
//
|
|
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
|
-
* ② **归属绑定 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
259
|
-
* `put` 带出处落库 + `ownerOf` 读回,读面改用出处判定(实现见 ②-a)
|
|
260
|
-
*
|
|
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 /
|
|
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
|
-
//
|
|
336
|
+
// 比对即可:零解析、零段转义规则复制,拼接歧义那条残余彻底消失,合成 id 那条**消了一半**
|
|
337
|
+
// (monitor/agent spill 两族的出处点名的 taskId 是 core handle id,与本函数收到的 server run id 不同
|
|
338
|
+
// 命名空间 ⇒ 恒判假;详见头注 🟡 段)。
|
|
326
339
|
//
|
|
327
|
-
// 🔴 为什么必须改而不是"锦上添花":5.26.0 起 offload ref
|
|
328
|
-
// 没有内容 ⇒
|
|
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
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 } : {}) });
|
package/dist/http/server.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
|
1815
|
-
// the stream path's
|
|
1816
|
-
// outer block calls setSuspended (keeps the session lock + resumable row). Driving it
|
|
1817
|
-
// release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
|
|
1818
|
-
|
|
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
|
-
|
|
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:
|
|
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 });
|