@sema-agent/server 7.5.0 → 7.6.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.
Files changed (94) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +14 -3
  4. package/USAGE.md +37 -1
  5. package/dist/approval-reconciler.d.ts +1 -1
  6. package/dist/approval-reconciler.js +6 -5
  7. package/dist/boot/config-center.js +15 -2
  8. package/dist/boot/execution-env.js +1 -1
  9. package/dist/boot/parked-revive-gate.d.ts +78 -0
  10. package/dist/boot/parked-revive-gate.js +114 -0
  11. package/dist/boot/resolve-spec.d.ts +3 -20
  12. package/dist/boot/resolve-spec.js +25 -119
  13. package/dist/budget.d.ts +1 -1
  14. package/dist/budget.js +1 -1
  15. package/dist/capabilities/repo-tools.d.ts +1 -1
  16. package/dist/capabilities/repo-tools.js +8 -2
  17. package/dist/config-provider.d.ts +1 -0
  18. package/dist/config-provider.js +23 -3
  19. package/dist/config-types.d.ts +5 -3
  20. package/dist/config.js +5 -4
  21. package/dist/deployment-governance.d.ts +168 -0
  22. package/dist/deployment-governance.js +206 -0
  23. package/dist/fleet/fleet-bus.d.ts +11 -1
  24. package/dist/fleet/fleet-bus.js +43 -0
  25. package/dist/fleet/fleet-terminal-window.d.ts +98 -0
  26. package/dist/fleet/fleet-terminal-window.js +316 -0
  27. package/dist/http/routes/approvals-assistant.js +6 -7
  28. package/dist/http/routes/fleet.js +160 -14
  29. package/dist/http/routes/runs.js +5 -1
  30. package/dist/http/routes/trace-usage.js +3 -4
  31. package/dist/http/send.d.ts +23 -0
  32. package/dist/http/send.js +23 -0
  33. package/dist/http/server.d.ts +4 -0
  34. package/dist/http/server.js +5 -2
  35. package/dist/http/sse-log.js +3 -4
  36. package/dist/leader/diffout.d.ts +10 -0
  37. package/dist/leader/diffout.js +14 -2
  38. package/dist/leader/diffup.js +3 -2
  39. package/dist/leader/planner.js +7 -0
  40. package/dist/main.js +22 -26
  41. package/dist/observability/fail-open.d.ts +4 -0
  42. package/dist/observability/fail-open.js +4 -0
  43. package/dist/orchestration/workflow-notify-journal.d.ts +1 -1
  44. package/dist/orchestration/workflow-notify-journal.js +14 -34
  45. package/dist/plugins/approval-ask-store-sql.d.ts +33 -0
  46. package/dist/plugins/approval-ask-store-sql.js +66 -33
  47. package/dist/plugins/background-agent-store-sql.js +16 -16
  48. package/dist/plugins/breaker-state-sql.js +2 -2
  49. package/dist/plugins/checkpoint-store-sql.d.ts +5 -2
  50. package/dist/plugins/checkpoint-store-sql.js +5 -2
  51. package/dist/plugins/image-bake-store-sql.d.ts +1 -1
  52. package/dist/plugins/image-bake-store-sql.js +27 -27
  53. package/dist/plugins/image-index-sql.js +15 -15
  54. package/dist/plugins/mailbox-store-sql.js +3 -3
  55. package/dist/plugins/memory-engine-pg.js +9 -9
  56. package/dist/plugins/memory-engine-tidb.js +7 -7
  57. package/dist/plugins/memory-sync-store-pg.js +13 -13
  58. package/dist/plugins/memory-sync-store-tidb.js +5 -5
  59. package/dist/plugins/outcome-ledger-sql.js +7 -7
  60. package/dist/plugins/pg-cost-quota.js +3 -3
  61. package/dist/plugins/pg-pool.js +84 -75
  62. package/dist/plugins/pg-rate-limiter.js +3 -3
  63. package/dist/plugins/pg-session-storage.d.ts +1 -1
  64. package/dist/plugins/pg-session-storage.js +12 -13
  65. package/dist/plugins/remote-env-host.js +3 -1
  66. package/dist/plugins/remote-env-local-docker.js +6 -3
  67. package/dist/plugins/remote-env-ssh.d.ts +13 -1
  68. package/dist/plugins/roster-store-sql.js +8 -8
  69. package/dist/plugins/store-contracts.d.ts +19 -0
  70. package/dist/plugins/store-contracts.js +42 -0
  71. package/dist/plugins/task-attachment-store.js +5 -5
  72. package/dist/plugins/task-list-store-sql.js +1 -1
  73. package/dist/plugins/tidb-cost-quota.js +1 -1
  74. package/dist/plugins/tidb-pool.js +83 -60
  75. package/dist/plugins/tidb-rate-limiter.js +1 -1
  76. package/dist/plugins/tidb-session-store.js +2 -5
  77. package/dist/plugins/tool-result-store-sql.js +2 -2
  78. package/dist/plugins/usage-window-store-sql.js +13 -13
  79. package/dist/plugins/write-behind-counter.d.ts +10 -2
  80. package/dist/plugins/write-behind-counter.js +13 -3
  81. package/dist/resource-suspend.d.ts +3 -1
  82. package/dist/resource-suspend.js +3 -1
  83. package/dist/run-local.d.ts +73 -1
  84. package/dist/run-local.js +146 -5
  85. package/dist/runs.d.ts +3 -1
  86. package/dist/runs.js +3 -1
  87. package/dist/security.d.ts +12 -0
  88. package/dist/security.js +12 -0
  89. package/dist/session-sync-kernel.d.ts +13 -0
  90. package/dist/session-sync-kernel.js +13 -0
  91. package/dist/trace/core-keyset-guard.d.ts +1 -1
  92. package/dist/trace/project.d.ts +10 -1
  93. package/dist/trace/project.js +31 -0
  94. package/package.json +3 -3
@@ -0,0 +1,206 @@
1
+ /**
2
+ * design/181 件一:**部署治理链的单一构造口**。
3
+ *
4
+ * 三条腿要装同一条「部署 ⊇ 操作员」治理链 —— HTTP 的 `resolveSpec`(boot/resolve-spec.ts)、durable park
5
+ * 的赎回腿(main.ts 的父约束重建)、`run-local`。此前只有第一条腿真装,另外两条各自手拼一小截,于是
6
+ * 「同一个部署旋钮在这条腿上生效、在那条腿上不存在」成了结构性可能。本文件把**构造**收成单一属主。
7
+ *
8
+ * 🔒 **折叠属主不在这里**:合成仍是 core 的 `tightenTaskSpec`,经由既有的 `applyRuntimeGovernance`
9
+ * (src/runtime-governance.ts)。本口**只产入参与基线**,一条 policy 都不合成 —— 折叠有六条规则
10
+ * (excludeTools/deferTools 并集、handsReadOnly/shellGate 放松即 throw、onAsk/hooks 冲突、键存在性
11
+ * profile),且 `applyRuntimeGovernance` 的 shellGate delete 臂读的是 `base.shellGate`、`governanceForced`
12
+ * 观察器包在其内部 —— 一个 base-free 的「已折好」新口复现不了这些,照抄=在 server 侧重写 core 的折叠。
13
+ *
14
+ * ⚠️ **禁 memoize**:`autonomy` / `commandPolicy` / `manualModeShellGate` / `sensitiveWritePatterns` 都是
15
+ * 热改字段(registry 热应用换 config 引用,resume 腿按**当前** config 重折 —— 与审批基线读活
16
+ * `config.approvalRequire` 同一姿势)。每次调用现取,boot 期缓存一份 = 把热改静默冻住。
17
+ */
18
+ import { posix } from "node:path";
19
+ import { FileError, StubExecutionEnv, combinePolicies, createAllowDenyPolicy, createDurableQuestionPolicy, createSensitivePathPolicy, err, ok, } from "@sema-agent/core";
20
+ import { createDurableAskPolicy } from "./approval.js";
21
+ /**
22
+ * #152([2703] 案二):durable 部署上的 AskUserQuestion 门。活体面(QuestionCoordinator)缺席 ⇒ 原形
23
+ * `createDurableQuestionPolicy()`(恒 ask ⇒ 恒 durable park)。在场 ⇒ **判决时**按活流上下文分腿:
24
+ * 活流腿(bg/SSE,coordinator.runWithContext 包裹且投递面此刻可达,ALS 判)allow——工具执行落到
25
+ * RunnerDeps.onQuestion 的 coordinator,问正在 tail 流的活人;无活流腿(sync /v1/tasks、verify/cascade、
26
+ * durable resume 驱动、断连后的 detach 腿)ask——durable park 原语义逐字保留(#166 后无活流腿放行执行
27
+ * 也不会产出空答:coordinator 无 ALS ctx ⇒ 冻结 `{kind:"unavailable"}`(src/question.ts),永不悬挂;
28
+ * park 仍是把问题送到人面前的唯一那条腿,正当性不变、只是反事实前提换了)。
29
+ * 工具名字面量与 server.ts 的 pre-CAS 守卫同源("AskUserQuestion",core 未根导出常量)。
30
+ * ⚠️ **单一属主**(复审 A2):AskUserQuestion 的 durable 判决只有这一处。任何需要「同参重建」这条判决的
31
+ * 地方(main.ts 的 parkedReviveInheritedGate 父约束链)必须调本工厂,不得自折 core 原形——两份拷贝里
32
+ * 只改一份正是本条 finding 的成因。**登记豁免一处**:leader worker 腿(src/leader/wire.ts provisionWorker)
33
+ * 自折 core 原形——该腿无活体问答面可装且 leader 不 import boot 层(分层),core 原形+sentinel 即其完整
34
+ * 语义;豁免注在彼处互指,接活体面之日必须并回本工厂。
35
+ */
36
+ export function createDurableQuestionGate(live) {
37
+ if (live === undefined)
38
+ return createDurableQuestionPolicy();
39
+ return {
40
+ check(req) {
41
+ if (req.toolName !== "AskUserQuestion")
42
+ return { action: "allow" };
43
+ return live.hasLiveContext()
44
+ ? { action: "allow" }
45
+ : { action: "ask", message: "AskUserQuestion: awaiting a human answer (durable)" };
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * 沙箱 lane 上**相对形**写目标的守卫补层用 env(codex 对抗复审 round1 finding 1,红先复现)。
51
+ *
52
+ * 缺口:沙箱 lane 的守卫策略拿不到 `rootPath`(沙箱 cwd 不是 server 能猜的,#165 裁定 1),而
53
+ * `DeferredSandboxPathEnv.absolutePath` 对相对形一律报错。core 的 `canonicalizeTarget` 在
54
+ * **absolutePath 失败**这一支不置 `unresolvedSymlink`,于是守卫策略走的是
55
+ * 「判不了就弃权」的 `allow`(dist 亲读)。写门在场时这条腿被门的 `ask` 兜住;而
56
+ * `bypassPermissions` / settings 缺席这几形**根本没有门**,于是 `Write(file_path: ".env")` 一路放行——
57
+ * 而 core 的结构化写工具会把相对形按 engine 跟踪的 cwd 解析后真写下去(fs-write.js `resolveKey`)。
58
+ *
59
+ * 补法:**同一只**守卫策略工厂再铸一个实例,只把「路径→canonical key」这一步换成
60
+ * 纯词法基准(本 env)。判定与提取(哪个参数是写目标、NotebookEdit 的 notebook_path 优先、段匹配)
61
+ * 全部仍是 core 的,server 侧零复刻——复刻 core 的裁决逻辑正是「同源谎」那一类错误。
62
+ *
63
+ * 三条不可动的边界:
64
+ * · **绝对形一律弃权**(absolutePath 报错 ⇒ canon 失败且非 unresolvedSymlink ⇒ core 判 allow):
65
+ * 绝对形归真身裁决那一层,#165「真身胜过名字」的裁定(域内良性软链名叫 `.ssh` 只 ask)不受影响。
66
+ * · **只会 deny,不会放行**:相对形自身拼写里出现的段,解析成绝对路径后仍在,所以词法命中即真命中;
67
+ * 反过来一条名叫 `.env` 而真身良性的相对软链会被误 deny —— 方向是 fail-closed,与守卫集语义同向。
68
+ * · **覆盖面(2026-08-08 按 core 5.19.0 #108 校正;旧文见下方「历史」段)**:本层只在
69
+ * `ToolCallRequest.cwd` **缺席**那一形上说话。core 5.19.0 起每条路径解析型守卫按 `req.cwd ?? rootPath`
70
+ * 解析写目标(dist `core/sensitive-path-policy.js`),而 `canonicalizeTarget` 拿到 baseCwd 后会先把
71
+ * 相对形**拼成绝对形**再交给 env(dist `tools/fs/safety.js` 的 `baseCwd && !isAbsolutePathForm(...)`
72
+ * 分支)—— 本层的 `absolutePath` 对绝对形一律报错弃权 ⇒ **有戳时本层自动让位**,由真身那一层
73
+ * (沙箱 `DeferredSandboxPathEnv` / host `NodeExecutionEnv`)按活 cwd 裁决,cwd 里的守卫段现在真看得见。
74
+ * ⇒ 本层今天的射程 = 「引擎没盖戳」的调用:相对形**自身拼写**里带守卫段的那一类(`Write(".env")`、
75
+ * `Write("cfg/.ssh/id_rsa")`),仍由本层 fail-closed 兜住。**不删臂**:让位与冗余不是一回事——
76
+ * 删掉它等于把「缺戳即无守卫」写死,而缺戳形在契约上是 core 明确保留的回落语义(直接调用形)。
77
+ * 两处特征化钉现在各带两臂(带戳 deny / 缺戳 allow):test/task-settings.test.ts 与
78
+ * test/run-local.test.ts(后者是真引擎端到端,已翻成 🔴 正控)。
79
+ *
80
+ * 📜 **历史(留档,别当现状读)**:2026-08-08 之前本层的覆盖面到「cwd 里的守卫段看不见」为止——
81
+ * 本层把相对形挂在 `/` 上,而工具挂在 engine 活 cwd 上,`cd .git` 后 `Write("config")` 真写
82
+ * `<root>/.git/config` 而本层只看得到 `/config` ⇒ 弃权。design/181 刀3 的口径更正查明这条残余面
83
+ * **不是沙箱 lane 局部的**:host 腿虽供了 `rootPath`,那也是装配期的静态值,一样追不上被 Bash `cd`
84
+ * 就地改写的 `cwdRef.current`(run-local 端到端真复现:`cd .git/hooks` 后 `Write("pre-commit")` 真落盘)。
85
+ * 当年判定属主是引擎那条缝(不是任何一条消费腿——在消费腿里自己拿静态 cwd 追 `cd`,是拿会漂的复制品
86
+ * 追引擎的真值,本文件反复点名的病),并把两条钉写成「引擎缝落地后一起翻面」。**该缝即 core backlog
87
+ * #108,已在 5.19.0 到货**,两条钉按上述翻面完毕。另一条备选收口(守卫集开启即把沙箱 lane 相对写
88
+ * 一律 deny,有真受损方且无对应旋钮)因此作废,无需部署方拍板。
89
+ */
90
+ export class RelativeTargetLexicalEnv extends StubExecutionEnv {
91
+ /** 相对形 → `/<词法归一>`;绝对形 / 空串 / 含 NUL 一律报错(= 弃权,见类注)。 */
92
+ absolutePath(path) {
93
+ if (path.length === 0 || path.startsWith("/") || path.includes("\u0000")) {
94
+ return Promise.resolve(err(new FileError("not_supported", "this guard layer only adjudicates RELATIVE write targets (absolute forms are adjudicated against the real sandbox filesystem)", path)));
95
+ }
96
+ return Promise.resolve(ok(posix.normalize(`/${path}`)));
97
+ }
98
+ /** 恒「不存在」⇒ core 的 `canonicalizeNewPath` 逐级回退,最终把词法归一形当 canonical key 交给段匹配。
99
+ * 这里绝不能报错:报错会被 core 读成 `unresolvedSymlink` 而对**每一个**相对目标 deny(含普通文件)。 */
100
+ exists(_path, _abortSignal) {
101
+ return Promise.resolve(ok(false));
102
+ }
103
+ }
104
+ /**
105
+ * boot 期的守卫集**可编译性**门(#177 收口①,随 design/181 件一搬进本口 —— 三条消费腿同得)。
106
+ *
107
+ * 守卫集的编译发生在**每个请求**上。core 的 `compilePatterns` 对「一个路径段都没有」的模式(`"/"`、
108
+ * `"//"`)THROW,那条 throw 会变成**每一个任务一条 500**,且运维从错误里看不出是自己的 env 写错了。
109
+ * 消费腿在装配期先编译一次:非法旋钮值当场炸在启动上(与 config.ts 的 env fail-loud 同族),指名键与
110
+ * core 的原因。env 只是编译期的占位(compilePatterns 不碰它),真裁决用的是每请求按 lane 铸的那一个。
111
+ */
112
+ export function assertGuardPatternsUsable(config) {
113
+ if (config.sensitiveWritePatterns.length === 0)
114
+ return;
115
+ try {
116
+ createSensitivePathPolicy({ env: new StubExecutionEnv(), patterns: config.sensitiveWritePatterns });
117
+ }
118
+ catch (e) {
119
+ throw new Error(`SENSITIVE_WRITE_PATTERNS is not a usable guard set: ${e instanceof Error ? e.message : String(e)}`);
120
+ }
121
+ }
122
+ /**
123
+ * UNGATED 信号的**补偿**(design/181 件一收编 / 件三三腿同得)。
124
+ *
125
+ * 审批基线铺开之后 core 的 `hasEffectAwareGate` 恒真,于是它那条 "write-capable hand tools are present
126
+ * but UNGATED" 的 onError 不再触发(判据是 `policyLayers.length > 0`,prepare-task dist 亲读)。那条信号
127
+ * 此前是「这个部署一个门都没接」这个 misconfig 的**唯一**提示,而守卫集只挡那二十来个路径段、其余写
128
+ * 目标照旧无裁决 —— 信号不能因为我们铺了基线就静默消失,所以由我们自己按同一判据说一次。
129
+ *
130
+ * 判据(与信号消失的条件逐字互补):守卫集在场 ∧ 两条产**真**门的腿都不在场(durable 门关 ∧ 单用户
131
+ * auto-accept 基线不适用)。三个量都是部署常量 ⇒ 消费腿在 boot 期说一次,不是每任务一次。
132
+ *
133
+ * ⚠️ 单一属主(design/181 件三):HTTP 腿与 run-local 腿共用本判据与文案。两处各写一份 = 一处改了另一处
134
+ * 没改,而两份都长得像对的 —— 那正是本文件存在的理由。
135
+ */
136
+ export function buildOnlySensitiveBaselineWarning(config, seat) {
137
+ if (config.sensitiveWritePatterns.length === 0 || seat.durableEnabled || seat.singleUserAutoAcceptBaseline)
138
+ return undefined;
139
+ return {
140
+ event: "tool_policy_only_sensitive_baseline",
141
+ fields: {
142
+ detail: "the sensitive-path DENY baseline (SENSITIVE_WRITE_PATTERNS) is this deployment's ONLY tool policy — writes outside the guarded segments are unadjudicated, and core's own UNGATED warning no longer fires because a policy is now always present",
143
+ remedy: "wire an approval face (DURABLE_APPROVAL / APPROVAL_REQUIRE with a store) or accept the single-user auto-accept baseline",
144
+ guardedPatterns: config.sensitiveWritePatterns.length,
145
+ },
146
+ };
147
+ }
148
+ /**
149
+ * `applyRuntimeGovernance` 的 governance 实参预铸(design/181 件一)。
150
+ *
151
+ * 键存在性 profile 逐字保持消费腿原样:`autonomy`/`commandPolicy` 恒在场(值可为 `undefined`),
152
+ * `manualModeShellGate`/`sensitivePathPolicy` 按在场性条件展开 —— `applyRuntimeGovernance` 的
153
+ * `!== undefined` 判据对两者等价,但 profile 是折叠面的可观测字节,搬家不许顺手改。
154
+ */
155
+ export function createDeploymentGovernanceInputs(config, pathAdjudication) {
156
+ const sensitivePathPolicy = config.sensitiveWritePatterns.length > 0
157
+ ? (() => {
158
+ const realTarget = createSensitivePathPolicy({
159
+ env: pathAdjudication.env,
160
+ patterns: config.sensitiveWritePatterns,
161
+ ...(pathAdjudication.cwd !== undefined ? { rootPath: pathAdjudication.cwd } : {}),
162
+ });
163
+ // cwd 在场(host 形)⇒ 相对形已被 core 按 rootPath 解析进真身裁决,一层就够。
164
+ // cwd 缺席(沙箱形)⇒ 相对形在真身那一层是「判不了 ⇒ 弃权 allow」,而写门恰恰在
165
+ // bypass/settings 缺席这几形不在场 ⇒ 补一层纯词法的相对形守卫(见 RelativeTargetLexicalEnv)。
166
+ if (pathAdjudication.cwd !== undefined)
167
+ return realTarget;
168
+ return combinePolicies(realTarget, createSensitivePathPolicy({ env: new RelativeTargetLexicalEnv(), patterns: config.sensitiveWritePatterns }));
169
+ })()
170
+ : undefined;
171
+ return {
172
+ autonomy: config.autonomy,
173
+ commandPolicy: config.commandPolicy,
174
+ ...(config.manualModeShellGate ? { manualModeShellGate: config.manualModeShellGate } : {}),
175
+ ...(sensitivePathPolicy ? { sensitivePathPolicy } : {}),
176
+ };
177
+ }
178
+ /**
179
+ * 审批基线 —— `applyRuntimeGovernance` 那个 base 的 `toolPolicy` 座(design/181 件一)。**恒非
180
+ * `undefined`**:治理层是 tighten-only 的叠加层,没有基线可叠时它自己也产不出「门在场」这件事。
181
+ *
182
+ * 两形:
183
+ * · `durable` 在场 ⇒ durable 轴 = AskUserQuestion 判决门 + F4 高危写审批门(deny/neverAuto/预算/
184
+ * 会话豁免全在 `createDurableAskPolicy` 里)。gated `ask` 由 core 变成 durable checkpoint suspend。
185
+ * · `durable` 缺席 ⇒ **adjudicated allow-all**(`createAllowDenyPolicy({})`):一条**在场的**、
186
+ * effect-aware 的策略。CC 的信任模型是 auto-accept,但门**机制**必须在场(core 原则「机制留、默认
187
+ * 可更宽」)—— 满足 core 的 `hasEffectAwareGate`,恢复可观测性与 hook/tighten 点,而运维照旧用
188
+ * `AUTONOMY`/`commandPolicy` 收紧不可逆操作(由 `applyRuntimeGovernance` tighten-only 叠上)。
189
+ *
190
+ * ⚠️ 「零门意图才铺 allow-all」这条准入判据**不在本口**:它是消费腿的部署形判断(单用户/多租户、
191
+ * 有无 checkpoint 店),属主是 `hasOperatorGateIntent`/`assertGateIntentServiceable`(src/approval.ts)。
192
+ * 本口只按调用方给的形铸策略,绝不替它判「这个部署该不该有门」。
193
+ */
194
+ export function createApprovalBaselinePolicy(config, durable) {
195
+ if (durable === undefined)
196
+ return createAllowDenyPolicy({});
197
+ return combinePolicies(createDurableQuestionGate(durable.question), createDurableAskPolicy({
198
+ requireApproval: config.approvalRequire,
199
+ deny: config.approvalDeny,
200
+ autoBudget: config.approvalAutoBudget,
201
+ neverAuto: config.approvalNeverAuto,
202
+ ...(durable.exempt ? { exempt: durable.exempt } : {}),
203
+ ...(durable.onExempted ? { onExempted: durable.onExempted } : {}),
204
+ }));
205
+ }
206
+ //# sourceMappingURL=deployment-governance.js.map
@@ -1,4 +1,4 @@
1
- import type { TaskNotificationPayload } from "@sema-agent/core";
1
+ import type { TaskNotificationPayload, WorkflowRun } from "@sema-agent/core";
2
2
  /** [2687-cli] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
3
3
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
4
4
  * 不属 agent fleet 树——它们此前每条都打,fleet-bus 的「无 tick 无 claim」臂给 b\* 与 m\* handle 合成
@@ -153,6 +153,16 @@ export interface FleetWorkflowRow {
153
153
  elapsedMs?: number;
154
154
  tokens?: number;
155
155
  }
156
+ /**
157
+ * durable {@link WorkflowRun} → 一条 fleet workflow 行的**纯投影**(`build*`=纯数据,无行为、不发布)。
158
+ *
159
+ * 从 `JournalingWorkflowRunStore.publishFleet` **原样**抽出,因为它现在有两个调用点,而两处各写一遍
160
+ * 必然漂移(本仓在 startedCount 口径上已吃过同款):
161
+ * ① 活写路径 —— put/update 的写观察点(行的唯一写者,见该类注);
162
+ * ② `/v1/fleet/stream` 连接时快照的**有界终态行窗**(#189 修方向 1)—— 引擎重启后 boot 扫描把前世
163
+ * running run 判死,终帧与撤行在同一同步栈内背靠背发出,之后才连上的客户端连快照都看不见那一行。
164
+ */
165
+ export declare function buildFleetWorkflowRow(id: string, run: WorkflowRun): FleetWorkflowRow;
156
166
  /** A push frame on the fleet stream. `snapshot` = the full current state on connect; `task`/`workflow` = a single
157
167
  * row upsert (the row transitioned); `task_remove`/`workflow_remove` = the row left the active set (terminal +
158
168
  * swept). Stable identity (`id`) lets the UI update/remove the right row in place. */
@@ -21,6 +21,7 @@
21
21
  * replica-local). A cross-replica fleet roll-up is a fleet-token-gated trace-API concern (separate), not this.
22
22
  */
23
23
  import { EventEmitter } from "node:events";
24
+ import { deriveAgentDisplayStatus } from "@sema-agent/core";
24
25
  import { recordFailOpen } from "../observability/fail-open.js";
25
26
  import { redactSecrets } from "../trace/redact.js";
26
27
  /** [2687-cli] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
@@ -33,6 +34,48 @@ import { redactSecrets } from "../trace/redact.js";
33
34
  export function isFleetAgentTerminalNotification(n) {
34
35
  return n.task_type === "background_agent" && (n.status === "completed" || n.status === "failed" || n.status === "killed" || n.status === "cancelled");
35
36
  }
37
+ /**
38
+ * durable {@link WorkflowRun} → 一条 fleet workflow 行的**纯投影**(`build*`=纯数据,无行为、不发布)。
39
+ *
40
+ * 从 `JournalingWorkflowRunStore.publishFleet` **原样**抽出,因为它现在有两个调用点,而两处各写一遍
41
+ * 必然漂移(本仓在 startedCount 口径上已吃过同款):
42
+ * ① 活写路径 —— put/update 的写观察点(行的唯一写者,见该类注);
43
+ * ② `/v1/fleet/stream` 连接时快照的**有界终态行窗**(#189 修方向 1)—— 引擎重启后 boot 扫描把前世
44
+ * running run 判死,终帧与撤行在同一同步栈内背靠背发出,之后才连上的客户端连快照都看不见那一行。
45
+ */
46
+ export function buildFleetWorkflowRow(id, run) {
47
+ const agents = run.agents ?? [];
48
+ // [2336] doneCount 与 failedCount **不相交**:done 只数 completed。契约以此为前提(本文件 startedCount
49
+ // 注的回退式 done+failed ≤ started);把 failed 也计进 done 会让全失败 workflow 的终帧渲成 "N done"
50
+ // (cli 4.1.3 实测 done=2 failed=2 started=2)。
51
+ const done = agents.filter((a) => a.status === "completed").length;
52
+ const failed = agents.filter((a) => a.status === "failed").length;
53
+ // cli [1726] 二①:CC 规模告警的分母是 **started**(已启动),而 `totalCount`(= agents.length)是**计划总数**
54
+ // (含排队中)。判别口径走 core 导出的 `deriveAgentDisplayStatus` —— 一个 agent 已在 `run.agents` 里但
55
+ // `startedAt` 未置就是排队中。**不自己写 `startedAt !== undefined`**:那是 core 的显示契约,两端各写一遍
56
+ // 必然漂移,用它的函数则 core 一改、两端同时跟随。
57
+ const started = agents.filter((a) => deriveAgentDisplayStatus(a, run.status) !== "queued").length;
58
+ return {
59
+ id,
60
+ // [WF2-A parity] redact the workflow label surfaces for parity with the run + subagent-child names (below):
61
+ // a tool-launched (LLM-authored) workflow's meta.name/description is task-controlled and could carry a secret shape.
62
+ name: redactSecrets(run.name ?? "Dynamic workflow"),
63
+ ...(run.description ? { description: redactSecrets(run.description) } : {}),
64
+ scope: run.scope,
65
+ // codex-6 F2:sessionId 必须随行——streamFleet 对无 sessionId 的行按「同 principal 全会话可见」
66
+ // 兜底,漏发=A 会话的 workflow 名/进度/token 泄进 B 会话的 ?session= 过滤流。
67
+ ...(run.originatingSessionId ? { sessionId: run.originatingSessionId } : {}),
68
+ status: run.status,
69
+ doneCount: done,
70
+ totalCount: agents.length,
71
+ failedCount: failed,
72
+ startedCount: started,
73
+ tokens: (run.stats?.tokens ?? 0) + (run.stats?.nested?.tokens ?? 0),
74
+ // [1294]:跑动中也带时长(1.232 只在 endedAt 后带——clay 验收轮实锚面板恒显 0s)。终态用
75
+ // endedAt 定格,活跑用 now-startedAt(每次 put/update 观察点刷新,壳侧读帧即当前时长)。
76
+ elapsedMs: (run.endedAt ?? Date.now()) - run.startedAt,
77
+ };
78
+ }
36
79
  /**
37
80
  * The process-local fleet aggregation bus. Holds the current active set + fans out deltas to SSE subscribers.
38
81
  * Upserts MERGE (a partial delta patches the existing row), so a publisher can emit just the field that changed
@@ -0,0 +1,98 @@
1
+ /**
2
+ * #189 修方向 1(黑板 [3112] 定谳)—— `/v1/fleet/stream` 连接时快照要并入的**近期终态 workflow 行窗**。
3
+ *
4
+ * ## 它修什么
5
+ * 引擎重启后 boot 扫描判死前世 `running` run,`publishWorkflow`(终帧)与 `removeWorkflow` 在**同一同步
6
+ * 调用栈内**背靠背执行;fleet bus 是纯内存 EventEmitter、无重放,`snapshot()` 只读活跃 Map ⇒ **重启之后
7
+ * 才连上的客户端连增量带快照都结构性看不见那一行**(cli 七轮正控实证)。durable 真源一直是对的
8
+ * (`GET /v1/workflows` 可达),缺的只是把它接到面板这一路上——所以这里是 **pull**:读同一份 durable
9
+ * store,**不碰 bus 的活跃 Map**(活行的单写者不变量、增量帧语义逐字不动)。
10
+ *
11
+ * ## 五条界(任何一条失手都只是少几行历史 ⇒ 整体 F 类 fail-open,登记 tag,不静默)
12
+ * · 窗长 {@link FLEET_SNAPSHOT_TERMINAL_WINDOW_MS} · 行数 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS}
13
+ * · 扫描面 {@link FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT} · 读预算 {@link FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS}
14
+ * · 熔断冷却 {@link FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS}
15
+ *
16
+ * ## 为什么本模块有进程级可变状态(路由域刻意保持零状态,故不放在 `routes/fleet.ts`)
17
+ * 三件都长在同一个事实上——**`WorkflowRunStore` 契约没有取消面**,`Promise.race` 只结束等待、撤不回
18
+ * 已发出的查询:合流(重连风暴不按连接数放大,R1-H2)、合流窗有限(不把陈旧结果发给后来者、也不让
19
+ * 一条永不 settle 的读把后续连接全钉死,R2-H1/R3-M2)、熔断冷却(挂死的后端不被每周期继续加压,
20
+ * R3-H1)。真解是店侧查询超时/取消 —— 要动契约与四个后端,已作移交项。
21
+ *
22
+ * ## scope
23
+ * `WorkflowRunStore` 契约**没有跨 scope 枚举**(见 `orchestration/workflow-notify-journal.ts` 头注),
24
+ * 所以窗恒按**调用者自己的 principal scope** 读:fleet-wide 观察者看到的是「全租户活行 + 自己 scope 的
25
+ * 终态窗」。这是诚实的不对称(读不到的东西不编),方向安全(绝不多给);真行的租户/会话可见性仍由
26
+ * 路由的 `visW` 统一执法,本模块不自行放行。
27
+ */
28
+ import { type WorkflowRunStore } from "@sema-agent/core";
29
+ import { type FleetWorkflowRow } from "./fleet-bus.js";
30
+ /**
31
+ * 窗长 —— 10 分钟,**不做旋钮**。
32
+ *
33
+ * 它覆盖的是一段具体的人机时长:「引擎重启 → 客户端重连 → 人看一眼面板上刚才那条 workflow 怎么了」。
34
+ * 比它短会漏掉重连慢一步的壳(即本 issue 的病灶);比它长就变成「拿 SSE 快照当历史列表」——而历史面
35
+ * 本来就有真源(`GET /v1/workflows`:durable、可分页、带 scope/session 过滤),不该在这里长出第二个。
36
+ * 两侧边界都由语义定死,旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
37
+ */
38
+ export declare const FLEET_SNAPSHOT_TERMINAL_WINDOW_MS: number;
39
+ /** 同一快照里终态行的行数上限(窗长之外的第二道界)。快照是面板首屏,不是历史列表——超出的部分归
40
+ * `GET /v1/workflows`。 */
41
+ export declare const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
42
+ /**
43
+ * 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
44
+ * 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
45
+ * 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
46
+ * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at DESC`
47
+ * 的窄查询(要过真双库门,已作移交项记在发车说明里)。
48
+ *
49
+ * 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
50
+ * 被挤掉。此时用户仍可从 `GET /v1/workflows` 看到真相——丢的只是面板首屏的一行。
51
+ */
52
+ export declare const FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT = 100;
53
+ /** durable 读的时间预算。超时=**放弃这段增益**(记 F 类 fail-open),绝不让一次慢查询把整条 SSE 握手
54
+ * 拖住——面板少一条历史行是难看,连不上流是坏掉。 */
55
+ export declare const FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS = 2000;
56
+ /**
57
+ * (原 `FLEET_SNAPSHOT_TERMINAL_COALESCE_MS` 已删)⚖️ 「同时只有一支探针」与「结果别太陈旧」的取舍
58
+ * —— codex R3-M2 与 R4-H1 是两轮方向相反的 finding,这里成文定案。
59
+ *
60
+ * 后来者**加入**在飞的那支读,只要它还在自己的预算之内({@link FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS})。
61
+ * · 曾短暂改成 250ms 的窄窗(想压小"采到"与"交付"的时差):实测判据下更坏 —— 慢而健康的店(比如
62
+ * 1.9s)配错峰重连,会变成每 250ms 起一批新读(2 次 list + ≤20 点读),而熔断要等某个调用方 2s
63
+ * 超时才开;单飞的意义被自己抵消(R4-H1)。
64
+ * · 保留的代价:刚好在探针尾巴上加入的调用方,可能拿到一份最多约"两倍预算"之前采到的行集(R3-M2)
65
+ * —— 后果是这条连接首屏少一条**刚刚**结束的行(且只有跨副本翻转才补不上帧),与本模块整体的 F 类
66
+ * 姿态同级;而 R4-H1 的后果是给一个已经很慢的库继续加压。取小害。
67
+ * 过了预算的在飞读 = 已被放弃的一代(等待方全走光了),不再加入:那一代由熔断收尾,新调用方在冷却期
68
+ * 外开新一代。
69
+ */
70
+ /**
71
+ * 熔断冷却期:一次读失败/超预算之后,同 (store, scope) 在这段时间内**不再发起新读**(直接放弃这段增益,
72
+ * 记 F 类 fail-open)。
73
+ *
74
+ * 为什么必须有(codex R3-H1,红先复现 4→2):`WorkflowRunStore` 契约没有取消面 —— 发出去的查询撤不回。
75
+ * 后端挂死时,只按"复用窗过期就再起一次"办,等于每个周期给那个已经挂死的后端再加两笔永不回来的活,
76
+ * 连接不断则永远堆下去(池/内存/驱动队列迟早耗尽),这正是"故障期被自己的重试放大"那一族。冷却期把
77
+ * 新增速率钉在「每 scope 每 10s 至多一次探针」,并且**自愈**:冷却一过,下一条连接就是一次真实探测。
78
+ * 残余(如实记档):挂死那几笔仍在后端占着;真解=店侧查询超时/取消(要动契约与四个后端,移交项)。
79
+ */
80
+ export declare const FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS = 10000;
81
+ /** fleet 行上的 `status` 是自由字符串(wire 面容忍未知词),而"是不是终态"必须与上面那张**同一张**
82
+ * 闭集表说同一句话 —— 供路由判断一条 workflow 行帧是否终态(不做裸 cast:词表就是判据)。 */
83
+ export declare function isTerminalWorkflowRowStatus(status: string): boolean;
84
+ export declare function seedTerminalWorkflowRow(row: FleetWorkflowRow): void;
85
+ /** 测试缝:清 seed 缓存(与 clearTerminalWindowCooldownForTest 同姿势,生产零调用)。 */
86
+ export declare function clearSeededTerminalRowsForTest(): void;
87
+ export declare function recentTerminalWorkflowRows(store: WorkflowRunStore | undefined, scope: string | null, pullScope?: string): Promise<readonly FleetWorkflowRow[]>;
88
+ /**
89
+ * test-only:把熔断冷却**提前**到现在(等价于"冷却期已过"),**不动**在飞读的登记。
90
+ *
91
+ * 为什么只清冷却:冷却期是墙钟(10s),测试不该真等;而"在飞读的复用是否会把毒 promise 传给后来者"
92
+ * 恰恰是要被测的行为,清掉它就等于把待测对象删了。生产路径不引用本函数(与
93
+ * `resetFailOpenRecorderForTest` 同一姿势:测试面显式,不给生产留旁路)。
94
+ */
95
+ export declare function clearTerminalWindowCooldownForTest(store: WorkflowRunStore, scope: string): void;
96
+ /** test-only:这个 store 目前还记着几个 scope(codex R4-M2 的空闲销号有没有真的销)。 */
97
+ export declare function trackedTerminalWindowScopesForTest(store: WorkflowRunStore): number;
98
+ //# sourceMappingURL=fleet-terminal-window.d.ts.map