@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,5 +1,5 @@
1
1
  import type { WorkflowRun, WorkflowRunStore, WorkflowRunStatus, WorkflowRunSummary } from "@sema-agent/core";
2
- import type { FleetEventBus } from "../fleet/fleet-bus.js";
2
+ import { type FleetEventBus } from "../fleet/fleet-bus.js";
3
3
  /** A workflow completion notification's terminal payload — the bounded, redacted shape core's notifier seam
4
4
  * carries, re-derivable from a durable {@link import("@sema-agent/core").WorkflowRun} on the recovery path. */
5
5
  export interface WorkflowCompletionPayload {
@@ -87,7 +87,21 @@ export declare class WorkflowNotifyGate {
87
87
  * recovery-sweep error. */
88
88
  onError?: (stage: "record" | "deliver" | "recover", runId: string, err: unknown) => void;
89
89
  });
90
+ /**
91
+ * The runIds whose journal entry THIS process recorded — the boot-orphan judgment's authoritative anchor
92
+ * (round-1 review, MEDIUM). A wall-clock cutoff alone is not sound: after a BACKWARD clock step the entries
93
+ * this process records land BELOW the boot cutoff, and the sweep would finalize runs whose in-process
94
+ * executor is alive and running (a false `failed` notify for a workflow that then keeps going — worse than
95
+ * the absent row it was fixing). Membership here is a fact about THIS incarnation, unforgeable by any clock.
96
+ * Retired on ack AND the moment a sweep observes the run terminal (the anchor only ever gates the `running`
97
+ * arm), so a delivery/ack outage cannot pile up entries for runs that are no longer executing.
98
+ */
99
+ private readonly recordedThisIncarnation;
90
100
  private now;
101
+ /** Record a journal entry AND remember that this incarnation is the one that recorded it (see the field). */
102
+ private recordPending;
103
+ /** Ack an entry + drop its incarnation mark (acked ⇒ never scanned again, so the mark has no further use). */
104
+ private ackDelivered;
91
105
  /**
92
106
  * Journal a STARTED run as pending-notify. Call this with the synchronous `runId` from `startWorkflow` /
93
107
  * `run_workflow` — BEFORE the workflow can reach terminal — so a crash mid-run still leaves a recoverable
@@ -129,14 +143,50 @@ export declare class WorkflowNotifyGate {
129
143
  * workflow died) + ack, instead of leaking the entry forever. This closes the exact crash topology SVC-1
130
144
  * exists for (a replica SIGKILLed mid-run).
131
145
  * - `running` and FRESH (within the grace window): genuinely in flight → leave pending (the owning process
132
- * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale).
146
+ * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale). The sweep
147
+ * publishes NOTHING here — see the single-writer invariant below.
133
148
  * - MISSING (reaped / never persisted): ack-as-abandoned so the journal doesn't chase a ghost forever.
134
149
  * `orphanGraceMs` MUST exceed the max expected workflow runtime (the run store has no cross-replica liveness
135
150
  * signal, so age is the only orphan proxy). Returns a tally. A per-entry throw is isolated so one bad entry
136
151
  * can't abort the sweep.
152
+ *
153
+ * BOTH abandoned arms (boot-orphan + stale-past-grace) flip the DURABLE row to `failed` FIRST (CAS on rev),
154
+ * so the run store, the fleet panel and the delivered notify give ONE answer — previously the notify said
155
+ * `failed` while `/workflows` kept saying `running` forever ([2999]: republishing without finalizing would
156
+ * have turned "panel empty" into "panel shows a row that never moves"). A lost CAS means the run moved under
157
+ * the sweep (e.g. its real terminal landed concurrently) — skip this pass; the entry stays pending and the
158
+ * next sweep handles the NEW state.
159
+ *
160
+ * 🔴 SINGLE-WRITER INVARIANT for the fleet row (round-1 review, two HIGH findings): the sweep only ever
161
+ * publishes a **TERMINAL** fleet frame (the flip above, via `publishTerminalFleetRow` → final frame + remove).
162
+ * It NEVER publishes a `running` row. Two reasons, both "a row we mint here can become one nobody can retire":
163
+ * 1. cross-replica (SQL journal): a pending `running` entry may belong to ANOTHER replica. Its terminal
164
+ * update lands on that replica's own (replica-local) fleet bus, and its ack removes the entry from the
165
+ * shared journal — so this replica would never see the run again and its minted row would sit `running`
166
+ * forever. (The terminal-redelivery arm deliberately doesn't publish either — it is a notify path.)
167
+ * 2. same-replica: `runStore.get` is a READ-TIME SNAPSHOT. If the live run commits its terminal (and the
168
+ * wrapper removes the row) between that read and the publish, a `running` republish resurrects a row
169
+ * that will never be removed again.
170
+ * A live row's ONE writer is this replica's `put`/`update` observation point ({@link JournalingWorkflowRunStore}),
171
+ * which by construction sees every transition including the terminal one.
137
172
  */
138
173
  recover(opts?: {
139
174
  orphanGraceMs?: number;
175
+ /** Replica-local (File/in-memory) journal ONLY: a pending entry recorded BEFORE this timestamp belongs to
176
+ * a previous incarnation of THIS process — workflow executors are in-process, so they died with it and a
177
+ * still-`running` run is a boot-orphan: finalize it NOW instead of leaving the user's "Waiting for
178
+ * workflow" pointed at a run that can never finish for the whole grace window ([2995] 顺带①/[3000]§二).
179
+ * NEVER pass this for a cross-replica (SQL) journal — there a pending running run may be genuinely alive
180
+ * on another replica, and age (`orphanGraceMs`) is the only sound orphan proxy.
181
+ * ⚠️ This wall-clock cutoff is a NECESSARY, not sufficient, condition — {@link recordedThisIncarnation}
182
+ * is the authoritative one (a clock rollback after boot would otherwise stamp THIS process's own new
183
+ * entries below the cutoff and finalize live runs). */
184
+ finalizeStartedBeforeMs?: number;
185
+ /** Retire a fleet row for a run this sweep just finalized ([2995]/[2999]): called ONLY with a TERMINAL run,
186
+ * so the {@link JournalingWorkflowRunStore} projection publishes the final frame and then removes the row —
187
+ * the store, the panel and the notify end up saying the same thing. Never called with a `running` run (see
188
+ * the single-writer invariant above). Late-bound because the wrapper is constructed after this gate. */
189
+ publishTerminalFleetRow?: (id: string, run: WorkflowRun) => void;
140
190
  }): Promise<{
141
191
  scanned: number;
142
192
  redelivered: number;
@@ -234,6 +284,12 @@ export declare class JournalingWorkflowRunStore implements WorkflowRunStore {
234
284
  /** Derive + publish the MF-Fleet workflow row from a WorkflowRun (doneCount/totalCount from agents, tokens from
235
285
  * stats); a terminal workflow LEAVES the fleet (publish terminal then remove), a running one stays/updates. */
236
286
  private publishFleet;
287
+ /** [2995] recovery projection seam: the same fleet projection as the put/update observation points, exposed so
288
+ * the recovery sweep can RETIRE a row for a run it just finalized (terminal input ⇒ final frame, then remove).
289
+ * Wire it as `recover`'s `publishTerminalFleetRow` hook — and only ever hand it a TERMINAL run: a `running`
290
+ * row published from outside the wrapper's own write path has no guaranteed retirement (see the invariant on
291
+ * {@link WorkflowNotifyGate.recover}). */
292
+ republishFleet(id: string, run: WorkflowRun): void;
237
293
  put(id: string, run: WorkflowRun): Promise<void>;
238
294
  update(id: string, scope: string, run: WorkflowRun, expect?: {
239
295
  rev: number;
@@ -41,8 +41,8 @@
41
41
  */
42
42
  import { mkdirSync, readFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync } from "node:fs";
43
43
  import { join } from "node:path";
44
- import { deriveAgentDisplayStatus } from "@sema-agent/core";
45
- import { redactSecrets } from "../trace/redact.js";
44
+ import { buildFleetWorkflowRow } from "../fleet/fleet-bus.js";
45
+ import { isTerminalWorkflowRowStatus, seedTerminalWorkflowRow } from "../fleet/fleet-terminal-window.js";
46
46
  /** Build a bounded + redacted completion summary from a durable run's terminal state (the recovery path — the
47
47
  * live path uses core's own `boundedSummary`). We never have the raw result here (the run store keeps the run,
48
48
  * not the originator's payload), so we synthesize a conservative one: status + the run's already-bounded error.
@@ -86,9 +86,29 @@ export class WorkflowNotifyGate {
86
86
  this.deliver = deliver;
87
87
  this.opts = opts;
88
88
  }
89
+ /**
90
+ * The runIds whose journal entry THIS process recorded — the boot-orphan judgment's authoritative anchor
91
+ * (round-1 review, MEDIUM). A wall-clock cutoff alone is not sound: after a BACKWARD clock step the entries
92
+ * this process records land BELOW the boot cutoff, and the sweep would finalize runs whose in-process
93
+ * executor is alive and running (a false `failed` notify for a workflow that then keeps going — worse than
94
+ * the absent row it was fixing). Membership here is a fact about THIS incarnation, unforgeable by any clock.
95
+ * Retired on ack AND the moment a sweep observes the run terminal (the anchor only ever gates the `running`
96
+ * arm), so a delivery/ack outage cannot pile up entries for runs that are no longer executing.
97
+ */
98
+ recordedThisIncarnation = new Set();
89
99
  now() {
90
100
  return this.opts.now ? this.opts.now() : Date.now();
91
101
  }
102
+ /** Record a journal entry AND remember that this incarnation is the one that recorded it (see the field). */
103
+ async recordPending(entry) {
104
+ await this.journal.record(entry);
105
+ this.recordedThisIncarnation.add(entry.runId);
106
+ }
107
+ /** Ack an entry + drop its incarnation mark (acked ⇒ never scanned again, so the mark has no further use). */
108
+ async ackDelivered(runId) {
109
+ await this.journal.ack(runId, this.now());
110
+ this.recordedThisIncarnation.delete(runId);
111
+ }
92
112
  /**
93
113
  * Journal a STARTED run as pending-notify. Call this with the synchronous `runId` from `startWorkflow` /
94
114
  * `run_workflow` — BEFORE the workflow can reach terminal — so a crash mid-run still leaves a recoverable
@@ -97,7 +117,7 @@ export class WorkflowNotifyGate {
97
117
  */
98
118
  async onWorkflowStart(input) {
99
119
  try {
100
- await this.journal.record({
120
+ await this.recordPending({
101
121
  runId: input.runId,
102
122
  scope: input.scope,
103
123
  ...(input.sourceTaskId ? { sourceTaskId: input.sourceTaskId } : {}),
@@ -142,7 +162,7 @@ export class WorkflowNotifyGate {
142
162
  if (!existing) {
143
163
  // The start hook didn't journal it (e.g. a run started before this gate existed, or the hook was skipped).
144
164
  // Record it now so the deliver-then-ack ordering still holds and a crash mid-delivery is recoverable.
145
- await this.journal.record({
165
+ await this.recordPending({
146
166
  runId: p.runId,
147
167
  scope,
148
168
  ...(p.sourceTaskId ? { sourceTaskId: p.sourceTaskId } : {}),
@@ -158,7 +178,7 @@ export class WorkflowNotifyGate {
158
178
  this.opts.onError?.("deliver", p.runId, err);
159
179
  return;
160
180
  }
161
- await this.journal.ack(p.runId, this.now());
181
+ await this.ackDelivered(p.runId);
162
182
  }
163
183
  /**
164
184
  * RECOVERY sweep — run at boot AND PERIODICALLY (wired into the service reaper), BEFORE/while serving traffic.
@@ -171,11 +191,32 @@ export class WorkflowNotifyGate {
171
191
  * workflow died) + ack, instead of leaking the entry forever. This closes the exact crash topology SVC-1
172
192
  * exists for (a replica SIGKILLed mid-run).
173
193
  * - `running` and FRESH (within the grace window): genuinely in flight → leave pending (the owning process
174
- * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale).
194
+ * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale). The sweep
195
+ * publishes NOTHING here — see the single-writer invariant below.
175
196
  * - MISSING (reaped / never persisted): ack-as-abandoned so the journal doesn't chase a ghost forever.
176
197
  * `orphanGraceMs` MUST exceed the max expected workflow runtime (the run store has no cross-replica liveness
177
198
  * signal, so age is the only orphan proxy). Returns a tally. A per-entry throw is isolated so one bad entry
178
199
  * can't abort the sweep.
200
+ *
201
+ * BOTH abandoned arms (boot-orphan + stale-past-grace) flip the DURABLE row to `failed` FIRST (CAS on rev),
202
+ * so the run store, the fleet panel and the delivered notify give ONE answer — previously the notify said
203
+ * `failed` while `/workflows` kept saying `running` forever ([2999]: republishing without finalizing would
204
+ * have turned "panel empty" into "panel shows a row that never moves"). A lost CAS means the run moved under
205
+ * the sweep (e.g. its real terminal landed concurrently) — skip this pass; the entry stays pending and the
206
+ * next sweep handles the NEW state.
207
+ *
208
+ * 🔴 SINGLE-WRITER INVARIANT for the fleet row (round-1 review, two HIGH findings): the sweep only ever
209
+ * publishes a **TERMINAL** fleet frame (the flip above, via `publishTerminalFleetRow` → final frame + remove).
210
+ * It NEVER publishes a `running` row. Two reasons, both "a row we mint here can become one nobody can retire":
211
+ * 1. cross-replica (SQL journal): a pending `running` entry may belong to ANOTHER replica. Its terminal
212
+ * update lands on that replica's own (replica-local) fleet bus, and its ack removes the entry from the
213
+ * shared journal — so this replica would never see the run again and its minted row would sit `running`
214
+ * forever. (The terminal-redelivery arm deliberately doesn't publish either — it is a notify path.)
215
+ * 2. same-replica: `runStore.get` is a READ-TIME SNAPSHOT. If the live run commits its terminal (and the
216
+ * wrapper removes the row) between that read and the publish, a `running` republish resurrects a row
217
+ * that will never be removed again.
218
+ * A live row's ONE writer is this replica's `put`/`update` observation point ({@link JournalingWorkflowRunStore}),
219
+ * which by construction sees every transition including the terminal one.
179
220
  */
180
221
  async recover(opts = {}) {
181
222
  const orphanGraceMs = opts.orphanGraceMs ?? 24 * 60 * 60 * 1000; // 24h default — well beyond any normal workflow
@@ -183,12 +224,28 @@ export class WorkflowNotifyGate {
183
224
  let redelivered = 0;
184
225
  let stillRunning = 0;
185
226
  let abandoned = 0;
227
+ /** Was this entry recorded by a PREVIOUS incarnation of this process? Only meaningful when the caller granted
228
+ * the replica-local finalize authority (a shared/SQL journal carries other replicas' entries). Both halves
229
+ * are required — see `finalizeStartedBeforeMs` + {@link recordedThisIncarnation}. */
230
+ const isPreBootEntry = (entry) => opts.finalizeStartedBeforeMs !== undefined &&
231
+ !this.recordedThisIncarnation.has(entry.runId) &&
232
+ entry.createdAt < opts.finalizeStartedBeforeMs;
186
233
  for (const entry of pending) {
187
234
  try {
188
235
  const run = await this.runStore.get(entry.runId);
189
236
  if (!run) {
190
- // No durable run — reaped or never persisted. Don't keep it pending forever.
191
- await this.journal.ack(entry.runId, this.now());
237
+ // No durable run — USUALLY a ghost (the row was reaped, or its `put` threw). But it is ALSO the
238
+ // transient shape of a run being started RIGHT NOW: JournalingWorkflowRunStore journals BEFORE it
239
+ // persists (deliberately — a persist that throws must still leave a recoverable entry), so there is a
240
+ // window where the entry exists and the row does not. Acking that window is unrecoverable: the owner's
241
+ // own terminal `deliverOnce` then sees `acked` and drops the completion for good — the exact loss SVC-1
242
+ // exists to prevent (round-2 review, HIGH). So only ack a ghost that PROVABLY isn't that window: one
243
+ // that predates this incarnation, or one older than the grace the whole sweep already trusts.
244
+ if (!isPreBootEntry(entry) && this.now() - entry.createdAt <= orphanGraceMs) {
245
+ stillRunning++; // "left pending this pass" — a start-in-progress is exactly that
246
+ continue;
247
+ }
248
+ await this.ackDelivered(entry.runId);
192
249
  abandoned++;
193
250
  continue;
194
251
  }
@@ -207,15 +264,43 @@ export class WorkflowNotifyGate {
207
264
  const originatingSessionId = run.originatingSessionId;
208
265
  if (run.status === "running") {
209
266
  const startedAt = run.startedAt || entry.createdAt;
210
- if (this.now() - startedAt <= orphanGraceMs) {
267
+ // [2995] boot-orphan judgment (replica-local journal only): the entry predates this process, so the
268
+ // in-process executor that owned it died with the previous incarnation — nothing will ever flip it.
269
+ // (Conditions in `isPreBootEntry`: replica-local authority + not recorded by US + predates boot.)
270
+ const bootOrphan = isPreBootEntry(entry);
271
+ if (!bootOrphan && this.now() - startedAt <= orphanGraceMs) {
211
272
  stillRunning++;
212
- continue; // fresh → genuinely in flight; the owner (or a later sweep) delivers terminal
273
+ // fresh → genuinely in flight; the owner (or a later sweep) delivers terminal. Publish NOTHING here:
274
+ // a `running` row minted from a read-time snapshot can outlive every chance to retire it (the
275
+ // single-writer invariant on the doc-comment above).
276
+ continue;
213
277
  }
214
- // Stale `running` crash-orphan (nothing will ever flip it terminal). Finalize as abandoned/failed.
278
+ // Orphaned `running` boot-orphan (executor died with the previous process) or stale past grace
279
+ // (nothing will ever flip it terminal). Finalize as abandoned/failed: flip the DURABLE row first
280
+ // ([2999] honesty half — store/fleet/notify must give one answer), then deliver + ack.
281
+ const abandonSummary = bootOrphan
282
+ ? "workflow abandoned — the engine restarted while it was running (its in-process executor did not survive the restart)"
283
+ : "workflow abandoned — still `running` past the orphan grace window (replica likely crashed mid-run)";
284
+ const flipped = { ...run, status: "failed", error: abandonSummary, endedAt: this.now() };
285
+ const flippedOk = await this.runStore.update(entry.runId, run.scope, flipped, run.rev !== undefined ? { rev: run.rev } : undefined);
286
+ if (!flippedOk)
287
+ continue; // the run moved under the sweep — leave pending; next sweep sees the NEW state
288
+ this.recordedThisIncarnation.delete(entry.runId); // terminal now — the anchor only ever gates `running`
289
+ // ORDER: retire the fleet row FIRST — immediately after the durable flip, before anything is awaited.
290
+ // Same order as the live leg (the wrapper publishes the terminal frame, then core fires its notify), and
291
+ // it is the only order where "a finalized run leaves the active fleet" survives every delivery outcome
292
+ // (rounds 4+5): the delivery may throw at its ack, or never settle at all, and a `finally` cannot help
293
+ // with the latter — while the NEXT sweep takes the terminal-redelivery arm, which deliberately publishes
294
+ // nothing, so a row skipped here would be stranded `running` with nobody left to retire it.
295
+ // The converse risk is bounded and self-healing: if the projection throws, this pass delivers nothing
296
+ // and the entry stays pending — the next sweep's terminal arm delivers it. Deferred by one sweep, never
297
+ // lost. (Subscriber throws are already isolated inside the fleet bus — per-callback try/catch with a
298
+ // registered fail-open tag — so a projection throw here means the publish machinery itself broke.)
299
+ opts.publishTerminalFleetRow?.(entry.runId, flipped); // final frame then remove
215
300
  await this.deliverOnce({
216
301
  runId: entry.runId,
217
302
  status: "failed",
218
- summary: "workflow abandoned — still `running` past the orphan grace window (replica likely crashed mid-run)",
303
+ summary: abandonSummary,
219
304
  ...(sourceTaskId ? { sourceTaskId } : {}),
220
305
  ...(principal ? { principal } : {}),
221
306
  ...(originatingSessionId ? { originatingSessionId } : {}),
@@ -225,6 +310,10 @@ export class WorkflowNotifyGate {
225
310
  continue;
226
311
  }
227
312
  // Terminal but not acked ⇒ the crash dropped its notify. Re-derive the bounded summary + re-deliver.
313
+ // Drop the incarnation mark FIRST (round-2 review, MEDIUM): the anchor only ever gates the `running` arm,
314
+ // so a terminal run has no use for it — retiring here (not only on a successful ack) keeps the registry
315
+ // tracking runs that are actually in flight even through a prolonged delivery outage.
316
+ this.recordedThisIncarnation.delete(entry.runId);
228
317
  await this.deliverOnce({
229
318
  runId: entry.runId,
230
319
  status: run.status,
@@ -411,40 +500,36 @@ export class JournalingWorkflowRunStore {
411
500
  publishFleet(id, run) {
412
501
  if (!this.fleetBus)
413
502
  return;
414
- const agents = run.agents ?? [];
415
- // [2336] doneCount failedCount **不相交**:done 只数 completed。契约以此为前提(fleet-bus.ts
416
- // startedCount 注的回退式 done+failed started);把 failed 也计进 done 会让全失败 workflow 的
417
- // 终帧渲成 "N done"(cli 4.1.3 实测 done=2 failed=2 started=2)
418
- const done = agents.filter((a) => a.status === "completed").length;
419
- const failed = agents.filter((a) => a.status === "failed").length;
420
- // cli [1726] 二①:CC 规模告警的分母是 **started**(已启动),而 `totalCount`(= agents.length)是**计划总数**
421
- // (含排队中)。判别口径走 core 导出的 `deriveAgentDisplayStatus` —— 一个 agent 已在 `run.agents` 里但
422
- // `startedAt` 未置就是排队中。**不自己写 `startedAt !== undefined`**:那是 core 的显示契约,两端各写一遍
423
- // 必然漂移(本仓刚在 coarse 门镜像上吃过同款),用它的函数则 core 一改、两端同时跟随。
424
- const started = agents.filter((a) => deriveAgentDisplayStatus(a, run.status) !== "queued").length;
425
- const terminal = run.status === "completed" || run.status === "failed";
426
- this.fleetBus.publishWorkflow({
427
- id,
428
- // [WF2-A parity] redact the workflow label surfaces for parity with the run + subagent-child names (fleet-bus.ts):
429
- // a tool-launched (LLM-authored) workflow's meta.name/description is task-controlled and could carry a secret shape.
430
- name: redactSecrets(run.name ?? "Dynamic workflow"),
431
- ...(run.description ? { description: redactSecrets(run.description) } : {}),
432
- scope: run.scope,
433
- // codex-6 F2:sessionId 必须随行——streamFleet 对无 sessionId 的行按「同 principal 全会话可见」
434
- // 兜底,漏发=A 会话的 workflow 名/进度/token 泄进 B 会话的 ?session= 过滤流。
435
- ...(run.originatingSessionId ? { sessionId: run.originatingSessionId } : {}),
436
- status: run.status,
437
- doneCount: done,
438
- totalCount: agents.length,
439
- failedCount: failed,
440
- startedCount: started,
441
- tokens: (run.stats?.tokens ?? 0) + (run.stats?.nested?.tokens ?? 0),
442
- // [1294]:跑动中也带时长(1.232 只在 endedAt 后带——clay 验收轮实锚面板恒显 0s)。终态用
443
- // endedAt 定格,活跑用 now-startedAt(每次 put/update 观察点刷新,壳侧读帧即当前时长)。
444
- elapsedMs: (run.endedAt ?? Date.now()) - run.startedAt,
445
- });
503
+ // A-002.3(#194 病族 F1 同源性断裂):终态判据走 fleet-terminal-window 的共享谓词(其穷举表对
504
+ // core WorkflowRunStatus 编译期闭合)——此前手写两值比较是第二份独立定义,core 加终态词时穷举表
505
+ // 红、这里恒 false 行既不 seed 进终态窗也不 removeWorkflow,面板留幽灵 running 行。
506
+ const terminal = isTerminalWorkflowRowStatus(run.status);
507
+ // 撤行**不挂在发帧成功上**(复审第 2 MEDIUM):「终态行必须离场」是不变量、发终帧是尽力而为。
508
+ // try/finally 发布本体抛错照样撤行(异常仍向上抛,不吞:吞了就是一条未登记的 fail-open )
509
+ // 订阅方抛错已在 fleet bus subscribe 隔离层被吞并记 fail-open——本 finally 守的是发布机器本身
510
+ // 的抛错(理论缝级,fresh 复审盘点后判近死防御但保留:防未来发布本体改动回退)。两腿一处收口。
511
+ // 行投影本体 = {@link buildFleetWorkflowRow}(#189:快照的终态行窗与本写路径共用同一份投影,
512
+ // 单源不漂;本函数的发布/撤行语义逐字不变)
513
+ // [3156] 轮2 修:终态行**写路径顺手 seed** 进程内终态窗缓存——重启-重连剧本里(boot recover 判死),
514
+ // fleetWide 连接的 durable pull 没有正确的查询键可用(caller principal 引擎铸行 scope),seed 是
515
+ // 唯一同源可达径。放在 publish 之前:seed 是纯内存 push,不会抛;终态行入窗与发帧同拍。
446
516
  if (terminal)
447
- this.fleetBus.removeWorkflow(id); // terminal workflow leaves the active fleet (the shell saw the final frame)
517
+ seedTerminalWorkflowRow(buildFleetWorkflowRow(id, run));
518
+ try {
519
+ this.fleetBus.publishWorkflow(buildFleetWorkflowRow(id, run));
520
+ }
521
+ finally {
522
+ if (terminal)
523
+ this.fleetBus.removeWorkflow(id); // terminal workflow leaves the active fleet (the shell saw the final frame)
524
+ }
525
+ }
526
+ /** [2995] recovery projection seam: the same fleet projection as the put/update observation points, exposed so
527
+ * the recovery sweep can RETIRE a row for a run it just finalized (terminal input ⇒ final frame, then remove).
528
+ * Wire it as `recover`'s `publishTerminalFleetRow` hook — and only ever hand it a TERMINAL run: a `running`
529
+ * row published from outside the wrapper's own write path has no guaranteed retirement (see the invariant on
530
+ * {@link WorkflowNotifyGate.recover}). */
531
+ republishFleet(id, run) {
532
+ this.publishFleet(id, run);
448
533
  }
449
534
  async put(id, run) {
450
535
  // Journal FIRST (best-effort, swallowed inside onWorkflowStart) so a started run is always recoverable, THEN
@@ -81,10 +81,15 @@ export async function decideParkedAgent(deps, req) {
81
81
  // RB-459(core 5.7.0,F1/F6 翻案后唯一剩下的 claim 前拒):问题门的 approve 必须携 answer——
82
82
  // parked 腿无 live answering face,消费 claim 后只会空跑一次 revive;claim 前拒是免损前置。带 answer 的
83
83
  // approve 与任何 deny 都放行透传,core 是语义权威(header 逐字匹配等)。
84
- // 🔴 #152:本判据**不再**有 core 侧同判兜底。core 的 pre-CAS 拒条件是「`taskConfig.onQuestion ??
85
- // deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」;ASK_QUESTION_ENABLED 部署上 spec 不再 stamp
86
- // 哨兵、deps.onQuestion 是 coordinator,条件恒不成立 ⇒ core 放行,赎回腿会拿 coordinator 的空答当人答
87
- // 并消费掉 checkpoint。这一手现在是该腿唯一的拒绝点(任务级孪生:src/http/server.ts decide 路由)。
84
+ // 🔴 #152 当年写这条时的机制理由是:core 的 pre-CAS 拒条件(「`taskConfig.onQuestion ??
85
+ // deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」)在 ASK_QUESTION_ENABLED 部署上恒不成立
86
+ // ⇒ core 放行,而赎回腿会拿 coordinator 的**空答**当人答并消费掉 checkpoint。
87
+ // ⚠️ 那个理由已被后续两处证伪,别再据它推理:①#166 QuestionCoordinator 的每一条无人应答臂(无
88
+ // ALS ctx / abort / 限流 / 呈现不了 / TTL)一律返 `{kind:"unavailable"}`,类头逐字承诺「It NEVER
89
+ // synthesizes an empty answer set」——赎回腿无 ctx,拿到的是 unavailable 而不是空答;②core 5.16 对
90
+ // 赎回中的 ask 走 redeemsApproval 臂返 isError(「will not silently self-answer」)。
91
+ // 本判据**保留**,理由换成免损前置:放过去也只会空跑一次 revive(claim 已被消费),claim 前拒是
92
+ // 更便宜、错误信息也更准的那一手(任务级孪生:src/http/server.ts 的 decide 路由)。
88
93
  return { status: 400, body: { error: "pending action is AskUserQuestion — approve requires body.answer (the operator's answers[]); deny needs none", errorCode: "decide.parked_answer_required", taskId: match.handle } };
89
94
  }
90
95
  if (row.name === undefined) {
@@ -12,11 +12,11 @@
12
12
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
13
13
  */
14
14
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
15
- import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
15
+ import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
16
16
  export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
17
17
  private readonly asks;
18
18
  private readonly batches;
19
- ensureAsk(row: NewAskRow): Promise<AskRow>;
19
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
20
20
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
21
21
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
22
22
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
@@ -50,8 +50,9 @@ export class InMemoryApprovalAskStore {
50
50
  });
51
51
  }
52
52
  const existing = this.asks.get(row.askId);
53
+ // 幂等命中:行是**别人**插的 ⇒ `inserted: false`(收尾权限判别位,语义见 `EnsureAskResult` 顶注)。
53
54
  if (existing)
54
- return { ...existing };
55
+ return { row: { ...existing }, inserted: false };
55
56
  // 🔴 车4 §12-E:`idempotency_key` 已归**回决专用**,`NewAskRow` 的同名字段随之摘除 ⇒ 铸行不再有
56
57
  // 唯一性可撞(列恒 NULL)。原先在这里镜像 UNIQUE 的那段扫描也随之下车;撞键判定搬到 `decideAsk`
57
58
  // (SQL twin 那侧是 `(task_id, idempotency_key)` 索引在库层拒 ⇒ typed `idempotency_conflict`)。
@@ -85,7 +86,7 @@ export class InMemoryApprovalAskStore {
85
86
  updatedAtMs: row.createdAtMs,
86
87
  };
87
88
  this.asks.set(row.askId, created);
88
- return { ...created };
89
+ return { row: { ...created }, inserted: true };
89
90
  }
90
91
  async transitionAsk(askId, from, to, patch) {
91
92
  if (!canAskTransition(from, to)) {
@@ -59,9 +59,13 @@ export interface AskRow {
59
59
  * 🔴 车5 §9 C2:铸卡时呈给人看的那份 input 的**服务端摘要**(`AskRequest.boundInputHash`,core 侧已在场)。
60
60
  * 与下面 PARKED 坐标里的 `gateBoundInputHash` 是**两件不同的东西**,别混:
61
61
  * - `boundInputHash`(本列)= ask **铸行时**的入参摘要,一次写定永不改;对账收敛器判据 1 用它跟
62
- * checkpoint 行的同名列做**硬相等**(identity 四元组之外的第二道等式)。缺席 ⇒ 判据 1 不命中
63
- * (禁「能取到时才比」的可选谓词——同 session toolCallId 会被网关重用,只靠 identity 会把旧 ask
64
- * PARK 到别人的 resume 坐标上,而 PARKED 是不可回滚的终态)。
62
+ * checkpoint 行的同名列做**硬相等**——它是**身份三元组之外的第二道等式**,身份本身是
63
+ * (`sourceTaskId`, `toolCallId`, 因果下界)三维(#168 件1 换轴,判据属主 = `approval-reconciler.ts`
64
+ * `classifyGateMatch`;本注上一版写的「identity 四元组」是换轴前的旧口径,`sessionId` 从来不是
65
+ * 内存判据维)。任一侧缺席 ⇒ 判据 1 **不命中**;归因看走到哪一层:身份先判(候选集非空却没有同身份
66
+ * 的一条 ⇒ `identity_miss`),身份这层还够得着时缺席才记 `single_mint`(禁「能取到时才比」的可选谓词
67
+ * ——同 session 内 toolCallId 会被网关重用,只靠身份会把旧 ask PARK 到别人的 resume 坐标上,而
68
+ * PARKED 是不可回滚的终态)。
65
69
  * - `gateBoundInputHash`(下面)= 真 **PARK 成功那一刻**从 checkpoint 抄回来的坐标之一,bindBatch 才写。
66
70
  * 本车只落店面承载(列 + 行形 + 读写),铸行调用点的供值归车2/3b。
67
71
  */
@@ -114,6 +118,23 @@ export interface NewAskRow {
114
118
  expiresAtMs: number;
115
119
  createdAtMs: number;
116
120
  }
121
+ /**
122
+ * `ensureAsk` 的产出 —— 行 **+ 这一次调用到底插没插**(#168 件2)。
123
+ *
124
+ * 🔴 为什么判别位必须由**店**给:`ensureAsk` 是幂等 upsert(`askId` 是确定性派生,重试 / failover /
125
+ * 闭包再入天然指向同一行),所以「拿到一条 STREAM_PENDING 行」有两种成因 —— 本次插的,或幂等命中了
126
+ * **别人正持有**的那条活行。两者的收尾权限完全相反:前者本次可以收(放弃时把孤儿行 VOID 掉),后者
127
+ * 一个字都不许动(动了就是把真属主正在等的那张卡作废)。调用侧此前只能拿 `createdAtMs === 本次传入值`
128
+ * 去**猜**归属,而那把尺在同一毫秒的两次并发插入上会给出假阳性(旧注里如实记着的残余)。
129
+ * `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected 行数是引擎对同一个问题的**权威**回答,
130
+ * 两方言都有;把它如实带出来,猜就退休了。
131
+ *
132
+ * `inserted: true` ⇒ 这条行是本次调用写下的(可收尾);`false` ⇒ 幂等命中既有行(只读,不许收尾)。
133
+ */
134
+ export interface EnsureAskResult {
135
+ row: AskRow;
136
+ inserted: boolean;
137
+ }
117
138
  /** `transitionAsk`/`resolveProvisional` 的可选补丁——只有出现的字段才落 SQL(未出现 = 该列不变),
118
139
  * `updatedAtMs` 恒必填(调用方是未来的协调器,时间戳由它按事件时钟决定,店不偷偷用 `Date.now()`)。 */
119
140
  export interface AskTransitionPatch {
@@ -206,7 +227,8 @@ export interface BatchRow {
206
227
  }
207
228
  /** design 定稿 §4 的持久层接口。协调器接线(车2)、恢复扫描消费(车5)不在本车范围——本车只落这些方法。 */
208
229
  export interface ApprovalAskStore {
209
- ensureAsk(row: NewAskRow): Promise<AskRow>;
230
+ /** 幂等 upsert。返回**行 + 本次是否真插入**(判别位语义见 {@link EnsureAskResult}) */
231
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
210
232
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
211
233
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
212
234
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
@@ -275,6 +297,39 @@ export declare const APPROVAL_BATCHES_TABLE = "approval_batches";
275
297
  *
276
298
  * SCHEMA POLICY 同 `tidb-pool.ts` 头注:纯 CREATE,禁 ALTER 增量 seam,改列直接改这里 + 重建库
277
299
  * (`docs/schema/baseline-mysql.sql` 是由这些文本机器录制出的**产物**,永不手改)。
300
+ *
301
+ * ── 列宽的**测量依据**(#192 A10;U2 逐列取证在档)──────────────────────────────────────────────────
302
+ * 本表家族原先一律 VARCHAR(255),是"先建表后想"的产物。按"每列的值到底由谁铸、有没有入口上限"重新裁:
303
+ *
304
+ * 收窄(有硬上限背书,证据即上限本身):
305
+ * · `ask_id` / `batch_id` / `bound_ask_id` → 64:`deriveAskId`/`deriveBatchId` 恒是 sha256 hex 取前
306
+ * 64 字符(`approval-ask-machine.ts` deterministicId),按构造不可能更长。
307
+ * · `task_id` → 64:入口 `UUIDV7_RE`(`security.ts:134`)只放行 36 字符的规范 uuidv7;与兄弟列
308
+ * `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。
309
+ * · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"),
310
+ * 与 `session_meta`/`task_run` 同宽。
311
+ * · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts:350`)硬拒更长者;
312
+ * 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。
313
+ * · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符),
314
+ * 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致,
315
+ * 否则抄的那一步就是一道静默截断。512 是原先的 16 倍冗余。
316
+ *
317
+ * 🔑 上面四条收窄之所以**不引入任何新暴露面**,靠的不是入口断言而是一条更硬的事实:同一个 session_id /
318
+ * task_id / owner **早就**同时躺在 `session_meta.session_id VARCHAR(64)` / `task_run.task_id VARCHAR(64)` /
319
+ * `task_run.owner VARCHAR(190)` 里。任何长到能撑爆本表新宽度的值,在写到那几张**更中心**的表时就已经
320
+ * 先炸了 —— 本表此前的 255 从来不是一道额外的安全余量,只是一处与全仓不一致的偏离。
321
+ * ⇒ 收窄的风险上界 = 0;真正要担心的是反过来:留着 255 会让人误以为这里可以存更长的 id。
322
+ *
323
+ * 🔴 **刻意不收窄**(测量结果不支持,记在这里免得下一个人以为是漏了):
324
+ * · `source_task_id` / `tool_call_id` / `parent_tool_call_id` / `gate_bound_call_id` —— 这四列的值是
325
+ * **模型/引擎铸的原始 id**,服务端在写行这一步**没有任何长度断言**(`approval-card.ts` 的
326
+ * `MAX_IDENT` 只裁了给人看的那份 card 投影,不是本列)。没有入口上限就收窄 = 把"存不下"这件事
327
+ * 推迟到 INSERT 才炸(MySQL 更糟:静默截断),换来的只是几十字节。要收窄,先补入口断言
328
+ * (照 `assertPrincipalShape`/`assertIdempotencyKeyShape` 的姿势),那是另一件事。
329
+ * · `bound_input_hash` / `gate_bound_input_hash` —— 按约定是 sha256 hex(64),但 `readBoundInputHash`
330
+ * 只校验"非空字符串",没校长度/字形 ⇒ 同上,先有断言再谈收窄。
331
+ * · `idempotency_key` —— 255 **正是**入口断言的上限本身(zod `.max(255)` + 店内 `assertIdempotencyKeyShape`
332
+ * 双执法),宽度与上限同源,恰好正确,动它反而制造截断面。
278
333
  */
279
334
  export declare const TIDB_APPROVAL_ASK_STATEMENTS: readonly string[];
280
335
  /** {@link TIDB_APPROVAL_ASK_STATEMENTS} 的遍历壳——生产路径走 `tidb-pool.ts` 的中央 `ensureSchema`
@@ -303,7 +358,7 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
303
358
  private getAskOn;
304
359
  /** {@link getAskOn} 的批侧同形(同一条纪律:失败臂回读走本连接)。 */
305
360
  private getBatchOn;
306
- ensureAsk(row: NewAskRow): Promise<AskRow>;
361
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
307
362
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
308
363
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
309
364
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;