@sema-agent/server 7.4.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 (201) 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 +80 -1
  5. package/dist/approval-card.d.ts +15 -3
  6. package/dist/approval-card.js +41 -7
  7. package/dist/approval-reconciler.d.ts +109 -12
  8. package/dist/approval-reconciler.js +152 -24
  9. package/dist/boot/config-center.js +15 -2
  10. package/dist/boot/coordinators.js +10 -2
  11. package/dist/boot/execution-env.js +1 -1
  12. package/dist/boot/org-memory.d.ts +6 -0
  13. package/dist/boot/org-memory.js +1 -1
  14. package/dist/boot/parked-revive-gate.d.ts +78 -0
  15. package/dist/boot/parked-revive-gate.js +114 -0
  16. package/dist/boot/reapers.d.ts +2 -0
  17. package/dist/boot/reapers.js +11 -4
  18. package/dist/boot/resolve-spec.d.ts +3 -19
  19. package/dist/boot/resolve-spec.js +73 -67
  20. package/dist/boot/runner-deps.d.ts +23 -1
  21. package/dist/boot/runner-deps.js +8 -11
  22. package/dist/boot/workflow-orchestration.d.ts +8 -3
  23. package/dist/boot/workflow-orchestration.js +23 -1
  24. package/dist/budget.d.ts +1 -1
  25. package/dist/budget.js +1 -1
  26. package/dist/capabilities/repo-tools.d.ts +1 -1
  27. package/dist/capabilities/repo-tools.js +8 -2
  28. package/dist/config-center/apply-effective.js +33 -10
  29. package/dist/config-provider.d.ts +1 -0
  30. package/dist/config-provider.js +23 -3
  31. package/dist/config-types.d.ts +27 -9
  32. package/dist/config.d.ts +6 -1
  33. package/dist/config.js +61 -15
  34. package/dist/deployment-governance.d.ts +168 -0
  35. package/dist/deployment-governance.js +206 -0
  36. package/dist/env-facts.d.ts +3 -1
  37. package/dist/env-facts.js +3 -1
  38. package/dist/fleet/fleet-bus.d.ts +17 -2
  39. package/dist/fleet/fleet-bus.js +68 -3
  40. package/dist/fleet/fleet-terminal-window.d.ts +98 -0
  41. package/dist/fleet/fleet-terminal-window.js +316 -0
  42. package/dist/governance-ask-marks.d.ts +31 -0
  43. package/dist/governance-ask-marks.js +122 -0
  44. package/dist/hooks/hook-runner.d.ts +28 -0
  45. package/dist/hooks/hook-runner.js +149 -25
  46. package/dist/http/routes/approvals-assistant.js +6 -7
  47. package/dist/http/routes/diagnostics.js +10 -5
  48. package/dist/http/routes/fleet.js +160 -14
  49. package/dist/http/routes/memory-policy.d.ts +2 -1
  50. package/dist/http/routes/memory-policy.js +77 -13
  51. package/dist/http/routes/runs.js +6 -2
  52. package/dist/http/routes/tasks.js +59 -22
  53. package/dist/http/routes/trace-usage.js +3 -4
  54. package/dist/http/send.d.ts +23 -0
  55. package/dist/http/send.js +23 -0
  56. package/dist/http/server.d.ts +9 -0
  57. package/dist/http/server.js +28 -14
  58. package/dist/http/sse-log.js +3 -4
  59. package/dist/http/wire-types.d.ts +7 -2
  60. package/dist/leader/diffout.d.ts +10 -0
  61. package/dist/leader/diffout.js +14 -2
  62. package/dist/leader/diffup.js +3 -2
  63. package/dist/leader/planner.js +7 -0
  64. package/dist/main.js +39 -31
  65. package/dist/observability/fail-open.d.ts +17 -2
  66. package/dist/observability/fail-open.js +19 -4
  67. package/dist/observability/prompt-manifest.d.ts +5 -1
  68. package/dist/orchestration/workflow-notify-journal.d.ts +58 -2
  69. package/dist/orchestration/workflow-notify-journal.js +130 -45
  70. package/dist/parked-decide.js +9 -4
  71. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  72. package/dist/plugins/approval-ask-store-memory.js +3 -2
  73. package/dist/plugins/approval-ask-store-sql.d.ts +60 -5
  74. package/dist/plugins/approval-ask-store-sql.js +75 -35
  75. package/dist/plugins/background-agent-store-sql.js +16 -16
  76. package/dist/plugins/background-shell-support.d.ts +1 -1
  77. package/dist/plugins/background-shell-support.js +2 -2
  78. package/dist/plugins/breaker-state-sql.js +2 -2
  79. package/dist/plugins/checkpoint-store-sql.d.ts +67 -8
  80. package/dist/plugins/checkpoint-store-sql.js +76 -13
  81. package/dist/plugins/image-bake-store-sql.d.ts +1 -1
  82. package/dist/plugins/image-bake-store-sql.js +27 -27
  83. package/dist/plugins/image-index-sql.js +15 -15
  84. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  85. package/dist/plugins/local-checkpoint-store.js +19 -0
  86. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  87. package/dist/plugins/mailbox-store-sql.js +59 -6
  88. package/dist/plugins/memory-engine-pg.js +9 -9
  89. package/dist/plugins/memory-engine-tidb.js +7 -7
  90. package/dist/plugins/memory-sync-store-pg.js +13 -13
  91. package/dist/plugins/memory-sync-store-tidb.js +5 -5
  92. package/dist/plugins/outcome-ledger-sql.js +7 -7
  93. package/dist/plugins/pg-cost-quota.js +3 -3
  94. package/dist/plugins/pg-pool.js +84 -75
  95. package/dist/plugins/pg-rate-limiter.js +3 -3
  96. package/dist/plugins/pg-session-storage.d.ts +1 -1
  97. package/dist/plugins/pg-session-storage.js +12 -13
  98. package/dist/plugins/remote-env-host.js +3 -1
  99. package/dist/plugins/remote-env-local-docker.js +6 -3
  100. package/dist/plugins/remote-env-ssh.d.ts +13 -1
  101. package/dist/plugins/roster-store-sql.js +8 -8
  102. package/dist/plugins/store-contracts.d.ts +19 -0
  103. package/dist/plugins/store-contracts.js +42 -0
  104. package/dist/plugins/task-attachment-store.js +5 -5
  105. package/dist/plugins/task-list-store-sql.js +1 -1
  106. package/dist/plugins/tidb-cost-quota.js +1 -1
  107. package/dist/plugins/tidb-pool.js +83 -60
  108. package/dist/plugins/tidb-rate-limiter.js +1 -1
  109. package/dist/plugins/tidb-session-store.js +2 -5
  110. package/dist/plugins/tool-result-store-sql.js +2 -2
  111. package/dist/plugins/usage-window-store-sql.js +13 -13
  112. package/dist/plugins/write-behind-counter.d.ts +10 -2
  113. package/dist/plugins/write-behind-counter.js +13 -3
  114. package/dist/resource-suspend.d.ts +3 -1
  115. package/dist/resource-suspend.js +3 -1
  116. package/dist/run-local.d.ts +73 -1
  117. package/dist/run-local.js +146 -5
  118. package/dist/runs.d.ts +11 -1
  119. package/dist/runs.js +18 -3
  120. package/dist/runtime-governance.d.ts +18 -0
  121. package/dist/runtime-governance.js +90 -3
  122. package/dist/security.d.ts +12 -0
  123. package/dist/security.js +12 -0
  124. package/dist/session-sync-kernel.d.ts +13 -0
  125. package/dist/session-sync-kernel.js +13 -0
  126. package/dist/task-settings.d.ts +3 -9
  127. package/dist/task-settings.js +16 -13
  128. package/dist/tool-approval.d.ts +33 -6
  129. package/dist/tool-approval.js +80 -23
  130. package/dist/trace/core-keyset-guard.d.ts +18 -4
  131. package/dist/trace/project.d.ts +10 -1
  132. package/dist/trace/project.js +31 -0
  133. package/package.json +3 -3
  134. package/dist/boot/lexical-path-env.d.ts +0 -10
  135. package/dist/boot/lexical-path-env.js +0 -88
  136. package/dist/capabilities/oa-tools.d.ts +0 -15
  137. package/dist/capabilities/oa-tools.js +0 -54
  138. package/dist/finance/cost-taxonomy.d.ts +0 -34
  139. package/dist/finance/cost-taxonomy.js +0 -26
  140. package/dist/plugins/approval-store-sql.d.ts +0 -116
  141. package/dist/plugins/approval-store-sql.js +0 -151
  142. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  143. package/dist/plugins/file-workflow-journal-store.js +0 -12
  144. package/dist/plugins/pg-approval-store.d.ts +0 -9
  145. package/dist/plugins/pg-approval-store.js +0 -9
  146. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  147. package/dist/plugins/pg-breaker-state.js +0 -8
  148. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  149. package/dist/plugins/pg-checkpoint-store.js +0 -10
  150. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  151. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  152. package/dist/plugins/pg-image-bake.d.ts +0 -12
  153. package/dist/plugins/pg-image-bake.js +0 -11
  154. package/dist/plugins/pg-image-index.d.ts +0 -12
  155. package/dist/plugins/pg-image-index.js +0 -11
  156. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  157. package/dist/plugins/pg-outcome-ledger.js +0 -11
  158. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  159. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  160. package/dist/plugins/pg-run-store.d.ts +0 -9
  161. package/dist/plugins/pg-run-store.js +0 -9
  162. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  163. package/dist/plugins/pg-session-policy-store.js +0 -7
  164. package/dist/plugins/pg-session-store.d.ts +0 -12
  165. package/dist/plugins/pg-session-store.js +0 -12
  166. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  167. package/dist/plugins/pg-tool-result-store.js +0 -9
  168. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  169. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  170. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  171. package/dist/plugins/pg-workflow-run-store.js +0 -9
  172. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  173. package/dist/plugins/tidb-approval-store.js +0 -8
  174. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  175. package/dist/plugins/tidb-breaker-state.js +0 -7
  176. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  177. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  178. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  179. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  180. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  181. package/dist/plugins/tidb-image-bake.js +0 -11
  182. package/dist/plugins/tidb-image-index.d.ts +0 -12
  183. package/dist/plugins/tidb-image-index.js +0 -11
  184. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  185. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  186. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  187. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  188. package/dist/plugins/tidb-run-store.d.ts +0 -10
  189. package/dist/plugins/tidb-run-store.js +0 -9
  190. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  191. package/dist/plugins/tidb-session-policy-store.js +0 -7
  192. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  193. package/dist/plugins/tidb-tool-result-store.js +0 -10
  194. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  195. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  196. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  197. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  198. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  199. package/dist/plugins/workflow-journal-limits.js +0 -12
  200. package/dist/sema-registry.d.ts +0 -41
  201. package/dist/sema-registry.js +0 -40
package/dist/env-facts.js CHANGED
@@ -113,7 +113,9 @@ export async function ensureScratchpadDir(localDataRoot, sessionId) {
113
113
  * - 绝对路径 + ≤{@link MAX_PATH}(core 渲染上限同源,超限=core 只能给模型假路径)+ canonical
114
114
  * 深度 ≥3(裸 /、/tmp、/home 一整块系统目录不能当豁免域;穿越形按 resolve 归一后判);
115
115
  * - mkdir -p 确保存在(gate 的 canonicalize 与 core 根围栏都要真目录)。
116
- * 敏感路径的最后一道在 core gate 折叠里(sensitivePatterns deny 恒赢,exemptDirs 越不过)。 */
116
+ * 敏感路径的最后一道**不在**写门折叠里(#177 起搬家):守卫集 `SENSITIVE_WRITE_PATTERNS`
117
+ * governance 拍(boot/resolve-spec.ts → applyRuntimeGovernance)无条件铸成 DENY 基线,经 tightenTaskSpec
118
+ * 与写门 deny-wins 折叠 —— 结论不变(deny 恒赢,本函数放出的 exemptDirs 越不过它),但施加点已换。 */
117
119
  export async function acceptShellScratchpadDir(raw, opts) {
118
120
  if (typeof raw !== "string" || raw.length === 0)
119
121
  return undefined; // additive 常态:键缺席/异形,零告警
@@ -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. */
@@ -318,7 +328,12 @@ export declare class FleetEventBus {
318
328
  type: "snapshot";
319
329
  }>;
320
330
  /** Subscribe to deltas. Returns an unsubscribe thunk. The caller typically sends `snapshot()` first, then every
321
- * delta this delivers — the snapshot+delta sequence is the live fleet view. */
331
+ * delta this delivers — the snapshot+delta sequence is the live fleet view.
332
+ *
333
+ * 每个回调各自隔离(#183 复审 R3 HIGH):扇出是同步的,修前一个抛错的订阅方会把异常传回**发布方**——
334
+ * 而发布方是 JournalingWorkflowRunStore.put/update 的投影点,core 持久化对 put 抛错 catch{} 且不推进
335
+ * storeRev ⇒ durable 行冻在 running 而 notify 已 ack,无恢复路径。投影消费方永远不该能楔死 durable
336
+ * 写路;抛错记 {@link recordFailOpen}(F 类,登记闸),不上抛、不影响其余订阅方。 */
322
337
  subscribe(cb: (frame: FleetFrame) => void): () => void;
323
338
  /** Live counts (test/observability). */
324
339
  get size(): {
@@ -21,6 +21,8 @@
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";
25
+ import { recordFailOpen } from "../observability/fail-open.js";
24
26
  import { redactSecrets } from "../trace/redact.js";
25
27
  /** [2687-cli] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
26
28
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
@@ -32,6 +34,48 @@ import { redactSecrets } from "../trace/redact.js";
32
34
  export function isFleetAgentTerminalNotification(n) {
33
35
  return n.task_type === "background_agent" && (n.status === "completed" || n.status === "failed" || n.status === "killed" || n.status === "cancelled");
34
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
+ }
35
79
  /**
36
80
  * The process-local fleet aggregation bus. Holds the current active set + fans out deltas to SSE subscribers.
37
81
  * Upserts MERGE (a partial delta patches the existing row), so a publisher can emit just the field that changed
@@ -121,10 +165,31 @@ export class FleetEventBus {
121
165
  return { type: "snapshot", tasks: [...this.tasks.values()], workflows: [...this.workflows.values()], ts: this.now() };
122
166
  }
123
167
  /** Subscribe to deltas. Returns an unsubscribe thunk. The caller typically sends `snapshot()` first, then every
124
- * delta this delivers — the snapshot+delta sequence is the live fleet view. */
168
+ * delta this delivers — the snapshot+delta sequence is the live fleet view.
169
+ *
170
+ * 每个回调各自隔离(#183 复审 R3 HIGH):扇出是同步的,修前一个抛错的订阅方会把异常传回**发布方**——
171
+ * 而发布方是 JournalingWorkflowRunStore.put/update 的投影点,core 持久化对 put 抛错 catch{} 且不推进
172
+ * storeRev ⇒ durable 行冻在 running 而 notify 已 ack,无恢复路径。投影消费方永远不该能楔死 durable
173
+ * 写路;抛错记 {@link recordFailOpen}(F 类,登记闸),不上抛、不影响其余订阅方。 */
125
174
  subscribe(cb) {
126
- this.emitter.on("frame", cb);
127
- return () => this.emitter.off("frame", cb);
175
+ const isolated = (frame) => {
176
+ try {
177
+ cb(frame);
178
+ }
179
+ catch (err) {
180
+ // detail 构造自身也包住:抛出值若带会抛错的 toString,String() 本身会抛并逃出隔离层(fresh 复审观察项)。
181
+ let detail = "subscriber threw a value whose String() also threw";
182
+ try {
183
+ detail = String(err);
184
+ }
185
+ catch {
186
+ /* keep the fallback detail */
187
+ }
188
+ recordFailOpen("server.fleet.subscriber-callback-threw", detail);
189
+ }
190
+ };
191
+ this.emitter.on("frame", isolated);
192
+ return () => this.emitter.off("frame", isolated);
128
193
  }
129
194
  /** Live counts (test/observability). */
130
195
  get size() {
@@ -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
@@ -0,0 +1,316 @@
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 { isTerminalWorkflowStatus } from "@sema-agent/core";
29
+ import { recordFailOpen } from "../observability/fail-open.js";
30
+ import { buildFleetWorkflowRow } from "./fleet-bus.js";
31
+ /**
32
+ * 窗长 —— 10 分钟,**不做旋钮**。
33
+ *
34
+ * 它覆盖的是一段具体的人机时长:「引擎重启 → 客户端重连 → 人看一眼面板上刚才那条 workflow 怎么了」。
35
+ * 比它短会漏掉重连慢一步的壳(即本 issue 的病灶);比它长就变成「拿 SSE 快照当历史列表」——而历史面
36
+ * 本来就有真源(`GET /v1/workflows`:durable、可分页、带 scope/session 过滤),不该在这里长出第二个。
37
+ * 两侧边界都由语义定死,旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
38
+ */
39
+ export const FLEET_SNAPSHOT_TERMINAL_WINDOW_MS = 10 * 60_000;
40
+ /** 同一快照里终态行的行数上限(窗长之外的第二道界)。快照是面板首屏,不是历史列表——超出的部分归
41
+ * `GET /v1/workflows`。 */
42
+ export const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
43
+ /**
44
+ * 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
45
+ * 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
46
+ * 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
47
+ * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at DESC`
48
+ * 的窄查询(要过真双库门,已作移交项记在发车说明里)。
49
+ *
50
+ * 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
51
+ * 被挤掉。此时用户仍可从 `GET /v1/workflows` 看到真相——丢的只是面板首屏的一行。
52
+ */
53
+ export const FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT = 100;
54
+ /** durable 读的时间预算。超时=**放弃这段增益**(记 F 类 fail-open),绝不让一次慢查询把整条 SSE 握手
55
+ * 拖住——面板少一条历史行是难看,连不上流是坏掉。 */
56
+ export const FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS = 2_000;
57
+ /**
58
+ * (原 `FLEET_SNAPSHOT_TERMINAL_COALESCE_MS` 已删)⚖️ 「同时只有一支探针」与「结果别太陈旧」的取舍
59
+ * —— codex R3-M2 与 R4-H1 是两轮方向相反的 finding,这里成文定案。
60
+ *
61
+ * 后来者**加入**在飞的那支读,只要它还在自己的预算之内({@link FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS})。
62
+ * · 曾短暂改成 250ms 的窄窗(想压小"采到"与"交付"的时差):实测判据下更坏 —— 慢而健康的店(比如
63
+ * 1.9s)配错峰重连,会变成每 250ms 起一批新读(2 次 list + ≤20 点读),而熔断要等某个调用方 2s
64
+ * 超时才开;单飞的意义被自己抵消(R4-H1)。
65
+ * · 保留的代价:刚好在探针尾巴上加入的调用方,可能拿到一份最多约"两倍预算"之前采到的行集(R3-M2)
66
+ * —— 后果是这条连接首屏少一条**刚刚**结束的行(且只有跨副本翻转才补不上帧),与本模块整体的 F 类
67
+ * 姿态同级;而 R4-H1 的后果是给一个已经很慢的库继续加压。取小害。
68
+ * 过了预算的在飞读 = 已被放弃的一代(等待方全走光了),不再加入:那一代由熔断收尾,新调用方在冷却期
69
+ * 外开新一代。
70
+ */
71
+ /**
72
+ * 熔断冷却期:一次读失败/超预算之后,同 (store, scope) 在这段时间内**不再发起新读**(直接放弃这段增益,
73
+ * 记 F 类 fail-open)。
74
+ *
75
+ * 为什么必须有(codex R3-H1,红先复现 4→2):`WorkflowRunStore` 契约没有取消面 —— 发出去的查询撤不回。
76
+ * 后端挂死时,只按"复用窗过期就再起一次"办,等于每个周期给那个已经挂死的后端再加两笔永不回来的活,
77
+ * 连接不断则永远堆下去(池/内存/驱动队列迟早耗尽),这正是"故障期被自己的重试放大"那一族。冷却期把
78
+ * 新增速率钉在「每 scope 每 10s 至多一次探针」,并且**自愈**:冷却一过,下一条连接就是一次真实探测。
79
+ * 残余(如实记档):挂死那几笔仍在后端占着;真解=店侧查询超时/取消(要动契约与四个后端,移交项)。
80
+ */
81
+ export const FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS = 10_000;
82
+ /** 终态词表:从 {@link WorkflowRunStatus} 闭集**穷举**(缺一个键或多一个键都是编译期错误)后按 core 的
83
+ * {@link isTerminalWorkflowStatus} 判定——集合的闭合由类型执法、终态的**语义**仍是 core 单源。core 若
84
+ * 加一个状态,这里编译期就红,而不是悄悄漏进/漏出终态窗。 */
85
+ const TERMINAL_WORKFLOW_STATUSES = (() => {
86
+ const all = { running: true, completed: true, failed: true };
87
+ return Object.keys(all).filter((s) => isTerminalWorkflowStatus(s));
88
+ })();
89
+ /** fleet 行上的 `status` 是自由字符串(wire 面容忍未知词),而"是不是终态"必须与上面那张**同一张**
90
+ * 闭集表说同一句话 —— 供路由判断一条 workflow 行帧是否终态(不做裸 cast:词表就是判据)。 */
91
+ export function isTerminalWorkflowRowStatus(status) {
92
+ return TERMINAL_WORKFLOW_STATUSES.some((s) => s === status);
93
+ }
94
+ const stateByStore = new WeakMap();
95
+ function scopeState(store, scope) {
96
+ let byScope = stateByStore.get(store);
97
+ if (!byScope) {
98
+ byScope = new Map();
99
+ stateByStore.set(store, byScope);
100
+ }
101
+ let st = byScope.get(scope);
102
+ if (!st) {
103
+ st = {};
104
+ byScope.set(scope, st);
105
+ }
106
+ return st;
107
+ }
108
+ /** 开熔断,并挂一个 unref 的到期回调自清 + 销号 —— 否则「读超时 2.0s、查询 2.1s 才落地」这条常见路径会
109
+ * 留下一条永久驻留的 scope 记录(冷却记号没人清、`evictIfIdle` 又因为冷却未过而不动手,codex R5-M2)。
110
+ * 代际校验(`coolingUntil === until`)保证到期回调只清自己开的那一次。 */
111
+ function openCooldown(store, scope, st) {
112
+ // codex R6-M:同一支探针失败的 N 个等待者各自到点都会走到这里——冷却语义是 per-probe-failure,不是
113
+ // per-waiter,首开者赢:已在冷却期内就不续期、不再铸第二支定时器(风暴+挂库时 O(连接数) 定时器与
114
+ // 闭包正好压在依赖已经退化的时刻)。
115
+ if (st.coolingUntil !== undefined && st.coolingUntil > Date.now())
116
+ return;
117
+ const until = Date.now() + FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS;
118
+ st.coolingUntil = until;
119
+ const t = setTimeout(() => {
120
+ if (st.coolingUntil !== until)
121
+ return; // 期间又开过新的一次冷却:那次自己管自己
122
+ st.coolingUntil = undefined;
123
+ evictIfIdle(store, scope, st);
124
+ }, FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS);
125
+ if (typeof t.unref === "function")
126
+ t.unref(); // 观测性的收尾绝不该拖住进程退出
127
+ }
128
+ /** 无事可记的 scope 就地销号(codex R4-M2):否则每一个**曾经**开过流的 principal 都会在这张强引用表里
129
+ * 永久留一条——多租户长跑进程下,租户流动/高基数身份会把它变成一条只增不减的内存线。 */
130
+ function evictIfIdle(store, scope, st) {
131
+ if (st.inFlight !== undefined)
132
+ return;
133
+ if (st.coolingUntil !== undefined && Date.now() < st.coolingUntil)
134
+ return;
135
+ const byScope = stateByStore.get(store);
136
+ if (byScope?.get(scope) === st)
137
+ byScope.delete(scope);
138
+ }
139
+ /**
140
+ * 取该 scope 的近期终态行窗。**永不抛**:任何失败/超预算都收成"少几行历史"并记 F 类 fail-open。
141
+ *
142
+ * ⚠️ 调用方仍须对每一行施加与活行**同一套**可见性判据(`visW`:scope + `?session=`)——本函数按 scope
143
+ * 读,但会话轴刻意留给调用方,好与活行"无 sessionId 的行 fail-open 可见"的既有语义逐字一致。
144
+ */
145
+ /** [3156] 轮2 真红的修:进程内终态行 seed 缓存 —— **写路径顺手喂**(publishFleet 的 terminal 臂),
146
+ * 读侧零库查询零时序竞态。存在理由:durable pull 的查询键是 caller 的 scope,而 fleetWide 连接
147
+ * (无 auth 部署恒是)的可见性判据放行**全部** scope —— 查询键与可见性判据不同源,引擎铸行的
148
+ * scope("anon:shell-live")永远不等于 caller 侧的 "default",pull 恒 miss。seed 缓存按「发生过什么」
149
+ * 记(与订阅方无关),读侧再过各连接自己的 visW。不是活跃集:bus 的 Map 零触碰,单写者不变量原样。
150
+ * 有界:行数 ≤ MAX_ROWS×2、窗长同 WINDOW_MS,超界丢最旧。 */
151
+ const seededTerminalRows = [];
152
+ export function seedTerminalWorkflowRow(row) {
153
+ const now = Date.now();
154
+ seededTerminalRows.push({ row, at: now });
155
+ // 双界修剪:窗长 + 行数(常数级,每次 seed 顺手)
156
+ while (seededTerminalRows.length > 0 && (now - seededTerminalRows[0].at > FLEET_SNAPSHOT_TERMINAL_WINDOW_MS || seededTerminalRows.length > FLEET_SNAPSHOT_TERMINAL_MAX_ROWS * 2)) {
157
+ seededTerminalRows.shift();
158
+ }
159
+ }
160
+ /** 测试缝:清 seed 缓存(与 clearTerminalWindowCooldownForTest 同姿势,生产零调用)。 */
161
+ export function clearSeededTerminalRowsForTest() {
162
+ seededTerminalRows.length = 0;
163
+ }
164
+ /** 窗内 seed 行(同 id 取最新一条;窗外的顺手剪掉)。 */
165
+ function seededRowsInWindow() {
166
+ const now = Date.now();
167
+ const byId = new Map();
168
+ for (const e of seededTerminalRows) {
169
+ if (now - e.at <= FLEET_SNAPSHOT_TERMINAL_WINDOW_MS)
170
+ byId.set(e.row.id, e.row);
171
+ }
172
+ return [...byId.values()];
173
+ }
174
+ export async function recentTerminalWorkflowRows(store, scope, pullScope) {
175
+ // [3156]:seed 缓存 = 本进程生命周期内全部终态(含 boot recover 判死)的同源可达径——fleetWide 连接
176
+ // (scope=null,可见性判据放行全 scope)取 seed **全量**,并以 `pullScope`(caller principal,尽力形)
177
+ // 做 durable pull 兜底:pull 的查询键与引擎铸行 scope 可能不同源(cli 轮2 实测 "default" vs
178
+ // "anon:shell-live" 恒 miss),中了是增益、中不了还有 seed——两径并集,谁都不当唯一真源。
179
+ // scoped 连接:seed 按本 scope 滤 + durable pull 本 scope(跨进程兜底)。
180
+ // [3193] 病理3:seed 侧不再按 caller scope 预滤——那是第二份可见性判据(F1 病族),与引擎铸行
181
+ // scope("anon:shell-live" vs caller "default")恒 miss。本函数只产**候选集**;可见/擦除由路由的
182
+ // visW/stripW 同一套判据裁(与快照活行、增量帧同源),窗与 MAX_ROWS 双界仍在。
183
+ const seeded = seededRowsInWindow();
184
+ const effectiveScope = scope ?? pullScope;
185
+ if (effectiveScope === undefined)
186
+ return seeded;
187
+ const scopeForPull = effectiveScope;
188
+ if (!store)
189
+ return seeded; // 无 durable store 的部署(memory 后端/未开自编排):seed 缓存仍工作
190
+ const cooling = scopeState(store, scopeForPull);
191
+ if (cooling.coolingUntil !== undefined && Date.now() < cooling.coolingUntil) {
192
+ // 熔断中:上一代读超预算(后端卡住),这段时间内连探都不探(见 FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS)。
193
+ recordFailOpen("server.fleet.snapshot-terminal-window-unavailable", "cooling down after an over-budget read");
194
+ return seeded; // 熔断只熔 durable 读;seed 是进程内的,照常(scope=null 的 fleetWide 形=全量,勿再按 null 过滤)
195
+ }
196
+ cooling.coolingUntil = undefined; // 冷却已到期:销掉这个记号,好让空闲 scope 能被回收
197
+ evictIfIdle(store, scopeForPull, cooling);
198
+ let timer;
199
+ const probe = sharedRead(store, scopeForPull);
200
+ try {
201
+ const budget = new Promise((_resolve, reject) => {
202
+ timer = setTimeout(() => reject(new Error(`terminal-window read exceeded ${FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS}ms`)), FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS);
203
+ if (typeof timer.unref === "function")
204
+ timer.unref();
205
+ });
206
+ const pulled = await Promise.race([probe.rows, budget]);
207
+ // scoped 腿 = durable pull ∪ seed(同 id durable 优先 —— 库行是 CAS 后真源,seed 是投影快照)
208
+ const pulledIds = new Set(pulled.map((r) => r.id));
209
+ return [...pulled, ...seeded.filter((r) => !pulledIds.has(r.id))];
210
+ }
211
+ catch (err) {
212
+ // 熔断**只由自己这一代开**(codex R4-H1):判据是「我等的那一代到现在还挂在飞」——那才是"后端卡住、
213
+ // 再发就是加压"。两个反例都被这条判据排除:① 我等的那代其实已经完成/失败并销号(快失败的店不累积
214
+ // 在飞的活,没有加压问题,不该罚 10 秒);② 我超时时表上挂的已经是**别人**开的新一代(我这代早settle
215
+ // 了),拿它当证据会让一个已经健康的 scope 被上一代的迟到超时按住。
216
+ const st = scopeState(store, scopeForPull);
217
+ if (st.inFlight === probe)
218
+ openCooldown(store, scopeForPull, st);
219
+ // detail 用 `err.message` 而不是 `String(err)`:后者会调用抛出值自己的 toString,而那本身可能再抛
220
+ // (逃出本兜底,把降级变成故障)。非 Error 抛出值就如实说"不是 Error",不去问它任何问题。
221
+ recordFailOpen("server.fleet.snapshot-terminal-window-unavailable", err instanceof Error ? err.message : "terminal-window read threw a non-Error value");
222
+ return seeded; // durable 读失败只失去跨进程兜底;本进程 seed 照常(读它不碰后端)
223
+ }
224
+ finally {
225
+ if (timer !== undefined)
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+ /**
230
+ * test-only:把熔断冷却**提前**到现在(等价于"冷却期已过"),**不动**在飞读的登记。
231
+ *
232
+ * 为什么只清冷却:冷却期是墙钟(10s),测试不该真等;而"在飞读的复用是否会把毒 promise 传给后来者"
233
+ * 恰恰是要被测的行为,清掉它就等于把待测对象删了。生产路径不引用本函数(与
234
+ * `resetFailOpenRecorderForTest` 同一姿势:测试面显式,不给生产留旁路)。
235
+ */
236
+ export function clearTerminalWindowCooldownForTest(store, scope) {
237
+ scopeState(store, scope).coolingUntil = undefined;
238
+ }
239
+ /** test-only:这个 store 目前还记着几个 scope(codex R4-M2 的空闲销号有没有真的销)。 */
240
+ export function trackedTerminalWindowScopesForTest(store) {
241
+ return stateByStore.get(store)?.size ?? 0;
242
+ }
243
+ /** 合流层:同 (store, scope) 上**同时只有一代**在飞的读;后来者加入仍在预算内的那一代,settle 即销号。 */
244
+ function sharedRead(store, scope) {
245
+ const st = scopeState(store, scope);
246
+ const existing = st.inFlight;
247
+ // 加入仍在预算内的那一代(见上方取舍段);更老的那代已被放弃,不加入。
248
+ if (existing && Date.now() - existing.startedAt < FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS)
249
+ return existing;
250
+ const entry = {
251
+ startedAt: Date.now(),
252
+ rows: readRecentTerminalWorkflowRows(store, scope).finally(() => {
253
+ // 只销自己那一号:一次慢读期间可能已经开始了下一代(本次先 settle 的竞态下,清空会误删新号)。
254
+ if (st.inFlight === entry) {
255
+ st.inFlight = undefined;
256
+ evictIfIdle(store, scope, st);
257
+ }
258
+ }),
259
+ };
260
+ // 所有等待方都可能因为超预算先走(race),没有 catch 的 promise 会变成 unhandledRejection 把进程带下去。
261
+ entry.rows.catch(() => undefined);
262
+ st.inFlight = entry;
263
+ return entry;
264
+ }
265
+ /**
266
+ * `Promise.all` 的**等齐**版:等**每一支**都落地,再决定成败(有失败就抛第一个失败原因)。
267
+ *
268
+ * 为什么不能用 `Promise.all`(codex R5-H1):它在**第一支**失败时立刻 reject,而兄弟支还挂在后端上。
269
+ * 于是这一代探针提前销号,超时的调用方看到"我这代已经不在表上了"就不开熔断(那条判据本意是排除
270
+ * 快失败),下一条连接照样再发一批 —— 正是这套机制要防的放大。等齐之后,"探针还在飞"与"后端还有
271
+ * 我发出去的活"这两件事重新同义,代际熔断的判据才成立。
272
+ */
273
+ async function allOrFirstFailureAfterAllSettled(work) {
274
+ const settled = await Promise.allSettled(work);
275
+ const values = [];
276
+ for (const r of settled) {
277
+ if (r.status === "rejected")
278
+ throw r.reason; // 第一支失败的原因原样上抛(调用方统一收成 fail-open)
279
+ values.push(r.value);
280
+ }
281
+ return values;
282
+ }
283
+ /** 读本体(抛错/超时由 {@link recentTerminalWorkflowRows} 统一收成 fail-open)。 */
284
+ async function readRecentTerminalWorkflowRows(store, scope) {
285
+ const cutoff = Date.now() - FLEET_SNAPSHOT_TERMINAL_WINDOW_MS;
286
+ /** 窗判据的时间轴 = 「这一行最后变成现在这样是什么时候」:终态行恒有 `endedAt`,回落 `createdAt`
287
+ * (与 core `reap` 的 `endedAt ?? createdAt` 同一口径——两处对「这行多老」必须给同一个答案)。 */
288
+ const settledAt = (r) => r.endedAt ?? r.createdAt;
289
+ // 每个终态各扫一页(店侧下推 status + limit),再按结束时刻归并。不用"一次不带 status 的取页":那会被
290
+ // 同 scope 里更新的 running 行挤掉真正要找的终态行。
291
+ const pages = await allOrFirstFailureAfterAllSettled(TERMINAL_WORKFLOW_STATUSES.map((status) => store.listByScope(scope, { status, limit: FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT })));
292
+ const windowed = pages
293
+ .flat()
294
+ .filter((s) => s.scope === scope && settledAt(s) >= cutoff)
295
+ .sort((a, b) => settledAt(b) - settledAt(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // 同刻结束按 id 定序:快照不许随机
296
+ .slice(0, FLEET_SNAPSHOT_TERMINAL_MAX_ROWS);
297
+ // summary 不带 doneCount/startedCount(core `WorkflowRunSummary` 没有这两位),而面板行的其余供数都在
298
+ // 它上面——与其在这里另写一份"近似"投影(两份投影必漂,本仓在 startedCount 口径上已吃过一次),不如
299
+ // 取回整行走**同一个** {@link buildFleetWorkflowRow}。代价=每行一次主键点读,受行数上限硬界(≤20),
300
+ // 且只发生在开流那一拍、同 scope 并发还会合流。
301
+ const runs = await allOrFirstFailureAfterAllSettled(windowed.map((s) => store.get(s.id)));
302
+ const rows = [];
303
+ for (const [i, run] of runs.entries()) {
304
+ if (!run)
305
+ continue; // list 与 get 之间被 reap 掉:诚实跳过,不为一个不存在的 run 造行
306
+ // `store.get` 是**无 scope 门**的裸读(带门的是 core 的 `getWorkflowRun`)——id 虽然来自本 scope 的
307
+ // 列表,仍按真行的 scope 复核一次:租户轴上宁可多判一次,也不靠"来源可信"。
308
+ if (run.scope !== scope)
309
+ continue;
310
+ if (!isTerminalWorkflowStatus(run.status) || settledAt(run) < cutoff)
311
+ continue; // 读回时已不在窗内/已不是终态
312
+ rows.push(buildFleetWorkflowRow(windowed[i].id, run));
313
+ }
314
+ return rows;
315
+ }
316
+ //# sourceMappingURL=fleet-terminal-window.js.map
@@ -0,0 +1,31 @@
1
+ /** 标记通道的活对象(CLAUDE.md 工厂命名律:有方法 ⇒ `create*`)。**一格 scope 的视图**。 */
2
+ export interface GovernanceAskMarks {
3
+ /**
4
+ * 写下「这次工具调用的门**当前**是不是治理层给的」。
5
+ *
6
+ * 🔴 为什么是 set 而不是只加不减(codex 交叉复审 round2 [medium],验真):**同一个 `toolCallId` 会被
7
+ * 复查**。core 的 `recheckApprovedEdit`(`prepare-task.js`)在人编辑了 args 之后拿**同一个**
8
+ * `creq.toolCallId` 重跑策略链,并可能就此再产一只 ask。若只加不减,「原始 args 治理层要问 / 编辑后
9
+ * 治理层放行、由**别的**策略提出第二问」这条真实路径会让第二张卡挂着一个陈旧的治理标 —— 而
10
+ * `isMarked` 又是非消费式的,这个假出处会一直传下去。所以每次治理层裁决都**如实覆写**本格。
11
+ */
12
+ setMarked(toolCallId: string, marked: boolean): void;
13
+ /** 回查(**非消费式**)。空串/缺席一律 false —— 没有身份就没有证据。 */
14
+ isMarked(toolCallId: string | undefined): boolean;
15
+ /** 测试用:清掉本视图能看到的格。 */
16
+ clear(): void;
17
+ /** 测试/可观测:本视图内的标记数。 */
18
+ size(): number;
19
+ }
20
+ /** 一张独立的标记表(不进程共享)。生产路径用的是下面 `governanceAskMarksFor` 给出的**分格视图**;
21
+ * 本工厂给测试(与将来的多实例形)一个不依赖任何环境的干净表。 */
22
+ export declare function createGovernanceAskMarks(opts?: {
23
+ max?: number;
24
+ }): GovernanceAskMarks;
25
+ /** 进入一条 run 腿的标记作用域(唯一调用点 = `ToolApprovalCoordinator.runWithContext`)。 */
26
+ export declare function runWithGovernanceAskScope<T>(scope: string, fn: () => T): T;
27
+ /** **写侧**入口:当前异步作用域那一格的视图;作用域外 = `undefined`(写 no-op,见顶注)。 */
28
+ export declare function currentGovernanceAskMarks(): GovernanceAskMarks | undefined;
29
+ /** **读侧**入口:按**显式** scope 取视图(读侧不读 ALS —— 理由见顶注 round5 那段)。 */
30
+ export declare function governanceAskMarksFor(scope: string): GovernanceAskMarks;
31
+ //# sourceMappingURL=governance-ask-marks.d.ts.map