@sema-agent/server 7.15.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/USAGE.md +36 -2
- 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/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 +20 -0
- 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 +8 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +63 -17
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- 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/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 +3 -0
- 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 +9 -0
- package/dist/tool-approval.js +74 -3
- 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
|
@@ -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
|
|
72
|
-
//
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
|
326
|
-
|
|
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
|
|
698
|
-
*
|
|
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,
|
|
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
|
|
728
|
-
// (SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
|
|
729
|
-
pending = (await cs.listPending(scope)).map(
|
|
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
|
|
742
|
-
res.write(`event: pending\ndata: ${
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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) {
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
|
616
|
-
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;
|
|
617
633
|
}
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
832
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|