@sema-agent/server 7.23.0 → 7.24.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.
@@ -2,12 +2,22 @@
2
2
  * v2 MVP — leader fan-out composition layer (design/50 §3, V2-MVP-PLAN.md M1, core seam).
3
3
  *
4
4
  * Runs N sub-task workers in parallel over `runner.runTaskStream` sharing ONE AbortController, bounded by a
5
- * Semaphore, with an overall deadline (no infinite wait — council#5), a concurrency claim (run-store
6
- * createRun unique-key CAS — the rule-5 dual-session tax solved at worker scale), and a
5
+ * Semaphore, with an overall deadline (no infinite wait — council#5), and a
7
6
  * `cancelAll` that ABORTS first (stopping not-yet-started workers + aborting running ones) then interrupts the
8
7
  * snapshot — closing the late-registration race (council round-2). Returns a typed WorkerReport per worker (council#6 — the
9
8
  * Coordinator needs a defined shape to route diffs in merge(b)).
10
9
  *
10
+ * ── 重复 spawn 的去重由谁负责(#193 车4 件1,台账 P1-9③ 更正)────────────────────────────────────
11
+ * 这段头注**曾经**写着 fan-out 带一道「concurrency claim(run-store createRun unique-key CAS)」并称
12
+ * 「the rule-5 dual-session tax solved at worker scale」。亲读后裁定:那道 claim 全链路无人传,而且
13
+ * **由构造即无效** —— 它的 CAS 键是 `(workerId, sessionId)`,而 sessionId 是 `wire.ts` 每次现铸的
14
+ * `leader-<workerId>-<Date.now()>`;一次重投的 leader run 铸出来的是**不同的** sessionId,claim 永远
15
+ * 不会输。真正需要去重的那件事(同一逻辑请求被重投两次 ⇒ 两次真 git push)只能在**准入**处判,它的
16
+ * 属主是 `leader_run.idem_key` UNIQUE(`plugins/leader-run-store-sql.ts` 的原子赢或观察)。
17
+ * ⇒ 那个可选字段、它的函数类型别名、以及只有它才产得出的 `status:"rejected"`,三者同批删除(留一个
18
+ * 没人传的字段 + 一句「已解决」的头注,比没有这道防线更坏:它让读者以为这件事有人管)。
19
+ * 常驻门:`test/leader-failclosed-193.test.ts` 的 P1-9③(声明了就必须有生产者)。
20
+ *
11
21
  * cancelAll uses `TaskStream.destroy()` (core 1.71): abort + await run-settle + CAS-expire the checkpoint
12
22
  * (fencing any concurrent resume) + reap a SUSPENDED worker's paused container — closing the interim-`interrupt()`
13
23
  * gap (a cancel hitting a suspended worker no longer leaks a paused container or an orphan checkpoint). Per core's
@@ -22,8 +32,9 @@ import type { TaskSpec, TaskResult, TaskStream, RepairTerminal } from "@sema-age
22
32
  export interface WorkerReport {
23
33
  workerId: string;
24
34
  sessionId: string;
25
- /** completed = mergeable; failed/suspended/timeout/rejected = not merged (quarantined). */
26
- status: "completed" | "failed" | "suspended" | "timeout" | "rejected";
35
+ /** completed = mergeable; failed/suspended/timeout = not merged (quarantined).
36
+ * (`rejected` 随那道并发去重面一并删除 —— 那面是它的唯一生产者,留下就是一个永不出现的闭集成员。) */
37
+ status: "completed" | "failed" | "suspended" | "timeout";
27
38
  /** Worker's branch + the base it forked from — drives the merge(b) `git format-patch <baseSha>` diff-out. */
28
39
  branch?: string;
29
40
  baseSha?: string;
@@ -64,8 +75,6 @@ export type FanOutStream = Pick<TaskStream, "result" | "destroy">;
64
75
  export interface RunnerLike {
65
76
  runTaskStream(spec: TaskSpec): FanOutStream;
66
77
  }
67
- /** Concurrency claim (run-store `createRun` unique-key CAS). Returns false = a duplicate spawn lost the claim. */
68
- export type ClaimFn = (workerId: string, sessionId: string) => Promise<boolean>;
69
78
  export interface FanOutOptions {
70
79
  runner: RunnerLike;
71
80
  /** Overall fan-out deadline (ms). On expiry → cancelAll, the still-running workers are cancelled. */
@@ -75,15 +84,13 @@ export interface FanOutOptions {
75
84
  cancelGraceMs?: number;
76
85
  /** Semaphore cap on concurrent workers (MVP = 2; structure scales to N). */
77
86
  maxConcurrency: number;
78
- /** Optional concurrency claim; omitted = always-claim (single-session MVP). */
79
- claim?: ClaimFn;
80
87
  logger?: {
81
88
  warn?: (msg: string, meta?: Record<string, unknown>) => void;
82
89
  info?: (msg: string, meta?: Record<string, unknown>) => void;
83
90
  };
84
91
  /** Optional per-worker run override (e.g. runWithVerification). When set, runOne routes through THIS instead of
85
- * the default runTaskStream-and-map — still bounded by the semaphore, the overall timeout→cancelAll, and the
86
- * claim. Lets the verify path reuse fan-out's concurrency/timeout/cancellation rather than an unbounded
92
+ * the default runTaskStream-and-map — still bounded by the semaphore and the overall
93
+ * timeout→cancelAll. Lets the verify path reuse fan-out's concurrency/timeout/cancellation rather than an unbounded
87
94
  * Promise.all (council). The override receives the shared abort signal; pass it into the run so a hung or
88
95
  * cancelled worker actually aborts (else the overall-timeout's `await work` can never unblock). Like the default
89
96
  * path it need not catch — runOne maps a throw to failed/timeout. */
@@ -88,10 +88,6 @@ export async function fanOut(subtasks, opts) {
88
88
  };
89
89
  const runOne = async (st) => {
90
90
  const base = { workerId: st.workerId, sessionId: st.sessionId, branch: st.branch, baseSha: st.baseSha };
91
- // Concurrency claim (CAS) BEFORE provisioning — a lost claim means a duplicate spawn; skip, don't run.
92
- if (opts.claim && !(await opts.claim(st.workerId, st.sessionId))) {
93
- return { ...base, status: "rejected", error: "claim lost (duplicate spawn)" };
94
- }
95
91
  return sem.run(async () => {
96
92
  if (sharedAC.signal.aborted)
97
93
  return { ...base, status: "timeout", error: "cancelled before start" };
@@ -16,12 +16,15 @@
16
16
  * Before re-measure, the caller mechanically checks the patch's changed-file list against an allowlist
17
17
  * (`changedFilesWithinAllowlist`, §10.4(ii)) — an out-of-allowlist / build-config touch denies the candidate.
18
18
  *
19
- * The standalone, mint-a-distinct-sandbox grader factory (`createGraderEnvFactory`) is the AUTO-ACCEPT slice path
20
- * (flag `LEADER_GRADER_FACTORY`, default OFF until the §7 OFF-Gate-1 end-to-end verification). It is documented +
21
- * stubbed here, NOT wiredv1 reuses the integration sandbox (R1) + re-seeds it (R2) instead.
19
+ * A standalone, mint-a-distinct-sandbox grader belongs to the AUTO-ACCEPT slice, which has NOT been designed or
20
+ * built. v1 reuses the integration sandbox (R1) + re-seeds it (R2) instead. **There is no config key that turns
21
+ * such a factory on**an earlier revision of this header named one and shipped a `throw`-only stub beside it,
22
+ * but no such key ever existed in `config.ts`, so an operator following that text set an env var that did
23
+ * nothing. Both the stub and the promise were removed (#193 车4 件3 / staleness ledger P2-9); the auto-accept
24
+ * slice, when it happens, is a new design件 that lands its factory, its wiring and its knob in one batch.
22
25
  *
23
26
  * Everything here is additive + OFF by default: nothing in this file runs unless the Stage-2 seams call it behind
24
- * `LEADER_REPAIR_LOOP` / `LEADER_MEASURE_GATES` / `LEADER_GRADER_FACTORY`.
27
+ * `LEADER_REPAIR_LOOP` / `LEADER_MEASURE_GATES`.
25
28
  */
26
29
  import { type ExecutionEnv, type ExecStep } from "@sema-agent/core";
27
30
  /**
@@ -63,48 +66,4 @@ export declare function applyAndGrade(handle: GraderHandle, patch: string, steps
63
66
  export declare function changedFilesOutOfAllowlist(changedFiles: string[], allowlist: string[]): string[];
64
67
  /** §10.4(ii): true iff EVERY changed file is within the allowlist and none is a denied build-config path. */
65
68
  export declare function changedFilesWithinAllowlist(changedFiles: string[], allowlist: string[]): boolean;
66
- /**
67
- * §3.2 — the FUTURE standalone grader factory (the auto-accept-slice sibling of `main.ts`'s `executionEnvFactory`,
68
- * `main.ts:204-249`). Mints a DISTINCT sandbox via the same `e2b/k8sExecutionEnvFactory`, seeds `seedCmd` + the
69
- * oracle files ONLY there, and returns a `GraderHandle` with `provenance:"control_plane"`. v1 does NOT mint a
70
- * standalone grader (R1 reuses the integration sandbox; R2 re-seeds it) — this is the path the auto-accept slice
71
- * (flag `LEADER_GRADER_FACTORY`, default OFF) opens once the §7 OFF-Gate-1 deployment-contract proof lands. It is
72
- * a documented STUB (NOT wired): minting + seeding a per-run sandbox is real provisioning work that belongs to the
73
- * auto-accept stage, and shipping a half-mint here would be a stopgap (clay rule). The signature is fixed so the
74
- * Stage-N auto-accept wiring drops in without a contract change.
75
- */
76
- export interface GraderEnvFactoryCfg {
77
- /** Mint a distinct, isolated sandbox env (the same factory the workers/integration sandbox use). */
78
- mint: (sessionId: string) => {
79
- env: ExecutionEnv;
80
- writeFile: (p: string, c: string) => Promise<void>;
81
- sh: (cmd: string) => Promise<string>;
82
- destroy: () => Promise<void>;
83
- };
84
- /** Shell to seed the base repo (idempotent) — runs ONLY in the minted grader. */
85
- seedCmd: string;
86
- /** Repo dir inside the grader. */
87
- repoDir: string;
88
- /** Hidden-oracle files seeded ONLY in the grader (never the worker env). */
89
- oracleFiles?: Array<{
90
- path: string;
91
- content: string;
92
- }>;
93
- }
94
- /** §3.2 — a minted standalone grader plus its lifecycle + isolation brand (for the auto-accept slice). */
95
- export interface MintedGrader extends GraderHandle {
96
- /** The control-plane provenance brand (distinguishes a minted grader from the reused integration sandbox). */
97
- provenance: "control_plane";
98
- /** The grader's hidden-oracle paths (passed to `runRepairLoop.immutableOraclePaths` ONLY when the auto-accept
99
- * slice runs the full `assertOracleIsolation` — v1 OMITS these; §10.2). */
100
- immutableOraclePaths: string[];
101
- /** Reap the minted sandbox. */
102
- destroy: () => Promise<void>;
103
- }
104
- /**
105
- * §3.2 STUB — build the standalone grader factory. NOT wired in v1 (the auto-accept slice owns it, flag
106
- * `LEADER_GRADER_FACTORY`). Throws a clearly-marked not-implemented error if invoked before that slice lands so a
107
- * stray call fails loudly rather than silently mis-grading — this is an explicit placeholder, not a quiet stopgap.
108
- */
109
- export declare function createGraderEnvFactory(_cfg: GraderEnvFactoryCfg): () => Promise<MintedGrader>;
110
69
  //# sourceMappingURL=grader-env-factory.d.ts.map
@@ -16,12 +16,15 @@
16
16
  * Before re-measure, the caller mechanically checks the patch's changed-file list against an allowlist
17
17
  * (`changedFilesWithinAllowlist`, §10.4(ii)) — an out-of-allowlist / build-config touch denies the candidate.
18
18
  *
19
- * The standalone, mint-a-distinct-sandbox grader factory (`createGraderEnvFactory`) is the AUTO-ACCEPT slice path
20
- * (flag `LEADER_GRADER_FACTORY`, default OFF until the §7 OFF-Gate-1 end-to-end verification). It is documented +
21
- * stubbed here, NOT wiredv1 reuses the integration sandbox (R1) + re-seeds it (R2) instead.
19
+ * A standalone, mint-a-distinct-sandbox grader belongs to the AUTO-ACCEPT slice, which has NOT been designed or
20
+ * built. v1 reuses the integration sandbox (R1) + re-seeds it (R2) instead. **There is no config key that turns
21
+ * such a factory on**an earlier revision of this header named one and shipped a `throw`-only stub beside it,
22
+ * but no such key ever existed in `config.ts`, so an operator following that text set an env var that did
23
+ * nothing. Both the stub and the promise were removed (#193 车4 件3 / staleness ledger P2-9); the auto-accept
24
+ * slice, when it happens, is a new design件 that lands its factory, its wiring and its knob in one batch.
22
25
  *
23
26
  * Everything here is additive + OFF by default: nothing in this file runs unless the Stage-2 seams call it behind
24
- * `LEADER_REPAIR_LOOP` / `LEADER_MEASURE_GATES` / `LEADER_GRADER_FACTORY`.
27
+ * `LEADER_REPAIR_LOOP` / `LEADER_MEASURE_GATES`.
25
28
  */
26
29
  import { runExecGate } from "@sema-agent/core";
27
30
  import { shellQuote as sq } from "../plugins/remote-shell.js";
@@ -116,16 +119,17 @@ export function changedFilesOutOfAllowlist(changedFiles, allowlist) {
116
119
  export function changedFilesWithinAllowlist(changedFiles, allowlist) {
117
120
  return changedFilesOutOfAllowlist(changedFiles, allowlist).length === 0;
118
121
  }
119
- /**
120
- * §3.2 STUB build the standalone grader factory. NOT wired in v1 (the auto-accept slice owns it, flag
121
- * `LEADER_GRADER_FACTORY`). Throws a clearly-marked not-implemented error if invoked before that slice lands so a
122
- * stray call fails loudly rather than silently mis-grading — this is an explicit placeholder, not a quiet stopgap.
122
+ /*
123
+ * §3.2 的「standalone 铸新沙箱 grader 工厂」曾以 `GraderEnvFactoryCfg` / `MintedGrader` / `createGraderEnvFactory`
124
+ * 三件的形式停在这里:一个固定好的签名 + 一个必抛的 not-implemented 实现,理由写的是「签名先定好,将来
125
+ * 自动接受切片直接接线,不改契约」。#193 车4(台账 P2-9)裁定删除,理由三条:
126
+ * ① 它零调用零测试,而它承诺的开关(见文件头注)在 `config.ts` 里从来不存在 —— 「签名已定」这句话
127
+ * 本身在骗读者:没有任何消费方按它写过一行码,所谓的契约稳定性没有被任何东西验证过;
128
+ * ② 一个 `throw` 工厂在 grep 结果里长得像「这条路已经有了」,恰恰是本仓 doc-rot 战役反复清理的那一类
129
+ * 「过期的在哪里比缺失的在哪里更危险」;
130
+ * ③ 真需求出现时,铸沙箱 + 播种 + oracle 隔离本来就要跟着 `assertOracleIsolation` 一起重新设计
131
+ * (§10.2 明说 v1 OMITS immutableOraclePaths),旧签名大概率也不会被原样采用。
132
+ * 复原坐标(需要时按此重建):sema-internal `server/docs/LEADER-REPAIRLOOP-INTEGRATION.md` §3.2/§7,
133
+ * 以及本文件的 git 历史(#193 车4 之前的任一版本)。
123
134
  */
124
- export function createGraderEnvFactory(_cfg) {
125
- return async () => {
126
- throw new Error("createGraderEnvFactory: the standalone control-plane grader is the auto-accept slice (LEADER_GRADER_FACTORY) — " +
127
- "NOT wired in v1 (which reuses the integration sandbox for R1 and re-seeds it for R2). " +
128
- "See sema-internal server/docs/LEADER-REPAIRLOOP-INTEGRATION.md §3.2/§7.");
129
- };
130
- }
131
135
  //# sourceMappingURL=grader-env-factory.js.map
package/dist/main.js CHANGED
@@ -850,7 +850,7 @@ async function main() {
850
850
  workflowCompletionInbox,
851
851
  });
852
852
  // design/158 A10:leader 段搬到 src/boot/leader.ts(逐字)。
853
- const leaderEndpoint = createLeaderFace({ config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore, governanceSeams });
853
+ const leaderEndpoint = createLeaderFace({ config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore, governanceSeams, backend });
854
854
  // Graceful drain: shared mutable state — SIGTERM flips `draining` (shutdown below), createHttpServer
855
855
  // assigns `inflight` (live leg count on this instance), /health + the submit 503 gate read it.
856
856
  const drainState = { draining: false };
@@ -0,0 +1,118 @@
1
+ import type { Pool as MySqlPool } from "mysql2/promise";
2
+ import type { Pool as PgPool } from "pg";
3
+ import { type SqlDriver } from "./sql-driver.js";
4
+ /** 表名(#192 单数纪律;`schema-naming-invariants` 的闭集词表执法)。 */
5
+ export declare const LEADER_RUN_TABLE = "leader_run";
6
+ /** leader run 的四态 —— 与 `leader/endpoint.ts` 的 `LeaderRun["status"]` 同词表(闭集)。 */
7
+ export type LeaderRunStatus = "running" | "completed" | "failed" | "needs_human";
8
+ /** 库里一行 leader run 的读出形。`result` 是 `LeaderResult` 的 JSON 原样(店不解释它的内部结构)。 */
9
+ export interface LeaderRunRecord {
10
+ id: string;
11
+ status: LeaderRunStatus;
12
+ /** BL-4 属主(null = 匿名/单租)。GET 的 404 门读它。 */
13
+ owner: string | null;
14
+ /** 运维列表面的可读预览(**不是**完整 objective —— 名字即语义,见 DDL 列注)。 */
15
+ objectivePreview: string;
16
+ result?: unknown;
17
+ error?: string;
18
+ startedAtMs: number;
19
+ finishedAtMs?: number;
20
+ }
21
+ /** 准入入参。`idemKey` 缺席(null)= 调用方没要求去重 ⇒ 每次都是新行(现行为)。 */
22
+ export interface NewLeaderRun {
23
+ objective: string;
24
+ owner: string | null;
25
+ /** 已 scope 过的幂等键的 sha256 hex(见 {@link leaderIdemKey});null = 不去重。 */
26
+ idemKey: string | null;
27
+ }
28
+ /** 终局补丁 —— `finishRun` 一次写完(状态 + 结果/错误 + 完成时刻)。 */
29
+ export interface LeaderRunTerminal {
30
+ status: Exclude<LeaderRunStatus, "running">;
31
+ result?: unknown;
32
+ error?: string;
33
+ }
34
+ /** `objective_preview` 的字符上限,与 DDL 的 VARCHAR(160) 同源(`task_run.objective_preview` 先例同宽)。 */
35
+ export declare const LEADER_OBJECTIVE_PREVIEW_CHARS = 160;
36
+ /**
37
+ * 幂等键的**落库形** = `sha256(scope \x1f raw)` 的 hex(64 字符,定宽)。
38
+ *
39
+ * 为什么 hash 而不是像 bake 店那样存 scope 过的明文:明文形的列宽(VARCHAR(255))与「principal(≤190)
40
+ * + 调用方自选的 raw 键(≤255)」的上界对不上 —— 溢出时 PG 报 `value too long`、非严格 MySQL **静默截断**,
41
+ * 而截断一个去重键的后果是**两个不同的请求折叠成同一次 leader run**(= 一次该跑的 run 被静默吞掉)。
42
+ * 定宽 hash 让这条路径按构造不存在;顺带,库里也不再留调用方的原始键字面量。
43
+ * (碰撞面:sha256,不在工程风险量级。)
44
+ */
45
+ export declare function leaderIdemKey(raw: string, owner: string | null): string;
46
+ /** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 SCHEMA_STATEMENTS)。 */
47
+ export declare const TIDB_LEADER_RUN_STATEMENTS: readonly string[];
48
+ /** PG 方言的建表语句(MySQL 孪生的逐条翻译:JSON→JSONB,逐列 `COLLATE "C"`,内联 KEY→独立 CREATE INDEX)。 */
49
+ export declare const PG_LEADER_RUN_SCHEMA: readonly string[];
50
+ /** 幂等的 PG schema 应用(中央 `ensurePgSchema` 组合它;集成套件也直接调)。 */
51
+ export declare function ensurePgLeaderRunSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
52
+ /** 清算腿写进 `error` 列的 WHY(消费方读到的就是这句;不是一个空的 failed)。 */
53
+ export declare const STALE_SWEEP_REASON: string;
54
+ /** 双方言 leader-run 店。方言差异台账见文件头注。 */
55
+ export declare class SqlLeaderRunStore {
56
+ protected readonly db: SqlDriver;
57
+ constructor(db: SqlDriver);
58
+ /** 挑本方言的 SQL 文本。两条语句**都写在调用点**(A12 判据:读者一眼看到两份)。 */
59
+ private q;
60
+ /**
61
+ * 准入一次 leader run(状态 `running`)。提交幂等照 `createBake`/`createRun` 的**原子赢或观察**:
62
+ * `idem_key` 是 UNIQUE,重投的那一方 INSERT 撞 dup-key ⇒ 回读**既有行**返回 `{created:false}`,
63
+ * 调用方据此**不再**起第二条后台腿。`idemKey=null` 恒插入(没要求去重)。
64
+ *
65
+ * 🔴 为什么不是「先 SELECT 再 INSERT」:两个副本的 check-then-act 会双双看到「没有」然后双双插入 ——
66
+ * 而这条路径的第二次插入意味着**第二次真 git push**。UNIQUE 是唯一能在两个进程之间成立的判据。
67
+ */
68
+ createRun(input: NewLeaderRun): Promise<{
69
+ created: boolean;
70
+ run: LeaderRunRecord;
71
+ }>;
72
+ /** 按 id 读一行(跨副本可见性的全部依据)。不存在 ⇒ undefined。 */
73
+ getRun(id: string): Promise<LeaderRunRecord | undefined>;
74
+ /** 按幂等键读回既有行(dup-key 观察臂;也给集成套件直查)。 */
75
+ getByIdem(idemKey: string): Promise<LeaderRunRecord | undefined>;
76
+ /**
77
+ * 陈旧 `running` 清算(codex 对抗复审 R1-high②,验真后修)。
78
+ *
79
+ * 病:准入(INSERT)与驱动(后台腿)是**两步**,而驱动腿只活在受理它的那个进程里。副本在这两步之间
80
+ * 挂掉、或在一条 24h 的 run 中途挂掉,行就永远停在 `running`:跨副本读面永远显示「在跑」,而没有任何
81
+ * 东西在跑;更坏的是那把幂等键被永久绑在这条无人驱动的行上,重投拿回的还是它。
82
+ *
83
+ * 处置 = **有界地说实话**,不是自动接管:把超期的 `running` 迁成 `failed` 并写清 WHY。
84
+ * 🔴 刻意**不做**跨副本接管(claim/lease/queued 状态机):接管一条产物是「真 git push」的 24h run
85
+ * 是一个独立的设计决定(要持久化完整可恢复请求、要 fencing、要想清楚两个副本同时以为自己是属主时
86
+ * 谁去 push),不是一次清算腿能顺手带过的。本腿把「永远 running」这个**静默**面消灭掉,把接管留给
87
+ * 一件具名的后续设计;当前语义写在这里,不藏着。
88
+ *
89
+ * 幂等键的后果同批写明:键**永久绑定**它当初准入的那条 run(与 `/v1/runs` 的 taskId 幂等同语义)。
90
+ * 一条被清算成 `failed` 的 run,用**同一个键**重投拿回的仍是那条 failed —— 调用方要重跑就换一个新键。
91
+ *
92
+ * @param staleMs 超过这个时长仍是 `running` 就算陈旧(部署旋钮 `LEADER_RUN_STALE_MS`)。
93
+ * @param now 注入时钟(测试要确定性)。
94
+ * @returns 本次迁走的行数。
95
+ */
96
+ reapStaleRunning(staleMs: number, now: number): Promise<number>;
97
+ /**
98
+ * TEST-ONLY:把一行的 `started_at_ms` 推到过去,好让 {@link reapStaleRunning} 的判据在一次测试里成立。
99
+ * 生产零调用(`started_at_ms` 只在准入时写一次)——具名 `ForTest` 是为了让这件事在 grep 里一眼可见,
100
+ * 而不是长成一个看起来像正经写口的方法(本仓 `setStatus` 那条欠账的教训)。
101
+ */
102
+ backdateStartedAtForTest(id: string, startedAtMs: number): Promise<void>;
103
+ /**
104
+ * 落终局。**只从 `running` 迁**(`WHERE status = 'running'`):终局是一次性的,一条已经结算过的 run
105
+ * 不该被后来的写者改写。回执 = 是否真的迁了,调用方据此决定要不要出声(静默丢一次终局写 = 用户永远
106
+ * 看到 `running`)。
107
+ */
108
+ finishRun(id: string, patch: LeaderRunTerminal): Promise<boolean>;
109
+ }
110
+ /** MySQL-协议孪生(TiDB / MySQL / MariaDB 同一只 mysql2 池)。 */
111
+ export declare class TiDBLeaderRunStore extends SqlLeaderRunStore {
112
+ constructor(pool: MySqlPool);
113
+ }
114
+ /** PostgreSQL 孪生。 */
115
+ export declare class PgLeaderRunStore extends SqlLeaderRunStore {
116
+ constructor(pool: PgPool);
117
+ }
118
+ //# sourceMappingURL=leader-run-store-sql.d.ts.map
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Durable **leader-run** registry — SINGLE-FILE DUAL-DIALECT (design/158 A12 定型半场;先例 =
3
+ * `image-bake-store-sql.ts`)。#193 车4 件1(台账 P1-9;设计稿 `docs/DESIGN-193-CAR4-leader-failclosed.md`)。
4
+ *
5
+ * ── 为什么这张表必须存在 ────────────────────────────────────────────────────────────────────────────
6
+ * 一次 leader run = 规划成 N 个子任务 → 起 N 个 worker 沙箱 → 真模型编码 → 合并 → **真 git push**,
7
+ * 最长 24h。它此前的登记表是 `leader/endpoint.ts` 里的一只进程内 `Map`,于是三条缺口同源:
8
+ * ① 跨副本 GET 404(提交打到 A、轮询打到 B),违反仓级前提「durable state lives in an external SQL store」;
9
+ * ② POST 零幂等 —— 每次都铸新 uuidv7,一次网络重试 = 第二次真跑 + 可能第二次 push;
10
+ * ③ `fanout.ts` 的 `claim` 面全链路无人传,而它的头注称并发去重「已解决」。
11
+ * 一张带 `idem_key UNIQUE` 的表一次解掉三条:①靠行本身,②靠 UNIQUE 的「原子赢或观察」,
12
+ * ③ 的真属主就是 ② —— 提交幂等挡在**准入**那一刻,比在 worker 层再补一道 CAS 早得多也准得多
13
+ * (worker 的 sessionId 是每次现铸的 `leader-<workerId>-<Date.now()>`,拿它做 CAS 键**永远不会**冲突,
14
+ * 那道 claim 由构造即无效;详见同批对 `fanout.ts` 头注的更正)。
15
+ *
16
+ * ── 这是一张 DEDICATED 表,不是 `task_run` 的复用 ────────────────────────────────────────────────────
17
+ * 与 bake 店同一条理由:`task_run` 与 `task_active`/run reaper/`GET /v1/runs` 列表 FK 式耦合,把 leader run
18
+ * (无 session、无 spend 账、无 checkpoint)混进去 = 让非任务行漏进 reapStale/listRuns 的类别错误。
19
+ *
20
+ * ── 两条保留/清算裁定(分开的两件事,别混)────────────────────────────────────────────────────────
21
+ * **① 行的保留:不建 TTL 腿。** 进程内 Map 时代那条 1h 的 `RUN_RETENTION_MS` 驱逐,目的是**给进程内存
22
+ * 封顶**,不是保留策略。落库后这个理由消失:一行 leader run 对应一次「N 个沙箱 + 数分钟到 24h + 一次真
23
+ * push」的动作,它就是这次动作的审计记录;行的产生速率与它的价值都不支持自动清账。要清账时按
24
+ * workflow_run/roster 的先例加一个 `*_RETENTION_MS` 旋钮 + reaper 腿,那是独立一件。
25
+ *
26
+ * **② 陈旧 `running` 的清算:建了**({@link SqlLeaderRunStore.reapStaleRunning},旋钮 `LEADER_RUN_STALE_MS`,
27
+ * 腿在 `boot/reapers.ts`)。这条与 ① 无关 —— 它不是为了控表大小,而是因为准入与驱动是两步、驱动腿只活在
28
+ * 受理它的那个进程里:副本一死,行就永远停在 `running`,跨副本读面永远显示「在跑」而没有任何东西在跑。
29
+ * 那是**静默**,不是保留问题。(本裁定是 codex 对抗复审 R1-high② 逼出来的:初版把两件事合成一件、一起
30
+ * 判了「不建」,而第二件的论据完全不同。)
31
+ *
32
+ * ── 方言差异台账(显式,永不藏进抽象;A12 判据)────────────────────────────────────────────────────
33
+ * - `?` 占位符 vs `$n` - `CAST(? AS JSON)` vs `$n::jsonb`
34
+ * - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(PG 无表级形;`isolation-key-collation` 门执法)
35
+ * - dup-key 判别走 `sql-errors.ts` 单一属主(`isDupKeyError`),本店只有一根 UNIQUE ⇒ 无需归属判别
36
+ * - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 SCHEMA_STATEMENTS(跟着中央
37
+ * named-lock 的那条 conn 建);PG DDL 由本文件的 `ensurePgLeaderRunSchema` 导出、由 `pg-pool.ts`
38
+ * 的中央 `ensurePgSchema` 组合(与 approval-ask 同姿势)。
39
+ */
40
+ import { uuidv7 } from "@sema-agent/core";
41
+ import { createHash } from "node:crypto";
42
+ import { isDupKeyError } from "./sql-errors.js";
43
+ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
44
+ import { parseJsonOr } from "./sql-row-helpers.js";
45
+ /** 表名(#192 单数纪律;`schema-naming-invariants` 的闭集词表执法)。 */
46
+ export const LEADER_RUN_TABLE = "leader_run";
47
+ const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
48
+ /** `objective_preview` 的字符上限,与 DDL 的 VARCHAR(160) 同源(`task_run.objective_preview` 先例同宽)。 */
49
+ export const LEADER_OBJECTIVE_PREVIEW_CHARS = 160;
50
+ /**
51
+ * 幂等键的**落库形** = `sha256(scope \x1f raw)` 的 hex(64 字符,定宽)。
52
+ *
53
+ * 为什么 hash 而不是像 bake 店那样存 scope 过的明文:明文形的列宽(VARCHAR(255))与「principal(≤190)
54
+ * + 调用方自选的 raw 键(≤255)」的上界对不上 —— 溢出时 PG 报 `value too long`、非严格 MySQL **静默截断**,
55
+ * 而截断一个去重键的后果是**两个不同的请求折叠成同一次 leader run**(= 一次该跑的 run 被静默吞掉)。
56
+ * 定宽 hash 让这条路径按构造不存在;顺带,库里也不再留调用方的原始键字面量。
57
+ * (碰撞面:sha256,不在工程风险量级。)
58
+ */
59
+ export function leaderIdemKey(raw, owner) {
60
+ return createHash("sha256").update(`${owner ?? ""}\x1f${raw}`).digest("hex");
61
+ }
62
+ /** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 SCHEMA_STATEMENTS)。 */
63
+ export const TIDB_LEADER_RUN_STATEMENTS = [
64
+ `CREATE TABLE IF NOT EXISTS ${LEADER_RUN_TABLE} (
65
+ leader_run_id VARCHAR(64) NOT NULL,
66
+ -- idem_key:提交幂等的**唯一**执法点(sha256 hex,定宽 64;见 leaderIdemKey 的头注)。NULL = 调用方
67
+ -- 没带 Idempotency-Key ⇒ 不参与去重(MySQL/PG 的 UNIQUE 都不约束 NULL,两方言同形)。
68
+ idem_key VARCHAR(64) NULL,
69
+ -- owner:BL-4 提交者(null = 匿名/单租)。GET 的属主门读它 —— 非属主 404(不是 403),不给存在性预言机。
70
+ owner VARCHAR(190) NULL,
71
+ -- objective_preview:**预览**不是全文(task_run 同名列先例同宽)。完整 objective 属于调用方的请求体,
72
+ -- 这里只为运维看一眼「这行是哪次 run」;写入侧按 LEADER_OBJECTIVE_PREVIEW_CHARS 截断,名字已说明这件事。
73
+ objective_preview VARCHAR(160) NULL,
74
+ status VARCHAR(16) NOT NULL,
75
+ -- result:LeaderResult 的 JSON 原样(店不解释内部结构)。仅终局时写。
76
+ result JSON NULL,
77
+ error TEXT NULL,
78
+ started_at_ms BIGINT NOT NULL,
79
+ finished_at_ms BIGINT NULL,
80
+ PRIMARY KEY (leader_run_id),
81
+ UNIQUE KEY uq_leader_run_idem (idem_key),
82
+ KEY idx_leader_run_owner (owner),
83
+ KEY idx_leader_run_started (started_at_ms)
84
+ ) COLLATE utf8mb4_bin`,
85
+ ];
86
+ /** PG 方言的建表语句(MySQL 孪生的逐条翻译:JSON→JSONB,逐列 `COLLATE "C"`,内联 KEY→独立 CREATE INDEX)。 */
87
+ export const PG_LEADER_RUN_SCHEMA = [
88
+ `CREATE TABLE IF NOT EXISTS ${LEADER_RUN_TABLE} (
89
+ leader_run_id VARCHAR(64) COLLATE "C" NOT NULL,
90
+ idem_key VARCHAR(64) COLLATE "C" NULL,
91
+ owner VARCHAR(190) COLLATE "C" NULL,
92
+ objective_preview VARCHAR(160) COLLATE "C" NULL,
93
+ status VARCHAR(16) COLLATE "C" NOT NULL,
94
+ result JSONB NULL,
95
+ error TEXT COLLATE "C" NULL,
96
+ started_at_ms BIGINT NOT NULL,
97
+ finished_at_ms BIGINT NULL,
98
+ PRIMARY KEY (leader_run_id),
99
+ CONSTRAINT uq_leader_run_idem UNIQUE (idem_key)
100
+ )`,
101
+ `CREATE INDEX IF NOT EXISTS idx_leader_run_owner ON ${LEADER_RUN_TABLE} (owner)`,
102
+ `CREATE INDEX IF NOT EXISTS idx_leader_run_started ON ${LEADER_RUN_TABLE} (started_at_ms)`,
103
+ ];
104
+ /** 幂等的 PG schema 应用(中央 `ensurePgSchema` 组合它;集成套件也直接调)。 */
105
+ export async function ensurePgLeaderRunSchema(q) {
106
+ for (const stmt of PG_LEADER_RUN_SCHEMA)
107
+ await q(stmt);
108
+ }
109
+ /** 清算腿写进 `error` 列的 WHY(消费方读到的就是这句;不是一个空的 failed)。 */
110
+ export const STALE_SWEEP_REASON = "leader run went stale: no replica settled it within LEADER_RUN_STALE_MS (the replica driving it most likely died). " +
111
+ "The run was NOT taken over — re-submit with a NEW Idempotency-Key to run it again.";
112
+ const SELECT_COLS = "leader_run_id, owner, objective_preview, status, result, error, started_at_ms, finished_at_ms";
113
+ /** 未知 status 字面量 = 库里躺着一条本进程不认识的行 ⇒ **响亮拒**,不静默折成 failed(闭集词表纪律 #157)。 */
114
+ function statusOf(raw, id) {
115
+ const s = String(raw);
116
+ if (!LEADER_RUN_STATUSES.has(s)) {
117
+ throw new Error(`leader_run ${id}: unknown status ${JSON.stringify(s)} (closed word table: ${[...LEADER_RUN_STATUSES].join("|")}) — a newer replica wrote a word this build cannot interpret`);
118
+ }
119
+ return s;
120
+ }
121
+ function mapRow(r) {
122
+ const id = String(r.leader_run_id);
123
+ const result = r.result == null ? undefined : parseJsonOr(r.result, undefined);
124
+ const error = r.error == null ? undefined : String(r.error);
125
+ const finished = r.finished_at_ms == null ? undefined : Number(r.finished_at_ms);
126
+ return {
127
+ id,
128
+ status: statusOf(r.status, id),
129
+ owner: r.owner == null ? null : String(r.owner),
130
+ objectivePreview: r.objective_preview == null ? "" : String(r.objective_preview),
131
+ ...(result === undefined ? {} : { result }),
132
+ ...(error === undefined ? {} : { error }),
133
+ startedAtMs: Number(r.started_at_ms),
134
+ ...(finished === undefined ? {} : { finishedAtMs: finished }),
135
+ };
136
+ }
137
+ /** 双方言 leader-run 店。方言差异台账见文件头注。 */
138
+ export class SqlLeaderRunStore {
139
+ db;
140
+ constructor(db) {
141
+ this.db = db;
142
+ }
143
+ /** 挑本方言的 SQL 文本。两条语句**都写在调用点**(A12 判据:读者一眼看到两份)。 */
144
+ q(tidb, pg) {
145
+ return this.db.dialect === "tidb" ? tidb : pg;
146
+ }
147
+ /**
148
+ * 准入一次 leader run(状态 `running`)。提交幂等照 `createBake`/`createRun` 的**原子赢或观察**:
149
+ * `idem_key` 是 UNIQUE,重投的那一方 INSERT 撞 dup-key ⇒ 回读**既有行**返回 `{created:false}`,
150
+ * 调用方据此**不再**起第二条后台腿。`idemKey=null` 恒插入(没要求去重)。
151
+ *
152
+ * 🔴 为什么不是「先 SELECT 再 INSERT」:两个副本的 check-then-act 会双双看到「没有」然后双双插入 ——
153
+ * 而这条路径的第二次插入意味着**第二次真 git push**。UNIQUE 是唯一能在两个进程之间成立的判据。
154
+ */
155
+ async createRun(input) {
156
+ const id = uuidv7();
157
+ const now = Date.now();
158
+ const preview = input.objective.slice(0, LEADER_OBJECTIVE_PREVIEW_CHARS);
159
+ const params = [id, input.idemKey, input.owner, preview, "running", now];
160
+ try {
161
+ await this.db.query(this.q(`INSERT INTO ${LEADER_RUN_TABLE} (leader_run_id, idem_key, owner, objective_preview, status, started_at_ms) VALUES (?,?,?,?,?,?)`, `INSERT INTO ${LEADER_RUN_TABLE} (leader_run_id, idem_key, owner, objective_preview, status, started_at_ms) VALUES ($1,$2,$3,$4,$5,$6)`), params);
162
+ }
163
+ catch (err) {
164
+ if (isDupKeyError(this.db.dialect, err) && input.idemKey) {
165
+ const existing = await this.getByIdem(input.idemKey);
166
+ // 回读为空 = 赢家的行在这两步之间消失了(本店无删除口 ⇒ 只可能是外部 DELETE)。原样上抛而不是
167
+ // 悄悄再插一次:后者会在「运维正在手工清行」的窗口里放出第二次真 push。
168
+ if (existing)
169
+ return { created: false, run: existing };
170
+ }
171
+ throw err;
172
+ }
173
+ const run = await this.getRun(id);
174
+ if (!run)
175
+ throw new Error(`createRun: leader_run ${id} vanished right after insert`);
176
+ return { created: true, run };
177
+ }
178
+ /** 按 id 读一行(跨副本可见性的全部依据)。不存在 ⇒ undefined。 */
179
+ async getRun(id) {
180
+ const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
181
+ const row = res.rows[0];
182
+ return row ? mapRow(row) : undefined;
183
+ }
184
+ /** 按幂等键读回既有行(dup-key 观察臂;也给集成套件直查)。 */
185
+ async getByIdem(idemKey) {
186
+ const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
187
+ const row = res.rows[0];
188
+ return row ? mapRow(row) : undefined;
189
+ }
190
+ /**
191
+ * 陈旧 `running` 清算(codex 对抗复审 R1-high②,验真后修)。
192
+ *
193
+ * 病:准入(INSERT)与驱动(后台腿)是**两步**,而驱动腿只活在受理它的那个进程里。副本在这两步之间
194
+ * 挂掉、或在一条 24h 的 run 中途挂掉,行就永远停在 `running`:跨副本读面永远显示「在跑」,而没有任何
195
+ * 东西在跑;更坏的是那把幂等键被永久绑在这条无人驱动的行上,重投拿回的还是它。
196
+ *
197
+ * 处置 = **有界地说实话**,不是自动接管:把超期的 `running` 迁成 `failed` 并写清 WHY。
198
+ * 🔴 刻意**不做**跨副本接管(claim/lease/queued 状态机):接管一条产物是「真 git push」的 24h run
199
+ * 是一个独立的设计决定(要持久化完整可恢复请求、要 fencing、要想清楚两个副本同时以为自己是属主时
200
+ * 谁去 push),不是一次清算腿能顺手带过的。本腿把「永远 running」这个**静默**面消灭掉,把接管留给
201
+ * 一件具名的后续设计;当前语义写在这里,不藏着。
202
+ *
203
+ * 幂等键的后果同批写明:键**永久绑定**它当初准入的那条 run(与 `/v1/runs` 的 taskId 幂等同语义)。
204
+ * 一条被清算成 `failed` 的 run,用**同一个键**重投拿回的仍是那条 failed —— 调用方要重跑就换一个新键。
205
+ *
206
+ * @param staleMs 超过这个时长仍是 `running` 就算陈旧(部署旋钮 `LEADER_RUN_STALE_MS`)。
207
+ * @param now 注入时钟(测试要确定性)。
208
+ * @returns 本次迁走的行数。
209
+ */
210
+ async reapStaleRunning(staleMs, now) {
211
+ const cutoff = now - staleMs;
212
+ const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = 'failed', error = ?, finished_at_ms = ? WHERE status = 'running' AND started_at_ms < ?`, `UPDATE ${LEADER_RUN_TABLE} SET status = 'failed', error = $1, finished_at_ms = $2 WHERE status = 'running' AND started_at_ms < $3`), [STALE_SWEEP_REASON, now, cutoff]);
213
+ return res.affected;
214
+ }
215
+ /**
216
+ * TEST-ONLY:把一行的 `started_at_ms` 推到过去,好让 {@link reapStaleRunning} 的判据在一次测试里成立。
217
+ * 生产零调用(`started_at_ms` 只在准入时写一次)——具名 `ForTest` 是为了让这件事在 grep 里一眼可见,
218
+ * 而不是长成一个看起来像正经写口的方法(本仓 `setStatus` 那条欠账的教训)。
219
+ */
220
+ async backdateStartedAtForTest(id, startedAtMs) {
221
+ await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET started_at_ms = ? WHERE leader_run_id = ?`, `UPDATE ${LEADER_RUN_TABLE} SET started_at_ms = $1 WHERE leader_run_id = $2`), [startedAtMs, id]);
222
+ }
223
+ /**
224
+ * 落终局。**只从 `running` 迁**(`WHERE status = 'running'`):终局是一次性的,一条已经结算过的 run
225
+ * 不该被后来的写者改写。回执 = 是否真的迁了,调用方据此决定要不要出声(静默丢一次终局写 = 用户永远
226
+ * 看到 `running`)。
227
+ */
228
+ async finishRun(id, patch) {
229
+ const json = dialectJsonEncoder(this.db.dialect);
230
+ const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result === undefined ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
231
+ return res.affected > 0;
232
+ }
233
+ }
234
+ /** MySQL-协议孪生(TiDB / MySQL / MariaDB 同一只 mysql2 池)。 */
235
+ export class TiDBLeaderRunStore extends SqlLeaderRunStore {
236
+ constructor(pool) {
237
+ super(mysqlDriver(pool));
238
+ }
239
+ }
240
+ /** PostgreSQL 孪生。 */
241
+ export class PgLeaderRunStore extends SqlLeaderRunStore {
242
+ constructor(pool) {
243
+ super(pgDriver(pool));
244
+ }
245
+ }
246
+ //# sourceMappingURL=leader-run-store-sql.js.map
@@ -38,6 +38,7 @@ import { ensurePgImageBakeSchema as ensureImageBakeSchema } from "./image-bake-s
38
38
  import { ensurePgApprovalAskSchema } from "./approval-ask-store-sql.js";
39
39
  import { ensurePgAdoptionLogSchema } from "./adoption-log-sql.js";
40
40
  import { ensurePgPermissionRuleSchema } from "./permission-rule-store-sql.js";
41
+ import { ensurePgLeaderRunSchema } from "./leader-run-store-sql.js";
41
42
  export const PG_SCHEMA_STATEMENTS = [
42
43
  `CREATE TABLE IF NOT EXISTS task_run (
43
44
  task_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -394,6 +395,8 @@ export async function ensurePgSchema(pool) {
394
395
  // #154 车二:持久化权限规则店三张表(MySQL twin 走 tidb-pool 的 SCHEMA_STATEMENTS 展开)。
395
396
  // 同上一行的绑法 —— DDL 跑在**同一条** client 上,advisory lock 的 session 语义不受影响。
396
397
  await ensurePgPermissionRuleSchema((text, params) => client.query(text, params));
398
+ // #193 车4 件1:durable leader-run 登记表(MySQL twin 走 tidb-pool 的 SCHEMA_STATEMENTS 展开)。同上绑法。
399
+ await ensurePgLeaderRunSchema((text, params) => client.query(text, params));
397
400
  }
398
401
  finally {
399
402
  if (locked) {
@@ -16,6 +16,7 @@ import { TiDBRunStore, PgRunStore } from "./run-store-sql.js";
16
16
  import { TiDBCheckpointStore, PgCheckpointStore } from "./checkpoint-store-sql.js";
17
17
  import { TiDBImageIndex, PgImageIndex } from "./image-index-sql.js";
18
18
  import { TiDBImageBake, PgImageBake } from "./image-bake-store-sql.js";
19
+ import { TiDBLeaderRunStore, PgLeaderRunStore } from "./leader-run-store-sql.js";
19
20
  import { TiDBBreakerState, PgBreakerState } from "./breaker-state-sql.js";
20
21
  import { TiDBCostQuota } from "./tidb-cost-quota.js";
21
22
  import { PgCostQuota } from "./pg-cost-quota.js";
@@ -80,6 +81,8 @@ export type ServiceWorkflowJournalStore = WorkflowJournalStore & {
80
81
  };
81
82
  export type ImageIndex = TiDBImageIndex | PgImageIndex;
82
83
  export type ImageBake = TiDBImageBake | PgImageBake;
84
+ /** #193 车4 件1:durable leader-run 登记表的双方言孪生(union 同族——TS 私有字段让具体类名义上不同)。 */
85
+ export type LeaderRunStore = TiDBLeaderRunStore | PgLeaderRunStore;
83
86
  /** The FULL checkpoint store (core CheckpointStore + the service operator-queue/ctx methods listPending/
84
87
  * listByScope/findPendingTokenBySession/peekPendingScope/putCtx/getCtx/reapCtx) — the SQL twins carry them,
85
88
  * and the LOCAL lane now does too (core FileCheckpointStore + the service half — TOC plan-mode /
@@ -195,6 +198,15 @@ export interface StoreBackend {
195
198
  toolResult?(): ToolResultStoreFull;
196
199
  imageIndex?(): ImageIndex;
197
200
  imageBake?(): ImageBake;
201
+ /**
202
+ * #193 车4 件1(台账 P1-9):durable leader-run 登记表。**SQL 后端专有**,`local` 刻意省略。
203
+ *
204
+ * 🔴 local 缺席不是欠账、也不是「以后补个 File 形」:leader run 是 N worker fan-out + 真 git push,
205
+ * 而 config 层已经把「LEADER_ENABLED=true 且无外部 SQL 店」整个组合**拒启**了(件2 门②)——
206
+ * 所以 local 车道上根本不存在一个需要这张表的 leader 端点。消费点按 `backend?.leaderRun?.()` 取,
207
+ * 缺席 ⇒ endpoint 落到显式的单副本内存降级臂(见 `leader/endpoint.ts` 的 store 缺席注)。
208
+ */
209
+ leaderRun?(): LeaderRunStore;
198
210
  breaker?(onWriteFail?: (streak: number) => void): BreakerStateStore;
199
211
  costQuota?(limitMicroUsd: number, windowMs: number, onDegraded?: CounterDegradeHook): CostQuotaStore;
200
212
  rateLimiter?(limit: number, onDegraded?: CounterDegradeHook): RateLimiterStore;