@sema-agent/server 3.21.0 → 3.23.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 (55) hide show
  1. package/dist/approval-hmac.d.ts +9 -9
  2. package/dist/approval-hmac.js +0 -31
  3. package/dist/approval.js +8 -1
  4. package/dist/bake-runner/main.d.ts +2 -2
  5. package/dist/bake-runner/main.js +32 -9
  6. package/dist/boot/config-center.d.ts +3 -2
  7. package/dist/boot/resolve-spec.js +4 -4
  8. package/dist/boot/session-faces.js +3 -2
  9. package/dist/boot/workflow-orchestration.js +1 -1
  10. package/dist/budget.d.ts +2 -2
  11. package/dist/budget.js +11 -6
  12. package/dist/capabilities/center-plugins.js +1 -1
  13. package/dist/capabilities/skills.d.ts +12 -1
  14. package/dist/capabilities/skills.js +31 -6
  15. package/dist/config.d.ts +12 -0
  16. package/dist/config.js +17 -0
  17. package/dist/fleet/fleet-bus.js +4 -1
  18. package/dist/hooks/hook-llm.d.ts +2 -2
  19. package/dist/hooks/hook-llm.js +2 -2
  20. package/dist/hooks/hook-runner.d.ts +4 -0
  21. package/dist/hooks/hook-runner.js +4 -4
  22. package/dist/http/principal-gate.d.ts +7 -3
  23. package/dist/http/principal-gate.js +7 -5
  24. package/dist/http/route-ctx.d.ts +34 -25
  25. package/dist/http/routes/approvals-assistant.js +5 -5
  26. package/dist/http/routes/images.js +8 -8
  27. package/dist/http/routes/runs.js +6 -1
  28. package/dist/http/server.d.ts +2 -2
  29. package/dist/http/server.js +10 -6
  30. package/dist/key-resolver.d.ts +7 -1
  31. package/dist/key-resolver.js +0 -23
  32. package/dist/leader/wire.d.ts +19 -1
  33. package/dist/leader/wire.js +48 -18
  34. package/dist/lsp/e2b-bridge.js +14 -1
  35. package/dist/lsp/e2b-manager.d.ts +8 -4
  36. package/dist/lsp/e2b-manager.js +55 -23
  37. package/dist/main.js +2 -2
  38. package/dist/parked-decide.js +4 -1
  39. package/dist/plugins/file-run-store.js +5 -5
  40. package/dist/plugins/host-platform.d.ts +11 -24
  41. package/dist/plugins/host-platform.js +14 -0
  42. package/dist/plugins/memory-engine-tidb.js +16 -0
  43. package/dist/plugins/memory-run-store.js +5 -5
  44. package/dist/plugins/remote-env-adb.js +12 -5
  45. package/dist/plugins/remote-env-local-docker.js +3 -7
  46. package/dist/plugins/remote-env-ssh.js +17 -2
  47. package/dist/plugins/run-store-sql.js +12 -12
  48. package/dist/plugins/store-backend.d.ts +1 -1
  49. package/dist/plugins/store-backend.js +2 -2
  50. package/dist/plugins/tool-result-store-sql.d.ts +7 -1
  51. package/dist/plugins/tool-result-store-sql.js +9 -8
  52. package/dist/plugins/workflow-run-store-sql.d.ts +11 -6
  53. package/dist/plugins/workflow-run-store-sql.js +18 -8
  54. package/dist/session-sync.js +6 -3
  55. package/package.json +1 -1
@@ -12,19 +12,28 @@ import { randomUUID } from "node:crypto";
12
12
  import { connectWsLspTransport } from "./ws-transport.js";
13
13
  import { E2bLspManager } from "./manager.js";
14
14
  import { BRIDGE_DIR, BRIDGE_SOURCE, lspInstallCommand, lspStartCommand, portForLanguage, SERVER_CMD } from "./e2b-bridge.js";
15
- /** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect. */
16
- export async function openE2bLspTransport(language, env, opts) {
17
- if (!SERVER_CMD[language])
18
- return undefined; // no server for this language graceful degrade
19
- const connect = opts?.connect ?? connectWsLspTransport;
20
- const retry = opts?.retry ?? { attempts: 6, delayMs: 1500 };
21
- const port = portForLanguage(language);
15
+ /** Write bridge.cjs + (foreground) install the language server, then start the bridge as a TRUE background
16
+ * command. Returns whether the bridge is now up-or-starting (`false` only when the foreground install failed).
17
+ * A single-shot primitive: it does NOT check whether a bridge is already listening on `port` — a caller that
18
+ * can have two languages share one port (`createE2bLspManager`, since `portForLanguage` maps typescript AND
19
+ * javascript onto 8123 off the same `SERVER_CMD`) MUST call this at most ONCE per env+port itself. A second
20
+ * `node bridge.cjs` on an already-bound port surfaces as an EADDRINUSE `wss` 'error' (e2b-bridge.ts D6
21
+ * hardening keeps that from crashing the sandbox, but the second attempt still fails and wastes a sandbox
22
+ * round-trip for nothing — HRD-LSP-4). */
23
+ async function startE2bBridge(language, env, port, token) {
22
24
  await env.writeFile(`${BRIDGE_DIR}/bridge.cjs`, BRIDGE_SOURCE).catch(() => undefined);
23
25
  const installed = await env.exec(lspInstallCommand(language)).catch(() => undefined);
24
26
  if (!installed || !installed.ok)
25
- return undefined;
26
- const token = opts?.token ?? randomUUID(); // per-session bridge auth — the getHost URL is public (council #4)
27
+ return false;
27
28
  await env.startBackground(lspStartCommand(language, port, token)).catch(() => undefined);
29
+ return true;
30
+ }
31
+ /** Connect (with retry) to an already-started bridge. Each call opens its OWN WS connection — the bridge spawns
32
+ * a FRESH child language-server process per connection (e2b-bridge.ts `wss.on('connection', …)`), so two
33
+ * languages sharing one bridge port each still get an isolated language-server instance. */
34
+ async function connectE2bLspBridge(env, port, token, opts) {
35
+ const connect = opts?.connect ?? connectWsLspTransport;
36
+ const retry = opts?.retry ?? { attempts: 6, delayMs: 1500 };
28
37
  const root = env.workspaceHandle().mountPath;
29
38
  // the bridge starts as a background command → retry the WS connect (with the auth token) while it comes up
30
39
  const scheme = opts?.scheme ?? "wss";
@@ -39,26 +48,49 @@ export async function openE2bLspTransport(language, env, opts) {
39
48
  }
40
49
  return undefined;
41
50
  }
51
+ /** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
52
+ * Every call starts (or re-starts) the bridge unconditionally — the one production caller is
53
+ * `createE2bLspManager`, which de-dupes per env+port before ever reaching here (see its doc). */
54
+ export async function openE2bLspTransport(language, env, opts) {
55
+ if (!SERVER_CMD[language])
56
+ return undefined; // no server for this language → graceful degrade
57
+ const port = portForLanguage(language);
58
+ const token = opts?.token ?? randomUUID(); // per-session bridge auth — the getHost URL is public (council #4)
59
+ const started = await startE2bBridge(language, env, port, token);
60
+ if (!started)
61
+ return undefined;
62
+ return connectE2bLspBridge(env, port, token, opts);
63
+ }
42
64
  /** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
43
65
  export function createE2bLspManager(opts) {
44
- // One bridge token per env+language, pinned across re-opens (heal after an idle-killed WS): the original
45
- // bridge keeps listening with the first token, so the reconnect must present the SAME one. WeakMap → a
46
- // destroyed task's tokens are collectible with its env.
47
- const tokens = new WeakMap();
66
+ // One bridge PROCESS + token per env+PORT not per env+language. `portForLanguage` maps typescript and
67
+ // javascript onto the SAME port (they share `SERVER_CMD`): keying by language minted two auth tokens for the
68
+ // one listening bridge (HRD-LSP-4) and re-invoked `startBackground` on an already-bound port on every open.
69
+ // WeakMap a destroyed task's bridge bookkeeping is collectible with its env.
70
+ const bridges = new WeakMap();
48
71
  return new E2bLspManager({
49
72
  log: opts?.log,
50
- transportFactory: (language, env) => {
51
- let perLang = tokens.get(env);
52
- if (!perLang) {
53
- perLang = new Map();
54
- tokens.set(env, perLang);
73
+ transportFactory: async (language, env) => {
74
+ if (!SERVER_CMD[language])
75
+ return undefined; // no server for this language → graceful degrade (mirrors
76
+ // openE2bLspTransport's own guard; MUST run before touching `bridges` — `portForLanguage` falls back to
77
+ // port 8123 for an unmapped language, which would otherwise silently borrow typescript's live bridge)
78
+ const port = portForLanguage(language);
79
+ let perPort = bridges.get(env);
80
+ if (!perPort) {
81
+ perPort = new Map();
82
+ bridges.set(env, perPort);
55
83
  }
56
- let token = perLang.get(language);
57
- if (!token) {
58
- token = opts?.token ?? randomUUID();
59
- perLang.set(language, token);
84
+ let bridge = perPort.get(port);
85
+ if (!bridge) {
86
+ const token = opts?.token ?? randomUUID();
87
+ bridge = { token, ready: startE2bBridge(language, env, port, token) };
88
+ perPort.set(port, bridge); // synchronous — no await above (run-to-completion dedupe guard, see E2bBridgeHandle.ready doc)
60
89
  }
61
- return openE2bLspTransport(language, env, { ...opts, token });
90
+ const started = await bridge.ready;
91
+ if (!started)
92
+ return undefined;
93
+ return connectE2bLspBridge(env, port, bridge.token, opts);
62
94
  },
63
95
  });
64
96
  }
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, explicitOperator } from "./http/server.js";
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: explicitOperator(principal, config.operatorPrincipals), tenantId: principal ?? null }),
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
@@ -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
- ...(req.reason ? { reason: req.reason } : {}),
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 set owner excludes null-owner rows
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 set owner excludes null-owner rows
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
- exitCode = code ?? 0;
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: code ?? 0, stdoutBytes: new Uint8Array(stdoutBytes) }));
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: code ?? 0 }));
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) ?? 0) : 1);
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) ?? 0) : 1);
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
- finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr: markTruncated(err.text, err.droppedBytes), exitCode: code ?? 0 }));
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
- exitCode = code ?? 0;
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
- try {
129
- 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)]);
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
- constructor(db: SqlDriver);
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 + ctor shapes preserved. */
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 + ctor shapes preserved. */
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);