@sema-agent/server 7.85.0 → 7.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +19 -1
- package/dist/approval-hmac.d.ts +77 -2
- package/dist/approval-hmac.js +16 -1
- package/dist/boot/stage-01-config.js +2 -2
- package/dist/capabilities/execution-lane.d.ts +22 -0
- package/dist/capabilities/execution-lane.js +8 -0
- package/dist/capabilities/memory-notice.d.ts +4 -1
- package/dist/config-catalog.js +2 -1
- package/dist/execution-lane-caps.d.ts +13 -0
- package/dist/execution-lane-caps.js +1 -0
- package/dist/fleet/fleet-bus.d.ts +23 -3
- package/dist/fleet/fleet-reconciler.d.ts +35 -0
- package/dist/fleet/fleet-reconciler.js +52 -9
- package/dist/http/approval-sweeps.js +1 -1
- package/dist/http/resume-legs.d.ts +2 -1
- package/dist/http/resume-legs.js +24 -8
- package/dist/http/route-ctx.d.ts +51 -17
- package/dist/http/route-ctx.js +0 -3
- package/dist/http/routes/a2a-serve.js +1 -1
- package/dist/http/routes/approvals-assistant.d.ts +46 -23
- package/dist/http/routes/approvals-assistant.js +167 -45
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/routes/images.js +38 -8
- package/dist/http/routes/mcp-reconnect.d.ts +1 -1
- package/dist/http/routes/mcp-reconnect.js +2 -2
- package/dist/http/routes/memory-policy.js +3 -6
- package/dist/http/routes/notify-wake.js +11 -3
- package/dist/http/routes/run-memory-capture.js +3 -5
- package/dist/http/routes/runs.js +21 -23
- package/dist/http/routes/sessions.js +10 -2
- package/dist/http/routes/shared-memory.js +5 -1
- package/dist/http/routes/tasks.js +2 -2
- package/dist/http/routes/workflows.js +1 -1
- package/dist/http/server.d.ts +2 -2
- package/dist/http/server.js +3 -3
- package/dist/leader/diffout.js +1 -1
- package/dist/leader/endpoint.js +1 -1
- package/dist/leader/fanout.js +1 -1
- package/dist/leader/leader.js +1 -1
- package/dist/leader/merge.js +1 -1
- package/dist/leader/planner.js +1 -1
- package/dist/leader/repair-oracle.js +1 -1
- package/dist/leader/repair-wire.js +1 -1
- package/dist/leader/wire.js +1 -1
- package/dist/memory-scope.d.ts +16 -0
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.d.ts +12 -0
- package/dist/observability/metrics.js +17 -0
- package/dist/observability/{run-terminal-log.d.ts → run-terminal.d.ts} +50 -10
- package/dist/observability/{run-terminal-log.js → run-terminal.js} +27 -8
- package/dist/plugins/checkpoint-store-sql.d.ts +8 -2
- package/dist/plugins/file-run-store.js +2 -2
- package/dist/plugins/memory-run-store.js +2 -2
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/run-store-sql.js +8 -7
- package/dist/plugins/store-contracts.d.ts +25 -2
- package/dist/plugins/store-contracts.js +3 -0
- package/dist/plugins/workflow-run-store-sql.js +1 -1
- package/dist/principal-jwt.d.ts +15 -7
- package/dist/principal-jwt.js +1 -1
- package/dist/runs.js +1 -1
- package/dist/session-titler.js +9 -5
- package/dist/task-settings.d.ts +35 -0
- package/dist/task-settings.js +7 -0
- package/dist/tool-approval.d.ts +22 -2
- package/dist/tool-approval.js +2 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/ledger-events.d.ts +1 -1
- package/dist/trace/project.d.ts +1 -1
- package/dist/trace/project.js +1 -1
- package/dist/trace/redact.d.ts +1 -1
- package/package.json +2 -2
package/USAGE.md
CHANGED
|
@@ -166,7 +166,7 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
|
|
|
166
166
|
- 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
|
|
167
167
|
- **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `limits.max_cost_exceeded` 停。
|
|
168
168
|
- **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使外泄也只暴露指标(只读)。
|
|
169
|
-
- **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, limitCeilingAfterAnswer, degraded, cascade, verifications, councilRuns, toolErrors}`(`limitCeilingAfterAnswer` 自 7.71.0,按 `origin` 分桶的「答后撞顶」计数;与 `budgetExceeded` **总体不同** —— 前者每条引擎 run 都过,后者只由 HTTP 提交腿递增 —— 不要无条件相加)。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
|
|
169
|
+
- **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, limitCeilingAfterAnswer, degraded, cascade, verifications, councilRuns, toolErrors, httpErrorsByClass, httpErrorsByRoute}`(`httpErrorsByClass` / `httpErrorsByRoute` 自 **7.87.0**,additive:窗内 HTTP **失败**请求按状态码类聚合(键只有 `4xx` / `5xx`,2xx/3xx 不进;⚠️ `4xx` 含本仓给「客户端提前断连」铸的 `499`,SSE 长连上是常态)+ 失败最多的前 10 条 route 标签(`routeLabel` 低基数形,id 折成 `:id`,未命中路由表的落 `other`)—— 这两格是**不装 Prometheus 的部署**看见一场 404 风暴的唯一地方;两键**恒在场**,零失败时是 `{}`,不用缺席表示零)(`limitCeilingAfterAnswer` 自 7.71.0,按 `origin` 分桶的「答后撞顶」计数;与 `budgetExceeded` **总体不同** —— 前者每条引擎 run 都过,后者只由 HTTP 提交腿递增 —— 不要无条件相加)。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
|
|
170
170
|
- 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(没花钱就拒)+ 流中途取消 + turn 边界(`limits.max_cost_exceeded`);`maxTokens` 超 → `limits.max_tokens_exceeded`(core 5.8.0 起 `budget.*` 前缀退役)。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
|
|
171
171
|
- 🔴 **三道 $ 闸的分工(别拿进场门当中途停机用)**:三根旋钮名字都带 cost,但**判的时刻**和**能不能打断正在烧的那张单**完全不同。按「谁触发 / 什么时刻判 / 拒或停长什么样」对表:
|
|
172
172
|
|
|
@@ -408,6 +408,12 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
|
|
|
408
408
|
车道会尊重它(那条车道的定义就是"运维自己这台机、无容器边界")。所以「服务跑在 A 机、代码在 B 机」不成立
|
|
409
409
|
—— 没有任何东西把 B 的路径映到 A。要么把服务(或 `run-local`)跑在放着文件的那台机器上,要么走容器车道
|
|
410
410
|
(`e2b`/`k8s`/`local-docker`),那里的工作区是沙箱自己的,`cwd` 被忽略。
|
|
411
|
+
- **读面(S-426 起)**:`GET /v1/capabilities` 的 `executionLane` 把本部署的车道广告成
|
|
412
|
+
`{provider:<REMOTE_EXEC 闭集词>, toolsOnThisHost:boolean}`(`REMOTE_EXEC` 未设 ⇒ `provider:"host"`)。
|
|
413
|
+
壳/客户端判「工具跑不跑在本机」**读这一位**,不要再从自己这一侧的 `REMOTE_EXEC` 猜 —— 连远端引擎时
|
|
414
|
+
那个 env 说的是**壳自己这台**的事。⚠️ 它是**文件系统同一性**位,**不是**权限位:`cwd` 兑不兑现仍由
|
|
415
|
+
`projectContext` / `callerCwd` 回答(那两位的判据多一条单用户约束,多租户 host 部署上两者会分叉)。
|
|
416
|
+
端点/凭据/镜像/`serial` 一个字都不上这条 wire,那些仍只在 operator 面 `GET /v1/config/catalog`。
|
|
411
417
|
|
|
412
418
|
**workspace 浏览面 —— 已随整树快照纪元退役**
|
|
413
419
|
- 旧四端点(list/tree/file/archive)恒 `501 capability.workspace_retired`,`capabilities.workspace`
|
|
@@ -1524,6 +1530,18 @@ GET /v1/capabilities?permissionMode=default
|
|
|
1524
1530
|
**per-run 面**:本批不在 run 记录上加 per-run `autoMode` 座——引擎侧的武装结果 / 未武装原因(含只有引擎知道的那些;core 7.12.0 起熔断族已退役)
|
|
1525
1531
|
将随 core 的状态座到货,届时 server 只投影不自算;此前 `armed` 是 server 侧三项可知判据的裁决。
|
|
1526
1532
|
|
|
1533
|
+
### 9.4b plan 模式(`permissionMode:"plan"`):批准一份计划时,顺带说「解到哪一档」(7.86.0 起)
|
|
1534
|
+
|
|
1535
|
+
只读起步的任务把计划交给人之后停在 `plan_review` 门上。`POST /v1/assistant/tasks/:id/plan_review` 的
|
|
1536
|
+
`decision:"approve"` 可携 **`permissionModeAfter`**(闭集 `"default" | "acceptEdits"`,**缺席 ⇒ `"default"`**):
|
|
1537
|
+
`acceptEdits` = 继续跑时对自己工作目录内的写不再逐次征询,`default` = 有手但每次写照常征询;`edit` / `reject`
|
|
1538
|
+
的语义是重做计划,只读**原样保留**,在它们上送这一位是 `400`(词表外的值、以及本来就不是只读起步的任务送它,
|
|
1539
|
+
同样是 `400`)。解除会写回这条会话的续跑输入 —— 此后每一次挂起→续跑都沿用解除后的档,不回落只读。
|
|
1540
|
+
🔴 **这个体是闭集(7.86.0,S-438)**:受理集 = `decision` / `editedPlan` / `reason` / `permissionModeAfter` /
|
|
1541
|
+
`boundCallId` 五键,集外键(含定谳前的旧拼法 `nextPermissionMode`)**响亮拒** `400 request.body_shape` +
|
|
1542
|
+
机读两桶逐字点名,**不再静默丢** —— 与 `/v1/approvals/:id/decide` 同一只机器、同码同串。
|
|
1543
|
+
逐字契约见 `docs/ASSISTANT-WIRE-CONTRACT.md` §4c。
|
|
1544
|
+
|
|
1527
1545
|
### 9.5 Web search:两车道优先级、per-request `settings.webSearch`、错误形
|
|
1528
1546
|
|
|
1529
1547
|
**两车道,整段整取(不是逐键 merge)**:部署 env(`WEB_SEARCH_*`,§0)与 per-request `body.settings.webSearch`
|
package/dist/approval-hmac.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type { QuestionAnswer } from "@sema-agent/core";
|
|
1
2
|
import type { ApprovalHmacKey } from "./auth-keys.js";
|
|
2
3
|
/**
|
|
3
4
|
* The canonical message an approval MAC signs — a fixed-order, JSON-escaped tuple so no field value (an id, a
|
|
4
5
|
* hash, a free-text note) can ambiguate the boundary with the next (same discipline as the approvals-stream diff
|
|
5
6
|
* key). 🔴 Bound to CLIENT-VISIBLE values only: `sessionId` (in the /decide URL) + `boundCallId`/`boundInputHash`
|
|
6
|
-
* (surfaced by listPending) + `reason` (the caller's own body). It must NOT include the checkpointToken — that is
|
|
7
|
+
* (surfaced by listPending) + `reason` and `permissionModeAfter` (the caller's own body). It must NOT include the checkpointToken — that is
|
|
7
8
|
* the unexposed resume credential the client can never see (it resumes by sessionId), so a checkpointToken-bound
|
|
8
9
|
* message would be uncomputable by the signer.
|
|
9
10
|
*
|
|
@@ -12,11 +13,35 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
|
|
|
12
13
|
* 被接管的客户端 —— 正是 HMAC 要防的风险模型)可以在操作员批准同一个动作的那次决定里,把落账理由换成
|
|
13
14
|
* 别的:动作没变,**审计记录变了**。三方裁定纳入签名载荷,不留 `reasonUnsigned` 宽限代际(未上生产)。
|
|
14
15
|
*
|
|
16
|
+
* ── 🔴 **两把绑定的分工**(S-433 F1 立此,别再把它们当成一回事)─────────────────────────────────
|
|
17
|
+
* `principal-jwt.ts` 的 `approvalBnd`(JWT `cnf.bnd`)只哈希**前三位** `[sessionId, boundCallId, boundInputHash]`
|
|
18
|
+
* —— 它绑的是**「哪一张卡」**(这份身份凭据被签发去决哪一次待决动作),所以决定内容改了它**不该**变。
|
|
19
|
+
* 本文件的 HMAC 信封绑的是**「决了什么」**—— 决定本身的每一个会改变后果的字段都在里面(`decision`、
|
|
20
|
+
* `reason`,以及 S-433 起的 `permissionModeAfter`)。⇒ 「同一张卡上把 approve 的档偷偷调宽」这件事,
|
|
21
|
+
* 结构上只有 HMAC 这一把拦得住;往 `cnf.bnd` 里加位既不解决它,还会把一份合法 JWT 绑死在一个它签发时
|
|
22
|
+
* 根本不知道的值上。**本次(S-433 F1)只动 HMAC 这一把,`approvalBnd` 一个字节不动。**
|
|
23
|
+
*
|
|
15
24
|
* ── 🔴 字节构造规则(权威定义;跨仓签名器照此实现,docs/ASSISTANT-WIRE-CONTRACT.md §6.2 同源)──────────
|
|
16
25
|
* 被 HMAC 的是下面这个字符串的 **UTF-8 字节**:
|
|
17
26
|
* 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**):
|
|
18
27
|
* `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]`
|
|
19
28
|
* —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。
|
|
29
|
+
* 🔴 **唯一的例外是尾位**(S-452,server 7.87.0 起是**一只扩展对象**;7.86.0 的裸尾位形已删除):
|
|
30
|
+
* 对扩展键**闭集** {@link APPROVAL_ENVELOPE_EXT_KEYS}(字典序即声明序)逐键判 `!== undefined`,
|
|
31
|
+
* 在场的收进一只对象 `ext`;**`ext` 为空 ⇒ 一个字节都不追加**(消息与 7.85.0 逐字节相同),
|
|
32
|
+
* 非空 ⇒ 追加为第 6 位 `[…head, ext]`。`JSON.stringify` 对 `ext` 的键序 = 插入序 = 闭集字典序,
|
|
33
|
+
* 所以规范化无歧义,**与调用方给键的顺序无关**。
|
|
34
|
+
* 为什么不继续用「裸的第 6、7 格」(S-433 的形):两个扩展位都可选 ⇒ 只有 `answer` 在场时它落在
|
|
35
|
+
* 第 6 格、与 `permissionModeAfter` 撞位,靠类型猜是谁 = 签名歧义。扩展对象让「在场的是谁」自带
|
|
36
|
+
* 名字,而且以后再加决裁字段不动数组形(规则集不再长)。
|
|
37
|
+
* 向后兼容仍然成立的那一半:**不带任何扩展键的体**,字节与 7.85.0 逐字相同 ⇒ 不签这些位的既有
|
|
38
|
+
* 签名器照常验过。带了扩展键而签名方没签(或按 7.86.0 的裸尾位形签)⇒ MAC 不匹配 ⇒ 既有
|
|
39
|
+
* `401 approval_mac_invalid`,不铸第二种拒绝形。
|
|
40
|
+
* 1b. **`answer` 的被签值 = 位置投影**(不是请求体上那个对象){@link projectQuestionAnswer}:
|
|
41
|
+
* `answers.map(i => [i.header, i.selected, i.note ?? null])`。**不做通用规范化、不做 Unicode
|
|
42
|
+
* 归一化、不排序**——投影只搬这三位,于是数组序天然保持、异常键名(`__proto__`)结构上不可达,
|
|
43
|
+
* 而「多余键可以不进签名却照样转给引擎」这个洞也一并消失(签的与转发的是同一份:引擎收到的是
|
|
44
|
+
* {@link questionAnswerFromProjection} 由同一份投影重建的对象)。
|
|
20
45
|
* 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。
|
|
21
46
|
* 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。
|
|
22
47
|
* 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩
|
|
@@ -30,7 +55,35 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
|
|
|
30
55
|
* 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
|
|
31
56
|
* 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
|
|
32
57
|
*/
|
|
33
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* `answer` 的**位置投影** —— 被 HMAC 签的那一份,也是转发给引擎的那一份的唯一来源。
|
|
60
|
+
*
|
|
61
|
+
* 形:`[header, selected, note ?? null]` 逐项、按 `answers` 的**原序**。三条性质都是它白送的,不是
|
|
62
|
+
* 额外规则(设计稿 §3.5 拿实现证伪了「通用递归按键排序」那一版):
|
|
63
|
+
* · **数组序保持** —— 投影不排序,`selected` 换序 ⇒ 不同 MAC;
|
|
64
|
+
* · **不做 Unicode 归一化** —— 只搬字符串,按收到的码点签;
|
|
65
|
+
* · **异常键名不可达** —— 投影不读任意键,`__proto__` / `constructor` 这类名字进不来。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 为什么不直接签请求体上那个对象:本仓的形状校验器对三个具名键严格,但**键序**由调用方决定
|
|
68
|
+
* (`JSON.stringify` 吃插入序)⇒ 两份语义相同的体会签出两串字节。投影把「哪些位、什么序」定死在
|
|
69
|
+
* 一个属主里,签名器(在别的仓)照抄这一行即可。
|
|
70
|
+
*/
|
|
71
|
+
export type QuestionAnswerProjection = readonly (readonly [header: string, selected: readonly string[], note: string | null])[];
|
|
72
|
+
export declare function projectQuestionAnswer(a: QuestionAnswer): QuestionAnswerProjection;
|
|
73
|
+
/** 投影 → 引擎收的那个对象。**「签的 = 转发的」的兑现点**:路由不把请求体上的 `answer` 原样上送,
|
|
74
|
+
* 而是先投影(签它)、再由本函数重建(送它)—— 中间没有第二条路径,所以「签了 A 却转发了 A′」
|
|
75
|
+
* 在结构上不可能。`note` 为 `null` ⇒ **不造键**(与 core `QuestionAnswerItem.note?` 的缺席同义)。 */
|
|
76
|
+
export declare function questionAnswerFromProjection(p: QuestionAnswerProjection): QuestionAnswer;
|
|
77
|
+
/**
|
|
78
|
+
* 信封的**扩展键闭集**(字典序 = 声明序 = `ext` 的 JSON 键序)。
|
|
79
|
+
*
|
|
80
|
+
* 加一个决裁字段进签名载荷 = 在 {@link ApprovalEnvelope} 上加一位 + 在本表加一个词;两者只做一半 ⇒
|
|
81
|
+
* 下面那两条编译期门当场红。**不许有第三种在场判**:恒 `!== undefined`(不是真值判、也不是 `?? null`
|
|
82
|
+
* 折叠)—— `null` 与缺席在扩展位上是两个不同的被签值(「签名方明说这次不动」≠「签名方不知道有这个键」)。
|
|
83
|
+
*/
|
|
84
|
+
export declare const APPROVAL_ENVELOPE_EXT_KEYS: readonly ["answer", "permissionModeAfter"];
|
|
85
|
+
export type ApprovalEnvelopeExtKey = (typeof APPROVAL_ENVELOPE_EXT_KEYS)[number];
|
|
86
|
+
/** The canonical envelope both {@link approvalHmacMessage} (the signer's/verifier's shared byte-rule) and
|
|
34
87
|
* {@link verifyApprovalHmac} take — ONE named type so the two signatures cannot silently drift into two
|
|
35
88
|
* structurally-similar-but-not-identical inline shapes (drift there would only surface at RUNTIME as a MAC
|
|
36
89
|
* mismatch, never at compile time — see the file-header canonical-message note for why each field's presence
|
|
@@ -41,6 +94,28 @@ export interface ApprovalEnvelope {
|
|
|
41
94
|
boundInputHash?: string | null;
|
|
42
95
|
decision: string;
|
|
43
96
|
reason?: string | null;
|
|
97
|
+
/**
|
|
98
|
+
* 🔴 **S-452(7.87.0)—— 操作员对一次 AskUserQuestion 的回答进签名载荷。**
|
|
99
|
+
*
|
|
100
|
+
* 它此前**不在**信封里 ⇒ 一个能改写线上字节的中间人(反代 / 被接管的客户端 —— 正是 HMAC 的风险
|
|
101
|
+
* 模型)可以在操作员签过的那次批准里只换 `answer`:动作没变、MAC 照过,**模型收到的答案变了**。
|
|
102
|
+
* 与 `reason`(改审计记录)、`permissionModeAfter`(改手)同一条判据的第三格:**凡改变续跑能做
|
|
103
|
+
* 什么 / 落什么账的决裁字段都在签名信封里**。
|
|
104
|
+
*
|
|
105
|
+
* 值是 {@link projectQuestionAnswer} 的产物,不是请求体上那个对象(理由见该函数)。
|
|
106
|
+
*/
|
|
107
|
+
answer?: QuestionAnswerProjection;
|
|
108
|
+
/**
|
|
109
|
+
* 🔴 **S-433 合并复审 F1 [high]**(7.86.0 立,S-452 把它从裸尾位挪进扩展对象)—— `plan_review` 的
|
|
110
|
+
* `permissionModeAfter` 选的是**批准之后这条 run 有没有手**(`acceptEdits` = 工作目录内的写不再逐次
|
|
111
|
+
* 征询)。它铸下来时没有进签名载荷 ⇒ 中间人可以把操作员签的那次 `default` 批准原地改成 `acceptEdits`:
|
|
112
|
+
* 动作没变、MAC 照过,**权限变了**。
|
|
113
|
+
*
|
|
114
|
+
* 🔴 编码位置在 7.87.0 变了(**BREAKING vs 7.86.0**,零双读):7.86.0 把它裸追加为第 6 位,
|
|
115
|
+
* 7.87.0 起它是扩展对象里的一个具名键。7.86.0 形已签的证明在 7.87.0 上一律 `401 approval_mac_invalid`
|
|
116
|
+
* —— 证明是瞬时物不是持久数据,没有迁移面,消费方的 401 就是通知(宪法「hard breaking 三句」)。
|
|
117
|
+
*/
|
|
118
|
+
permissionModeAfter?: string | null;
|
|
44
119
|
}
|
|
45
120
|
export declare function approvalHmacMessage(env: ApprovalEnvelope): string;
|
|
46
121
|
/** 签名载荷里 `reason` 的字符上限。**拒**而不是截 —— 截断在这条路径上**结构上不可行**:服务端截过的字节
|
package/dist/approval-hmac.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function projectQuestionAnswer(a) {
|
|
3
|
+
return a.answers.map((i) => [i.header, i.selected, i.note ?? null]);
|
|
4
|
+
}
|
|
5
|
+
export function questionAnswerFromProjection(p) {
|
|
6
|
+
return { answers: p.map(([header, selected, note]) => ({ header, selected: [...selected], ...(note === null ? {} : { note }) })) };
|
|
7
|
+
}
|
|
8
|
+
export const APPROVAL_ENVELOPE_EXT_KEYS = ["answer", "permissionModeAfter"];
|
|
9
|
+
const APPROVAL_ENVELOPE_HEAD_KEYS = ["sessionId", "boundCallId", "boundInputHash", "decision", "reason"];
|
|
2
10
|
export function approvalHmacMessage(env) {
|
|
3
|
-
|
|
11
|
+
const head = [env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null, env.decision, env.reason ?? null];
|
|
12
|
+
const ext = {};
|
|
13
|
+
for (const k of APPROVAL_ENVELOPE_EXT_KEYS) {
|
|
14
|
+
const v = env[k];
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
ext[k] = v;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(Object.keys(ext).length === 0 ? head : [...head, ext]);
|
|
4
19
|
}
|
|
5
20
|
export const MAX_APPROVAL_REASON_CHARS = 4_096;
|
|
6
21
|
export function verifyApprovalHmac(env, mac, kid, keys) {
|
|
@@ -9,7 +9,7 @@ import { createMetrics } from "../observability/metrics.js";
|
|
|
9
9
|
import { installFailOpenRecorder } from "../observability/fail-open.js";
|
|
10
10
|
import { installFileHistoryFailureRecorder } from "../file-history-disclosure.js";
|
|
11
11
|
import { installSecretEnvScrubRecorder } from "../observability/secret-env-scrub.js";
|
|
12
|
-
import {
|
|
12
|
+
import { setRunTerminalLogObserver } from "../observability/run-terminal.js";
|
|
13
13
|
import { setRedactionObserver } from "../trace/redact.js";
|
|
14
14
|
export function runStage01() {
|
|
15
15
|
const config = loadConfig();
|
|
@@ -36,7 +36,7 @@ export function runStage01() {
|
|
|
36
36
|
metrics.inc("web_search_bad_payload_total", { provider });
|
|
37
37
|
logger.warn("web_search_non_array_payload", { provider });
|
|
38
38
|
});
|
|
39
|
-
|
|
39
|
+
setRunTerminalLogObserver((level, fields) => {
|
|
40
40
|
if (level === "error")
|
|
41
41
|
logger.error("run_failed", fields);
|
|
42
42
|
else
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RemoteExecProvider } from "../execution-lane-caps.js";
|
|
2
|
+
export interface ExecutionLaneCapability {
|
|
3
|
+
/** 本部署的执行车道词(闭集 = {@link RemoteExecProvider};`REMOTE_EXEC` 未设 ⇒ `"host"`,见头注)。 */
|
|
4
|
+
readonly provider: RemoteExecProvider;
|
|
5
|
+
/** 工具跑的那台机器就是本进程这台吗(= server 盘上的绝对路径对模型的工具有没有意义)。
|
|
6
|
+
* 属主 = `task-cwd.ts` 的 {@link toolsRunOnThisHost},与 `skills[].baseDir` 的铸点同一只函数。 */
|
|
7
|
+
readonly toolsOnThisHost: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 部署席 → 消费端窄投影。形照 `projectWebSearchCapability` / `projectSqlEngineCapability`:
|
|
11
|
+
* **只 import 型**,不碰 config.ts,于是本模块是叶。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 **唯一**写缺席折词的地方。`"host"` 这个字面量不入 `api-notice-text-freeze`:它是
|
|
14
|
+
* {@link RemoteExecProvider} 的成员,配置联合里那条臂改名 ⇒ 本行当场 tsc 红(S-382 对三个 provider 词
|
|
15
|
+
* 免入表的同一条理由 —— 会静默漂的只有**本仓自铸**的词,这里一个都没有)。
|
|
16
|
+
*/
|
|
17
|
+
export declare function projectExecutionLaneCapability(config: {
|
|
18
|
+
readonly remoteExec?: {
|
|
19
|
+
readonly provider: RemoteExecProvider;
|
|
20
|
+
} | undefined;
|
|
21
|
+
}): ExecutionLaneCapability;
|
|
22
|
+
//# sourceMappingURL=execution-lane.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { toolsRunOnThisHost } from "../task-cwd.js";
|
|
2
|
+
export function projectExecutionLaneCapability(config) {
|
|
3
|
+
return {
|
|
4
|
+
provider: config.remoteExec?.provider ?? "host",
|
|
5
|
+
toolsOnThisHost: toolsRunOnThisHost(config),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=execution-lane.js.map
|
|
@@ -38,7 +38,10 @@ export interface MemoryFaceFacts {
|
|
|
38
38
|
/** 这一腿挂不挂记忆面 —— 由唯一属主 `memory-scope.ts` `memoryFaceMounted(spec.memory)` 算出(与 core `prepareMemory`
|
|
39
39
|
* 同判:`normalizeMemorySpec(...)?.enabled === true`)。`false` = 引擎未接 / 多租户 dark / remote lane fail-closed /
|
|
40
40
|
* S-405 `memory:"off"`(键在场但 `enabled:false`)—— 殊途同归:core 不会 prepare 记忆,也就不会挂任何披露。
|
|
41
|
-
* 🔴 不再收 `TaskSpec.memory` 本体:按「键在不在场」判是 7.84.0 前的病(合并复审 车HF F1)。
|
|
41
|
+
* 🔴 不再收 `TaskSpec.memory` 本体:按「键在不在场」判是 7.84.0 前的病(合并复审 车HF F1)。
|
|
42
|
+
* 🔴 属主会**抛**(值闭包门没过的手搓 spec —— 单数 `scope` / 非 `"off"` 的 `capture`),理由与两个属主
|
|
43
|
+
* 的坐标逐字写在 `memory-scope.ts` 的 `memoryFaceMounted` 头注里;本判别式不接这条臂(接了就是在
|
|
44
|
+
* 披露轴上静默 fail-open)。 */
|
|
42
45
|
memoryFaceMounted: boolean;
|
|
43
46
|
/** 部署声明 `MEMORY_PERSISTENCE_CAPABLE`(三态)。`true` = operator 自证有推断看不见的持久通道
|
|
44
47
|
* (自定义 writer / MCP 记忆服务)⇒ 披露会变成假话,撤回;`false` = 强制披露;缺席 ⇒ 走推断。 */
|
package/dist/config-catalog.js
CHANGED
|
@@ -2,6 +2,7 @@ import { CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES } fr
|
|
|
2
2
|
import { THINKING_FORMATS } from "./model-compat.js";
|
|
3
3
|
import { RESILIENCE_DEFAULTS } from "./config-types.js";
|
|
4
4
|
import { webSearchConfigFromEnv, WEB_SEARCH_PROVIDERS } from "./plugins/web-search.js";
|
|
5
|
+
import { REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
|
|
5
6
|
import { CENTER_READ_FACE_LIVE_KEYS } from "./config-center/read-face.js";
|
|
6
7
|
export const CATALOG_VALUE_TYPES = ["boolean", "number", "string", "enum", "csv", "json", "path", "url", "secret", "opaque"];
|
|
7
8
|
export const CATALOG_TIMINGS = ["hot", "restart", "deploy", "inert"];
|
|
@@ -347,7 +348,7 @@ export const CONFIG_CATALOG = [
|
|
|
347
348
|
r("READ_FACE", "orchestration", "enum", "READ 容纳面档位(env 逐键恒赢 center —— 部署主权表达)", { enumValues: ["open", "roots"], danger: SEC, derivedDefaultNote: "缺席 ⇒ 按 posture 派生(S-167):单用户 turnkey(REQUIRE_PRINCIPAL 未设)铸 open;多租户不铸键、core 默认 roots。生效来源见 GET /v1/diagnostics/wiring 的 readFace.source 四词", configKey: "readFace", center: cRead("face", "readFace") }),
|
|
348
349
|
r("REAP_INTERVAL_SEC", "limitsHttp", "number", "reaper 周期(秒)", { staticDefault: "60", configKey: "reapIntervalSec" }),
|
|
349
350
|
r("REAP_RUN_STALE_SEC", "limitsHttp", "number", "run 活性判陈旧阈(秒)", { staticDefault: "120", configKey: "runStaleSec", center: cLimits("runStaleSec") }),
|
|
350
|
-
r("REMOTE_EXEC", "orchestration", "enum", "执行车道选择(点名而配不齐/认不出 ⇒ 拒启,绝不静默降级到 host/stub)", { enumValues:
|
|
351
|
+
r("REMOTE_EXEC", "orchestration", "enum", "执行车道选择(点名而配不齐/认不出 ⇒ 拒启,绝不静默降级到 host/stub)", { enumValues: REMOTE_EXEC_PROVIDER_WORDS, danger: SEC, derivedDefaultNote: "缺席:CONFIG_PROVIDER=local ⇒ host;其余 ⇒ 进程内 stub", configKey: "remoteExec", resolve: (c) => c.remoteExec?.provider ?? null }),
|
|
351
352
|
r("REMOTE_EXEC_HOME_DIR", "orchestration", "path", "执行环境的 home 目录(`~/` 形权限规则的基;core ExecutionEnv.homeDir 座)", { danger: SEC, derivedDefaultNote: "缺席:host/in-process ⇒ os.homedir();e2b ⇒ /home/user;k8s/local-docker/ssh/adb/device ⇒ 无(`~/` 规则 422 响亮拒)", configKey: "remoteExecHomeDir", resolve: (c) => c.remoteExecHomeDir ?? null }),
|
|
352
353
|
r("REQUIRE_PRINCIPAL", "auth", "boolean", "多租户开关(posture 判别位:true ⇒ CC 能力族默认收紧)", { staticDefault: "false", danger: SEC, configKey: "requirePrincipal" }),
|
|
353
354
|
r("RESOURCE_SUSPEND", "approval", "boolean", "durable 路径 opt-in core resourceSuspend(预算/preempt ⇒ 可恢复挂起)", { staticDefault: "false", configKey: "resourceSuspend" }),
|
|
@@ -89,6 +89,19 @@ export declare function executionLaneCaps(lane: ExecutionLane): ExecutionLaneCap
|
|
|
89
89
|
export declare function isExecutionLane(word: string): word is ExecutionLane;
|
|
90
90
|
/** 车道普查全集(顺序 = 词表声明序;对账测试按它逐 lane 跑真判别式)。 */
|
|
91
91
|
export declare const ALL_EXECUTION_LANES: readonly ExecutionLane[];
|
|
92
|
+
/**
|
|
93
|
+
* `REMOTE_EXEC` **真能写下去的**那几个词(= {@link ALL_EXECUTION_LANES} 去掉 `in-process`:后者是
|
|
94
|
+
* 「没有 remoteExec」这个状态的名字,env 里写它没有意义)。
|
|
95
|
+
*
|
|
96
|
+
* 🔴 S-426 以点改面:此前 `config-catalog.ts` 的 `REMOTE_EXEC` 行把这张表**手抄**成一个
|
|
97
|
+
* `enumValues: ["e2b","k8s",…]` 字面量(字段静态型是 `readonly string[]`,与 {@link RemoteExecProvider}
|
|
98
|
+
* 零类型联系)。病形与 S-382 逮到的 `WEB_SEARCH_PROVIDER` 逐字同族:给
|
|
99
|
+
* `ServiceConfigFlat["remoteExec"]` 加一条 provider 臂(config-types.ts 里写着的下一条就是
|
|
100
|
+
* `remote-docker`),本文件的穷举 switch 与词表**会**编译红,而目录行不会 —— 于是运维在
|
|
101
|
+
* `GET /v1/config/catalog` 上读到的可写词集静默少一个,新车道成了「配了但目录说不认识」。
|
|
102
|
+
* 收成一只派生数组之后,目录行**就用它本人**(身份相等有钉),全仓只剩这一张表。
|
|
103
|
+
*/
|
|
104
|
+
export declare const REMOTE_EXEC_PROVIDER_WORDS: readonly RemoteExecProvider[];
|
|
92
105
|
/**
|
|
93
106
|
* `config.remoteExec?.provider` → 车道词。
|
|
94
107
|
*
|
|
@@ -156,6 +156,7 @@ export function isExecutionLane(word) {
|
|
|
156
156
|
return Object.hasOwn(EXECUTION_LANE_WORDS, word);
|
|
157
157
|
}
|
|
158
158
|
export const ALL_EXECUTION_LANES = Object.freeze(Object.keys(EXECUTION_LANE_WORDS).filter(isExecutionLane));
|
|
159
|
+
export const REMOTE_EXEC_PROVIDER_WORDS = Object.freeze(ALL_EXECUTION_LANES.filter((lane) => lane !== "in-process"));
|
|
159
160
|
export function executionLaneOf(provider) {
|
|
160
161
|
if (provider === undefined)
|
|
161
162
|
return "in-process";
|
|
@@ -216,8 +216,28 @@ export type FleetFrame = {
|
|
|
216
216
|
id: string;
|
|
217
217
|
ts: number;
|
|
218
218
|
/**
|
|
219
|
-
* [ref] §2③:**陈述级退场** ——
|
|
220
|
-
*
|
|
219
|
+
* [ref] §2③:**陈述级退场** —— 行没了,但**没有**结算语义。
|
|
220
|
+
*
|
|
221
|
+
* 🔴 **本键缺席的那一族不是「终局已发过」**(S-449 / 车HX codex r1 [medium];本注此前逐字写着
|
|
222
|
+
* 「无此键的 remove = 终态之后的常规清场……两者都先发过终态 `task` 帧」,**那句是假的** ——
|
|
223
|
+
* 车HW 逐条亲读本文件的七处 `removeTask` 铸点后改成真话,消费方面的成文版 =
|
|
224
|
+
* `docs/ASSISTANT-WIRE-CONTRACT.md` 附录 H)。缺席形其实是**两族**:
|
|
225
|
+
* (**按臂名找,不给行号** —— 本注与铸点同住一个文件,写死数字会被下一次改注自己顶歪。)
|
|
226
|
+
* · **结算级** —— 这一行**自己的**终态 `task` 帧刚发过,remove 只是成对的后半。四处:
|
|
227
|
+
* `fleetRunPublisher.onChildTerminal` 的「bash 无 tick 形」合成瞬态帧(publish → remove 同一个
|
|
228
|
+
* `for` 体)、`fleetRunPublisher.onTerminal` 的顶层行(`publishTask(run.runId, fleetStatus)` →
|
|
229
|
+
* `if (terminal) removeTask(run.runId)`)、`fleetBackgroundChildPublisher` 的子代终局
|
|
230
|
+
* (带残局键的行帧 → `removeTask(e.taskId)`)、以及对账腿 `fleet-reconciler.ts` 的 `retire` 与
|
|
231
|
+
* 候补窗 `upgradeWatched`(两者都先帧后撤)。
|
|
232
|
+
* · **投影级 —— 没有自己的终态帧,但也不是「这个任务结束了」**:① **双生让位 / 双生抑制**
|
|
233
|
+
* (`yieldToBackgroundChildLane` 把 run-leg 行让给 BCE 权威行;tick 期与子代终局期两处按
|
|
234
|
+
* ` ${uuid}` 后缀扫掉 run-leg 复合键双生)—— 同一个实体改由**另一行**渲染,终局会落在**那一行**上;
|
|
235
|
+
* ② **父 leg settle 时把子代行收走**(`onTerminal` 里的 `for (const cid of children) removeTask(cid)`),
|
|
236
|
+
* **含父 parked 的形**(理由逐字在该处:子代在父 park 期间并不在跑,resume 后新的 `task_progress`
|
|
237
|
+
* 会重新建行;而子代本来就从不发终态 tick)。
|
|
238
|
+
* ⇒ 对消费端的纪律只有一条:**`task_remove` 在任何形态下都只说「这一行从本副本的面板上离场了」,
|
|
239
|
+
* 不说结算**。唯一的终局证据是那一行**自己的**终态 `task` 帧(`status` ∈ completed/failed/killed),
|
|
240
|
+
* 或对 durable 面的权威查询(`GET /v1/runs/:id`)。
|
|
221
241
|
*
|
|
222
242
|
* 铸点只有对账腿:durable 说这条 run 还 `running`(reaper 宽限窗内)、或 claim 在他副本、或
|
|
223
243
|
* durable 行压根不存在 —— 三形共同点是**没有终局可投影**,而本副本的行必然陈旧。此时造一个终态
|
|
@@ -624,7 +644,7 @@ export declare function runStatusToFleet(status: RunStatusForFleet, opts?: {
|
|
|
624
644
|
* 的每一个 core 词。
|
|
625
645
|
*
|
|
626
646
|
* 这个词的病史:`blocked` 曾在本仓有**两个独立写者**且答案互斥 —— 本文件读它为「非成功终局(agent
|
|
627
|
-
* 放弃)⇒ 行离场」,而 `observability/run-terminal
|
|
647
|
+
* 放弃)⇒ 行离场」,而 `observability/run-terminal.ts` 读它为「治理判决,不是失败 ⇒ 不打行」。两处
|
|
628
648
|
* 各自穷举、各自正确地编译,结果是一条 `report_blocked` 的 run 在面板上离场、在引擎日志里一个字都没有。
|
|
629
649
|
* 语义收归 `plugins/store-contracts.ts` 的那张表之后,本行不再自持判断:表把某个词挪出/挪进
|
|
630
650
|
* `unsuccessful`(或 core 新增一个非成功终态)而上面的 switch 没跟上 ⇒ 下面这条收不敛到 `never` ⇒ tsc 红。
|
|
@@ -42,6 +42,31 @@
|
|
|
42
42
|
* 只做同款「durable 终局投影」([ref] 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场),
|
|
43
43
|
* **不加新判据**:活行恒不动,没有陈述级退场这一形。
|
|
44
44
|
*
|
|
45
|
+
* ## 🔴 S-450:候补窗的升级**由事实驱动**,周期臂降为兜底
|
|
46
|
+
* 陈述级退场(`orphanExit`)恒不造终态、行进候补窗;把它升级成真终局此前**只**发生在下一个对账周期的
|
|
47
|
+
* ① 臂 ⇒ 成对终帧最晚 {@link FLEET_RECONCILE_INTERVAL_MS} + 预算抖动才到面板,而消费方
|
|
48
|
+
* (`@sema-agent/client-core` 的面板投影)对「行缺席」按 30s 结算 —— 两侧各自正确,合成的是「面板先结算
|
|
49
|
+
* 成缺席、一分钟后又闪回一帧终局」。**定时器在错误的一侧**:补偿的触发条件是**事实**(durable 行翻终局),
|
|
50
|
+
* 却挂在**周期**上。现在多一条事件腿({@link FleetReconcilerDeps.subscribeRunTerminal},源头 =
|
|
51
|
+
* `observability/run-terminal.ts` 的**唯一铸点** `notifyRunTerminal` —— 三只 store 的 CAS **赢**分支各调一次):
|
|
52
|
+
* `setTerminal` 落地的同一 tick 就去读。
|
|
53
|
+
* **提示只带 `taskId`,事实仍只有 durable 一个属主** —— 两个触发源走同一串判据
|
|
54
|
+
* (`getRun` → `isTerminalRunStatus` → `upgradeWatched`),提速的是「什么时候去读」而不是「读什么」。
|
|
55
|
+
* 升级动作**只有一份**(`upgradeWatched`),幂等闸 = `watch.delete(id)` 的返回值。
|
|
56
|
+
* **两处仍走周期臂,如实**:① 终局由**别的副本**写(本副本收不到进程内提示);② **批量 reaper**
|
|
57
|
+
* (`reapStale` 一族是整批 UPDATE、拿不到 id,不经 `setTerminal`)—— 这两族**与本件存在之前的行为逐字等同**。
|
|
58
|
+
* 上界成文在契约与 DEPLOY-PREREQS:「单副本经写口的终局:落地即升级;其余:最迟一个对账周期」。
|
|
59
|
+
*
|
|
60
|
+
* 🔴 **补发帧推一次,不设回执、不设重放队列**(主会话 09-18 定,设计稿 §3.5;test [ref] 提问的答案)。
|
|
61
|
+
* 理由:fleet 面是**投影**(bus 帧 + SSE,没有 durable 队列),给它加「待取 / 回执」就是造第二本账
|
|
62
|
+
* (与「不铸并行账本」同一条纪律);而终局的**真源**本来就可寻址、可重读 —— 顶层 run 走
|
|
63
|
+
* `GET /v1/runs/:id`,后台代理 `a*` 走 `GET /v1/runs/:id/tasks/:handle`(父 run 终结后仍可读)。
|
|
64
|
+
* ⇒ 消费方的闭环义务(契约附录 H 与发车帖点名):**回收一条「缺席」行之前、或收到某 id 的帧而行已不在时,
|
|
65
|
+
* 按 id 向 durable 面补读一次**;读到终局以它为准,404 = 行已被 retention 清,如实渲「结果已不可得」。
|
|
66
|
+
* 静默丢弃迟到帧是消费方的病,不是本件要用 wire 机制兜的。server 侧只保证**留痕**:见下方
|
|
67
|
+
* `upgradeWatched` 的 `fleet_rows_reconciled_total{disposition="retired_deferred", trigger=…}` ——
|
|
68
|
+
* 「补发发生过」在 server 侧可查;**接收端有没有收到不是 server 能知道的事,不假装知道**。
|
|
69
|
+
*
|
|
45
70
|
* ## 五条界(任何一条失手都只是幽灵行多活一个周期 ⇒ 整体 F 类 fail-open,登记 tag,不静默)
|
|
46
71
|
* · 节律 {@link FLEET_RECONCILE_INTERVAL_MS} · 每周期读上限 {@link FLEET_RECONCILE_MAX_ROWS}
|
|
47
72
|
* · 读预算 {@link FLEET_RECONCILE_READ_BUDGET_MS} · 熔断冷却 {@link FLEET_RECONCILE_COOLDOWN_MS}
|
|
@@ -56,6 +81,7 @@ import { type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
|
|
|
56
81
|
import type { Logger } from "../observability/logger.js";
|
|
57
82
|
import type { Metrics } from "../observability/metrics.js";
|
|
58
83
|
import { type RunRecord } from "../plugins/store-contracts.js";
|
|
84
|
+
import { type RunTerminalRecord } from "../observability/run-terminal.js";
|
|
59
85
|
import { type FleetEventBus } from "./fleet-bus.js";
|
|
60
86
|
/** 对账节律 —— 60s 档。比它短 = 每分钟给 durable 加一批点读却换不来更快的面板真相(幽灵行的时间尺度
|
|
61
87
|
* 是"发布方已经死了",分钟级);比它长 = 壳的引擎温切门(cli engineSwapGate 读活行集)被幽灵行按住
|
|
@@ -93,6 +119,15 @@ export interface FleetReconcilerDeps {
|
|
|
93
119
|
* 对账腿只问一个是非题(搬 Map 会造出第二份活性判据,而两份判据必漂移)。
|
|
94
120
|
*/
|
|
95
121
|
isLiveOnThisReplica: (taskId: string) => boolean;
|
|
122
|
+
/**
|
|
123
|
+
* S-450 测试缝:「run 行 CAS 赢了翻终局」的订阅口。**生产恒缺席** —— 缺席时本腿自己去
|
|
124
|
+
* `observability/run-terminal.ts` 的**唯一铸点**登记(那口是进程级的自由函数,与 `recordFailOpen`
|
|
125
|
+
* 同姿势:真正的铸点在没有任何句柄的深处)。给它一个注入形只是为了让用例能在一台假铸点上跑。
|
|
126
|
+
*
|
|
127
|
+
* 🔴 **跨副本上界照旧、如实**:终局由**别的副本**写(对方的 reaper / 续跑腿)⇒ 本副本的进程内铸点
|
|
128
|
+
* 不会响,升级帧仍走周期臂。本版**不**引入跨副本总线(那是 leader run store 一族的独立设计)。
|
|
129
|
+
*/
|
|
130
|
+
subscribeRunTerminal?: (fn: (rec: RunTerminalRecord) => void) => () => void;
|
|
96
131
|
runs?: FleetReconcileRunPort;
|
|
97
132
|
workflows?: FleetReconcileWorkflowPort | WorkflowRunStore;
|
|
98
133
|
/** 本副本 id(与 `RunRecord.instanceId` 同域)。只进日志/读数 —— 两条 `running` 臂的**处置相同**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isTerminalWorkflowStatus } from "@sema-agent/core";
|
|
2
2
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
3
|
-
import { isParkedRunStatus, isTerminalRunStatus } from "../plugins/store-contracts.js";
|
|
3
|
+
import { isParkedRunStatus, isTerminalRunStatus, runProvenElsewhere } from "../plugins/store-contracts.js";
|
|
4
|
+
import { subscribeRunTerminal } from "../observability/run-terminal.js";
|
|
4
5
|
import { buildFleetWorkflowRow, fleetRunResiduals, runStatusToFleet } from "./fleet-bus.js";
|
|
5
6
|
import { isTerminalWorkflowRowStatus } from "./fleet-terminal-window.js";
|
|
6
7
|
export const FLEET_RECONCILE_INTERVAL_MS = 60_000;
|
|
@@ -28,12 +29,53 @@ export function createFleetReconciler(deps) {
|
|
|
28
29
|
let coolingUntil;
|
|
29
30
|
let taskCursor;
|
|
30
31
|
let wfCursor;
|
|
32
|
+
const upgradeWatched = (id, w, rec, trigger) => {
|
|
33
|
+
if (!watch.delete(id))
|
|
34
|
+
return false;
|
|
35
|
+
deps.bus.publishTask({ ...w.row, status: runStatusToFleet(rec.status), retiredBy: "reconcile", ...fleetRunResiduals(rec.result) });
|
|
36
|
+
deps.bus.removeTask(id, { ...(w.row.cycleSeq !== undefined ? { cycleSeq: w.row.cycleSeq } : {}) });
|
|
37
|
+
lastFrameAt.delete(id);
|
|
38
|
+
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired_deferred", trigger });
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
31
41
|
const unsubscribe = deps.bus.subscribe((frame) => {
|
|
32
42
|
if (frame.type === "task")
|
|
33
43
|
lastFrameAt.set(frame.row.id, now());
|
|
34
44
|
else if (frame.type === "task_remove")
|
|
35
45
|
lastFrameAt.delete(frame.id);
|
|
36
46
|
});
|
|
47
|
+
let stopped = false;
|
|
48
|
+
const unsubscribeRunTerminal = (deps.subscribeRunTerminal ?? subscribeRunTerminal)((rec) => {
|
|
49
|
+
if (stopped)
|
|
50
|
+
return;
|
|
51
|
+
const id = rec.runId;
|
|
52
|
+
const w = watch.get(id);
|
|
53
|
+
if (!w)
|
|
54
|
+
return;
|
|
55
|
+
if (deps.bus.taskRow(id) !== undefined) {
|
|
56
|
+
watch.delete(id);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!deps.runs)
|
|
60
|
+
return;
|
|
61
|
+
if (coolingUntil !== undefined && now() < coolingUntil)
|
|
62
|
+
return;
|
|
63
|
+
void deps.runs.getRun(id).then((rec) => {
|
|
64
|
+
if (stopped)
|
|
65
|
+
return;
|
|
66
|
+
if (watch.get(id) !== w || deps.bus.taskRow(id) !== undefined)
|
|
67
|
+
return;
|
|
68
|
+
if (rec === undefined) {
|
|
69
|
+
watch.delete(id);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!isTerminalRunStatus(rec.status))
|
|
73
|
+
return;
|
|
74
|
+
upgradeWatched(id, w, rec, "event");
|
|
75
|
+
}, (err) => {
|
|
76
|
+
recordFailOpen("server.fleet.terminal-notify", `taskId=${id} read-failed err=${err instanceof Error ? err.message : "non-Error throw"}`);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
37
79
|
const wfGet = deps.workflows ? (id) => Promise.resolve(deps.workflows.get(id)) : undefined;
|
|
38
80
|
async function runOnce(nowMs) {
|
|
39
81
|
const tally = emptyTally();
|
|
@@ -125,12 +167,8 @@ export function createFleetReconciler(deps) {
|
|
|
125
167
|
}
|
|
126
168
|
if (!isTerminalRunStatus(rec.status))
|
|
127
169
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
lastFrameAt.delete(id);
|
|
131
|
-
watch.delete(id);
|
|
132
|
-
tally.retired++;
|
|
133
|
-
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired_deferred" });
|
|
170
|
+
if (upgradeWatched(id, w, rec, "cycle"))
|
|
171
|
+
tally.retired++;
|
|
134
172
|
}
|
|
135
173
|
for (const row of rotate(snap.tasks, taskCursor)) {
|
|
136
174
|
if (row.parentId !== undefined)
|
|
@@ -169,7 +207,7 @@ export function createFleetReconciler(deps) {
|
|
|
169
207
|
tally.skippedParked++;
|
|
170
208
|
continue;
|
|
171
209
|
}
|
|
172
|
-
orphanExit(row, rec
|
|
210
|
+
orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
|
|
173
211
|
}
|
|
174
212
|
for (const row of snap.tasks) {
|
|
175
213
|
if (row.parentId === undefined || present.has(row.parentId))
|
|
@@ -222,7 +260,12 @@ export function createFleetReconciler(deps) {
|
|
|
222
260
|
}
|
|
223
261
|
return {
|
|
224
262
|
runOnce,
|
|
225
|
-
stop:
|
|
263
|
+
stop: () => {
|
|
264
|
+
stopped = true;
|
|
265
|
+
unsubscribe();
|
|
266
|
+
unsubscribeRunTerminal();
|
|
267
|
+
watch.clear();
|
|
268
|
+
},
|
|
226
269
|
};
|
|
227
270
|
}
|
|
228
271
|
class ReadAborted extends Error {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { redactHead } from "../observability/run-terminal
|
|
1
|
+
import { redactHead } from "../observability/run-terminal.js";
|
|
2
2
|
import { findParkOrigin } from "../parked-decide.js";
|
|
3
3
|
import { decodeCheckpointScope } from "../security.js";
|
|
4
4
|
import { personDecision } from "../host-decision.js";
|
|
@@ -16,6 +16,7 @@ import { type QuestionAnswer, type CheckpointToken } from "@sema-agent/core";
|
|
|
16
16
|
import type { DecideBinding } from "./wire-types.js";
|
|
17
17
|
import type { CheckpointStoreFull } from "../plugins/store-backend.js";
|
|
18
18
|
import { type HostDecision } from "../host-decision.js";
|
|
19
|
+
import { type PlanApprovePermissionModeAfter } from "../task-settings.js";
|
|
19
20
|
import type { RouteCtxBase, DriveResumeArgs } from "./route-ctx.js";
|
|
20
21
|
import { type TrustedRunInternals } from "./durable-run-leg.js";
|
|
21
22
|
import type { RunRegistry, RouteHelpers, RouteLegs } from "./route-ctx.js";
|
|
@@ -72,7 +73,7 @@ export declare function createResumeLegs(ctx: {
|
|
|
72
73
|
}, acceptEarly?: boolean, decider?: {
|
|
73
74
|
principal?: string;
|
|
74
75
|
explicitOperator: boolean;
|
|
75
|
-
}) => Promise<{
|
|
76
|
+
}, permissionModeAfter?: PlanApprovePermissionModeAfter) => Promise<{
|
|
76
77
|
status: number;
|
|
77
78
|
body: object;
|
|
78
79
|
}>;
|