@sema-agent/server 3.21.0 → 3.22.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/dist/approval-hmac.d.ts +9 -9
- package/dist/approval-hmac.js +0 -31
- package/dist/approval.js +8 -1
- package/dist/boot/config-center.d.ts +3 -2
- package/dist/boot/resolve-spec.js +4 -4
- package/dist/boot/session-faces.js +3 -2
- package/dist/boot/workflow-orchestration.js +1 -1
- package/dist/budget.d.ts +2 -2
- package/dist/budget.js +11 -6
- package/dist/hooks/hook-llm.d.ts +2 -2
- package/dist/hooks/hook-llm.js +1 -1
- package/dist/http/principal-gate.d.ts +7 -3
- package/dist/http/principal-gate.js +7 -5
- package/dist/http/route-ctx.d.ts +34 -25
- package/dist/http/routes/approvals-assistant.js +5 -5
- package/dist/http/routes/images.js +8 -8
- package/dist/http/server.d.ts +2 -2
- package/dist/http/server.js +2 -2
- package/dist/key-resolver.d.ts +7 -1
- package/dist/key-resolver.js +0 -23
- package/dist/leader/wire.d.ts +19 -1
- package/dist/leader/wire.js +48 -18
- package/dist/main.js +2 -2
- package/dist/parked-decide.js +4 -1
- package/dist/plugins/file-run-store.js +5 -5
- package/dist/plugins/host-platform.d.ts +11 -24
- package/dist/plugins/host-platform.js +14 -0
- package/dist/plugins/memory-engine-tidb.js +16 -0
- package/dist/plugins/memory-run-store.js +5 -5
- package/dist/plugins/remote-env-adb.js +12 -5
- package/dist/plugins/remote-env-local-docker.js +3 -7
- package/dist/plugins/remote-env-ssh.js +17 -2
- package/dist/plugins/run-store-sql.js +12 -12
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/store-backend.js +2 -2
- package/dist/plugins/tool-result-store-sql.d.ts +7 -1
- package/dist/plugins/tool-result-store-sql.js +9 -8
- package/dist/plugins/workflow-run-store-sql.d.ts +11 -6
- package/dist/plugins/workflow-run-store-sql.js +18 -8
- package/dist/session-sync.js +6 -3
- package/package.json +1 -1
package/dist/leader/wire.js
CHANGED
|
@@ -98,6 +98,18 @@ export async function stageWorkerEnv(env, opts) {
|
|
|
98
98
|
throw new Error(`upload-script staging failed: ${wr.error.message}`);
|
|
99
99
|
}
|
|
100
100
|
/** Build the `runLeader(body)` the HTTP endpoint invokes. Constructs real per-task deps + runs the leader. */
|
|
101
|
+
// 🔴 review-batch(重构池②,2026-08-01):`parseNumOrFail` alone still lets a NEGATIVE value through — a negative
|
|
102
|
+
// number is JS-truthy, so every `parseNumOrFail(...) || default` chain in this file passed it straight through
|
|
103
|
+
// instead of falling back (LEADER_WORKER_MAX_TURNS=-5 ⇒ workerMaxTurns=-5, unchanged by any later `|| default`).
|
|
104
|
+
// This is the one extra domain bound (A2: illegal input INCLUDING negatives must fail loud) layered on the same
|
|
105
|
+
// shared parse primitive — not a second parser. `undefined` (unset) stays NaN (`NaN < 0` is false) so the
|
|
106
|
+
// existing `|| default` fallback chains are untouched; only "non-numeric" and "negative" newly throw.
|
|
107
|
+
function parseNumOrFailNonNegative(name, raw) {
|
|
108
|
+
const n = parseNumOrFail(name, raw);
|
|
109
|
+
if (n < 0)
|
|
110
|
+
throw new Error(`env ${name}=${n} must not be negative`);
|
|
111
|
+
return n;
|
|
112
|
+
}
|
|
101
113
|
/**
|
|
102
114
|
* Per-leader-run resource bounds derived from env (2026-06-14). Exported for unit tests. The worker sandbox
|
|
103
115
|
* lifetime (BUG2), the worker's diff-upload presigned-URL TTL (BUG1), and the worker spec's triple bound
|
|
@@ -111,13 +123,13 @@ export async function stageWorkerEnv(env, opts) {
|
|
|
111
123
|
// ⇒ diff 上传预签名 URL 的暴露窗口一起放大。改走与 numEnv 同源的 parseNumOrFail:非法值当场报错。
|
|
112
124
|
// 刻意**零行为变更**:`|| default` 保留,所以未设与显式 0 的既有语义一个没动。
|
|
113
125
|
export function leaderResourceConfig(env = process.env) {
|
|
114
|
-
const leaderTimeoutMs = Math.max(600_000, Math.floor(
|
|
115
|
-
const workerMaxTurns = Math.floor(
|
|
116
|
-
const workerBudgetUsd = (
|
|
126
|
+
const leaderTimeoutMs = Math.max(600_000, Math.floor(parseNumOrFailNonNegative("LEADER_TIMEOUT_MS", env.LEADER_TIMEOUT_MS) || 0) || 86_400_000); // default 24h (was 10min)
|
|
127
|
+
const workerMaxTurns = Math.floor(parseNumOrFailNonNegative("LEADER_WORKER_MAX_TURNS", env.LEADER_WORKER_MAX_TURNS) || 0) || 10_000;
|
|
128
|
+
const workerBudgetUsd = (parseNumOrFailNonNegative("LEADER_WORKER_BUDGET_USD", env.LEADER_WORKER_BUDGET_USD) || 0) || 100;
|
|
117
129
|
const resourceSuspendOn = String(env.LEADER_RESOURCE_SUSPEND ?? "").toLowerCase() === "true";
|
|
118
130
|
// Per-slice window: a fraction of the total so a worker suspends+resumes several times across its budget
|
|
119
131
|
// (default = total/4, min $1). Smaller window ⇒ more, shorter slices ⇒ progress saved more often.
|
|
120
|
-
const sliceMaxCostUsd = (
|
|
132
|
+
const sliceMaxCostUsd = (parseNumOrFailNonNegative("LEADER_WORKER_SLICE_BUDGET_USD", env.LEADER_WORKER_SLICE_BUDGET_USD) || 0) || Math.max(1, workerBudgetUsd / 4);
|
|
121
133
|
return {
|
|
122
134
|
leaderTimeoutMs,
|
|
123
135
|
workerMaxTurns,
|
|
@@ -135,24 +147,41 @@ export function leaderResourceConfig(env = process.env) {
|
|
|
135
147
|
: {}),
|
|
136
148
|
};
|
|
137
149
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
150
|
+
// 🔴 review-batch(重构池②,2026-08-01): every knob below used to be a bare `Number(process.env.X ?? default)`
|
|
151
|
+
// with ZERO fail-loud guard — worst case was `LEADER_BUDGET_USD="20usd"` silently becoming `budgetUsd: NaN`,
|
|
152
|
+
// which makes the replan-lite budget gate's `spent > budgetUsd` comparison permanently `false` (the whole
|
|
153
|
+
// gate goes dark, not "loose" — `NaN` compares false in every direction). Converged onto the same
|
|
154
|
+
// `parseNumOrFailNonNegative` primitive leaderResourceConfig uses: non-numeric and negative now throw at
|
|
155
|
+
// startup; `undefined`/explicit-`0` fallback semantics are byte-identical to before.
|
|
156
|
+
export function leaderLoopConfig(cfg, env = process.env) {
|
|
157
|
+
const repairRounds = Math.max(0, Math.floor(env.LEADER_REPAIR_ROUNDS !== undefined
|
|
158
|
+
? parseNumOrFailNonNegative("LEADER_REPAIR_ROUNDS", env.LEADER_REPAIR_ROUNDS)
|
|
159
|
+
: (cfg.repairRounds ?? 0)) || 0);
|
|
160
|
+
const repairBudgetUsd = parseNumOrFailNonNegative("LEADER_REPAIR_BUDGET_USD", env.LEADER_REPAIR_BUDGET_USD ?? "4") || 4;
|
|
161
|
+
const conflictRounds = Math.max(0, Math.floor(env.LEADER_CONFLICT_ROUNDS !== undefined
|
|
162
|
+
? parseNumOrFailNonNegative("LEADER_CONFLICT_ROUNDS", env.LEADER_CONFLICT_ROUNDS)
|
|
163
|
+
: (cfg.conflictRounds ?? 0)) || 0);
|
|
146
164
|
// ── LEADER-REPAIRLOOP-INTEGRATION §10 env knobs (FRESH hunk, §10.8 — mirrors LEADER_REPAIR_ROUNDS above) ──
|
|
147
165
|
// Every flag default-OFF so the whole feature is inert until flipped on a canary (the default path is
|
|
148
166
|
// byte-identical to today). `LEADER_REPAIR_LOOP` is the MASTER (gates the single-agent `runRepairLoop` dep);
|
|
149
167
|
// `LEADER_MEASURE_GATES` gates the merge push-hold-on-signal + measure-drives-repair (§10.3/§10.4);
|
|
150
168
|
// `LEADER_REPAIR_LOOP_ATTEMPTS` is the in-loop attempt ceiling (validated 2-3, default 2 — design/78 §5);
|
|
151
169
|
// `LEADER_ORACLE_FLAKY_K` is the flaky-settle re-isolation count (default 2 — §10.1 / repair-oracle.ts).
|
|
152
|
-
const repairLoopOn = String(
|
|
153
|
-
const measureGatesOn = String(
|
|
154
|
-
const repairLoopAttempts = Math.min(3, Math.max(2, Math.floor(
|
|
155
|
-
const oracleFlakyK = Math.max(1, Math.floor(
|
|
170
|
+
const repairLoopOn = String(env.LEADER_REPAIR_LOOP ?? "").toLowerCase() === "true";
|
|
171
|
+
const measureGatesOn = String(env.LEADER_MEASURE_GATES ?? "").toLowerCase() === "true";
|
|
172
|
+
const repairLoopAttempts = Math.min(3, Math.max(2, Math.floor(parseNumOrFailNonNegative("LEADER_REPAIR_LOOP_ATTEMPTS", env.LEADER_REPAIR_LOOP_ATTEMPTS ?? "2")) || 2));
|
|
173
|
+
const oracleFlakyK = Math.max(1, Math.floor(parseNumOrFailNonNegative("LEADER_ORACLE_FLAKY_K", env.LEADER_ORACLE_FLAKY_K ?? "2")) || 2);
|
|
174
|
+
return {
|
|
175
|
+
repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK,
|
|
176
|
+
...(env.LEADER_BUDGET_USD ? { replanBudgetUsd: parseNumOrFailNonNegative("LEADER_BUDGET_USD", env.LEADER_BUDGET_USD) } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function createLeaderRunner(cfg) {
|
|
180
|
+
const W = cfg.workspace ?? "/home/user";
|
|
181
|
+
const repo = `${W}/repo`;
|
|
182
|
+
const ident = cfg.git ?? { name: "leader", email: "leader@local" };
|
|
183
|
+
const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
|
|
184
|
+
const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
|
|
156
185
|
// worker resource bounds (limits/maxCostUsd) + presign ttl are derived in leaderResourceConfig(), above.
|
|
157
186
|
// Bounded integration-repair (search 2026-06-14): when the merged tree compiles-clean-but-fails, run a strong
|
|
158
187
|
// agent WITH HANDS in the live merged sandbox to fix the cross-worker integration, then mergeBranches re-runs
|
|
@@ -590,8 +619,9 @@ export function createLeaderRunner(cfg) {
|
|
|
590
619
|
// found live: task02b run5, porting workers + collapse-solo all 'run aborted' at the 600s default while
|
|
591
620
|
// mid-gradle; the per-worker presign ttl below tracks the same knob).
|
|
592
621
|
timeoutMs: leaderTimeoutMs, maxConcurrency: 4, // Semaphore cap; the planner emits ≤6 sub-tasks
|
|
593
|
-
// replan-lite (design/68 §6) always on in the wired leader; LEADER_BUDGET_USD bounds the fan-out spend
|
|
594
|
-
|
|
622
|
+
// replan-lite (design/68 §6) always on in the wired leader; LEADER_BUDGET_USD bounds the fan-out spend
|
|
623
|
+
// (parsed + fail-loud-guarded in leaderLoopConfig(), above — see LEADER_BUDGET_USD review-batch note).
|
|
624
|
+
replan: { ...(replanBudgetUsd !== undefined ? { budgetUsd: replanBudgetUsd } : {}) },
|
|
595
625
|
// LEADER-REPAIRLOOP-INTEGRATION §10.3 — the merge push-hold-on-signal (OFF unless LEADER_MEASURE_GATES). It
|
|
596
626
|
// needs NO extra sandbox: `strongOracleSeeded` keys on whether a hidden held-out oracle was injected into the
|
|
597
627
|
// integration sandbox (`injectOracles` runs `body.oracleFiles`), and the §10.3 hold combines it with the
|
package/dist/main.js
CHANGED
|
@@ -24,7 +24,7 @@ import { createRegistryJwtVerifier } from "./auth-bridge.js";
|
|
|
24
24
|
import { createMetrics } from "./observability/metrics.js";
|
|
25
25
|
import { setRedactionObserver, redactSecrets } from "./trace/redact.js";
|
|
26
26
|
import { RateLimiter } from "./observability/rate-limit.js";
|
|
27
|
-
import { createHttpServer,
|
|
27
|
+
import { createHttpServer, explicitOperatorOk } from "./http/server.js";
|
|
28
28
|
import { exportMemoryScope } from "./memory-export.js";
|
|
29
29
|
import { performMemorySync } from "./memory-sync.js";
|
|
30
30
|
import { startOtlpExporter } from "./observability/otel-exporter.js";
|
|
@@ -421,7 +421,7 @@ async function main() {
|
|
|
421
421
|
? selectEnvironmentTool({
|
|
422
422
|
catalog: imageIndex,
|
|
423
423
|
selection: sessionEnvSelection,
|
|
424
|
-
viewerFor: (principal) => ({ operator:
|
|
424
|
+
viewerFor: (principal) => ({ operator: explicitOperatorOk(principal, config.operatorPrincipals), tenantId: principal ?? null }),
|
|
425
425
|
})
|
|
426
426
|
: undefined;
|
|
427
427
|
// Sandbox-image-pool BAKE control plane (IMAGE-API-DESIGN.md §P2): enables /v1/images/bakes* when a pool exists
|
package/dist/parked-decide.js
CHANGED
|
@@ -135,7 +135,10 @@ export async function decideParkedAgent(deps, req) {
|
|
|
135
135
|
boundInputHash: req.binding?.boundInputHash ?? persistedHash,
|
|
136
136
|
decision: req.decision === "approve" ? "allow" : "deny",
|
|
137
137
|
...(req.decision === "approve" && req.binding?.updatedInput !== undefined ? { updatedInput: req.binding.updatedInput } : {}),
|
|
138
|
-
|
|
138
|
+
// B10:存在性判定,不是真值判定 —— reason 可以是空串("运维显式选择不写理由"),这与"没传 reason"
|
|
139
|
+
// 是两件不同的事(姊妹 approval-hmac.ts `env.reason ?? null` 同判据:`??` 只在 null/undefined 时落
|
|
140
|
+
// null,空串照样入签名载荷)。真值判定会把显式 "" 与缺席折成同一个结果,审计/签名面丢了这个区分。
|
|
141
|
+
...(req.reason !== undefined ? { reason: req.reason } : {}),
|
|
139
142
|
};
|
|
140
143
|
const ctx = {
|
|
141
144
|
toolCallId: `drv-${ticket.claimId}`,
|
|
@@ -363,9 +363,9 @@ export class FileRunStore {
|
|
|
363
363
|
const cursorAt = opts.cursor ? Date.parse(opts.cursor.createdAt) : undefined;
|
|
364
364
|
const rows = [...this.runs.values()]
|
|
365
365
|
.filter((r) => (opts.status ? r.status === opts.status : true))
|
|
366
|
-
.filter((r) => (opts.jobId ? r.jobId === opts.jobId : true))
|
|
367
|
-
.filter((r) => (opts.source ? r.source === opts.source : true))
|
|
368
|
-
.filter((r) => (opts.owner ? r.owner === opts.owner : true)) // exact (SQL `owner = ?`): a
|
|
366
|
+
.filter((r) => (opts.jobId !== undefined ? r.jobId === opts.jobId : true)) // "" is a valid exact jobId, not "no filter" (B10)
|
|
367
|
+
.filter((r) => (opts.source !== undefined ? r.source === opts.source : true)) // "" is a valid exact source, not "no filter" (B10)
|
|
368
|
+
.filter((r) => (opts.owner !== undefined ? r.owner === opts.owner : true)) // exact (SQL `owner = ?`): "" is a valid exact owner, not "no filter" (B10)
|
|
369
369
|
.filter((r) => {
|
|
370
370
|
if (cursorAt === undefined)
|
|
371
371
|
return true;
|
|
@@ -379,8 +379,8 @@ export class FileRunStore {
|
|
|
379
379
|
async listSessions(opts) {
|
|
380
380
|
const bySession = new Map();
|
|
381
381
|
for (const r of this.runs.values()) {
|
|
382
|
-
if (opts.owner && r.owner !== opts.owner)
|
|
383
|
-
continue; // exact owner filter (SQL `owner = ?`)
|
|
382
|
+
if (opts.owner !== undefined && r.owner !== opts.owner)
|
|
383
|
+
continue; // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10)
|
|
384
384
|
const arr = bySession.get(r.sessionId) ?? [];
|
|
385
385
|
arr.push(r);
|
|
386
386
|
bySession.set(r.sessionId, arr);
|
|
@@ -1,27 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-lane PLATFORM seam (DESIGN-windows-native.md, FINAL r3) — the ONE place the host exec adapter's
|
|
3
|
-
* POSIX/win32 differences live, so `remote-env-host.ts` stays a single code path with platform-gated leaves.
|
|
4
|
-
*
|
|
5
|
-
* 🔴 Iron invariant (design §4.1): the POSIX path is BYTE-IDENTICAL to the pre-Windows code — every helper is
|
|
6
|
-
* `win32 ? new : exactly-what-the-inline-code-did` (same syscall, same throw behavior). Never "improve" POSIX
|
|
7
|
-
* here; the existing full test suite is the regression net.
|
|
8
|
-
*
|
|
9
|
-
* win32 semantics (design D1-D4):
|
|
10
|
-
* - shell = Git Bash via core 1.224 `getShellConfig` (WSL-launcher-filtered — the `System32\bash.exe`
|
|
11
|
-
* trap). Fail-LOUD when absent; never silently degrade to cmd (D1).
|
|
12
|
-
* - kill = core 1.224 `signalProcessTree` (taskkill /T, /F for hard). Soft is a no-op for console trees
|
|
13
|
-
* (taskkill errors "can only be terminated forcefully") — the SIGTERM→grace→SIGKILL ladder is
|
|
14
|
-
* effectively delay→hard-kill on win32; accepted, CC-identical (D2).
|
|
15
|
-
* - spawn = `detached:false` + `windowsHide:true` (no console window; no POSIX process group — the kill
|
|
16
|
-
* side uses the tree, not the group) (D3).
|
|
17
|
-
* - env = case-insensitive key collapse before spawn (win32 env keys are case-insensitive; a `Path`+`PATH`
|
|
18
|
-
* pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
|
|
19
|
-
* entry). Canonical casing = the first-seen key (process.env's native casing wins since the
|
|
20
|
-
* inherit base is spread first).
|
|
21
|
-
*
|
|
22
|
-
* ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
|
|
23
|
-
* structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
|
|
24
|
-
*/
|
|
25
1
|
import { killProcessTree } from "@sema-agent/core";
|
|
26
2
|
export declare const IS_WIN32: boolean;
|
|
27
3
|
export interface HostShell {
|
|
@@ -82,6 +58,17 @@ export { killProcessTree };
|
|
|
82
58
|
* POSIX: returns the input UNTOUCHED (case-sensitive env is real there — `Path` and `PATH` are distinct).
|
|
83
59
|
*/
|
|
84
60
|
export declare function collapseWin32EnvKeys(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
61
|
+
/**
|
|
62
|
+
* Conventional exit-code mapping for a process killed by a signal (128 + signal number). Single pattern-home
|
|
63
|
+
* construction point for every exec adapter that must turn a `close` event's external-kill case
|
|
64
|
+
* (`code===null`, `signal` set) into an exit code instead of silently reporting a fake success 0 — the host
|
|
65
|
+
* lane, ssh, adb, and local-docker all consume THIS function; none of them may hand-write their own signal
|
|
66
|
+
* table. Uses Node's authoritative platform table (`os.constants.signals`) rather than a hand-written list —
|
|
67
|
+
* a hand-written table has previously missed `SIGABRT`/`SIGPIPE` (`kill -ABRT` reported 137, impersonating
|
|
68
|
+
* `SIGKILL`). Callers keep a `?? 9` fallback for a name the platform table doesn't define, matching core's
|
|
69
|
+
* `SIGNUM[signal] ?? 9`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function signalNumber(signal: NodeJS.Signals): number | undefined;
|
|
85
72
|
/** The platform-free collapse algorithm (exported so the mac/Linux suite can pin the win32 behavior — design
|
|
86
73
|
* D5: structural verification everywhere, behavioral bite on the Windows runner). */
|
|
87
74
|
export declare function collapseEnvKeysCaseInsensitive(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
|
|
23
23
|
* structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
|
|
24
24
|
*/
|
|
25
|
+
import os from "node:os";
|
|
25
26
|
import { getShellConfig, killProcessTree, signalProcessTree } from "@sema-agent/core";
|
|
26
27
|
export const IS_WIN32 = process.platform === "win32";
|
|
27
28
|
/** S1([1870] test AI 全归因,2026-07-27):POSIX 不再硬编码 `/bin/sh`——Debian/Ubuntu 的 /bin/sh 是
|
|
@@ -135,6 +136,19 @@ export function collapseWin32EnvKeys(env) {
|
|
|
135
136
|
return env;
|
|
136
137
|
return collapseEnvKeysCaseInsensitive(env);
|
|
137
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Conventional exit-code mapping for a process killed by a signal (128 + signal number). Single pattern-home
|
|
141
|
+
* construction point for every exec adapter that must turn a `close` event's external-kill case
|
|
142
|
+
* (`code===null`, `signal` set) into an exit code instead of silently reporting a fake success 0 — the host
|
|
143
|
+
* lane, ssh, adb, and local-docker all consume THIS function; none of them may hand-write their own signal
|
|
144
|
+
* table. Uses Node's authoritative platform table (`os.constants.signals`) rather than a hand-written list —
|
|
145
|
+
* a hand-written table has previously missed `SIGABRT`/`SIGPIPE` (`kill -ABRT` reported 137, impersonating
|
|
146
|
+
* `SIGKILL`). Callers keep a `?? 9` fallback for a name the platform table doesn't define, matching core's
|
|
147
|
+
* `SIGNUM[signal] ?? 9`.
|
|
148
|
+
*/
|
|
149
|
+
export function signalNumber(signal) {
|
|
150
|
+
return os.constants.signals[signal];
|
|
151
|
+
}
|
|
138
152
|
/** The platform-free collapse algorithm (exported so the mac/Linux suite can pin the win32 behavior — design
|
|
139
153
|
* D5: structural verification everywhere, behavioral bite on the Windows runner). */
|
|
140
154
|
export function collapseEnvKeysCaseInsensitive(env) {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// (`${name ?? slug} ${description} ${body}`——memoryBackendContract 的 search 等价断言依赖)。
|
|
24
24
|
import { jaccardDistance, termSet } from "./memory-engine-vector-util.js";
|
|
25
25
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
26
|
+
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
26
27
|
/** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
|
|
27
28
|
* one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
|
|
28
29
|
export const TIDB_MEMORY_ENGINE_TABLES = {
|
|
@@ -224,6 +225,15 @@ export class TiDBMemoryEngineBackend {
|
|
|
224
225
|
report.conflicts.push({ op: "add", id: patch.id, reason: "add patch without an entry" });
|
|
225
226
|
return;
|
|
226
227
|
}
|
|
228
|
+
// Reject-not-rewrite for bytes SQL cannot store(Pg 版 applyOne add 分支同一守卫,复用同一
|
|
229
|
+
// pgHasUnstorable——不是重派生一份近似正则):NUL / 孤立 UTF-16 代理不是 PG 独有的病,mysql2/
|
|
230
|
+
// utf8mb4 对同样的字节没有 PG 的 22P05 fail-loud 报错,驱动或服务端会静默 mangle(丢字节/替换为
|
|
231
|
+
// U+FFFD 视驱动版本而定),写入侧不炸、读回侧才发现内容偏离——这比 PG 的写时报错更隐蔽。三后端
|
|
232
|
+
// (pg/tidb/local)必须对同一输入给出同一行为,否则同一份 sync 数据在不同部署形态下悄悄分叉。
|
|
233
|
+
if (pgHasUnstorable(entry.frontmatter) || pgHasUnstorable(entry.body) || pgHasUnstorable(entry.slug)) {
|
|
234
|
+
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
|
+
return;
|
|
236
|
+
}
|
|
227
237
|
// Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
|
|
228
238
|
// scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
|
|
229
239
|
// One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
|
|
@@ -369,6 +379,12 @@ export class TiDBMemoryEngineBackend {
|
|
|
369
379
|
report.conflicts.push({ op: "update", id: patch.id, reason: "update patch without an entry" });
|
|
370
380
|
return;
|
|
371
381
|
}
|
|
382
|
+
// Same reject-not-rewrite guard as the add leg above(Pg 版 applyOne update 分支同一守卫)—
|
|
383
|
+
// an update carrying unstorable bytes must refuse before the CAS write, not mangle silently.
|
|
384
|
+
if (pgHasUnstorable(entry.frontmatter) || pgHasUnstorable(entry.body) || pgHasUnstorable(entry.slug)) {
|
|
385
|
+
report.conflicts.push({ op: "update", id: patch.id, reason: "unstorable_bytes (utf8mb4 cannot store NUL/lone surrogates without mangling; strip them at the source — the store never rewrites content)" });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
372
388
|
const rev = computeEntryRev(entry);
|
|
373
389
|
const res = await this.write(`UPDATE ${T} SET scope = ?, slug = ?, frontmatter = ?, body = ?, rev = ?, mtime_ms = ?, size_bytes = ?, embedding = NULL WHERE id = ? AND rev = ?`, [entry.scope, entry.slug, JSON.stringify(entry.frontmatter), entry.body, rev, this.clock(), Buffer.byteLength(serializeEntryFile(entry), "utf8"), entry.id, currentRev]);
|
|
374
390
|
if (res.affectedRows === 0) {
|
|
@@ -191,9 +191,9 @@ export class MemoryRunStore {
|
|
|
191
191
|
const cursorAt = opts.cursor ? Date.parse(opts.cursor.createdAt) : undefined;
|
|
192
192
|
const rows = [...this.runs.values()]
|
|
193
193
|
.filter((r) => (opts.status ? r.status === opts.status : true))
|
|
194
|
-
.filter((r) => (opts.jobId ? r.jobId === opts.jobId : true))
|
|
195
|
-
.filter((r) => (opts.source ? r.source === opts.source : true))
|
|
196
|
-
.filter((r) => (opts.owner ? r.owner === opts.owner : true)) // exact (SQL `owner = ?`): a
|
|
194
|
+
.filter((r) => (opts.jobId !== undefined ? r.jobId === opts.jobId : true)) // "" is a valid exact jobId, not "no filter" (B10)
|
|
195
|
+
.filter((r) => (opts.source !== undefined ? r.source === opts.source : true)) // "" is a valid exact source, not "no filter" (B10)
|
|
196
|
+
.filter((r) => (opts.owner !== undefined ? r.owner === opts.owner : true)) // exact (SQL `owner = ?`): "" is a valid exact owner, not "no filter" (B10)
|
|
197
197
|
.filter((r) => {
|
|
198
198
|
if (cursorAt === undefined)
|
|
199
199
|
return true;
|
|
@@ -207,8 +207,8 @@ export class MemoryRunStore {
|
|
|
207
207
|
async listSessions(opts) {
|
|
208
208
|
const bySession = new Map();
|
|
209
209
|
for (const r of this.runs.values()) {
|
|
210
|
-
if (opts.owner && r.owner !== opts.owner)
|
|
211
|
-
continue; // exact owner filter (SQL `owner = ?`)
|
|
210
|
+
if (opts.owner !== undefined && r.owner !== opts.owner)
|
|
211
|
+
continue; // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10)
|
|
212
212
|
const arr = bySession.get(r.sessionId) ?? [];
|
|
213
213
|
arr.push(r);
|
|
214
214
|
bySession.set(r.sessionId, arr);
|
|
@@ -29,6 +29,7 @@ import os from "node:os";
|
|
|
29
29
|
import fs from "node:fs/promises";
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
31
|
import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
|
|
32
|
+
import { signalNumber } from "./host-platform.js";
|
|
32
33
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
33
34
|
import { fileErrorFromExec } from "./remote-env-file-error.js";
|
|
34
35
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
@@ -234,13 +235,15 @@ export class RemoteAdbExecutionEnv {
|
|
|
234
235
|
finished = true;
|
|
235
236
|
signalReady();
|
|
236
237
|
});
|
|
237
|
-
child.on("close", (code) => {
|
|
238
|
+
child.on("close", (code, signal) => {
|
|
238
239
|
if ((code ?? 0) !== 0 && isAdbTransportLost(stderrTail) && !failure) {
|
|
239
240
|
// device transport died mid-stream — typed retryable, not a fake exit ([R78]#1)
|
|
240
241
|
this.connected = false;
|
|
241
242
|
failure = new RemoteExecutionError("transport_lost", `adb transport lost mid-stream: ${stderrTail.trim().slice(0, 200)}`);
|
|
242
243
|
}
|
|
243
|
-
|
|
244
|
+
// code==null WITH a signal = the adb child was killed by an external signal (SIGKILL/OOM) — conventional
|
|
245
|
+
// 128+signo, NOT a fake success 0 ([R78]#2; the second `close` argument was dropped before this fix).
|
|
246
|
+
exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0);
|
|
244
247
|
finished = true;
|
|
245
248
|
signalReady();
|
|
246
249
|
});
|
|
@@ -483,19 +486,23 @@ export class RemoteAdbExecutionEnv {
|
|
|
483
486
|
opts?.onStderr?.(d.toString());
|
|
484
487
|
});
|
|
485
488
|
child.on("error", (e) => finish({ ok: false, error: new ExecutionError("spawn_error", `adb spawn failed (is '${this.cfg.adbPath}' installed?): ${e.message}`, e) }));
|
|
486
|
-
child.on("close", (code) => {
|
|
489
|
+
child.on("close", (code, signal) => {
|
|
487
490
|
if (forceSettleTimer)
|
|
488
491
|
clearTimeout(forceSettleTimer); // `close` fired → pipes closed naturally; clear D5 grace
|
|
489
492
|
const err = errBuf.result();
|
|
490
493
|
const stderr = markTruncated(err.text, err.droppedBytes);
|
|
494
|
+
// code==null WITH a signal = the adb child was killed by an external signal (SIGKILL/OOM) —
|
|
495
|
+
// conventional 128+signo, NOT a fake success 0 ([R78]#2; the second `close` argument was dropped
|
|
496
|
+
// before this fix).
|
|
497
|
+
const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0);
|
|
491
498
|
if (binary) {
|
|
492
499
|
// byte-exact: readBinaryFile decodes stdoutBytes; never truncate.
|
|
493
500
|
const stdoutBytes = Buffer.concat(outBufs);
|
|
494
|
-
finish(ok({ stdout: stdoutBytes.toString("utf8"), stderr, exitCode
|
|
501
|
+
finish(ok({ stdout: stdoutBytes.toString("utf8"), stderr, exitCode, stdoutBytes: new Uint8Array(stdoutBytes) }));
|
|
495
502
|
}
|
|
496
503
|
else {
|
|
497
504
|
const out = outTail.result();
|
|
498
|
-
finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr, exitCode
|
|
505
|
+
finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr, exitCode }));
|
|
499
506
|
}
|
|
500
507
|
});
|
|
501
508
|
});
|
|
@@ -42,6 +42,7 @@ import fs from "node:fs/promises";
|
|
|
42
42
|
import { spawn } from "node:child_process";
|
|
43
43
|
import { randomBytes } from "node:crypto";
|
|
44
44
|
import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
|
|
45
|
+
import { signalNumber } from "./host-platform.js";
|
|
45
46
|
import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
46
47
|
import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
|
|
47
48
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
@@ -290,7 +291,7 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
290
291
|
child.on("close", (code, signal) => {
|
|
291
292
|
if (finished)
|
|
292
293
|
return;
|
|
293
|
-
exitCode = code ?? (signal ? 128 + (signalNumber(signal) ??
|
|
294
|
+
exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 1);
|
|
294
295
|
finished = true;
|
|
295
296
|
signalReady();
|
|
296
297
|
});
|
|
@@ -586,7 +587,7 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
586
587
|
clearTimeout(forceSettleTimer); // `close` fired → pipes closed naturally; clear D5 grace
|
|
587
588
|
// A signal-killed process (external SIGKILL/OOM) has code===null → derive the conventional 128+signo
|
|
588
589
|
// (parity with execStream + the host adapter); NEVER report a killed command as a success exitCode 0.
|
|
589
|
-
const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ??
|
|
590
|
+
const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 1);
|
|
590
591
|
const err = errBuf.result();
|
|
591
592
|
const stderr = markTruncated(err.text, err.droppedBytes);
|
|
592
593
|
if (binary) {
|
|
@@ -664,11 +665,6 @@ function sanitizeId(id) {
|
|
|
664
665
|
function errMsg(e) {
|
|
665
666
|
return e instanceof Error ? e.message : String(e);
|
|
666
667
|
}
|
|
667
|
-
/** Conventional exit code for a process killed by a signal (128 + signal number); falls back to undefined. */
|
|
668
|
-
function signalNumber(signal) {
|
|
669
|
-
const table = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15, SIGSEGV: 11 };
|
|
670
|
-
return table[signal];
|
|
671
|
-
}
|
|
672
668
|
/**
|
|
673
669
|
* `ExecutionEnvFactory` for the TOC `local-docker` backend (DUAL-MODE-DESIGN §5). Wiring this onto a deployment
|
|
674
670
|
* makes its agent run each task in a per-task container on the worker's OWN docker daemon (isolation:true,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { Client } from "ssh2";
|
|
24
24
|
import { shellQuote, kindFromMode } from "./remote-shell.js";
|
|
25
|
+
import { signalNumber } from "./host-platform.js";
|
|
25
26
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
26
27
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
27
28
|
import { fileErrorFromExec } from "./remote-env-file-error.js";
|
|
@@ -31,6 +32,13 @@ const unsupported = (op) => ({
|
|
|
31
32
|
ok: false,
|
|
32
33
|
error: new RemoteExecutionError("unsupported", `${op} is not supported on an SSH target (real machine — not snapshotable; capabilities.suspendable=false)`),
|
|
33
34
|
});
|
|
35
|
+
/** ssh2's ClientChannel "close" event gives the killing signal's BARE POSIX name (e.g. "KILL"), unlike Node's
|
|
36
|
+
* own `child.on("close")` which gives `NodeJS.Signals` names (e.g. "SIGKILL") — re-prefix before looking it
|
|
37
|
+
* up in the shared, authoritative signal table (host-platform.ts `signalNumber`; single pattern-home, no
|
|
38
|
+
* hand-written table here). `?? 9` matches the convention every other exec adapter uses for an unmapped name. */
|
|
39
|
+
function exitCodeForSignalKill(signal) {
|
|
40
|
+
return 128 + (signalNumber(`SIG${signal}`) ?? 9);
|
|
41
|
+
}
|
|
34
42
|
export class RemoteSshExecutionEnv {
|
|
35
43
|
capabilities = { isolation: false, suspendable: false };
|
|
36
44
|
/** Working directory; relative paths resolve against it (ExecutionEnv contract). Starts at mountPath. */
|
|
@@ -341,7 +349,12 @@ export class RemoteSshExecutionEnv {
|
|
|
341
349
|
}
|
|
342
350
|
const out = outBuf.result();
|
|
343
351
|
const err = errBuf.result();
|
|
344
|
-
|
|
352
|
+
// code==null WITH a signal = the remote command was killed by that signal — conventional 128+signo,
|
|
353
|
+
// NOT a fake success 0 ([R78]#2; was silently mapped to exitCode 0 before this fix). The `: 1`
|
|
354
|
+
// arm is unreachable (the transport_lost return above already covers code==null && !signal) but
|
|
355
|
+
// keeps the type honest without a non-null assertion.
|
|
356
|
+
const exitCode = code ?? (signal ? exitCodeForSignalKill(signal) : 1);
|
|
357
|
+
finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr: markTruncated(err.text, err.droppedBytes), exitCode }));
|
|
345
358
|
})
|
|
346
359
|
.stderr.on("data", (d) => {
|
|
347
360
|
errBuf.push(d);
|
|
@@ -445,7 +458,9 @@ export class RemoteSshExecutionEnv {
|
|
|
445
458
|
// channel died without an exit status (transport drop) — typed retryable, not a fake exit 0 ([R78]#1)
|
|
446
459
|
failure = new RemoteExecutionError("transport_lost", "ssh channel closed without exit status (transport lost)");
|
|
447
460
|
}
|
|
448
|
-
|
|
461
|
+
// code==null WITH a signal = the remote command was killed by that signal — conventional 128+signo,
|
|
462
|
+
// NOT a fake success 0 ([R78]#2; was silently mapped to exitCode 0 before this fix).
|
|
463
|
+
exitCode = code ?? (signal ? exitCodeForSignalKill(signal) : 0);
|
|
449
464
|
finished = true;
|
|
450
465
|
signalReady();
|
|
451
466
|
})
|
|
@@ -289,16 +289,16 @@ export class SqlRunStore {
|
|
|
289
289
|
where.push("status = ?");
|
|
290
290
|
params.push(opts.status);
|
|
291
291
|
}
|
|
292
|
-
if (opts.jobId) {
|
|
292
|
+
if (opts.jobId !== undefined) {
|
|
293
293
|
where.push("job_id = ?");
|
|
294
294
|
params.push(opts.jobId);
|
|
295
295
|
}
|
|
296
|
-
if (opts.source) {
|
|
296
|
+
if (opts.source !== undefined) {
|
|
297
297
|
where.push("source = ?");
|
|
298
298
|
params.push(opts.source);
|
|
299
299
|
}
|
|
300
|
-
if (opts.owner) {
|
|
301
|
-
where.push("owner = ?"); // idx_owner — the per-user list view (portal scopes a normal user to their own)
|
|
300
|
+
if (opts.owner !== undefined) {
|
|
301
|
+
where.push("owner = ?"); // idx_owner — the per-user list view (portal scopes a normal user to their own); "" is a valid exact owner, not "no filter" (B10)
|
|
302
302
|
params.push(opts.owner);
|
|
303
303
|
}
|
|
304
304
|
if (opts.cursor) {
|
|
@@ -320,12 +320,12 @@ export class SqlRunStore {
|
|
|
320
320
|
const where = [];
|
|
321
321
|
if (opts.status)
|
|
322
322
|
where.push(`status = ${p(opts.status)}`);
|
|
323
|
-
if (opts.jobId)
|
|
323
|
+
if (opts.jobId !== undefined)
|
|
324
324
|
where.push(`job_id = ${p(opts.jobId)}`);
|
|
325
|
-
if (opts.source)
|
|
326
|
-
where.push(`source = ${p(opts.source)}`);
|
|
327
|
-
if (opts.owner)
|
|
328
|
-
where.push(`owner = ${p(opts.owner)}`);
|
|
325
|
+
if (opts.source !== undefined)
|
|
326
|
+
where.push(`source = ${p(opts.source)}`); // "" exact-matches (B10, same family as owner)
|
|
327
|
+
if (opts.owner !== undefined)
|
|
328
|
+
where.push(`owner = ${p(opts.owner)}`); // "" is a valid exact owner, not "no filter" (B10)
|
|
329
329
|
if (opts.cursor) {
|
|
330
330
|
const a = p(new Date(opts.cursor.createdAt));
|
|
331
331
|
const b = p(new Date(opts.cursor.createdAt));
|
|
@@ -348,8 +348,8 @@ export class SqlRunStore {
|
|
|
348
348
|
if (this.db.dialect === "tidb") {
|
|
349
349
|
const params = [];
|
|
350
350
|
let innerWhere = "";
|
|
351
|
-
if (opts.owner) {
|
|
352
|
-
innerWhere = " WHERE owner = ?";
|
|
351
|
+
if (opts.owner !== undefined) {
|
|
352
|
+
innerWhere = " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
|
|
353
353
|
params.push(opts.owner);
|
|
354
354
|
}
|
|
355
355
|
const outer = ["rn = 1"];
|
|
@@ -381,7 +381,7 @@ export class SqlRunStore {
|
|
|
381
381
|
params.push(v);
|
|
382
382
|
return `$${params.length}`;
|
|
383
383
|
};
|
|
384
|
-
const innerWhere = opts.owner ? ` WHERE owner = ${p(opts.owner)}` : "";
|
|
384
|
+
const innerWhere = opts.owner !== undefined ? ` WHERE owner = ${p(opts.owner)}` : ""; // "" is a valid exact owner, not "no filter" (B10)
|
|
385
385
|
const outer = ["rn = 1"];
|
|
386
386
|
// `?q=` — the PG twin of the TiDB EXISTS predicate above.
|
|
387
387
|
if (opts.q)
|
|
@@ -144,7 +144,7 @@ export interface StoreBackend {
|
|
|
144
144
|
/** P1 (fleet failover): durable cross-replica WorkflowRunStore + completion-inbox twins. OPTIONAL —
|
|
145
145
|
* SQL backends only (local keeps the File pair: single box, no cross-replica surface). main.ts prefers
|
|
146
146
|
* these under WORKFLOW_RUN_STORE=auto (the default). */
|
|
147
|
-
workflowRun?(): import("@sema-agent/core").WorkflowRunStore;
|
|
147
|
+
workflowRun?(onWarn?: InboxWarn): import("@sema-agent/core").WorkflowRunStore;
|
|
148
148
|
completionInbox?(onWarn?: InboxWarn): import("../orchestration/workflow-completion-inbox.js").WorkflowCompletionInbox;
|
|
149
149
|
/** 1.108 review fix (lens③ HIGH): the notify-JOURNAL twin — third leg of the same axis (a SQL run store +
|
|
150
150
|
* inbox with a replica-LOCAL File journal stranded a dead replica's un-acked notify forever). */
|
|
@@ -113,7 +113,7 @@ class TiDBBackend {
|
|
|
113
113
|
fileSnapshot() { return new TiDBFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "tidb", this.pool), snapshotBoundsFromConfig(this.config)); }
|
|
114
114
|
workflowJournal() { return new TiDBWorkflowJournalStore(this.pool); }
|
|
115
115
|
outcomeSink() { return new TiDBOutcomeLedger(this.pool); } // design/73 §1→§7 bridge (recordCore)
|
|
116
|
-
workflowRun() { return new TiDBWorkflowRunStore(this.pool); } // P1: cross-replica workflow record
|
|
116
|
+
workflowRun(onWarn) { return new TiDBWorkflowRunStore(this.pool, onWarn); } // P1: cross-replica workflow record(onWarn = C5 oversize-slim 留痕,completionInbox 同款先例)
|
|
117
117
|
completionInbox(onWarn) { return new TiDBWorkflowCompletionInbox(this.pool, onWarn); } // P1: cross-replica push half
|
|
118
118
|
notifyJournal() { return new TiDBWorkflowNotifyJournalStore(this.pool); } // 1.108: cross-replica at-least-once notify
|
|
119
119
|
checkpoint(logger) { return new TiDBCheckpointStore(this.pool, logger); }
|
|
@@ -147,7 +147,7 @@ class PgBackend {
|
|
|
147
147
|
fileSnapshot() { return new PgFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "pg", this.pool), snapshotBoundsFromConfig(this.config)); }
|
|
148
148
|
workflowJournal() { return new PgWorkflowJournalStore(this.pool); }
|
|
149
149
|
outcomeSink() { return new PgOutcomeLedger(this.pool); } // design/73 §1→§7 bridge (recordCore)
|
|
150
|
-
workflowRun() { return new PgWorkflowRunStore(this.pool); } // P1: cross-replica workflow record
|
|
150
|
+
workflowRun(onWarn) { return new PgWorkflowRunStore(this.pool, onWarn); } // P1: cross-replica workflow record(onWarn = C5 oversize-slim 留痕,completionInbox 同款先例)
|
|
151
151
|
completionInbox(onWarn) { return new PgWorkflowCompletionInbox(this.pool, onWarn); } // P1: cross-replica push half
|
|
152
152
|
notifyJournal() { return new PgWorkflowNotifyJournalStore(this.pool); } // 1.108: cross-replica at-least-once notify
|
|
153
153
|
checkpoint(logger) { return new PgCheckpointStore(this.pool, logger); }
|
|
@@ -29,7 +29,13 @@ export declare class SqlToolResultStore implements ToolResultStore {
|
|
|
29
29
|
offset?: number;
|
|
30
30
|
limit?: number;
|
|
31
31
|
}): Promise<ToolResultSlice | undefined>;
|
|
32
|
-
/** TTL reap: delete results older than `cutoffMs`. Returns rows removed.
|
|
32
|
+
/** TTL reap: delete results older than `cutoffMs`. Returns rows removed.
|
|
33
|
+
* C6: a real DB error must NOT collapse into the same `0` a legitimate "nothing was old enough" returns —
|
|
34
|
+
* those are different facts (query failed vs. query succeeded on an empty set) and folding them together
|
|
35
|
+
* makes a stuck/broken reaper indistinguishable from a healthy quiet one. Matches every sibling reaper's
|
|
36
|
+
* form (roster-store-sql.ts `reapOlderThan`, checkpoint-store-sql.ts `reapExpired`,
|
|
37
|
+
* workflow-journal-store-sql.ts `reapExpired`): let the error propagate — the periodic-sweep call site
|
|
38
|
+
* (boot/reapers.ts) already wraps this call in `.catch(() => undefined)` so the reap loop itself never dies. */
|
|
33
39
|
reapOlderThan(cutoffMs: number): Promise<number>;
|
|
34
40
|
/**
|
|
35
41
|
* E21 (§0.5 session delete) — purge offloaded tool results for one session. core namespaces every ref as
|
|
@@ -123,15 +123,16 @@ export class SqlToolResultStore {
|
|
|
123
123
|
return undefined; // unknown ref (e.g. reaped) → core reports "no longer available"
|
|
124
124
|
return { content: String(rows[0].slice ?? ""), offset, totalChars: Number(rows[0].total) };
|
|
125
125
|
}
|
|
126
|
-
/** TTL reap: delete results older than `cutoffMs`. Returns rows removed.
|
|
126
|
+
/** TTL reap: delete results older than `cutoffMs`. Returns rows removed.
|
|
127
|
+
* C6: a real DB error must NOT collapse into the same `0` a legitimate "nothing was old enough" returns —
|
|
128
|
+
* those are different facts (query failed vs. query succeeded on an empty set) and folding them together
|
|
129
|
+
* makes a stuck/broken reaper indistinguishable from a healthy quiet one. Matches every sibling reaper's
|
|
130
|
+
* form (roster-store-sql.ts `reapOlderThan`, checkpoint-store-sql.ts `reapExpired`,
|
|
131
|
+
* workflow-journal-store-sql.ts `reapExpired`): let the error propagate — the periodic-sweep call site
|
|
132
|
+
* (boot/reapers.ts) already wraps this call in `.catch(() => undefined)` so the reap loop itself never dies. */
|
|
127
133
|
async reapOlderThan(cutoffMs) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return affected;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return 0; // best-effort reaper — never throw into the loop
|
|
134
|
-
}
|
|
134
|
+
const { affected } = await this.db.query(this.q("DELETE FROM tool_result WHERE created_at < ?", "DELETE FROM tool_result WHERE created_at < $1"), [new Date(cutoffMs)]);
|
|
135
|
+
return affected;
|
|
135
136
|
}
|
|
136
137
|
/**
|
|
137
138
|
* E21 (§0.5 session delete) — purge offloaded tool results for one session. core namespaces every ref as
|
|
@@ -79,10 +79,13 @@ export declare function slimOversizeRun(run: WorkflowRun & {
|
|
|
79
79
|
id: string;
|
|
80
80
|
scope: string;
|
|
81
81
|
}, maxBytes: number): string | null;
|
|
82
|
-
/** Dual-dialect durable `WorkflowRunStore`. See the file header for the dialect-delta ledger.
|
|
82
|
+
/** Dual-dialect durable `WorkflowRunStore`. See the file header for the dialect-delta ledger.
|
|
83
|
+
* `onWarn` (optional, same shape as the completion-inbox's {@link InboxWarn}) is the C5 trace channel for
|
|
84
|
+
* `update`'s oversize-slim degrade (see there) — omit it and construction/behavior is unchanged (additive). */
|
|
83
85
|
export declare class SqlWorkflowRunStore implements WorkflowRunStore {
|
|
84
86
|
protected readonly db: SqlDriver;
|
|
85
|
-
|
|
87
|
+
private readonly onWarn?;
|
|
88
|
+
constructor(db: SqlDriver, onWarn?: InboxWarn | undefined);
|
|
86
89
|
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
87
90
|
private q;
|
|
88
91
|
put(id: string, run: WorkflowRun): Promise<void>;
|
|
@@ -145,9 +148,10 @@ export declare class SqlWorkflowNotifyJournalStore implements WorkflowNotifyJour
|
|
|
145
148
|
reapAcked(before: number): Promise<number>;
|
|
146
149
|
get(runId: string): Promise<WorkflowNotifyJournalEntry | null>;
|
|
147
150
|
}
|
|
148
|
-
/** MySQL-protocol (TiDB) bindings — historical class names
|
|
151
|
+
/** MySQL-protocol (TiDB) bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd
|
|
152
|
+
* ctor arg (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
|
|
149
153
|
export declare class TiDBWorkflowRunStore extends SqlWorkflowRunStore {
|
|
150
|
-
constructor(pool: MySqlPool);
|
|
154
|
+
constructor(pool: MySqlPool, onWarn?: InboxWarn);
|
|
151
155
|
}
|
|
152
156
|
export declare class TiDBWorkflowCompletionInbox extends SqlWorkflowCompletionInbox {
|
|
153
157
|
constructor(pool: MySqlPool, onWarn?: InboxWarn);
|
|
@@ -155,9 +159,10 @@ export declare class TiDBWorkflowCompletionInbox extends SqlWorkflowCompletionIn
|
|
|
155
159
|
export declare class TiDBWorkflowNotifyJournalStore extends SqlWorkflowNotifyJournalStore {
|
|
156
160
|
constructor(pool: MySqlPool);
|
|
157
161
|
}
|
|
158
|
-
/** PostgreSQL bindings — historical class names
|
|
162
|
+
/** PostgreSQL bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd ctor arg
|
|
163
|
+
* (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
|
|
159
164
|
export declare class PgWorkflowRunStore extends SqlWorkflowRunStore {
|
|
160
|
-
constructor(pool: PgPool);
|
|
165
|
+
constructor(pool: PgPool, onWarn?: InboxWarn);
|
|
161
166
|
}
|
|
162
167
|
export declare class PgWorkflowCompletionInbox extends SqlWorkflowCompletionInbox {
|
|
163
168
|
constructor(pool: PgPool, onWarn?: InboxWarn);
|