@sema-agent/server 7.86.0 → 7.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/USAGE.md +1 -1
  2. package/dist/approval-hmac.d.ts +62 -14
  3. package/dist/approval-hmac.js +15 -1
  4. package/dist/boot/stage-01-config.js +2 -2
  5. package/dist/brain.js +5 -3
  6. package/dist/capabilities/approvals-push.d.ts +47 -0
  7. package/dist/capabilities/approvals-push.js +4 -0
  8. package/dist/config-catalog.js +2 -1
  9. package/dist/config-invariants.d.ts +2 -2
  10. package/dist/config-invariants.js +8 -0
  11. package/dist/config.js +7 -0
  12. package/dist/fleet/fleet-bus.d.ts +23 -4
  13. package/dist/fleet/fleet-reconciler.d.ts +35 -0
  14. package/dist/fleet/fleet-reconciler.js +50 -7
  15. package/dist/http/approval-sweeps.js +1 -1
  16. package/dist/http/resume-legs.js +3 -2
  17. package/dist/http/routes/a2a-serve.js +1 -1
  18. package/dist/http/routes/approvals-assistant.d.ts +35 -23
  19. package/dist/http/routes/approvals-assistant.js +107 -30
  20. package/dist/http/routes/capabilities.js +2 -0
  21. package/dist/http/routes/fleet.js +1 -2
  22. package/dist/http/routes/runs.js +2 -2
  23. package/dist/http/routes/tasks.js +2 -2
  24. package/dist/http/routes/workflows.js +1 -1
  25. package/dist/http/server.d.ts +2 -2
  26. package/dist/http/server.js +3 -3
  27. package/dist/leader/diffout.js +1 -1
  28. package/dist/leader/endpoint.js +1 -1
  29. package/dist/leader/fanout.js +1 -1
  30. package/dist/leader/leader.js +1 -1
  31. package/dist/leader/merge.js +1 -1
  32. package/dist/leader/planner.js +1 -1
  33. package/dist/leader/repair-oracle.js +1 -1
  34. package/dist/leader/repair-wire.js +1 -1
  35. package/dist/leader/wire.js +1 -1
  36. package/dist/model-provider.d.ts +77 -0
  37. package/dist/model-provider.js +26 -0
  38. package/dist/observability/fail-open.d.ts +8 -0
  39. package/dist/observability/fail-open.js +8 -0
  40. package/dist/observability/metrics.d.ts +12 -0
  41. package/dist/observability/metrics.js +17 -0
  42. package/dist/observability/{run-terminal-log.d.ts → run-terminal.d.ts} +50 -10
  43. package/dist/observability/{run-terminal-log.js → run-terminal.js} +27 -8
  44. package/dist/plugins/file-run-store.js +2 -2
  45. package/dist/plugins/memory-run-store.js +2 -2
  46. package/dist/plugins/remote-scratchpad.js +1 -1
  47. package/dist/plugins/run-store-sql.js +8 -7
  48. package/dist/plugins/store-contracts.d.ts +2 -2
  49. package/dist/plugins/workflow-run-store-sql.js +1 -1
  50. package/dist/runs.js +1 -1
  51. package/dist/session-titler.js +9 -5
  52. package/dist/tool-approval.d.ts +22 -2
  53. package/dist/tool-approval.js +2 -0
  54. package/dist/trace/core-keyset-guard.d.ts +1 -1
  55. package/dist/trace/ledger-events.d.ts +1 -1
  56. package/dist/trace/project.d.ts +1 -1
  57. package/dist/trace/project.js +1 -1
  58. package/dist/trace/redact.d.ts +1 -1
  59. 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
 
@@ -1,3 +1,4 @@
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
@@ -25,11 +26,22 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
25
26
  * 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**):
26
27
  * `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]`
27
28
  * —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。
28
- * 🔴 **唯一的例外是尾位**(S-433,server 7.86.0):`permissionModeAfter` **在场时**追加为第 6 位
29
- * `[…, permissionModeAfter]`,**缺席时一个字节都不追加**。之所以在这一位上接受那个条件分支:
30
- * 它换来的是「不签这一位的既有签名器对不带本键的体照常验过」—— `reason` 入签那次是**全体签名器
31
- * 同拍 BREAKING**(1.284.0),本次刻意不再来一次。带了本键而签名方没签 ⇒ MAC 不匹配 ⇒ 既有
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 不匹配 ⇒ 既有
32
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} 由同一份投影重建的对象)。
33
45
  * 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。
34
46
  * 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。
35
47
  * 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩
@@ -43,7 +55,35 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
43
55
  * 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
44
56
  * 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
45
57
  */
46
- /** The canonical 5-field envelope both {@link approvalHmacMessage} (the signer's/verifier's shared byte-rule) and
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
47
87
  * {@link verifyApprovalHmac} take — ONE named type so the two signatures cannot silently drift into two
48
88
  * structurally-similar-but-not-identical inline shapes (drift there would only surface at RUNTIME as a MAC
49
89
  * mismatch, never at compile time — see the file-header canonical-message note for why each field's presence
@@ -55,17 +95,25 @@ export interface ApprovalEnvelope {
55
95
  decision: string;
56
96
  reason?: string | null;
57
97
  /**
58
- * 🔴 **S-433 合并复审 F1 [high] —— 「凡改变续跑能做什么的决裁字段都在签名信封里」**(7.86.0,验真后修)。
98
+ * 🔴 **S-452(7.87.0)—— 操作员对一次 AskUserQuestion 的回答进签名载荷。**
99
+ *
100
+ * 它此前**不在**信封里 ⇒ 一个能改写线上字节的中间人(反代 / 被接管的客户端 —— 正是 HMAC 的风险
101
+ * 模型)可以在操作员签过的那次批准里只换 `answer`:动作没变、MAC 照过,**模型收到的答案变了**。
102
+ * 与 `reason`(改审计记录)、`permissionModeAfter`(改手)同一条判据的第三格:**凡改变续跑能做
103
+ * 什么 / 落什么账的决裁字段都在签名信封里**。
59
104
  *
60
- * `plan_review` 的 `permissionModeAfter` 选的是**批准之后这条 run 有没有手**(`acceptEdits` = 工作目录内
61
- * 的写不再逐次征询)。它铸下来时**没有**进签名载荷 ⇒ 一个能改写线上字节的中间人(反代 / 被接管的
62
- * 客户端 —— 正是 HMAC 的风险模型)可以把操作员签的那次 `default` 批准原地改成 `acceptEdits`:动作没变、
63
- * MAC 照过,**权限变了**。这与 `reason` 入签是同一条判据的更贵一格(那条改的是审计记录,这条改的是手)。
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 照过,**权限变了**。
64
113
  *
65
- * 🔴 **尾位、按在场序列化**(编码规则见 {@link approvalHmacMessage}):键缺席 ⇒ 消息字节与 7.85.0 **逐字
66
- * 节相同**,所以不签这一位的既有签名器(cli / BFF / deploy 夹具)对**不带本键的体**照常验过 —— 这一位
67
- * 不是第二次 `reason` 式的 BREAKING。带了键而签名方没签 ⇒ MAC 不匹配 ⇒ 落既有的
68
- * `401 approval_mac_invalid`,**不铸第二种拒绝形**(不加 400/401 串,不加「直连门直接拒收本键」的特判)。
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 三句」)。
69
117
  */
70
118
  permissionModeAfter?: string | null;
71
119
  }
@@ -1,7 +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];
4
- return JSON.stringify(env.permissionModeAfter === undefined ? head : [...head, env.permissionModeAfter]);
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]);
5
19
  }
6
20
  export const MAX_APPROVAL_REASON_CHARS = 4_096;
7
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 { setRunTerminalObserver } from "../observability/run-terminal-log.js";
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
- setRunTerminalObserver((level, fields) => {
39
+ setRunTerminalLogObserver((level, fields) => {
40
40
  if (level === "error")
41
41
  logger.error("run_failed", fields);
42
42
  else
package/dist/brain.js CHANGED
@@ -2,6 +2,7 @@ import { adjudicateModelRoute, createAnthropicBrain, createCircuitBreakerBrain,
2
2
  import { normalizeBaseUrl } from "@sema-agent/core";
3
3
  import { buildDefaultResilience } from "./config-types.js";
4
4
  import { resolveModelApiKey } from "./key-resolver.js";
5
+ import { catalogBrainLane } from "./model-provider.js";
5
6
  function isOffDeploymentRoot(baseUrl, config) {
6
7
  if (!baseUrl)
7
8
  return false;
@@ -74,8 +75,9 @@ export function createBrain(config, deps = {}) {
74
75
  if (fallbackModel) {
75
76
  const fbOwnApiKey = () => resolveModelApiKey(fallbackModel.name, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {}) ??
76
77
  resolveModelApiKey(degradeTo, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {});
77
- const anthropicLaneHop = fallbackModel.provider === "anthropic" && anthropicBrain !== undefined;
78
- const fallback = fallbackModel.provider === "anthropic" && anthropicBrain
78
+ const fallbackLane = catalogBrainLane(fallbackModel.provider);
79
+ const anthropicLaneHop = fallbackLane === "anthropic" && anthropicBrain !== undefined;
80
+ const fallback = fallbackLane === "anthropic" && anthropicBrain
79
81
  ?
80
82
  anthropicBrain
81
83
  : createOpenAIBrain({
@@ -141,7 +143,7 @@ export function brainSummary(config) {
141
143
  const target = config.degrade?.reactive ? config.models?.[config.degrade.to] : undefined;
142
144
  if (!target)
143
145
  return { reactiveDegradeRoutePairing: null, reactiveDegradeRouteRefusal: null };
144
- if (target.provider === "anthropic" && config.anthropic) {
146
+ if (catalogBrainLane(target.provider) === "anthropic" && config.anthropic) {
145
147
  return { reactiveDegradeRoutePairing: "anthropic-lane", reactiveDegradeRouteRefusal: null };
146
148
  }
147
149
  const declaresOwnCredential = config.modelApiKeyEnv?.[target.name] !== undefined ||
@@ -0,0 +1,47 @@
1
+ /**
2
+ * S-455 —— `GET /v1/capabilities` 的 `approvalsStreamLive` 位:**本部署的审批推送面带不带 live 事件族**。
3
+ *
4
+ * ## 病
5
+ * S-454(7.87.0)给 `GET /v1/approvals/stream` 接上了**第二条 diff 源** —— `listLivePending`(与
6
+ * `GET /v1/approvals` 的 `livePending` 同一只读函数),事件另立两词 `live_pending` / `live_resolved`。
7
+ * 那条源的在场性挂在协调器上:`TOOL_APPROVAL_ENABLED=false` 的部署里 `ToolApprovalCoordinator` 根本不装配
8
+ * ⇒ 源整个缺席、流的字节与 S-454 之前逐字相同(能力缺席 = 零变化,不是空数组;见 `streamApprovals` 的
9
+ * `live` 参数注)。
10
+ *
11
+ * 于是消费端(client-core 的 approvalsFeed / 运营面板)**判不出自己连的是哪一种**:两形都是一条正常开着、
12
+ * 正常心跳的 SSE,差别只在「某一类卡永远不会从推面来」。用「等不到帧」去推断能力缺席不是判据 —— 那正是
13
+ * S-454 立案时那个病的形状(代理静默卡一小时、屏上零提示),只是换到了消费端这一侧。探位就是为了把
14
+ * 「这台机器会不会推 live 卡」从一次运气变成一次读。
15
+ *
16
+ * ## 形(与 `mcpReconnect` / `executionLane` 同族)
17
+ * 布尔,谓词 = **两项合取**,逐项对着那条腿自己的在场判据写:
18
+ * ① **审批底座**(`checkpointStore`)—— `/v1/approvals/*` 与 `/v1/assistant/*` 整域的挂载条件
19
+ * (`route-table.ts` 那一行的 `enabled`)。缺席 ⇒ 端点 404,**连流都不存在**;
20
+ * ② **live 协调器**(`toolApproval`)—— 第二条 diff 源本人。缺席 ⇒ 端点在、但推面只有 durable park 行。
21
+ * 两项与路由/腿从**同一批 deps** 推导 ⇒「说 yes 但没有 live 帧」结构上不可能。
22
+ *
23
+ * 🔴 **刻意不合并进 `approvals` 或 `toolApproval`**:
24
+ * · `approvals` 答的是「这条车道在不在」(它为真而本位为假的部署是**常态**:没开工具审批的那些);
25
+ * · `toolApproval` 答的是「活体工具审批 HITL 在不在」,与「那条 SSE 推不推 live 行」不是一回事 ——
26
+ * 没有 checkpoint 底座的部署上它可以为真而**根本没有这条流**。
27
+ * 任一位单读都会对一半部署说谎,所以本位是它们的合取、而不是其中之一的别名。
28
+ *
29
+ * ⚠️ **位为真只保证这条源在场**,不保证这一次有任何悬挂 ask、也不保证跨副本可见:live 半场是**进程内
30
+ * 投影**(与 `GET /v1/approvals` 的 `livePending` 逐字同限,非本件引入),durable park 行那一半才是
31
+ * 跨副本的。消费端据此渲「本机会推 live 卡」,不据此推断「别的副本上的卡也会到我这儿」。
32
+ */
33
+ import type { ToolApprovalCoordinator } from "../tool-approval.js";
34
+ /**
35
+ * 部署席 → 消费端窄投影。形照 `projectExecutionLaneCapability`:**只 import 型**,不碰 config.ts / server.ts,
36
+ * 于是本模块是叶。
37
+ *
38
+ * 🔴 `toolApproval` 的形是 `Pick<…, "listLivePending">` 而不是 `unknown`:本位声明的是**那一只读函数**
39
+ * 在场,而路由层递给 `streamApprovals` 的 `live` 参数是同一个 `Pick` —— 协调器哪天把这只函数改名,
40
+ * 本文件与那条腿**一起**编译红,不会出现「位还说 true、腿已经没源了」。
41
+ */
42
+ export declare function projectApprovalsStreamLiveCapability(deps: {
43
+ /** 审批底座(`/v1/approvals/*` 整域的挂载条件);形取 `unknown` —— 本位只问在场性,不碰店面。 */
44
+ readonly checkpointStore?: unknown;
45
+ readonly toolApproval?: Pick<ToolApprovalCoordinator, "listLivePending"> | undefined;
46
+ }): boolean;
47
+ //# sourceMappingURL=approvals-push.d.ts.map
@@ -0,0 +1,4 @@
1
+ export function projectApprovalsStreamLiveCapability(deps) {
2
+ return deps.checkpointStore !== undefined && deps.toolApproval !== undefined;
3
+ }
4
+ //# sourceMappingURL=approvals-push.js.map
@@ -4,6 +4,7 @@ import { RESILIENCE_DEFAULTS } from "./config-types.js";
4
4
  import { webSearchConfigFromEnv, WEB_SEARCH_PROVIDERS } from "./plugins/web-search.js";
5
5
  import { REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
6
6
  import { CENTER_READ_FACE_LIVE_KEYS } from "./config-center/read-face.js";
7
+ import { MODEL_PROVIDER_WORDS } from "./model-provider.js";
7
8
  export const CATALOG_VALUE_TYPES = ["boolean", "number", "string", "enum", "csv", "json", "path", "url", "secret", "opaque"];
8
9
  export const CATALOG_TIMINGS = ["hot", "restart", "deploy", "inert"];
9
10
  export const CATALOG_DANGER_AXES = ["security", "billing", "data"];
@@ -286,7 +287,7 @@ export const CONFIG_CATALOG = [
286
287
  r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
287
288
  r("MODEL_MAX_TOKENS", "modelPlane", "number", "主模型输出上限", { derivedDefaultNote: "缺席 ⇒ min(32000, contextWindow)", resolve: (c) => c.model.maxTokens ?? null, center: cModels("models[].maxTokens") }),
288
289
  r("MODEL_PRESENCE_PENALTY", "modelPlane", "number", "presence_penalty 直通"),
289
- r("MODEL_PROVIDER", "modelPlane", "string", "主模型 provider(路由选 brain)", { derivedDefaultNote: "缺席:ANTHROPIC base+cred 同在 ⇒ anthropic,否则 gateway", resolve: (c) => c.model.provider, center: cModels("models[].provider") }),
290
+ r("MODEL_PROVIDER", "modelPlane", "enum", "主模型 provider(路由选 brain;S-461 起是闭集,词表外的值启动期拒启并点名机读码 `config.model_provider_invalid`)", { enumValues: MODEL_PROVIDER_WORDS, derivedDefaultNote: "缺席:ANTHROPIC base+cred 同在 ⇒ anthropic,否则 gateway", resolve: (c) => c.model.provider, center: cModels("models[].provider") }),
290
291
  r("MODEL_REASONING", "modelPlane", "boolean", "主模型 reasoning 声明", { staticDefault: "true", resolve: (c) => c.model.reasoning ?? null, center: cModels("models[].reasoning") }),
291
292
  r("MODEL_REASONING_EFFORT_LEVELS", "modelPlane", "csv", "openai 端点接受的思考档集(六档子集;高档 clamp-down 不 422;anthropic 腿不铸此键;未知词拒启)", {
292
293
  derivedDefaultNote: "缺席 ⇒ 不铸 compat,core 走保守梯(minimal|low|medium|high),xhigh/max 被 clamp 下来",
@@ -28,9 +28,9 @@ import type { ServiceConfigFlat } from "./config-types.js";
28
28
  */
29
29
  export declare const LIVENESS_HEARTBEAT_MS = 30000;
30
30
  /** 闭集:新增一条不变量必须在这里加词(拼错 = 编译红,而不是运行期悄悄不判)。 */
31
- export type CrossInvariantId = "run-stale-heartbeat" | "bind-posture" | "bake-runner-credential" | "direct-door-anchors" | "operator-principals-required" | "bake-operator-principals" | "sensitive-write-patterns" | "leader-host-lane" | "leader-durable-store" | "device-durable-store" | "device-durable-approval";
31
+ export type CrossInvariantId = "run-stale-heartbeat" | "bind-posture" | "bake-runner-credential" | "direct-door-anchors" | "operator-principals-required" | "bake-operator-principals" | "sensitive-write-patterns" | "leader-host-lane" | "leader-durable-store" | "device-durable-store" | "device-durable-approval" | "model-provider-word";
32
32
  /** 表读的键集(取自平铺配置型 ⇒ 键名/类型永不与真配置漂)。 */
33
- export type CrossInvariantView = Readonly<Pick<ServiceConfigFlat, "runStaleSec" | "authToken" | "authTokens" | "allowUnauthedWrites" | "imageBakes" | "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals" | "sensitiveWritePatterns" | "leaderEnabled" | "remoteExec" | "dbBackend" | "tidb" | "pg">>;
33
+ export type CrossInvariantView = Readonly<Pick<ServiceConfigFlat, "runStaleSec" | "authToken" | "authTokens" | "allowUnauthedWrites" | "imageBakes" | "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals" | "sensitiveWritePatterns" | "leaderEnabled" | "remoteExec" | "dbBackend" | "tidb" | "pg" | "envModel">>;
34
34
  export interface CrossInvariantInput {
35
35
  /** 现役配置(boot 期 = 候选自身)。 */
36
36
  readonly live: CrossInvariantView;
@@ -1,4 +1,5 @@
1
1
  import { assertGuardPatternsUsable } from "./deployment-governance.js";
2
+ import { modelProviderRefusal } from "./model-provider.js";
2
3
  export const LIVENESS_HEARTBEAT_MS = 30_000;
3
4
  const anyServiceAuth = (v) => Boolean(v.authToken) || Object.keys(v.authTokens ?? {}).length > 0;
4
5
  const mustNarrowBind = (v) => Boolean(v.allowUnauthedWrites) && !anyServiceAuth(v);
@@ -129,6 +130,13 @@ export const CONFIG_CROSS_INVARIANTS = [
129
130
  ? "REMOTE_EXEC=device without DURABLE_APPROVAL=true has no park exit for an approval that outlives the device's offline window"
130
131
  : undefined,
131
132
  },
133
+ {
134
+ id: "model-provider-word",
135
+ keys: ["envModel"],
136
+ restartReason: "model-provider",
137
+ bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:model-provider-word" },
138
+ check: ({ next }) => (next.envModel.provider === undefined ? undefined : modelProviderRefusal(next.envModel.provider)),
139
+ },
132
140
  ];
133
141
  export function crossInvariant(id) {
134
142
  const inv = CONFIG_CROSS_INVARIANTS.find((i) => i.id === id);
package/dist/config.js CHANGED
@@ -17,6 +17,7 @@ import { readFaceForPosture } from "./read-face-posture.js";
17
17
  import { THINKING_LEVELS, THINKING_FORMATS, buildModelCompat, isThinkingFormat, isThinkingTier } from "./model-compat.js";
18
18
  import {} from "./posture-source.js";
19
19
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
20
+ import { modelProviderRefusal } from "./model-provider.js";
20
21
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
21
22
  function csv(name) {
22
23
  return (process.env[name] ?? "")
@@ -776,6 +777,12 @@ function parseModelDomain() {
776
777
  "that made every external deployment fail far from the cause (gateway 400)");
777
778
  }
778
779
  const explicitProvider = process.env.MODEL_PROVIDER;
780
+ if (explicitProvider !== undefined && explicitProvider !== "") {
781
+ const refusal = modelProviderRefusal(explicitProvider);
782
+ if (refusal !== undefined) {
783
+ throw new Error(refusal);
784
+ }
785
+ }
779
786
  const inferredAnthropicBaseUrl = firstSetEnv("ANTHROPIC_BASE_URL");
780
787
  const inferredAnthropicCred = firstSetEnv("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN");
781
788
  const provider = (() => {
@@ -74,7 +74,6 @@ export interface FleetTaskRow {
74
74
  startedAt?: number;
75
75
  elapsedMs?: number;
76
76
  tokens?: number;
77
- queuedCount?: number;
78
77
  awaitingPlanApproval?: boolean;
79
78
  /** core 1.262.0: the child's most recent tool intent as one
80
79
  * human line ("Bash npm test") — from the bg sink tick's `currentAction` (core's only attachment point
@@ -216,8 +215,28 @@ export type FleetFrame = {
216
215
  id: string;
217
216
  ts: number;
218
217
  /**
219
- * [ref] §2③:**陈述级退场** —— 行没了,但**没有**结算语义。对照:无此键的 remove = 终态之后的
220
- * 常规清场(发布方亲报终态、或 `retiredBy:"reconcile"` 的投影退休,两者都先发过终态 `task` 帧)。
218
+ * [ref] §2③:**陈述级退场** —— 行没了,但**没有**结算语义。
219
+ *
220
+ * 🔴 **本键缺席的那一族不是「终局已发过」**(S-449 / 车HX codex r1 [medium];本注此前逐字写着
221
+ * 「无此键的 remove = 终态之后的常规清场……两者都先发过终态 `task` 帧」,**那句是假的** ——
222
+ * 车HW 逐条亲读本文件的七处 `removeTask` 铸点后改成真话,消费方面的成文版 =
223
+ * `docs/ASSISTANT-WIRE-CONTRACT.md` 附录 H)。缺席形其实是**两族**:
224
+ * (**按臂名找,不给行号** —— 本注与铸点同住一个文件,写死数字会被下一次改注自己顶歪。)
225
+ * · **结算级** —— 这一行**自己的**终态 `task` 帧刚发过,remove 只是成对的后半。四处:
226
+ * `fleetRunPublisher.onChildTerminal` 的「bash 无 tick 形」合成瞬态帧(publish → remove 同一个
227
+ * `for` 体)、`fleetRunPublisher.onTerminal` 的顶层行(`publishTask(run.runId, fleetStatus)` →
228
+ * `if (terminal) removeTask(run.runId)`)、`fleetBackgroundChildPublisher` 的子代终局
229
+ * (带残局键的行帧 → `removeTask(e.taskId)`)、以及对账腿 `fleet-reconciler.ts` 的 `retire` 与
230
+ * 候补窗 `upgradeWatched`(两者都先帧后撤)。
231
+ * · **投影级 —— 没有自己的终态帧,但也不是「这个任务结束了」**:① **双生让位 / 双生抑制**
232
+ * (`yieldToBackgroundChildLane` 把 run-leg 行让给 BCE 权威行;tick 期与子代终局期两处按
233
+ * ` ${uuid}` 后缀扫掉 run-leg 复合键双生)—— 同一个实体改由**另一行**渲染,终局会落在**那一行**上;
234
+ * ② **父 leg settle 时把子代行收走**(`onTerminal` 里的 `for (const cid of children) removeTask(cid)`),
235
+ * **含父 parked 的形**(理由逐字在该处:子代在父 park 期间并不在跑,resume 后新的 `task_progress`
236
+ * 会重新建行;而子代本来就从不发终态 tick)。
237
+ * ⇒ 对消费端的纪律只有一条:**`task_remove` 在任何形态下都只说「这一行从本副本的面板上离场了」,
238
+ * 不说结算**。唯一的终局证据是那一行**自己的**终态 `task` 帧(`status` ∈ completed/failed/killed),
239
+ * 或对 durable 面的权威查询(`GET /v1/runs/:id`)。
221
240
  *
222
241
  * 铸点只有对账腿:durable 说这条 run 还 `running`(reaper 宽限窗内)、或 claim 在他副本、或
223
242
  * durable 行压根不存在 —— 三形共同点是**没有终局可投影**,而本副本的行必然陈旧。此时造一个终态
@@ -624,7 +643,7 @@ export declare function runStatusToFleet(status: RunStatusForFleet, opts?: {
624
643
  * 的每一个 core 词。
625
644
  *
626
645
  * 这个词的病史:`blocked` 曾在本仓有**两个独立写者**且答案互斥 —— 本文件读它为「非成功终局(agent
627
- * 放弃)⇒ 行离场」,而 `observability/run-terminal-log.ts` 读它为「治理判决,不是失败 ⇒ 不打行」。两处
646
+ * 放弃)⇒ 行离场」,而 `observability/run-terminal.ts` 读它为「治理判决,不是失败 ⇒ 不打行」。两处
628
647
  * 各自穷举、各自正确地编译,结果是一条 `report_blocked` 的 run 在面板上离场、在引擎日志里一个字都没有。
629
648
  * 语义收归 `plugins/store-contracts.ts` 的那张表之后,本行不再自持判断:表把某个词挪出/挪进
630
649
  * `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
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
- deps.bus.publishTask({ ...w.row, status: runStatusToFleet(rec.status), retiredBy: "reconcile", ...fleetRunResiduals(rec.result) });
129
- deps.bus.removeTask(id, { ...(w.row.cycleSeq !== undefined ? { cycleSeq: w.row.cycleSeq } : {}) });
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)
@@ -222,7 +260,12 @@ export function createFleetReconciler(deps) {
222
260
  }
223
261
  return {
224
262
  runOnce,
225
- stop: unsubscribe,
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-log.js";
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";
@@ -1,4 +1,4 @@
1
- import { redactErrorMessage, redactHead } from "../observability/run-terminal-log.js";
1
+ import { redactErrorMessage, redactHead } from "../observability/run-terminal.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { uuidv7, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, GLOBAL_USAGE_KEY, terminalProjection, PAUSE_REGISTRY } from "@sema-agent/core";
4
4
  import { decideParkedAgent, denyWorkflowParksUnreadable, findParkOrigin, planWorkflowParkedDecide } from "../parked-decide.js";
@@ -430,7 +430,8 @@ export function createResumeLegs(ctx) {
430
430
  try {
431
431
  return (await store.get(token))?.status === "pending";
432
432
  }
433
- catch {
433
+ catch (e) {
434
+ recordFailOpen("server.resume.reopen-confirm-unreadable", `task=${taskId ?? ""} err=${e instanceof Error ? e.message : String(e)}`);
434
435
  return true;
435
436
  }
436
437
  };
@@ -1,6 +1,6 @@
1
1
  import { persistedPlaneOf } from "../../terminal.js";
2
2
  import { createHash } from "node:crypto";
3
- import { memoRedactedRead, redactErrorMessage } from "../../observability/run-terminal-log.js";
3
+ import { memoRedactedRead, redactErrorMessage } from "../../observability/run-terminal.js";
4
4
  import { A2A_TASK_STATES } from "@sema-agent/core";
5
5
  import { isParkedRunStatus, isTerminalRunStatus } from "../../plugins/store-contracts.js";
6
6
  import { HttpError } from "../../security.js";