@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.
- package/USAGE.md +6 -0
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval-reconciler.js +13 -5
- package/dist/boot/execution-env.js +6 -0
- package/dist/boot/reapers.js +3 -3
- package/dist/capabilities/repo-tools.d.ts +36 -2
- package/dist/capabilities/repo-tools.js +125 -11
- package/dist/capabilities/scenarios.d.ts +2 -2
- package/dist/capabilities/scenarios.js +11 -2
- package/dist/config-types.d.ts +15 -6
- package/dist/config.js +43 -2
- package/dist/fleet/fleet-terminal-window.d.ts +1 -1
- package/dist/fleet/fleet-terminal-window.js +1 -1
- package/dist/git-api-kind.d.ts +7 -0
- package/dist/git-api-kind.js +8 -0
- package/dist/hooks/hook-runner.js +41 -26
- package/dist/http/routes/approvals-assistant.d.ts +7 -0
- package/dist/http/routes/approvals-assistant.js +9 -2
- package/dist/http/routes/side-query.d.ts +4 -0
- package/dist/http/routes/side-query.js +85 -5
- package/dist/http/routes/workflows.js +16 -1
- package/dist/http/server.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/main.js +2 -2
- package/dist/memory-scope.d.ts +16 -0
- package/dist/memory-scope.js +42 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +9 -9
- package/dist/plugins/checkpoint-store-sql.js +32 -31
- package/dist/plugins/local-checkpoint-store.d.ts +1 -1
- package/dist/plugins/local-checkpoint-store.js +5 -4
- package/dist/plugins/memory-engine-pg.js +13 -3
- package/dist/plugins/memory-engine-tidb.js +11 -0
- package/dist/plugins/memory-key-guards.d.ts +8 -0
- package/dist/plugins/memory-key-guards.js +13 -0
- package/dist/plugins/pg-pool.js +7 -7
- package/dist/plugins/remote-env-k8s.js +15 -4
- package/dist/plugins/run-store-sql.d.ts +3 -3
- package/dist/plugins/run-store-sql.js +3 -3
- package/dist/plugins/tidb-pool.js +8 -8
- package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
- package/dist/plugins/workflow-journal-store-sql.js +6 -6
- package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
- package/dist/plugins/workflow-run-store-sql.js +12 -12
- package/dist/run-local.js +18 -5
- package/dist/tool-approval.d.ts +9 -0
- package/dist/tool-approval.js +10 -0
- 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 `
|
|
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 `
|
|
49
|
-
* `deadline`, never AT it. `
|
|
50
|
-
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's
|
|
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
|
|
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,
|
|
306
|
-
"VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline,
|
|
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
|
|
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 = ?,
|
|
416
|
-
(expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb,
|
|
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',
|
|
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',
|
|
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/
|
|
522
|
-
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `
|
|
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
|
|
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',
|
|
537
|
-
|
|
538
|
-
|
|
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(
|
|
553
|
-
"AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?",
|
|
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.
|
|
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.
|
|
566
|
-
: await this.db.query(`${base} ORDER BY c.
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
664
|
-
"WHERE scope=? AND session_id=? AND
|
|
665
|
-
"WHERE scope=$1 AND session_id=$2 AND
|
|
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.
|
|
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
|
|
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 `
|
|
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
|
|
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 || !
|
|
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
|
-
|
|
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
|
|
47
|
-
slug
|
|
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
|
|
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
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -96,11 +96,11 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
96
96
|
checkpoint JSONB NOT NULL,
|
|
97
97
|
outcome JSONB,
|
|
98
98
|
deadline BIGINT,
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
* `
|
|
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 `
|
|
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 `
|
|
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
|
-
* `
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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)——**租约无心跳**(
|
|
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) 守卫的
|
|
93
|
-
* ③ TTL 崩溃兜底接管:
|
|
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) 守卫的
|
|
80
|
-
* ③ TTL 崩溃兜底接管:
|
|
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,
|
|
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
|
|
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 = ?,
|
|
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 缺省协议下只数**被改变**的行——同毫秒重入(
|
|
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,
|
|
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).
|