@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
@@ -1,3 +1,4 @@
1
+ import { FLEET_SNAPSHOT_TERMINAL_MAX_ROWS, isTerminalWorkflowRowStatus, recentTerminalWorkflowRows } from "../../fleet/fleet-terminal-window.js";
1
2
  import { taskNotificationFoldKey } from "../../orchestration/workflow-completion-inbox.js";
2
3
  import { sendJson, sendError, sseHeaders } from "../send.js";
3
4
  import { gatedPrincipal } from "../principal-gate.js";
@@ -44,7 +45,12 @@ async function handleFleetBody(req, res, url, ctx, miss) {
44
45
  // (a non-shell observer following one session). Default (absent) keeps the frame-即-投递 semantics —
45
46
  // today's only session-bound consumer IS the model-injecting shell.
46
47
  const observeOnly = fleetParams.get("observe") === "1";
47
- await streamFleet(req, res, deps.fleetBus, fleetWide ? null : (principal ?? "default"), callerSession || null, observeOnly ? undefined : deps.workflowCompletionInbox);
48
+ // #189:终态行窗的 durable 读交给 streamFleet 在**订阅注册之后**做(见那里的缓冲段)——放在这里
49
+ // (开流之前)会让"帧已扇出而本连接尚未订阅"的窗口凭空多出一个读的时长,codex R1-H1 红先复现过。
50
+ // [3156] 轮2 真红修:终态窗的查询键必须与可见性判据**同源**——fleetWide 连接(callerScope=null,
51
+ // visW 放行全 scope)此前窗却按 caller principal("default")查,而行的 scope 是引擎铸的
52
+ // ("anon:shell-live")⇒ durable pull 恒 miss。fleetWide ⇒ null(窗走进程内 seed 缓存,全 scope)。
53
+ await streamFleet(req, res, deps.fleetBus, fleetWide ? null : (principal ?? "default"), callerSession || null, observeOnly ? undefined : deps.workflowCompletionInbox, { store: deps.workflowRunStore, scope: fleetWide ? null : (principal ?? "default"), pullScope: principal ?? "default", ready: deps.fleetRecoveryDone });
48
54
  return;
49
55
  }
50
56
  miss.fell = true;
@@ -54,7 +60,7 @@ async function handleFleetBody(req, res, url, ctx, miss) {
54
60
  * else owner-scoped (only rows whose `scope` matches). The tenant-internal `scope` field is STRIPPED from every
55
61
  * emitted row (it's a gate input, never rendered). A removal is forwarded ONLY for a row this connection actually
56
62
  * saw (no id-existence oracle for an unseen tenant's run). Heartbeat + dual-close cleanup unsubscribe the bus. */
57
- function streamFleet(req, res, bus, callerScope, callerSession = null, completionInbox) {
63
+ function streamFleet(req, res, bus, callerScope, callerSession = null, completionInbox, terminal) {
58
64
  return new Promise((resolve) => {
59
65
  sseHeaders(res);
60
66
  // [1508] 1.247 `bgNotifyFailClosed` — the GENERATION marker cli asked for ([1500]📌/[1502] F2): declares
@@ -81,19 +87,68 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
81
87
  // subscriber gets a content-scrubbed projection (ids/status/progress/timing kept); a fleet-wide observer
82
88
  // (trace/dashboard, callerSession === null) keeps the full row. Rows tagged with the caller's own session
83
89
  // pass sessionOk and are never scrubbed (r.sessionId present ⇒ it equals callerSession here).
90
+ // codex R6:FleetTaskRow 逐键处置表——`satisfies Record<keyof FleetTaskRow,…>` 使**任何**行新键先在
91
+ // 编译期红(TS2741),强迫对跨会话可见性表态;"keep" 键必须同步进下方白名单构造,配套钉在
92
+ // test/fleet-bg-child.test.ts(R6 格:kept 键集 ⊆ 表内 keep∪rewrite 集)。
93
+ const SCOPED_TASK_KEY_DISPOSITION = {
94
+ id: "keep", parentId: "keep", parentToolCallId: "keep", workflowRunId: "keep", status: "keep",
95
+ startedAt: "keep", elapsedMs: "keep", tokens: "keep", queuedCount: "keep",
96
+ awaitingPlanApproval: "keep", toolUses: "keep", usage: "keep", resumable: "keep", sourceLane: "keep",
97
+ name: "rewrite", scope: "strip", sessionId: "strip",
98
+ description: "scrub", agentType: "scrub", agentName: "scrub", currentAction: "scrub",
99
+ currentTool: "scrub", transcriptId: "scrub", stoppedBy: "scrub", editedFiles: "scrub",
100
+ };
101
+ void SCOPED_TASK_KEY_DISPOSITION;
84
102
  const stripT = (r) => {
85
103
  const { scope: _s, sessionId: _sid, ...rest } = r;
86
104
  if (callerSession !== null && r.sessionId === undefined) {
87
- const { description: _d, agentType: _at, agentName: _an, currentAction: _ca, ...keep } = rest;
88
- return { ...keep, name: "(unattributed)" };
105
+ // [3136]②→codex R6(确认轮):黑名单形被两次击穿(currentTool、editedFiles)——同一信任边界的键
106
+ // 靠逐个想起来列,漏一个就是跨会话内容泄漏。改**白名单构造**:kept 对象逐键显式拼,未列键默认
107
+ // 不过;新键先过上方处置表的编译期门。
108
+ return {
109
+ id: rest.id,
110
+ status: rest.status,
111
+ name: "(unattributed)",
112
+ ...(rest.parentId !== undefined ? { parentId: rest.parentId } : {}),
113
+ ...(rest.parentToolCallId !== undefined ? { parentToolCallId: rest.parentToolCallId } : {}),
114
+ ...(rest.workflowRunId !== undefined ? { workflowRunId: rest.workflowRunId } : {}),
115
+ ...(rest.startedAt !== undefined ? { startedAt: rest.startedAt } : {}),
116
+ ...(rest.elapsedMs !== undefined ? { elapsedMs: rest.elapsedMs } : {}),
117
+ ...(rest.tokens !== undefined ? { tokens: rest.tokens } : {}),
118
+ ...(rest.queuedCount !== undefined ? { queuedCount: rest.queuedCount } : {}),
119
+ ...(rest.awaitingPlanApproval !== undefined ? { awaitingPlanApproval: rest.awaitingPlanApproval } : {}),
120
+ ...(rest.toolUses !== undefined ? { toolUses: rest.toolUses } : {}),
121
+ ...(rest.usage !== undefined ? { usage: rest.usage } : {}),
122
+ ...(rest.resumable !== undefined ? { resumable: rest.resumable } : {}),
123
+ ...(rest.sourceLane !== undefined ? { sourceLane: rest.sourceLane } : {}),
124
+ };
89
125
  }
90
126
  return rest;
91
127
  };
128
+ // A-002.5(#194 病族 F2):workflow 行与 task 行同一条降权跨会话边界——stripT 的黑名单三次被
129
+ // 击穿后改了白名单,stripW 当时漏改(同函数体孪生)。处置表同姿势:行加新键先编译红强迫表态。
130
+ const SCOPED_WORKFLOW_KEY_DISPOSITION = {
131
+ id: "keep", status: "keep", doneCount: "keep", totalCount: "keep", failedCount: "keep",
132
+ startedCount: "keep", elapsedMs: "keep", tokens: "keep",
133
+ name: "rewrite", scope: "strip", sessionId: "strip",
134
+ description: "scrub",
135
+ };
136
+ void SCOPED_WORKFLOW_KEY_DISPOSITION;
92
137
  const stripW = (r) => {
93
138
  const { scope: _s, sessionId: _sid, ...rest } = r;
94
139
  if (callerSession !== null && r.sessionId === undefined) {
95
- const { description: _d, ...keep } = rest;
96
- return { ...keep, name: "(unattributed)" };
140
+ // 白名单构造:kept 逐键显式拼,未列键默认不过(与 stripT 同形;"keep" 键必须同步进处置表)。
141
+ return {
142
+ id: rest.id,
143
+ status: rest.status,
144
+ name: "(unattributed)",
145
+ ...(rest.doneCount !== undefined ? { doneCount: rest.doneCount } : {}),
146
+ ...(rest.totalCount !== undefined ? { totalCount: rest.totalCount } : {}),
147
+ ...(rest.failedCount !== undefined ? { failedCount: rest.failedCount } : {}),
148
+ ...(rest.startedCount !== undefined ? { startedCount: rest.startedCount } : {}),
149
+ ...(rest.elapsedMs !== undefined ? { elapsedMs: rest.elapsedMs } : {}),
150
+ ...(rest.tokens !== undefined ? { tokens: rest.tokens } : {}),
151
+ };
97
152
  }
98
153
  return rest;
99
154
  };
@@ -102,14 +157,18 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
102
157
  // 单点注入:调用方已带 type 的帧(hook_notice/bg_notification)保持原值。
103
158
  const send = (event, data) => { if (!res.writableEnded)
104
159
  res.write(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`); };
105
- // connect-time snapshot (filtered + scope-stripped) — the visible rows become this connection's "seen" set.
106
- const snap = bus.snapshot();
107
- const tasks = snap.tasks.filter(visT);
108
- const workflows = snap.workflows.filter(visW);
109
- tasks.forEach((r) => seenTasks.add(r.id));
110
- workflows.forEach((r) => seenWf.add(r.id));
111
- send("snapshot", { tasks: tasks.map(stripT), workflows: workflows.map(stripW), ts: snap.ts });
112
- const unsubscribe = bus.subscribe((frame) => {
160
+ /** #189:快照里那些**按窗保留**的 workflow 行 id(来自 durable 终态窗,或握手期间才翻终态的行)
161
+ * —— workflow / workflow_remove 两臂。 */
162
+ const retainedTerminalWf = new Set();
163
+ /** 现在是否正在重放握手期缓冲(只在这段里,终态行帧才升级为"保留行")。 */
164
+ let replayingBuffer = false;
165
+ /** [3193] codex R4:首见即终态的历史行保留**预算**(每连接)。retainedTerminalWf 本身不积累
166
+ * (每个 id 的撤行帧即刻消费),真正会无界的是「被豁免撤行、留在面板与 seenWf 里的历史行数」。
167
+ * 预算=终态窗容量同阶(×2);耗尽后退回旧「终帧→撤行」契约(行瞬态可见)——有界降级,不吞帧。
168
+ * 快照径的保留行不占预算(已由窗的行数+窗龄双界)。 */
169
+ let retainedHistoryBudget = FLEET_SNAPSHOT_TERMINAL_MAX_ROWS * 2;
170
+ /** 一条增量帧的处理(**先注册订阅、后组装快照**,故与快照解耦成具名函数——见下方缓冲段)。 */
171
+ const handleFrame = (frame) => {
113
172
  switch (frame.type) {
114
173
  case "task":
115
174
  if (visT(frame.row)) {
@@ -119,6 +178,19 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
119
178
  break;
120
179
  case "workflow":
121
180
  if (visW(frame.row)) {
181
+ // #189 / codex R4-M3 → [3193] ①a 泛化:**本连接首见即终态**的行(重放期或活径皆然)与终态窗
182
+ // 里的行是同一件事——它对这条连接从来不是"活跃集成员",是历史;它的撤行帧描述的活跃集离场
183
+ // 对本连接无意义,照发只会让"看不看得见刚结束的 workflow"取决于 recover 落在握手窗哪一侧
184
+ // (cli [3193] 插桩实证:落在重放窗内=保留可见,落在重放窗后=终帧+撤行背靠背、行瞬现即消)。
185
+ // ⇒ 判据=「重放期 ∪ 首见即终态」并集(#189 原判据的严格超集):重放期臂保住"快照见过
186
+ // running、缓冲里才翻终态"的行(R4-M3);首见臂保住活径上 recover 晚到的行(①a)。见过
187
+ // running 的行在活径仍按既有契约走(终帧→撤行),在线消费端语义不变。豁免仍一次性,集合有界如旧。
188
+ // [3193] codex R5:已在保留集里的 id(快照径收的)不重复扣预算——否则握手重放把同一批行
189
+ // 按"快照+重放"记两次账,40 预算被快照行白耗光,其后真正的首见历史行立刻失去豁免。
190
+ if ((replayingBuffer || !seenWf.has(frame.row.id)) && isTerminalWorkflowRowStatus(frame.row.status) && !retainedTerminalWf.has(frame.row.id) && retainedHistoryBudget > 0) {
191
+ retainedTerminalWf.add(frame.row.id);
192
+ retainedHistoryBudget--;
193
+ }
122
194
  seenWf.add(frame.row.id);
123
195
  send("workflow", { row: stripW(frame.row), ts: frame.ts });
124
196
  }
@@ -128,6 +200,17 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
128
200
  send("task_remove", { id: frame.id, ts: frame.ts });
129
201
  break;
130
202
  case "workflow_remove":
203
+ // 🔴 #189 / codex R2-M1:按窗**刻意保留**的历史行不受本连接的撤行帧影响。行在 durable 读
204
+ // 期间翻终态时,活写路径的「终帧 + 撤行」会落进缓冲,重放到这里就会把快照刚保住的那一行
205
+ // 又撤掉 —— 于是「看不看得见刚结束的 workflow」重新取决于连接时机(晚 1ms 连上的人看得见,
206
+ // 撞上这一拍的人看不见),正是本 issue 要消灭的竞态。撤行帧描述的是**活跃集**离场,而这些
207
+ // 行进快照时就已经是终态历史,不在活跃集语义里。id 不复用(runId 每次新铸,终态不回滚),
208
+ // 故这层豁免既有界又不会误伤后来的真活行。
209
+ // 豁免**一次性**:配对的那一帧撤行到达即消费(codex R5-M3)。否则这个 id 被永久噤声——
210
+ // 同 id 若之后又出现活行(帧乱序/未来的行复用),它的撤行帧也会被吞,面板留一条撤不掉的死行;
211
+ // 顺带,每条长连接的保留集不再随握手期完成数无界增长。
212
+ if (retainedTerminalWf.delete(frame.id))
213
+ break;
131
214
  if (seenWf.delete(frame.id))
132
215
  send("workflow_remove", { id: frame.id, ts: frame.ts });
133
216
  break;
@@ -217,6 +300,21 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
217
300
  default:
218
301
  break; // snapshot frames are connect-time only, never fanned out
219
302
  }
303
+ };
304
+ // 🔴 #189 / codex R1-H1:订阅**先于**快照注册,快照组装期间到达的帧先进缓冲、发完快照再按序重放。
305
+ // 快照第一次有了一段 await(durable 终态窗读),而 fleet bus 只扇给**当前**订阅者、不重放 —— 先读后
306
+ // 订阅就等于开了一个"这段时间的帧对本连接不存在"的窗口(bg_notification 是模型注入面,丢一帧=这次
307
+ // 完成回执要等到下次开流才由 durable inbox 补;task_remove/hook_notice 同理)。
308
+ // 顺序安全:`bus.snapshot()` 取在**读之后**,缓冲里的行帧描述的都是快照那一刻或更早的状态,重放
309
+ // 只会把同一状态再叠一次(upsert 幂等);撤行帧仍按 seen 集判(没见过的行不发撤除)。
310
+ // 缓冲无显式上限:窗口被读预算硬界在 2s,且缓冲存的是**帧对象引用**(不复制行);设一个会丢帧的
311
+ // 上限反而正好丢掉这段代码要保住的东西。
312
+ let buffered = [];
313
+ const unsubscribe = bus.subscribe((frame) => {
314
+ if (buffered !== null)
315
+ buffered.push(frame);
316
+ else
317
+ handleFrame(frame);
220
318
  });
221
319
  // Real frame (not an SSE comment) — a per-frame-parsing BFF drops comments (zero-frame window downstream)
222
320
  const hb = setInterval(() => { if (!res.writableEnded)
@@ -234,8 +332,56 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
234
332
  res.end();
235
333
  resolve();
236
334
  };
335
+ // 关闭钩子在 await **之前**挂上:快照还在组装时对端就断了,也照常收摊(不然那段时间的断连没人处理)。
237
336
  req.on("close", shutdown);
238
337
  res.on("close", shutdown);
338
+ void (async () => {
339
+ // [3193] 病理1:boot recover(判死前世 running 行)与快照组装赛跑——SIGKILL 后壳重连常抢在
340
+ // recover 之前,此刻 seed/durable 两径皆空,快照永远缺那一行。屏障=recovery 完成 promise
341
+ // (main.ts 铸,memory 后端/无 gate 部署=已 resolve),**有界**等待(帧订阅已挂上,等待期帧全
342
+ // 进缓冲不丢):超时=recover 真挂了,按旧行为出快照(可用性路径,行还有 first-sight-terminal
343
+ // 豁免与下次重连兜底),不无限拖住连接。
344
+ if (terminal?.ready !== undefined) {
345
+ await Promise.race([terminal.ready, new Promise((r) => { const t = setTimeout(r, 3000); if (typeof t.unref === "function")
346
+ t.unref(); })]);
347
+ if (done || res.writableEnded)
348
+ return;
349
+ }
350
+ // #189:durable 近期终态行窗(有界 + 永不抛;详见 fleet-terminal-window.ts)。
351
+ const terminalWindow = terminal === undefined ? [] : await recentTerminalWorkflowRows(terminal.store, terminal.scope, terminal.pullScope);
352
+ if (done || res.writableEnded)
353
+ return; // 组装期间断连:shutdown 已 unsubscribe,不再发任何东西
354
+ // connect-time snapshot (filtered + scope-stripped) — the visible rows become this connection's "seen" set.
355
+ const snap = bus.snapshot();
356
+ const tasks = snap.tasks.filter(visT);
357
+ const live = snap.workflows.filter(visW);
358
+ // #189:活行(bus 的活跃集)+ durable 终态窗。
359
+ // · 一行一 id:同 id 两侧都有(行刚翻转的那一拍)时 **活行优先** —— 活行是唯一写者写的当前态,
360
+ // 终态窗是读时快照,后者覆盖前者会让面板倒退。
361
+ // · 并入行同样过 `visW`(scope + `?session=`),与活行**同一套判据**:终态行泄跨租户是事故。
362
+ // · bus 的 Map **不被触碰** —— 单写者不变量原样:活跃集里永远没有终态行,增量帧语义逐字不变。
363
+ const liveIds = new Set(live.map((r) => r.id));
364
+ const retained = terminalWindow.filter((r) => !liveIds.has(r.id) && visW(r));
365
+ retained.forEach((r) => retainedTerminalWf.add(r.id));
366
+ const workflows = [...live, ...retained];
367
+ tasks.forEach((r) => seenTasks.add(r.id));
368
+ workflows.forEach((r) => seenWf.add(r.id));
369
+ send("snapshot", { tasks: tasks.map(stripT), workflows: workflows.map(stripW), ts: snap.ts });
370
+ const pending = buffered ?? [];
371
+ buffered = null; // 自此直投(先置空再重放:重放期间新到的帧走直投,不会插到旧帧前面)
372
+ replayingBuffer = true;
373
+ try {
374
+ for (const frame of pending)
375
+ handleFrame(frame);
376
+ }
377
+ finally {
378
+ replayingBuffer = false; // 重放一结束立刻恢复常态语义(哪怕上面抛了)
379
+ }
380
+ })().catch(() => shutdown());
381
+ // ⚠️ 上面这个 catch 不是"静默降级":快照组装本身抛错(写向已销毁的 socket 那一族)时**没有**可以继续
382
+ // 的语义——这条连接已经没救了,干净收摊即正解。它真正防的是把原先同步执行器里的抛错(会直接 reject
383
+ // 出去)变成一条 **unhandled rejection**(Node 默认带走整个进程)。订阅回调自身的抛错另有隔离层
384
+ // (fleet bus 的 subscribe,记 fail-open),不经这里。
239
385
  });
240
386
  }
241
387
  //# sourceMappingURL=fleet.js.map
@@ -2,7 +2,8 @@
2
2
  * design/158 A9 域模块 —— memory 导出/同步 + 只读策略面。
3
3
  *
4
4
  * 路由:`GET /v1/memory/export`、`POST /v1/memory/sync/:scope`、`GET /v1/policy`。
5
- * owner 门 = 「本人 user scope 或显式 operator」,越界一律 404(零存在性 oracle)。本域零可变状态。
5
+ * owner 门 = 「本人 user scope / 显式 operator / `org:` 键的目录成员」( {@link gateMemoryScope}),
6
+ * 越界一律 404(零存在性 oracle)。本域零可变状态。
6
7
  */
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
8
9
  import type { RouteCtx } from "../route-ctx.js";
@@ -2,6 +2,70 @@ import { formatUserScope } from "@sema-agent/core";
2
2
  import { parseMemorySyncRequest } from "../../memory-sync.js";
3
3
  import { sendJson, sendError } from "../send.js";
4
4
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
5
+ const ORG_PREFIX = "org:";
6
+ /** 本面是否让目录参与判决。闭集穷举 switch:新增第三个模式是**编译**错误,不会静默落进某个默认臂
7
+ * ——这条门在授权轴上,静默默认值正是它不许有的东西。 */
8
+ function directoryDecidesThisFace(mode) {
9
+ switch (mode) {
10
+ case "enforce":
11
+ return true;
12
+ case "audit":
13
+ return false; // 见 gateMemoryScope 顶注的 codex R1 段
14
+ }
15
+ }
16
+ /**
17
+ * memory-policy 两面(export 读 / sync 写)共用的属主门 —— design/170 件A §7「第二消费者收编」。
18
+ *
19
+ * 收编前本门把 `org:*` 写死 operator-only(注逐字「membership waits for the S3 登记簿」),于是同一进程里
20
+ * 「谁属于 org:acme」有两个答案:core 准入门问授权目录、本面问 operatorPrincipals。收编后 `org:` 成员性
21
+ * 只由 `deps.orgMemoryDirectory` 回答(与准入 seam 同一个实例、同一份缓存)。
22
+ *
23
+ * 判决序(前两级刻意在目录之前 —— 它们不需要目录也成立,顺带让常路零额外 I/O):
24
+ * ① 本人 user 盘逐字匹配 ⇒ 通过;② 显式 operator ⇒ 通过(ops/迁移面不依赖目录在场);
25
+ * ③ `org:` 键 + 目录在场 + **enforce 模式** ⇒ 查目录:瞬时不可用照原样上抛;取数成功但无该键 ⇒
26
+ * `not_found`;`need==="write"` 另要条目 `write === true`(读授予不隐含写授予,与准入面 writeScope
27
+ * 收窄同源);④ 其余(`userproj:` 等目录没有答案的键、无目录源的部署、audit 模式)⇒ `not_found`。
28
+ *
29
+ * **audit 模式为什么在本面等于「不接目录」**(codex 对抗审 R1,2026-08-07):`audit` 是 operator 表态
30
+ * 「这份目录还没被验证过,先别拿它做判决」。拿未验证的目录去开真数据面(读)甚至写面,是这条表态能
31
+ * 造成的最严重后果——方向恰好反了;`audit=零行为变化` 的公开契约在本面因此读作「逐字保持收编前的
32
+ * operator-only」,连额外那一跳目录 I/O 和 enforce 才有的 503 响应形都不引入。org 面的诊断信号属于
33
+ * 准入侧的 `memory_admission_total{outcome,mode}`,不在本面另开一路。
34
+ *
35
+ * 拒绝一律 `not_found` 而非 403:本面的零存在性 oracle 纪律不变(与 run/trace 属主门同口径),
36
+ * 「不是成员」和「没这个 scope」对调用方必须不可区分。
37
+ */
38
+ async function gateMemoryScope(deps, principal, scope, need) {
39
+ if (scope === formatUserScope(principal))
40
+ return { kind: "allow" };
41
+ if (explicitOperatorOk(principal, deps.config.operatorPrincipals))
42
+ return { kind: "allow" };
43
+ const directory = deps.orgMemoryDirectory;
44
+ if (!scope.startsWith(ORG_PREFIX) || directory === undefined || !directoryDecidesThisFace(deps.config.memoryOrgAdmissionMode)) {
45
+ return { kind: "not_found" };
46
+ }
47
+ const lookup = await directory.lookup(principal);
48
+ if (lookup.kind === "unavailable")
49
+ return { kind: "unavailable", retryAfterMs: lookup.retryAfterMs };
50
+ const grant = lookup.scopes[scope];
51
+ if (grant === undefined)
52
+ return { kind: "not_found" };
53
+ return need === "write" && grant.write !== true ? { kind: "not_found" } : { kind: "allow" };
54
+ }
55
+ /** 门的拒绝臂下发:瞬时 ⇒ 503 `memory.admission_required` + `retryAfterSec`(秒上取整,永不 0 —— C6;
56
+ * 族形与 sync 提交腿的同码响应逐字一致);终局 ⇒ 404 零 oracle。返回 true = 已应答,调用方须 return。 */
57
+ function sendScopeGateRefusal(res, gate) {
58
+ if (gate.kind === "allow")
59
+ return false;
60
+ if (gate.kind === "unavailable") {
61
+ const retryAfterSec = Math.max(1, Math.ceil(gate.retryAfterMs / 1000));
62
+ res.setHeader("retry-after", String(retryAfterSec)); // 体里有等待提示 ⇒ 标准头同值(与同步提交腿的同码应答逐字一致)
63
+ sendError(res, 503, "memory.admission_required", "org memory directory unavailable — authorization could not be established", { retryAfterSec });
64
+ return true;
65
+ }
66
+ sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
67
+ return true;
68
+ }
5
69
  export async function handleMemoryPolicy(req, res, url, ctx) {
6
70
  const miss = { fell: false };
7
71
  await handleMemoryPolicyBody(req, res, url, ctx, miss);
@@ -17,11 +81,13 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
17
81
  // C-additive 落点). After the global service-credential gate (a credential/SSO-JWT is already
18
82
  // verified); the OWNER gate here is the tenant boundary: the verified principal may export exactly its OWN
19
83
  // user disk (`scope === formatUserScope(principal)` — core's mint, byte-identical to memoryScopeFor's) or be
20
- // an explicit operator (explicitOperatorOk, ops/migration face). Anything else = 404, NEVER 403 (zero
21
- // existence oracle, run/trace owner-gate parity). org:*/userproj:* membership waits for the S3 登记簿
22
- // (design §1.4) until then those keys are operator-only by construction. proj:* keys of repo-backed
23
- // projects never live in this DB (铁律: git is their authority) an owner asking for one just gets
24
- // an empty set, honestly.
84
+ // an explicit operator (explicitOperatorOk, ops/migration face), or design/170 件A §7 收编 — a
85
+ // DIRECTORY MEMBER of an `org:` key (the same org-memory directory instance core's admission seam reads,
86
+ // so one process holds ONE answer to "who belongs to org:acme"). Anything else = 404, NEVER 403 (zero
87
+ // existence oracle, run/trace owner-gate parity); a directory that cannot answer = 503, never 404.
88
+ // `userproj:*` stays operator-only by construction (the directory has no answer for that key shape).
89
+ // proj:* keys of repo-backed projects never live in this DB (铁律: git is their authority) — an owner
90
+ // asking for one just gets an empty set, honestly.
25
91
  if (url === "/v1/memory/export" && req.method === "GET") {
26
92
  // Auth BEFORE availability (501-before-auth precedent): the backend posture must not oracle
27
93
  // to a caller who couldn't read anything anyway.
@@ -39,10 +105,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
39
105
  sendError(res, 400, "request.query_invalid", "missing ?scope=<key>");
40
106
  return;
41
107
  }
42
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
43
- sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle
108
+ if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "read")))
44
109
  return;
45
- }
46
110
  try {
47
111
  const entries = await deps.memoryExport(scope);
48
112
  sendJson(res, 200, { scope, exportedAt: new Date().toISOString(), entries });
@@ -57,8 +121,8 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
57
121
  // plan 随数据走——reconcile/nextSyncBaseline 是 core 纯函数,由持数据的本 server 在 performMemorySync
58
122
  // (memory-sync.ts) 里直接调用;此处只持 HTTP 半场). Gate ORDER mirrors the export face verbatim:
59
123
  // auth (401) → backend posture (501, file 形态诚实拒绝——单用户 file 面自己就是 TOC 侧,没有中心
60
- // 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user 盘逐字匹配,或 explicit
61
- // operator;org:/userproj: 键在 S3 登记簿前 = operator-only by construction) → 验型 (422 typed)。
124
+ // 权威半场可服务) → owner gate (404 zero-oracle: verified principal 的 user 盘逐字匹配 / explicit
125
+ // operator / `org:` 键的目录成员且条目 write:true;userproj: operator-only) → 验型 (422 typed)。
62
126
  if (req.method === "POST" && url.startsWith("/v1/memory/sync/")) {
63
127
  const principal = gatedPrincipal(req, deps.config); // direct-door safe: verified identity, never the spoofable header
64
128
  if (!principal) {
@@ -83,10 +147,10 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
83
147
  sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
84
148
  return;
85
149
  }
86
- if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
87
- sendError(res, 404, "not_found.memory_scope", "not found"); // owner gate: no existence oracle (export-face parity)
150
+ // 写面:门与 export 面同一个 `gateMemoryScope`,只在 `need` 上分家 —— org 条目须 `write === true`
151
+ // 才放行(读授予不隐含写授予)。属主/operator 两级与读面逐字同判。
152
+ if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "write")))
88
153
  return;
89
- }
90
154
  const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
91
155
  const parsed = parseMemorySyncRequest(body, scope);
92
156
  if (!parsed.ok) {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
3
+ import { isTerminalRunStatus } from "../../plugins/store-contracts.js";
3
4
  import { redactSecrets } from "../../trace/redact.js";
4
5
  import { HttpError, verifiedPrincipal, isUuidV7, encodeCheckpointScope } from "../../security.js";
5
6
  import { runInBackground } from "../../runs.js";
@@ -157,7 +158,7 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
157
158
  // 🔴 开关谓词是**必须**的(codex 复审第二轮 high,2026-08-06):原按「关的时候表里本就没行 ⇒
158
159
  // 无需分支」落地,但这个前提在**回滚**下不成立 —— 开着协议的副本(或更新版本)已经落下的
159
160
  // `STREAM_PENDING` 行在关掉开关后**仍在表里**,无谓词的重放腿会把它们照常投上 wire,
160
- // 「默认关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
161
+ // 「开关关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
161
162
  // 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
162
163
  preamble: async (signal) => {
163
164
  if (!deps.config.streamApproval.enabled)
@@ -575,7 +576,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
575
576
  // 并发腿可能先终态化(如 approval.expired),我方写打空却仍答 "cancelled" 就是响应与账本不一致。
576
577
  // 写后复读,照实际行应答(锁一样释放了;谁先写赢谁的 errorCode)。
577
578
  const finalRow = await rs.getRun(taskId).catch(() => undefined);
578
- if (finalRow && finalRow.errorCode !== "cancelled" && (finalRow.status === "failed" || finalRow.status === "completed" || finalRow.status === "blocked")) {
579
+ // 终局判据取自词表属主 `store-contracts.ts` 的穷举 `isTerminalRunStatus`(原地手抄的三连 `===`
580
+ // 对 core **增**一个终局态是静默的:新态会被判成「还没终局」⇒ 这里改答 "cancelled",与账本不一致,
581
+ // 正是本段注释要防的那件事)。
582
+ if (finalRow && finalRow.errorCode !== "cancelled" && isTerminalRunStatus(finalRow.status)) {
579
583
  sendJson(res, 202, { taskId, status: finalRow.status, errorCode: finalRow.errorCode ?? null, note: `session unlocked; the run was terminalized concurrently (${finalRow.errorCode ?? finalRow.status}) before this cancel's write — reporting the actual ledger state` });
580
584
  return;
581
585
  }
@@ -158,14 +158,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
158
158
  // relay keeps burning billable tokens to completion (council). Mirrors the closed-flag pattern of the
159
159
  // poll-based SSE endpoints, and additionally cancels the generator via spec.signal.
160
160
  const ac = new AbortController();
161
- // probe-stoppedby: a client DISCONNECT on the sync stream lane is a USER stop too —
162
- // Ctrl+C on the shell drops the SSE and this abort kills the run, but the mark was only wired on the
163
- // cancel-verb/resume legs, so background children reaped by THIS abort read "parent"/"system" instead
164
- // of "user". Same registration discipline as runs.ts (FIRST on the signal, before core's teardown
165
- // listeners). Owner = sessionId (the sync leg's core-canonical taskId — resolveSpec sets no spec.taskId,
166
- // same key as the resume leg); skipSessionScoped inside the helper keeps CC Backgrounded children unmarked.
167
- if (prepared.spec.sessionId)
168
- markChildrenStoppedByUserOnAbort(ac.signal, prepared.spec.sessionId, prepared.auth?.principal);
161
+ // 🔴 stoppedBy 归因的注册点在**下面**(createRun 认领成功之后),不在这里 —— #168 件3,见那里的注。
169
162
  let closed = false;
170
163
  let detachLogged = false; // [854]①b:detach 断连 info 只打一条(req/res 两个 close listener 都可能进来)
171
164
  const onDisconnect = () => {
@@ -225,7 +218,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
225
218
  // 跨腿投递,租户门必须带上,不能让 A 租户的开流看到 B 租户的卡。
226
219
  // 有界(2s)+ fail-open:store 抛或挂起都只记一次 warn、零卡继续开流(§5.3)。
227
220
  // 🔴 开关谓词与 runs.ts 同源同理由(codex 复审第二轮 high:回滚下表里可能有残留 STREAM_PENDING
228
- // 行,无谓词会破「默认关 = 逐字零变化」;顺带省掉关闭态每次开流的一次 store 往返)。钉:场景9。
221
+ // 行,无谓词会破「开关关 = 逐字零变化」;顺带省掉关闭态每次开流的一次 store 往返)。钉:场景9。
229
222
  const replaySessionId = deps.config.streamApproval.enabled ? prepared.spec.sessionId : undefined;
230
223
  if (replaySessionId) {
231
224
  const replayOwner = gatedPrincipal(req, deps.config) ?? null;
@@ -323,6 +316,23 @@ async function handleTasksBody(req, res, url, ctx, miss) {
323
316
  }
324
317
  durableTaskId = tid;
325
318
  }
319
+ // ── stoppedBy 归因(#168 件3:注册点必须晚于 run 认领)────────────────────────────────────
320
+ // probe-stoppedby: a client DISCONNECT on the sync stream lane is a USER stop too — Ctrl+C on the
321
+ // shell drops the SSE and this abort kills the run, but the mark was only wired on the
322
+ // cancel-verb/resume legs, so background children reaped by THIS abort read "parent"/"system"
323
+ // instead of "user". Same registration discipline as runs.ts (FIRST on the signal, before core's
324
+ // teardown listeners). Owner = sessionId (the sync leg's core-canonical taskId — resolveSpec sets no
325
+ // spec.taskId, same key as the resume leg); skipSessionScoped inside the helper keeps CC Backgrounded
326
+ // children unmarked.
327
+ //
328
+ // 🔴 **位置即正确性**(#168 件3,3c codex R3 定性的先存病):这条规则说的是「本请求断连 ⇒ 把
329
+ // owner=sessionId 的 task-scoped 子代记成人为停止」,而**只有抢到了这条 session 的请求**才有资格
330
+ // 那么说。注册早于 `createRun` 时,一次 pre-start 断连会把**另一条活 run** 的子代标成 user-stopped
331
+ // (认领随后 409,本请求根本没代表过这条 session)。窗 = SSE 头/首帧 + 开流 preamble(有界 2s)+
332
+ // 一次 DB 往返。认领失败那条路在上面就 return 了 ⇒ 一次都不注册。
333
+ // 无 durable 账本(无 runStore / 无 sessionId)的部署没有认领这回事,注册点与从前等价。
334
+ if (prepared.spec.sessionId)
335
+ markChildrenStoppedByUserOnAbort(ac.signal, prepared.spec.sessionId, prepared.auth?.principal);
326
336
  // [900]①/[906]③ detach 车道逐事件落账:sync 腿历史上只 createRun+setTerminal(events 账本恒空,
327
337
  // detach 后壳只能轮询终局)。opt-in 车道把 for-await 的每个事件喂给与 bg 腿(runs.ts)共享的
328
338
  // LedgerSink(src/trace/ledger-sink.ts —— 单一 switch,落账 type/data 形状与 GET /v1/runs/:id/events
@@ -815,9 +825,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
815
825
  const askOwner = gatedPrincipal(req, deps.config) ?? null;
816
826
  const emitAsk = (frame) => {
817
827
  // #157(codex R2 复审 HIGH):流已断时这里是**静默 no-op** —— 上游 coordinator 的 try/catch
818
- // 因此拿不到任何信号,一次断连就被记成"投递成功",问题挂到 TTL 才按无人应答结算。方向本批
819
- // 不动(改成 throw 是既有的刻意决定,见下面 drain 包装处的注释),但**必须留痕**:否则
820
- // `open-frame-undelivered` 只覆盖"持久化抛错",覆盖不到真正常见的那条路。
828
+ // 因此拿不到任何信号,一次断连就被记成"投递成功",该 ask 挂到 TTL 才按无人应答结算。当时
829
+ // 方向不动,但**必须留痕**:否则 `open-frame-undelivered` 只覆盖"持久化抛错",覆盖不到真正
830
+ // 常见的那条路。
831
+ // ⚠️ **question 半场已在 #173 换了方向**(a318fa1):question / question_complete 两型帧改走
832
+ // 下方 `emitQuestion` 的断流 THROW,于是它们**到不了**本 no-op 臂(两道守卫同步相邻、其间无
833
+ // await);实测 park 墙钟 8002ms → <160ms。今天真正还落在这条静默臂上的只有 elicitation 族
834
+ // 与其它运行流帧。别再据本段把 question 的 TTL 悬挂当作现行行为。
821
835
  // `destroyed` 与 `writableEnded` 一起判——emitApproval 用的就是这对谓词。
822
836
  // 分类按帧型走(`failOpenTagForDroppedFrame`):这条闭包同时驮着 open 帧、完成面包屑与
823
837
  // 其它运行流帧,一刀切会让**一次**断连把保护型计数记两次,真 open 失败率被面包屑盖住。
@@ -832,13 +846,18 @@ async function handleTasksBody(req, res, url, ctx, miss) {
832
846
  // runId). `askOwner` = the VERIFIED principal → only this caller's own completions are emitted.
833
847
  // 🔒 THROW on a dead/ended stream instead of emitAsk's silent no-op (1.80 review: a disconnect
834
848
  // between request-send and this drain made the two-phase ack a FALSE success — the entry was
835
- // removed with no frame ever written; throwing keeps it pending for the next leg). Only THIS
836
- // drain wrapper throws the shared emitAsk (question/elicit frames) keeps its no-op semantics.
849
+ // removed with no frame ever written; throwing keeps it pending for the next leg). This wrapper was
850
+ // once the ONLY one that threw; since #173 (a318fa1) emitQuestion, emitApproval and writeLive all
851
+ // throw on a dead stream too. Of the HITL family only ELICIT still rides emitAsk's no-op — but emitAsk
852
+ // is NOT elicit-only: SendUserFile's `file_link` (below) and other runtime frames also write straight
853
+ // through it, so its dead-stream drop surface is wider than the HITL lanes (file_link is covered by its
854
+ // own durable twin, not by a throw).
837
855
  // 🔴 drain 案: write these as ANONYMOUS `data:` frames, NOT emitAsk's NAMED
838
856
  // `event: <type>` form — the run's own live-leg task_notification frames on this SAME connection
839
857
  // are anonymous (L1392/L1474), so a shell parsing the default message channel silently never saw
840
- // a drained frame (the Monitor "park✓ drain✗" fingerprint). question/elicit stay on emitAsk
841
- // (their own established named-event contract).
858
+ // a drained frame (the Monitor "park✓ drain✗" fingerprint). The HITL frames keep their own
859
+ // established NAMED-event contract — elicit still writes straight through emitAsk, question rides
860
+ // emitQuestion (same named-event write, one extra dead-stream guard in front, #173).
842
861
  await emitPendingWorkflowCompletions(deps.workflowCompletionInbox, prepared.spec.sessionId, askOwner, (frame) => {
843
862
  if (closed || res.writableEnded || res.destroyed)
844
863
  throw new Error("stream ended before the completion frame was written");
@@ -860,11 +879,26 @@ async function handleTasksBody(req, res, url, ctx, miss) {
860
879
  : streamBody;
861
880
  // #152 复审 A1:`deliverable` 是 durable 部署上 park-vs-live 判决的投递面谓词(见 question.ts 该键注)。
862
881
  // 本腿的投递面就是这条 SSE 连接,而 ①b detach 车道下客户端断连**不 abort、不结束这条腿**——run 继续在
863
- // 本 runWithContext 作用域里跑,emitAsk 往已死 socket 写被静默丢弃(question 帧无 ledgerSink 双写)。
864
- // 谓词让判决面此时如实读作「无活流」⇒ durable park(运维可经 /decide 补答),而不是 allow 后挂满 ttl
865
- // 以空答喂模型。断连发生在判决之后的窗口仍落 ttl 空答(与 elicit/approval 同栏,各自 TTL 语义)。
882
+ // 本 runWithContext 作用域里跑。谓词让判决面此时如实读作「无活流」⇒ durable park(运维可经 /decide
883
+ // 补答),而不是 allow 后挂满 ttl
884
+ // #173([2937] test 墙钟反证定谳):question 开帧的 emit 不再共享 emitAsk 的静默 no-op——死流上
885
+ // no-op = 开帧「假投递成功」,coordinator 等满 TTL 才以 unavailable 结算(实测 park 墙钟 ≈TTL,
886
+ // 断连→ask 间隔拉到 15s 也一样:判决在 gate 时刻,而 gate 早已判过的窗口里 ask 只剩 emit 一道门)。
887
+ // 收紧与 emitApproval 同形:断流 THROW ⇒ #166 的 delivery==="failed" 臂**立即** settle
888
+ // unavailable,不再等满 TTL。
889
+ // ⚠️ 收紧买到的是**结算速度**,不是可恢复性(2026-08-07 亲读 core 5.16 定):本闭包只在工具已
890
+ // 执行时被调用,即 `createDurableQuestionGate` 已判 allow、core 已过 `suspendAsk` 挂起点 ⇒ 这一
891
+ // 形落 `declined_unavailable` 自答续跑卡(赎回既有审批的腿返 isError),**不铸 checkpoint**。
892
+ // 真正 park 的是判决时投递面就不可达的腿(`deliverable`/`hasLiveContext` 读 false ⇒ 门判 ask),
893
+ // 它们根本走不到这里。别把两形写成一句。
894
+ // elicitation 保持 no-op(其 decline 语义 fail-closed,#166 车登记另议)。
895
+ const emitQuestion = (frame) => {
896
+ if (res.writableEnded || res.destroyed)
897
+ throw new Error("live stream ended — question frame undeliverable");
898
+ emitAsk(frame);
899
+ };
866
900
  const withQuestion = () => deps.question
867
- ? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitAsk, abortSignal: ac.signal, deliverable: () => !res.writableEnded && !res.destroyed }, withElicit)
901
+ ? deps.question.runWithContext({ taskId: askTaskId, owner: askOwner, emit: emitQuestion, abortSignal: ac.signal, deliverable: () => !res.writableEnded && !res.destroyed }, withElicit)
868
902
  : withElicit();
869
903
  // [816]/[820]②: the live tool-approval context — SAME id/owner/emit/signal as question/elicit
870
904
  // (nested ALS, each its own store). sessionId keys the "allow all edits this session" state.
@@ -874,8 +908,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
874
908
  // 🔒 approval 帧不用共享 emitAsk 的静默 no-op 语义:res 已结束时 no-op = 卡「假投递成功」,
875
909
  // coordinator 等满 TTL 落 deny —— G1 "unavailable"→park 腿在真 wiring 里就不可达(cross-review
876
910
  // 抓获)。这里 THROW,coordinator 的 emit-catch 按「无活人可达」返 "unavailable"(durable 部署
877
- // park;非 durable 由 core fail-closed deny,行为不劣于旧 no-op)。仅 approval 帧收紧;question/
878
- // elicit 的共享 emitAsk 保持 no-op(它们各有 TTL/abort 语义,另议)。
911
+ // park;非 durable 由 core fail-closed deny,行为不劣于旧 no-op)
912
+ // 当年本注写的「仅 approval 帧收紧」已被 #173 推进一格:question 帧此后与本臂**同形**收紧(见上面
913
+ // `emitQuestion`)。**HITL 三族里**仍留在共享 emitAsk 静默 no-op 上的只有 elicitation(其 decline
914
+ // 结算本身就是 fail-closed,收紧收益小,另议);emitAsk 本身还驮着 SendUserFile 的 `file_link` 等
915
+ // 非 HITL 帧,那些不在本条收紧的辖域内(file_link 的补偿是它自己的 durable 双写,不是 throw)。
879
916
  const emitApproval = (frame) => {
880
917
  if (res.writableEnded || res.destroyed)
881
918
  throw new Error("live stream ended — approval card undeliverable");
@@ -2,7 +2,7 @@ import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.j
2
2
  import { projectArtifacts } from "../../trace/artifacts.js";
3
3
  import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
4
4
  import { sleep } from "../sse-log.js";
5
- import { sendJson, sendError, sseHeaders } from "../send.js";
5
+ import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
6
6
  import { gatedPrincipal } from "../principal-gate.js";
7
7
  const TRACE_RE = /^\/v1\/tasks(?:\/([^/]+)(\/turns|\/stream|\/artifacts))?$/;
8
8
  export async function handleTraceUsage(req, res, url, ctx) {
@@ -248,7 +248,6 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
248
248
  closed = true;
249
249
  });
250
250
  const start = Date.now();
251
- const MAX_MS = 15 * 60 * 1000;
252
251
  let lastBeat = Date.now();
253
252
  const write = (events) => {
254
253
  for (const ev of events) {
@@ -272,14 +271,14 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
272
271
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
273
272
  break;
274
273
  }
275
- if (Date.now() - start > MAX_MS) {
274
+ if (Date.now() - start > SSE_MAX_STREAM_MS) {
276
275
  // BL-16: the stream hit its 15-min cap but the RUN is still going — emit a terminal event so the client
277
276
  // can tell "reconnect to continue" from "task completed" (a silent close looked like completion).
278
277
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
279
278
  break;
280
279
  }
281
280
  if (events.length === 0) {
282
- if (Date.now() - lastBeat > 15_000) {
281
+ if (Date.now() - lastBeat > SSE_HEARTBEAT_IDLE_MS) {
283
282
  res.write(`event: heartbeat\ndata: {}\n\n`);
284
283
  lastBeat = Date.now();
285
284
  }