@sema-agent/server 7.8.1 → 7.10.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 (48) hide show
  1. package/USAGE.md +6 -0
  2. package/dist/approval-reconciler.d.ts +1 -1
  3. package/dist/approval-reconciler.js +13 -5
  4. package/dist/boot/execution-env.js +6 -0
  5. package/dist/boot/reapers.js +3 -3
  6. package/dist/capabilities/repo-tools.d.ts +36 -2
  7. package/dist/capabilities/repo-tools.js +125 -11
  8. package/dist/capabilities/scenarios.d.ts +2 -2
  9. package/dist/capabilities/scenarios.js +11 -2
  10. package/dist/config-types.d.ts +15 -6
  11. package/dist/config.js +43 -2
  12. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  13. package/dist/fleet/fleet-terminal-window.js +1 -1
  14. package/dist/git-api-kind.d.ts +7 -0
  15. package/dist/git-api-kind.js +8 -0
  16. package/dist/hooks/hook-runner.js +41 -26
  17. package/dist/http/routes/approvals-assistant.d.ts +7 -0
  18. package/dist/http/routes/approvals-assistant.js +9 -2
  19. package/dist/http/routes/side-query.d.ts +4 -0
  20. package/dist/http/routes/side-query.js +85 -5
  21. package/dist/http/routes/workflows.js +16 -1
  22. package/dist/http/server.js +8 -8
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/main.js +2 -2
  26. package/dist/memory-scope.d.ts +16 -0
  27. package/dist/memory-scope.js +42 -2
  28. package/dist/plugins/checkpoint-store-sql.d.ts +9 -9
  29. package/dist/plugins/checkpoint-store-sql.js +32 -31
  30. package/dist/plugins/local-checkpoint-store.d.ts +1 -1
  31. package/dist/plugins/local-checkpoint-store.js +5 -4
  32. package/dist/plugins/memory-engine-pg.js +13 -3
  33. package/dist/plugins/memory-engine-tidb.js +11 -0
  34. package/dist/plugins/memory-key-guards.d.ts +8 -0
  35. package/dist/plugins/memory-key-guards.js +13 -0
  36. package/dist/plugins/pg-pool.js +7 -7
  37. package/dist/plugins/remote-env-k8s.js +15 -4
  38. package/dist/plugins/run-store-sql.d.ts +3 -3
  39. package/dist/plugins/run-store-sql.js +3 -3
  40. package/dist/plugins/tidb-pool.js +8 -8
  41. package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
  42. package/dist/plugins/workflow-journal-store-sql.js +6 -6
  43. package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
  44. package/dist/plugins/workflow-run-store-sql.js +12 -12
  45. package/dist/run-local.js +18 -5
  46. package/dist/tool-approval.d.ts +9 -0
  47. package/dist/tool-approval.js +10 -0
  48. package/package.json +2 -2
@@ -27,6 +27,7 @@
27
27
  * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
28
28
  */
29
29
  import { createHash } from "node:crypto";
30
+ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js"; // A-002.1 单一属主(SQL IN 片段从闭集数组派生)
30
31
  import { CheckpointError, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
31
32
  import { redactDeep } from "../trace/redact.js";
32
33
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
@@ -36,7 +37,7 @@ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.
36
37
  const MAX_TOOL_INPUT_CHARS = 8192;
37
38
  /**
38
39
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
39
- * checkpoint's lifetime, stamped at put() into `terminal_at` INDEPENDENT of the per-approval `deadline`. The
40
+ * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
40
41
  * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
41
42
  * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
42
43
  * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
@@ -45,15 +46,15 @@ const MAX_TOOL_INPUT_CHARS = 8192;
45
46
  */
46
47
  export const TERMINAL_BACKSTOP_MS = Math.max(60_000, Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) || 30 * 86_400_000);
47
48
  /**
48
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at` backstop must fall STRICTLY AFTER any SLA
49
- * `deadline`, never AT it. `terminal_at = max(createdAt+backstop, deadline)` made the two coincide whenever an
50
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at-branch (which
49
+ * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
50
+ * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
51
+ * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
51
52
  * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
52
53
  * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
53
54
  * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
54
55
  * it never meaningfully delays the eventual crash-safe GC.
55
56
  */
56
- export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
57
+ export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
57
58
  /**
58
59
  * The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
59
60
  * resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
@@ -302,8 +303,8 @@ export class SqlCheckpointStore {
302
303
  // payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
303
304
  const toolInput = boundedToolInput(pa?.args);
304
305
  try {
305
- await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
306
- "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
306
+ await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
307
+ "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
307
308
  "VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)"), [
308
309
  token,
309
310
  cp.scope,
@@ -315,7 +316,7 @@ export class SqlCheckpointStore {
315
316
  this.json(cp, "checkpoint"),
316
317
  cp.deadline ?? null,
317
318
  cp.createdAt,
318
- Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
319
+ Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at_ms-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
319
320
  cp.gate?.kind ?? null, // D-D SLA split: human/irreversible_ask deadline → resolve-deny; others → expire
320
321
  pa?.boundInputHash ?? null, // D-1: the opaque hash the portal must echo on /decide (surfaced via listPending so the TOCTOU binding is reachable)
321
322
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate), // riskDescriptor inbox: stamp core's INERT descriptor for triage-sort
@@ -412,8 +413,8 @@ export class SqlCheckpointStore {
412
413
  const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
413
414
  if (expect)
414
415
  params.push(expect.rev);
415
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
416
- (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
416
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at_ms = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
417
+ (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at_ms = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
417
418
  (expect ? " AND rev = $5" : "")), params);
418
419
  return res.affected === 1;
419
420
  }
@@ -507,19 +508,19 @@ export class SqlCheckpointStore {
507
508
  * Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
508
509
  */
509
510
  async expire(token, scope) {
510
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
511
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
511
512
  return res.affected === 1;
512
513
  }
513
514
  /** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
514
515
  async reap(scope, cutoff) {
515
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
516
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
516
517
  return res.affected;
517
518
  }
518
519
  /**
519
520
  * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
520
521
  * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
521
- * Called with `cutoff = Date.now()` (deadline/terminal_at are ABSOLUTE epoch-ms), so it expires any pending row
522
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at` crash-safe backstop has passed —
522
+ * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
523
+ * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
523
524
  * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
524
525
  *
525
526
  * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
@@ -528,14 +529,14 @@ export class SqlCheckpointStore {
528
529
  * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
529
530
  * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
530
531
  * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
531
- * stay on the expire path. The terminal_at-branch is the crash-safe backstop for ANY kind (incl. a human gate
532
+ * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
532
533
  * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
533
534
  * the deadline, so it never races the deny-sweep at the deadline instant).
534
535
  */
535
536
  async reapExpired(cutoff) {
536
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? " +
537
- "WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ('human','irreversible_ask') OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= ?))", "UPDATE checkpoint SET status = 'expired', decided_at = $1 " +
538
- "WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ('human','irreversible_ask') OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= $3))"), [Date.now(), cutoff, cutoff]);
537
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
538
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
539
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
539
540
  return res.affected;
540
541
  }
541
542
  /**
@@ -549,8 +550,8 @@ export class SqlCheckpointStore {
549
550
  * reapExpired's abort-expire path instead of this graceful-deny path.
550
551
  */
551
552
  async listExpiredApprovalGates(cutoff, limit = 100) {
552
- const { rows } = await this.db.query(this.q("SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ('human','irreversible_ask') " +
553
- "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", "SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ('human','irreversible_ask') " +
553
+ const { rows } = await this.db.query(this.q(`SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
554
+ "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", `SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
554
555
  "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= $1 ORDER BY deadline ASC LIMIT $2"), [cutoff, limit]);
555
556
  return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
556
557
  }
@@ -559,11 +560,11 @@ export class SqlCheckpointStore {
559
560
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
560
561
  * session claim, so the join is live for every pending row; null only in pathological windows). */
561
562
  async listPending(scope) {
562
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at, c.deadline, c.gate_kind, ta.task_id " +
563
+ const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
563
564
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
564
565
  const { rows } = scope
565
- ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at ASC`, [scope])
566
- : await this.db.query(`${base} ORDER BY c.created_at ASC`);
566
+ ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
567
+ : await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
567
568
  const out = rows.map((r) => {
568
569
  const toolCallId = r.tool_call_id ?? null;
569
570
  const boundInputHash = r.bound_input_hash ?? null;
@@ -586,7 +587,7 @@ export class SqlCheckpointStore {
586
587
  // Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
587
588
  // (拒绝式)——直读即审阅面=执行面。
588
589
  input: r.tool_input ?? null,
589
- createdAt: Number(r.created_at),
590
+ createdAt: Number(r.created_at_ms),
590
591
  deadline: r.deadline == null ? null : Number(r.deadline),
591
592
  riskDescriptor: parseJson(r.risk_descriptor),
592
593
  };
@@ -601,14 +602,14 @@ export class SqlCheckpointStore {
601
602
  * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
602
603
  * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
603
604
  * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
604
- * created_at ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
605
+ * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
605
606
  * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
606
607
  *
607
608
  * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
608
609
  * ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
609
610
  */
610
611
  async listByScope(scope) {
611
- const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at ASC LIMIT 500"), [scope]);
612
+ const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
612
613
  const out = [];
613
614
  for (const r of rows) {
614
615
  // A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
@@ -637,7 +638,7 @@ export class SqlCheckpointStore {
637
638
  *
638
639
  * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
639
640
  * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
640
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
641
+ * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
641
642
  * 一次全量返回,结构上没有分页假阴性。
642
643
  *
643
644
  * 三条口径,逐条都是判据:
@@ -660,9 +661,9 @@ export class SqlCheckpointStore {
660
661
  * 维,列命中行结构上也必须解 blob 才拿得到它。
661
662
  */
662
663
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
663
- const { rows } = await this.db.query(this.q("SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
664
- "WHERE scope=? AND session_id=? AND created_at>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at ASC", "SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
665
- "WHERE scope=$1 AND session_id=$2 AND created_at>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at ASC"), [scope, sessionId, sinceMs, toolCallId]);
664
+ const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
665
+ "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
666
+ "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
666
667
  const out = [];
667
668
  for (const r of rows) {
668
669
  // `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
@@ -670,7 +671,7 @@ export class SqlCheckpointStore {
670
671
  const base = {
671
672
  token: String(r.token),
672
673
  status: String(r.status),
673
- createdAtMs: Number(r.created_at),
674
+ createdAtMs: Number(r.created_at_ms),
674
675
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
675
676
  sourceTaskId: null,
676
677
  };
@@ -84,7 +84,7 @@ export declare class LocalCheckpointStore {
84
84
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
85
85
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
86
86
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
87
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
87
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
88
88
  */
89
89
  reapExpired(cutoff: number): Promise<number>;
90
90
  /** design/80 D-D SLA deny-sweep input — pending human/irreversible_ask gates past deadline (excl. AskUserQuestion). */
@@ -23,9 +23,10 @@
23
23
  import { createHash } from "node:crypto";
24
24
  import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
+ import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
26
27
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
27
28
  import { boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
28
- /** design/80 D-D read-time twin of the TiDB put-time `terminal_at` column (same formula — anti-drift). */
29
+ /** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
29
30
  function terminalAtOf(cp) {
30
31
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
31
32
  }
@@ -285,7 +286,7 @@ export class LocalCheckpointStore {
285
286
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
286
287
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
287
288
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
288
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
289
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
289
290
  */
290
291
  async reapExpired(cutoff) {
291
292
  let n = 0;
@@ -294,7 +295,7 @@ export class LocalCheckpointStore {
294
295
  const toolName = cp.pendingAction?.toolName ?? "";
295
296
  const deadlineBranch = cp.deadline !== undefined &&
296
297
  cp.deadline <= cutoff &&
297
- (gateKind === undefined || !["human", "irreversible_ask"].includes(gateKind) || toolName === "AskUserQuestion");
298
+ (gateKind === undefined || !isApprovalGateKind(gateKind) || toolName === "AskUserQuestion");
298
299
  const terminalBranch = terminalAtOf(cp) <= cutoff;
299
300
  if ((deadlineBranch || terminalBranch) && (await this.inner.expire(token, cp.scope)))
300
301
  n++;
@@ -308,7 +309,7 @@ export class LocalCheckpointStore {
308
309
  const gateKind = cp.gate?.kind;
309
310
  const toolName = cp.pendingAction?.toolName ?? "";
310
311
  if (gateKind !== undefined &&
311
- ["human", "irreversible_ask"].includes(gateKind) &&
312
+ isApprovalGateKind(gateKind) &&
312
313
  toolName !== "AskUserQuestion" &&
313
314
  cp.deadline !== undefined &&
314
315
  cp.deadline <= cutoff)
@@ -23,6 +23,7 @@ import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
23
23
  import { isUniqueViolation } from "./memory-engine-vector-util.js";
24
24
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
25
25
  import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
26
+ import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
26
27
  /** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
27
28
  * the retired MemoryStore plane and this entry plane must never cross-write. */
28
29
  export const PG_MEMORY_ENGINE_TABLES = {
@@ -43,8 +44,8 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
43
44
  const embeddingCol = vec?.pgvector ? `vector(${vec.dimensions})` : "jsonb";
44
45
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.entry} (
45
46
  id text COLLATE "C" PRIMARY KEY,
46
- scope text COLLATE "C" NOT NULL,
47
- slug text COLLATE "C" NOT NULL,
47
+ scope varchar(190) COLLATE "C" NOT NULL,
48
+ slug varchar(512) COLLATE "C" NOT NULL,
48
49
  frontmatter jsonb NOT NULL,
49
50
  body text COLLATE "C" NOT NULL,
50
51
  rev text COLLATE "C" NOT NULL,
@@ -65,7 +66,7 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
65
66
  // suffix. NOT wrapped in try/catch — if this constraint cannot be created, the backend is unsafe.
66
67
  await query(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope, slug)`);
67
68
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.cursor} (
68
- scope text COLLATE "C" PRIMARY KEY,
69
+ scope varchar(190) COLLATE "C" PRIMARY KEY,
69
70
  cursor text COLLATE "C" NOT NULL
70
71
  )`);
71
72
  }
@@ -278,6 +279,15 @@ export class PgMemoryEngineBackend {
278
279
  report.conflicts.push({ op: "add", id: rawEntry.id, reason: "unstorable_bytes (PG cannot store NUL/lone surrogates; strip them at the source — the store never rewrites content)" });
279
280
  return;
280
281
  }
282
+ // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形;tidb 版同注)。守卫常量与 DDL 同源门
283
+ // 在 test/key-width-guards.test.ts。
284
+ try {
285
+ assertSlugWidth(rawEntry.slug);
286
+ }
287
+ catch (e) {
288
+ report.conflicts.push({ op: "add", id: rawEntry.id, reason: e instanceof Error ? e.message : String(e) });
289
+ return;
290
+ }
281
291
  const entry = rawEntry;
282
292
  // opus 审 C3: an add whose id already lives in a DIFFERENT scope is refused explicitly — the bare
283
293
  // `ON CONFLICT (id) DO UPDATE SET scope=…` would silently MOVE the row across scopes (and diverge
@@ -24,6 +24,7 @@
24
24
  import { jaccardDistance, termSet } from "@sema-agent/core";
25
25
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
26
26
  import { pgHasUnstorable } from "./pg-safe-json.js";
27
+ import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
27
28
  /** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
28
29
  * one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
29
30
  export const TIDB_MEMORY_ENGINE_TABLES = {
@@ -234,6 +235,16 @@ export class TiDBMemoryEngineBackend {
234
235
  report.conflicts.push({ op: "add", id: entry.id, reason: "unstorable_bytes (utf8mb4 cannot store NUL/lone surrogates without mangling; strip them at the source — the store never rewrites content)" });
235
236
  return;
236
237
  }
238
+ // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形)——非严格 MySQL 会静默截断,
239
+ // 截断=两个不同 slug 折叠成一行互串;PG/严格 MySQL 落裸错不可分类。守卫常量与 DDL 同源门在
240
+ // test/key-width-guards.test.ts。
241
+ try {
242
+ assertSlugWidth(entry.slug);
243
+ }
244
+ catch (e) {
245
+ report.conflicts.push({ op: "add", id: entry.id, reason: e instanceof Error ? e.message : String(e) });
246
+ return;
247
+ }
237
248
  // Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
238
249
  // scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
239
250
  // One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
@@ -0,0 +1,8 @@
1
+ /** R5(车A [3191] 欠账,批γ 落地):memory-engine 键宽写前守卫。
2
+ * 列宽收窄后,模型可控的条目名(slug)超宽此前落裸 SQL 错(PG `value too long`)或非严格 MySQL
3
+ * 静默截断(截断=两个不同 slug 折叠成一行=条目互串,最危险形)。写前响亮拒,错误可分类。
4
+ * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表)。 */
5
+ /** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
6
+ export declare const MEMORY_SLUG_COLUMN_CHARS = 512;
7
+ export declare function assertSlugWidth(slug: string): void;
8
+ //# sourceMappingURL=memory-key-guards.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** R5(车A [3191] 欠账,批γ 落地):memory-engine 键宽写前守卫。
2
+ * 列宽收窄后,模型可控的条目名(slug)超宽此前落裸 SQL 错(PG `value too long`)或非严格 MySQL
3
+ * 静默截断(截断=两个不同 slug 折叠成一行=条目互串,最危险形)。写前响亮拒,错误可分类。
4
+ * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表)。 */
5
+ /** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
6
+ export const MEMORY_SLUG_COLUMN_CHARS = 512;
7
+ export function assertSlugWidth(slug) {
8
+ if (slug.length > MEMORY_SLUG_COLUMN_CHARS) {
9
+ throw new Error(`memory entry slug exceeds ${MEMORY_SLUG_COLUMN_CHARS} characters (the slug column width both SQL dialects pin); ` +
10
+ `got ${slug.length} — shorten the entry name`);
11
+ }
12
+ }
13
+ //# sourceMappingURL=memory-key-guards.js.map
@@ -96,11 +96,11 @@ export const PG_SCHEMA_STATEMENTS = [
96
96
  checkpoint JSONB NOT NULL,
97
97
  outcome JSONB,
98
98
  deadline BIGINT,
99
- created_at BIGINT NOT NULL,
100
- decided_at BIGINT,
99
+ created_at_ms BIGINT NOT NULL,
100
+ decided_at_ms BIGINT,
101
101
  rev BIGINT NOT NULL DEFAULT 0,
102
102
  reopen_reason VARCHAR(32) COLLATE "C",
103
- terminal_at BIGINT,
103
+ terminal_at_ms BIGINT,
104
104
  gate_kind VARCHAR(32) COLLATE "C",
105
105
  bound_input_hash VARCHAR(190) COLLATE "C",
106
106
  -- pending_steer / pending_steer_queue / pending_steer_rev:队列化后的 durable steering(core 5.14.0
@@ -222,7 +222,7 @@ export const PG_SCHEMA_STATEMENTS = [
222
222
  source_run_id VARCHAR(191) COLLATE "C" NOT NULL,
223
223
  scope VARCHAR(190) COLLATE "C" NOT NULL,
224
224
  new_run_id VARCHAR(191) COLLATE "C" NOT NULL,
225
- claimed_at BIGINT NOT NULL,
225
+ claimed_at_ms BIGINT NOT NULL,
226
226
  PRIMARY KEY (source_run_id, scope)
227
227
  )`,
228
228
  // P1 (fleet failover): WorkflowRunStore + completion-inbox PG twins (design doc in workflow-run-store-sql.ts).
@@ -234,7 +234,7 @@ export const PG_SCHEMA_STATEMENTS = [
234
234
  run TEXT COLLATE "C" NOT NULL,
235
235
  rev INTEGER NOT NULL DEFAULT 0,
236
236
  created_at_ms BIGINT NOT NULL,
237
- ended_at BIGINT,
237
+ ended_at_ms BIGINT,
238
238
  PRIMARY KEY (id)
239
239
  )`,
240
240
  `CREATE INDEX IF NOT EXISTS idx_wfrun_scope_created ON workflow_run (scope, created_at_ms)`,
@@ -279,8 +279,8 @@ export const PG_SCHEMA_STATEMENTS = [
279
279
  acked SMALLINT NOT NULL DEFAULT 0,
280
280
  source_task_id VARCHAR(191) COLLATE "C",
281
281
  principal VARCHAR(190) COLLATE "C",
282
- created_at BIGINT NOT NULL,
283
- acked_at BIGINT,
282
+ created_at_ms BIGINT NOT NULL,
283
+ acked_at_ms BIGINT,
284
284
  PRIMARY KEY (run_id)
285
285
  )`,
286
286
  `CREATE INDEX IF NOT EXISTS idx_wfnotify_acked ON workflow_notify_journal (acked)`,
@@ -949,10 +949,21 @@ export class RemoteK8sExecutionEnv {
949
949
  if (finished)
950
950
  break;
951
951
  const waitMs = Math.max(1, idleMs - (Date.now() - lastChunk));
952
- await new Promise((r) => {
953
- wake = r;
954
- setTimeout(r, Math.min(waitMs, 30_000));
955
- });
952
+ // A-002.11: the wait races the drain timer against `wake` (a chunk / completion). When `wake` wins —
953
+ // the common case on a chatty command — the timer stays armed for up to 30s unless it is cleared, so a
954
+ // long-running command leaks one per drain cycle. Same `finally` cleanup form the E2B twin already uses
955
+ // for its idle timer (remote-env-e2b.ts execStream). Clearing an already-fired timer is a no-op.
956
+ let waitTimer;
957
+ try {
958
+ await new Promise((r) => {
959
+ wake = r;
960
+ waitTimer = setTimeout(r, Math.min(waitMs, 30_000));
961
+ });
962
+ }
963
+ finally {
964
+ if (waitTimer !== undefined)
965
+ clearTimeout(waitTimer);
966
+ }
956
967
  if (!finished && Date.now() - lastChunk >= idleMs) {
957
968
  throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (suspected hang)`);
958
969
  }
@@ -263,11 +263,11 @@ export declare class SqlRunStore {
263
263
  */
264
264
  reapSuspended(olderThanMs: number): Promise<number>;
265
265
  /**
266
- * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at` sweep): fail a
266
+ * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
267
267
  * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
268
- * absolute `terminal_at`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
268
+ * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
269
269
  * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
270
- * `terminal_at` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
270
+ * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
271
271
  * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
272
272
  * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
273
273
  * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
@@ -563,11 +563,11 @@ export class SqlRunStore {
563
563
  return reaped;
564
564
  }
565
565
  /**
566
- * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at` sweep): fail a
566
+ * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
567
567
  * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
568
- * absolute `terminal_at`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
568
+ * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
569
569
  * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
570
- * `terminal_at` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
570
+ * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
571
571
  * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
572
572
  * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
573
573
  * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
@@ -233,8 +233,8 @@ export const SCHEMA_STATEMENTS = [
233
233
  checkpoint JSON NOT NULL,
234
234
  outcome JSON NULL,
235
235
  deadline BIGINT NULL,
236
- created_at BIGINT NOT NULL,
237
- decided_at BIGINT NULL,
236
+ created_at_ms BIGINT NOT NULL,
237
+ decided_at_ms BIGINT NULL,
238
238
  -- rev (design/80 D-1): monotonic optimistic-concurrency counter bumped on every resolve/reopen, so a
239
239
  -- resolve(expect) requires the rev the resume observed to still be live → fail-closed on a concurrent
240
240
  -- resolve-reopen cycle (core → checkpoint.reopened_concurrently).
@@ -244,10 +244,10 @@ export const SCHEMA_STATEMENTS = [
244
244
  -- tool_unavailable (a fresh decision is allowed). Drives core's reopen-revote validation.
245
245
  -- NULL = NEVER REOPENED (the first resume is unconstrained).
246
246
  reopen_reason VARCHAR(32) NULL,
247
- -- terminal_at: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
247
+ -- terminal_at_ms: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
248
248
  -- deadline, stamped at put(). NULL on PRE-MIGRATION rows — those keep their DEADLINE-BASED expiry (the old
249
249
  -- path); the backstop only covers rows suspended after the column existed.
250
- terminal_at BIGINT NULL,
250
+ terminal_at_ms BIGINT NULL,
251
251
  -- gate_kind (design/80 D-D, SLA-timer): the CheckpointGate.kind, stamped at put(), so the SLA sweep splits by
252
252
  -- kind WITHOUT parsing the JSON blob per row — human/irreversible_ask past deadline are resolve-DENIED (the
253
253
  -- model continues with a denial), resource_limit/needs_review are abandonment-TTL → expire().
@@ -430,7 +430,7 @@ export const SCHEMA_STATEMENTS = [
430
430
  source_run_id VARCHAR(191) NOT NULL,
431
431
  scope VARCHAR(190) NOT NULL,
432
432
  new_run_id VARCHAR(191) NOT NULL,
433
- claimed_at BIGINT NOT NULL,
433
+ claimed_at_ms BIGINT NOT NULL,
434
434
  PRIMARY KEY (source_run_id, scope)
435
435
  ) COLLATE utf8mb4_bin`,
436
436
  // P1 (fleet failover, 2026-07-05): durable cross-replica WorkflowRunStore twin — one row per run, the full
@@ -443,7 +443,7 @@ export const SCHEMA_STATEMENTS = [
443
443
  run MEDIUMTEXT NOT NULL,
444
444
  rev INT NOT NULL DEFAULT 0,
445
445
  created_at_ms BIGINT NOT NULL,
446
- ended_at BIGINT NULL,
446
+ ended_at_ms BIGINT NULL,
447
447
  PRIMARY KEY (id),
448
448
  KEY idx_wfrun_scope_created (scope, created_at_ms),
449
449
  KEY idx_wfrun_scope_status (scope, status)
@@ -494,8 +494,8 @@ export const SCHEMA_STATEMENTS = [
494
494
  acked TINYINT(1) NOT NULL DEFAULT 0,
495
495
  source_task_id VARCHAR(191) NULL,
496
496
  principal VARCHAR(190) NULL,
497
- created_at BIGINT NOT NULL,
498
- acked_at BIGINT NULL,
497
+ created_at_ms BIGINT NOT NULL,
498
+ acked_at_ms BIGINT NULL,
499
499
  PRIMARY KEY (run_id),
500
500
  KEY idx_wfnotify_acked (acked)
501
501
  ) COLLATE utf8mb4_bin`,
@@ -41,7 +41,7 @@ import { type SqlDriver } from "./sql-driver.js";
41
41
  /** Dual-dialect durable WorkflowJournalStore. See the file header for the dialect-delta ledger. */
42
42
  /** RB-242 租约旋钮。TTL 只是**崩溃兜底**(engine 终态 finally 显式释放;[1981]/[1984] 两层分工:
43
43
  * engine 崩了没释放 ⇒ 陈旧 claim 可被接管)。
44
- * 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at 在授予时刻定格,
44
+ * 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at_ms 在授予时刻定格,
45
45
  * 运行期间不刷新),所以 TTL 必须盖过最长合法 run 时长:取小了(我初版 15min)会在一次 >TTL 的活跑
46
46
  * 中把 claim 判陈旧、放另一副本进来接管——恰是本缝要防的双跑。要更短的接管等待,先给 engine 半场
47
47
  * 加心跳刷新,再谈调小。 */
@@ -89,8 +89,8 @@ export declare class SqlWorkflowJournalStore implements WorkflowJournalStore {
89
89
  * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
90
90
  * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
91
91
  * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
92
- * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at 刷新;
93
- * ③ TTL 崩溃兜底接管:claimed_at < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
92
+ * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
93
+ * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
94
94
  * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
95
95
  * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
96
96
  resumeClaim(input: {
@@ -76,8 +76,8 @@ export class SqlWorkflowJournalStore {
76
76
  * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
77
77
  * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
78
78
  * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
79
- * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at 刷新;
80
- * ③ TTL 崩溃兜底接管:claimed_at < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
79
+ * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
80
+ * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
81
81
  * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
82
82
  * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
83
83
  async resumeClaim(input) {
@@ -85,20 +85,20 @@ export class SqlWorkflowJournalStore {
85
85
  const ins = await this.db.query(this.q(
86
86
  // INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
87
87
  // (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
88
- "INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
88
+ "INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
89
89
  if (ins.affected === 1)
90
90
  return { granted: true };
91
- const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
91
+ const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
92
92
  if (refresh.affected >= 1)
93
93
  return { granted: true };
94
- const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at = ? WHERE source_run_id = ? AND scope = ? AND claimed_at < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
94
+ const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND claimed_at_ms < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at_ms = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at_ms < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
95
95
  if (takeover.affected >= 1)
96
96
  return { granted: true };
97
97
  const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
98
98
  // 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
99
99
  const row = holder.rows[0];
100
100
  const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
101
- // ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at 未变)会 affected=0
101
+ // ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
102
102
  // 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
103
103
  if (holderId === input.newRunId)
104
104
  return { granted: true };
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * ## WorkflowRunStore twin
14
14
  * One row per run: the full `WorkflowRun` as a JSON blob + EXTRACTED columns for everything SQL needs to
15
- * index/filter (scope, status, created_at_ms, ended_at) + an authoritative `rev` column (the OCC key — the
15
+ * index/filter (scope, status, created_at_ms, ended_at_ms) + an authoritative `rev` column (the OCC key — the
16
16
  * blob's own `rev` is OVERLAID from the column on every read, so writes never have to know the bumped value
17
17
  * up front). `update` is a single-statement CAS (`WHERE id AND scope [AND rev]`, `SET rev = rev + 1`);
18
18
  * `listByScope` projects через core's SHARED `summarizeWorkflowRun` (anti-drift — identical to InMemory/File).