machine-bridge-mcp 3.0.0-beta.21 → 3.0.0-beta.26

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 (102) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/GOVERNANCE.md +2 -2
  4. package/README.md +24 -6
  5. package/browser-extension/manifest.json +1 -1
  6. package/docs/AGENT_CONTEXT.md +10 -7
  7. package/docs/ARCHITECTURE.md +35 -22
  8. package/docs/AUDIT.md +85 -1
  9. package/docs/CLIENTS.md +6 -2
  10. package/docs/ENGINEERING.md +31 -9
  11. package/docs/LOCAL_AUTOMATION.md +4 -2
  12. package/docs/LOGGING.md +8 -8
  13. package/docs/OPERATIONS.md +43 -17
  14. package/docs/PRIVACY.md +18 -4
  15. package/docs/PROJECT_STANDARDS.md +2 -2
  16. package/docs/RELEASING.md +35 -11
  17. package/docs/TESTING.md +36 -16
  18. package/docs/THREAT_MODEL.md +20 -5
  19. package/docs/TOOL_REFERENCE.md +18 -12
  20. package/docs/UPGRADING.md +32 -0
  21. package/package.json +15 -6
  22. package/scripts/check-plan.mjs +8 -0
  23. package/scripts/coverage-check.mjs +30 -1
  24. package/scripts/foreground-daemon-recovery.mjs +88 -0
  25. package/scripts/github-release.mjs +22 -16
  26. package/scripts/install-published-prerelease.mjs +7 -7
  27. package/scripts/official-mcp-conformance.mjs +243 -0
  28. package/scripts/persistent-activation-process.mjs +36 -0
  29. package/scripts/release-candidate-manifest.mjs +12 -0
  30. package/scripts/release-publication-guard.mjs +65 -0
  31. package/scripts/release-state.mjs +1 -1
  32. package/scripts/sbom-check.mjs +99 -0
  33. package/scripts/start-release-candidate.mjs +39 -13
  34. package/src/local/agent-context-projection.mjs +26 -7
  35. package/src/local/agent-context.mjs +25 -4
  36. package/src/local/autostart-log-maintenance.mjs +36 -0
  37. package/src/local/capability-observer.mjs +5 -0
  38. package/src/local/child-process-settlement.mjs +103 -0
  39. package/src/local/cli-activate.mjs +42 -5
  40. package/src/local/cli-service.mjs +55 -5
  41. package/src/local/cli.mjs +59 -10
  42. package/src/local/daemon-process.mjs +24 -3
  43. package/src/local/delegated-process-sandbox.mjs +1 -0
  44. package/src/local/execution-routing.mjs +231 -0
  45. package/src/local/git-service.mjs +3 -1
  46. package/src/local/job-runner.mjs +55 -19
  47. package/src/local/macos-trust-broker.mjs +7 -0
  48. package/src/local/managed-job-runner-claim.mjs +54 -0
  49. package/src/local/managed-job-runner.mjs +13 -2
  50. package/src/local/process-execution.mjs +2 -2
  51. package/src/local/process-identity.mjs +11 -0
  52. package/src/local/process-tree-ownership-types.d.ts +37 -0
  53. package/src/local/process-tree-ownership.mjs +49 -41
  54. package/src/local/process-tree.mjs +1 -1
  55. package/src/local/relay-call-recovery.mjs +40 -21
  56. package/src/local/runtime-activation.mjs +357 -38
  57. package/src/local/runtime-capabilities.mjs +22 -6
  58. package/src/local/runtime-diagnostics.mjs +9 -2
  59. package/src/local/runtime.mjs +18 -4
  60. package/src/local/service-convergence.mjs +33 -0
  61. package/src/local/service-owner.mjs +147 -0
  62. package/src/local/service-restart-handoff.mjs +22 -8
  63. package/src/local/service-runtime.mjs +145 -0
  64. package/src/local/service.mjs +143 -25
  65. package/src/local/state.mjs +104 -7
  66. package/src/local/stdio.mjs +139 -45
  67. package/src/local/system-network-route.mjs +76 -0
  68. package/src/local/tool-executor.mjs +24 -6
  69. package/src/local/tools.mjs +6 -5
  70. package/src/local/windows-service-convergence.mjs +49 -0
  71. package/src/local/windows-service.mjs +30 -53
  72. package/src/shared/mcp-protocol.d.mts +27 -0
  73. package/src/shared/mcp-protocol.mjs +256 -0
  74. package/src/shared/mcp-subscriptions.d.mts +4 -0
  75. package/src/shared/mcp-subscriptions.mjs +59 -0
  76. package/src/shared/relay-contract.json +1 -0
  77. package/src/shared/result-projection.d.mts +2 -1
  78. package/src/shared/result-projection.mjs +13 -2
  79. package/src/shared/server-metadata.json +11 -4
  80. package/src/shared/tool-argument-validation.d.mts +17 -0
  81. package/src/shared/tool-argument-validation.mjs +325 -0
  82. package/src/shared/tool-catalog.json +18 -12
  83. package/src/worker/durable-stream-calls.ts +12 -24
  84. package/src/worker/http.ts +36 -2
  85. package/src/worker/index.ts +181 -165
  86. package/src/worker/mcp-http-contract.ts +276 -0
  87. package/src/worker/mcp-jsonrpc.ts +12 -6
  88. package/src/worker/mcp-legacy-dispatch.ts +104 -0
  89. package/src/worker/mcp-modern-controller.ts +199 -0
  90. package/src/worker/mcp-modern-proxy.ts +126 -0
  91. package/src/worker/mcp-modern-stream.ts +71 -0
  92. package/src/worker/mcp-session.ts +12 -3
  93. package/src/worker/mcp-stream-proxy-contract.ts +67 -0
  94. package/src/worker/mcp-stream-proxy.ts +17 -60
  95. package/src/worker/mcp-tool-call-input.ts +23 -0
  96. package/src/worker/tool-catalog.ts +29 -1
  97. package/src/worker/tool-timeout.ts +53 -12
  98. package/src/worker/worker-mcp-config.ts +23 -0
  99. package/src/worker/worker-metadata.ts +10 -1
  100. package/src/worker/worker-runtime-config.ts +19 -0
  101. package/src/worker/worker-static-routes.ts +7 -2
  102. package/tsconfig.local.json +7 -1
@@ -0,0 +1,54 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
4
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
5
+ import { ownerOnlyFile } from "./state.mjs";
6
+ import { readBoundedFile } from "./managed-job-storage.mjs";
7
+
8
+ const RUNNER_CLAIM_BYTES = 1024;
9
+ const RUNNER_CLAIM_WAIT_MS = 30_000;
10
+
11
+ export function publishProvisionalRunnerClaim(dir, pid, launchToken) {
12
+ const file = join(dir, "runner.pid");
13
+ const claim = { pid, startedAt: new Date().toISOString(), launchToken };
14
+ try {
15
+ createExclusiveFileSync(file, `${JSON.stringify(claim)}\n`, { mode: 0o600 });
16
+ } catch (error) {
17
+ if (error?.code !== "EEXIST") throw error;
18
+ const existing = readRunnerClaim(file, "managed job runner claim already exists but is unreadable");
19
+ if (Number(existing?.pid) !== pid) throw new Error("managed job runner claim is owned by another process");
20
+ }
21
+ ownerOnlyFile(file);
22
+ }
23
+
24
+ export async function confirmRunnerClaim({ file, pid, processStartedAt, launchToken }) {
25
+ const exact = { pid, startedAt: new Date().toISOString(), processStartedAt };
26
+ if (!launchToken) {
27
+ createExclusiveFileSync(file, `${JSON.stringify(exact)}\n`, { mode: 0o600 });
28
+ return;
29
+ }
30
+ if (!/^[a-f0-9]{32}$/.test(launchToken)) throw new Error("runner launch token is invalid");
31
+ const deadline = createMonotonicDeadline(RUNNER_CLAIM_WAIT_MS);
32
+ while (!deadline.expired()) {
33
+ if (!existsSync(file)) {
34
+ await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); });
35
+ continue;
36
+ }
37
+ const provisional = readRunnerClaim(file, "runner ownership claim is unreadable");
38
+ if (Number(provisional?.pid) !== pid || provisional?.launchToken !== launchToken) {
39
+ throw new Error("runner ownership claim does not match the spawned process");
40
+ }
41
+ exact.startedAt = typeof provisional.startedAt === "string" && provisional.startedAt
42
+ ? provisional.startedAt
43
+ : exact.startedAt;
44
+ replaceFileAtomicallySync(file, `${JSON.stringify(exact)}\n`, { mode: 0o600 });
45
+ return;
46
+ }
47
+ throw new Error("runner ownership claim was not published before startup deadline");
48
+ }
49
+
50
+ function readRunnerClaim(file, message) {
51
+ try { return JSON.parse(readBoundedFile(file, RUNNER_CLAIM_BYTES).toString("utf8")); } catch {
52
+ throw new Error(message);
53
+ }
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { closeSync } from "node:fs";
3
4
  import { basename, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -6,10 +7,12 @@ import { inspectProcessInstance } from "./process-identity.mjs";
6
7
  import { classifyOperationalError } from "./log.mjs";
7
8
  import { ownerOnlyFile } from "./state.mjs";
8
9
  import { openPrivateAppendFile, readBoundedFile, trimDiagnosticFile } from "./managed-job-storage.mjs";
10
+ import { publishProvisionalRunnerClaim } from "./managed-job-runner-claim.mjs";
9
11
 
10
12
  const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
11
13
 
12
14
  export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
15
+ const launchToken = randomBytes(16).toString("hex");
13
16
  const args = [RUNNER_PATH, "--job-dir", dir];
14
17
  if (recover) args.push("--recover");
15
18
  const stdoutFile = join(dir, "runner.out.log");
@@ -28,7 +31,7 @@ export function launchRunner(dir, recover = false, recoveryToken = "", options =
28
31
  stdio: ["ignore", stdoutFd, stderrFd],
29
32
  windowsHide: true,
30
33
  shell: false,
31
- env: managedRunnerEnvironment({ fullEnv: options.fullEnv === true, recoveryToken, source: options.env || process.env }),
34
+ env: managedRunnerEnvironment({ fullEnv: options.fullEnv === true, recoveryToken, launchToken, source: options.env || process.env }),
32
35
  });
33
36
  } finally {
34
37
  if (stdoutFd !== undefined) closeSync(stdoutFd);
@@ -46,6 +49,12 @@ export function launchRunner(dir, recover = false, recoveryToken = "", options =
46
49
  });
47
50
  const pid = Number(child.pid);
48
51
  if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
52
+ try {
53
+ publishProvisionalRunnerClaim(dir, pid, launchToken);
54
+ } catch (error) {
55
+ try { child.kill?.("SIGKILL"); } catch {}
56
+ throw error;
57
+ }
49
58
  child.unref();
50
59
  return pid;
51
60
  }
@@ -72,7 +81,7 @@ function readRunnerOwner(dir, fallback = {}) {
72
81
  }
73
82
  }
74
83
 
75
- export function managedRunnerEnvironment({ fullEnv = false, recoveryToken = "", source = process.env } = {}) {
84
+ export function managedRunnerEnvironment({ fullEnv = false, recoveryToken = "", launchToken = "", source = process.env } = {}) {
76
85
  const env = fullEnv ? { ...source } : {};
77
86
  if (!fullEnv) {
78
87
  for (const key of ["PATH", "HOME", "USERPROFILE", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP"]) {
@@ -81,5 +90,7 @@ export function managedRunnerEnvironment({ fullEnv = false, recoveryToken = "",
81
90
  }
82
91
  if (recoveryToken) env.MBM_RECOVERY_LOCK_TOKEN = recoveryToken;
83
92
  else delete env.MBM_RECOVERY_LOCK_TOKEN;
93
+ if (launchToken) env.MBM_RUNNER_LAUNCH_TOKEN = launchToken;
94
+ else delete env.MBM_RUNNER_LAUNCH_TOKEN;
84
95
  return env;
85
96
  }
@@ -78,9 +78,9 @@ export class ProcessExecutionService {
78
78
  return publicProcessToolResult({ name: command.name, cwd: this.displayPath(cwd, context), timeout_seconds: timeoutSeconds, ...result });
79
79
  }
80
80
 
81
- async probeShell(context = {}) {
81
+ async probeShell(context = {}, timeoutMs = 5_000) {
82
82
  const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
83
- return this.run(shell.cmd, shell.args, 5000, true, 64 * 1024, context);
83
+ return this.run(shell.cmd, shell.args, timeoutMs, true, 64 * 1024, context);
84
84
  }
85
85
 
86
86
  async runFixedInternal(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace) {
@@ -34,6 +34,16 @@ export function processStartTimeMs(pid) {
34
34
  return result.ok ? parseTime(result.stdout) : null;
35
35
  }
36
36
 
37
+ export function processState(pid) {
38
+ const parsed = normalizePid(pid);
39
+ if (!parsed || process.platform === "win32") return "unknown";
40
+ const result = runBounded("ps", ["-p", String(parsed), "-o", "state="]);
41
+ if (!result.ok) return "unknown";
42
+ const state = result.stdout.trim().charAt(0).toUpperCase();
43
+ if (state === "Z") return "zombie";
44
+ return state ? "running" : "unknown";
45
+ }
46
+
37
47
  export function processCommandLine(pid) {
38
48
  const parsed = normalizePid(pid);
39
49
  if (!parsed) return "";
@@ -123,6 +133,7 @@ function runBounded(command, args) {
123
133
  const result = spawnSync(command, args, {
124
134
  encoding: "utf8",
125
135
  timeout: COMMAND_TIMEOUT_MS,
136
+ killSignal: "SIGKILL",
126
137
  maxBuffer: COMMAND_OUTPUT_BYTES,
127
138
  windowsHide: true,
128
139
  env: process.platform === "win32" ? process.env : { ...process.env, LC_ALL: "C", LANG: "C" },
@@ -0,0 +1,37 @@
1
+ export interface ChildProcessIdentity {
2
+ pid?: unknown;
3
+ exitCode?: unknown;
4
+ signalCode?: unknown;
5
+ }
6
+
7
+ export interface ProcessGroupEntry {
8
+ pid: number;
9
+ pgid: number;
10
+ startedAt: number;
11
+ }
12
+
13
+ export interface ProcessOwnershipMember {
14
+ pid: number;
15
+ startedAt: number | null;
16
+ }
17
+
18
+ export interface ProcessOwnershipSnapshot {
19
+ platform: string;
20
+ pid: number;
21
+ members: ProcessOwnershipMember[];
22
+ }
23
+
24
+ export interface ProcessSnapshotResult {
25
+ error?: unknown;
26
+ status?: number | null;
27
+ stdout?: string | Buffer;
28
+ }
29
+
30
+ export interface ProcessOwnershipOptions {
31
+ platform?: string;
32
+ listProcessGroups?: (options: ProcessOwnershipOptions, pid: number, timeoutMs: number) => ProcessGroupEntry[];
33
+ spawnSyncProcess?: (command: string, args: string[], options: Record<string, unknown>) => ProcessSnapshotResult;
34
+ ownershipCheckBudgetMs?: unknown;
35
+ processSnapshotTimeoutMs?: unknown;
36
+ monotonicNow?: () => number;
37
+ }
@@ -1,10 +1,16 @@
1
+ // @ts-check
2
+
1
3
  import { spawnSync } from "node:child_process";
4
+ import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
2
5
 
3
- const PROCESS_SNAPSHOT_TIMEOUT_MS = 3000;
4
- const PROCESS_SNAPSHOT_BYTES = 512 * 1024;
5
- const START_TIME_TOLERANCE_MS = 1500;
6
+ export const DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS = 3000;
7
+ /** @typedef {import("./process-tree-ownership-types.d.ts").ChildProcessIdentity} ChildProcessIdentity */
8
+ /** @typedef {import("./process-tree-ownership-types.d.ts").ProcessGroupEntry} ProcessGroupEntry */
9
+ /** @typedef {import("./process-tree-ownership-types.d.ts").ProcessOwnershipMember} ProcessOwnershipMember */
10
+ /** @typedef {import("./process-tree-ownership-types.d.ts").ProcessOwnershipSnapshot} ProcessOwnershipSnapshot */
11
+ /** @typedef {import("./process-tree-ownership-types.d.ts").ProcessOwnershipOptions} ProcessOwnershipOptions */
6
12
 
7
- export function captureProcessTreeOwnership(child, options = {}) {
13
+ export function captureProcessTreeOwnership(/** @type {ChildProcessIdentity} */ child, /** @type {ProcessOwnershipOptions} */ options = {}) {
8
14
  const pid = positivePid(child?.pid);
9
15
  const platform = String(options.platform || process.platform);
10
16
  if (!pid) return { platform, pid: 0, members: [] };
@@ -12,7 +18,7 @@ export function captureProcessTreeOwnership(child, options = {}) {
12
18
  return { platform, pid, members: processGroupMembers(pid, options) };
13
19
  }
14
20
 
15
- export function refreshProcessTreeOwnership(snapshot, options = {}) {
21
+ export function refreshProcessTreeOwnership(/** @type {ProcessOwnershipSnapshot | null | undefined} */ snapshot, /** @type {ProcessOwnershipOptions} */ options = {}) {
16
22
  if (!snapshot?.pid || snapshot.platform === "win32") return snapshot;
17
23
  const members = [...(snapshot.members || [])];
18
24
  for (const observed of processGroupMembers(snapshot.pid, options)) {
@@ -21,51 +27,53 @@ export function refreshProcessTreeOwnership(snapshot, options = {}) {
21
27
  return { ...snapshot, members };
22
28
  }
23
29
 
24
- export function processTreeOwnershipStillCurrent(snapshot, child, options = {}) {
30
+ export function processTreeOwnershipStillCurrent(/** @type {ProcessOwnershipSnapshot | null | undefined} */ snapshot, /** @type {ChildProcessIdentity} */ child, /** @type {ProcessOwnershipOptions} */ options = {}) {
25
31
  if (!snapshot?.pid) return false;
26
32
  if (snapshot.platform === "win32") return !childHasExited(child);
27
- if (!Array.isArray(snapshot.members) || snapshot.members.length === 0) return !childHasExited(child);
28
- const current = processGroupMembers(snapshot.pid, options);
29
- if (snapshot.members.some((expected) => current.some((observed) => sameProcessIdentity(expected, observed)))) return true;
30
- return snapshot.members.some((expected) => processGroupMembers(snapshot.pid, options, expected.pid)
31
- .some((observed) => sameProcessIdentity(expected, observed)));
33
+ const members = Array.isArray(snapshot.members) ? snapshot.members : [];
34
+ if (members.length === 0) return false;
35
+ const budget = createSnapshotBudget(options);
36
+ const fullSnapshotTimeoutMs = budget.take(members.length + 1);
37
+ if (!fullSnapshotTimeoutMs) return false;
38
+ const current = processGroupMembers(snapshot.pid, options, 0, fullSnapshotTimeoutMs);
39
+ if (members.some((expected) => current.some((observed) => sameProcessIdentity(expected, observed)))) return true;
40
+ for (let index = 0; index < members.length; index += 1) {
41
+ const timeoutMs = budget.take(members.length - index);
42
+ if (!timeoutMs) return false;
43
+ const expected = members[index];
44
+ if (processGroupMembers(snapshot.pid, options, expected.pid, timeoutMs).some((observed) => sameProcessIdentity(expected, observed))) return true;
45
+ }
46
+ return false;
32
47
  }
33
48
 
34
- function processGroupMembers(groupId, options = {}, pid = 0) {
35
- const list = typeof options.listProcessGroups === "function" ? options.listProcessGroups : listProcessGroups;
36
- return list(options, pid).filter((entry) => entry.pgid === groupId).map(({ pid: memberPid, startedAt }) => ({ pid: memberPid, startedAt }));
49
+ function processGroupMembers(/** @type {number} */ groupId, /** @type {ProcessOwnershipOptions} */ options = {}, /** @type {number} */ pid = 0, /** @type {number} */ timeoutMs = snapshotTimeout(options)) {
50
+ const entries = typeof options.listProcessGroups === "function"
51
+ ? options.listProcessGroups(options, pid, timeoutMs) : listProcessGroups(options, pid, timeoutMs, groupId);
52
+ return entries.filter((entry) => entry.pgid === groupId).map(({ pid: memberPid, startedAt }) => ({ pid: memberPid, startedAt }));
37
53
  }
38
54
 
39
- function listProcessGroups(options = {}, pid = 0) {
40
- const run = typeof options.spawnSyncProcess === "function" ? options.spawnSyncProcess : spawnSync;
41
- const args = pid ? ["-p", String(pid), "-o", "pid=,pgid=,lstart="] : ["-axo", "pid=,pgid=,lstart="];
55
+ function listProcessGroups(/** @type {ProcessOwnershipOptions} */ options = {}, /** @type {number} */ pid = 0, /** @type {number} */ timeoutMs = snapshotTimeout(options), /** @type {number} */ groupId = 0) {
56
+ const run = typeof options.spawnSyncProcess === "function" ? options.spawnSyncProcess : defaultSpawnSyncProcess;
57
+ const platform = String(options.platform || process.platform);
58
+ const args = pid ? ["-p", String(pid), "-o", "pid=,pgid=,lstart="]
59
+ : platform === "darwin" ? ["-g", String(groupId), "-o", "pid=,pgid=,lstart="] : ["-axo", "pid=,pgid=,lstart="];
42
60
  const result = run("ps", args, {
43
- encoding: "utf8",
44
- timeout: PROCESS_SNAPSHOT_TIMEOUT_MS,
45
- maxBuffer: PROCESS_SNAPSHOT_BYTES,
46
- windowsHide: true,
47
- env: { ...process.env, LC_ALL: "C", LANG: "C" },
48
- stdio: ["ignore", "pipe", "ignore"],
61
+ encoding: "utf8", timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 512 * 1024,
62
+ windowsHide: true, env: { ...process.env, LC_ALL: "C", LANG: "C" }, stdio: ["ignore", "pipe", "ignore"],
49
63
  });
50
64
  if (result?.error || result?.status !== 0) return [];
51
- return String(result.stdout || "").split(/\r?\n/).map(parseProcessRow).filter(Boolean);
65
+ return String(result.stdout || "").split(/\r?\n/).map(parseProcessRow).filter(isProcessGroupEntry);
52
66
  }
53
67
 
54
- function parseProcessRow(line) {
55
- const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(String(line || ""));
56
- if (!match) return null;
57
- const pid = positivePid(match[1]);
58
- const pgid = positivePid(match[2]);
59
- const startedAt = Date.parse(match[3]);
60
- return pid && pgid && Number.isFinite(startedAt) ? { pid, pgid, startedAt } : null;
61
- }
62
-
63
- function sameProcessIdentity(left, right) {
64
- return left.pid === right.pid && Number.isFinite(left.startedAt) && Number.isFinite(right.startedAt)
65
- && Math.abs(left.startedAt - right.startedAt) <= START_TIME_TOLERANCE_MS;
66
- }
67
- function positivePid(value) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; }
68
- function childHasExited(child) {
69
- return child?.exitCode !== null && child?.exitCode !== undefined
70
- || child?.signalCode !== null && child?.signalCode !== undefined;
68
+ function defaultSpawnSyncProcess(/** @type {string} */ command, /** @type {string[]} */ args, /** @type {Record<string, unknown>} */ options) {
69
+ const result = spawnSync(command, args, /** @type {import("node:child_process").SpawnSyncOptionsWithStringEncoding} */ (/** @type {unknown} */ (options)));
70
+ return { error: result.error, status: result.status, stdout: result.stdout };
71
71
  }
72
+ function createSnapshotBudget(/** @type {ProcessOwnershipOptions} */ options) { const total = boundedPositive(options.ownershipCheckBudgetMs, DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS); const deadline = createMonotonicDeadline(total, options.monotonicNow); let unallocated = total; return { take(/** @type {number} */ slots) { const remaining = Math.min(unallocated, Math.floor(deadline.remainingMs())); if (remaining < 1) return 0; const value = Math.max(1, Math.min(DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS, Math.floor(remaining / Math.max(1, slots)))); unallocated -= value; return value; } }; }
73
+ function snapshotTimeout(/** @type {ProcessOwnershipOptions} */ options) { return boundedPositive(options.processSnapshotTimeoutMs, DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS); }
74
+ function boundedPositive(/** @type {unknown} */ value, /** @type {number} */ fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 1 ? Math.min(DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS, Math.floor(parsed)) : fallback; }
75
+ function parseProcessRow(/** @type {unknown} */ line) { const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(String(line || "")); if (!match) return null; const pid = positivePid(match[1]); const pgid = positivePid(match[2]); const startedAt = Date.parse(match[3]); return pid && pgid && Number.isFinite(startedAt) ? { pid, pgid, startedAt } : null; }
76
+ function isProcessGroupEntry(/** @type {ProcessGroupEntry | null} */ value) { return value !== null; }
77
+ function sameProcessIdentity(/** @type {ProcessOwnershipMember} */ left, /** @type {ProcessOwnershipMember} */ right) { return left.pid === right.pid && typeof left.startedAt === "number" && Number.isFinite(left.startedAt) && typeof right.startedAt === "number" && Number.isFinite(right.startedAt) && left.startedAt === right.startedAt; }
78
+ function positivePid(/** @type {unknown} */ value) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; }
79
+ function childHasExited(/** @type {ChildProcessIdentity} */ child) { return child?.exitCode !== null && child?.exitCode !== undefined || child?.signalCode !== null && child?.signalCode !== undefined; }
@@ -4,7 +4,7 @@ import {
4
4
  processTreeOwnershipStillCurrent,
5
5
  refreshProcessTreeOwnership,
6
6
  } from "./process-tree-ownership.mjs";
7
- export { captureProcessTreeOwnership, processTreeOwnershipStillCurrent, refreshProcessTreeOwnership } from "./process-tree-ownership.mjs";
7
+ export { DEFAULT_PROCESS_OWNERSHIP_CHECK_BUDGET_MS, captureProcessTreeOwnership, processTreeOwnershipStillCurrent, refreshProcessTreeOwnership } from "./process-tree-ownership.mjs";
8
8
 
9
9
  export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000;
10
10
 
@@ -43,28 +43,36 @@ export class RelayCallRecovery {
43
43
  /** @param {RelayResult} response */
44
44
  deliver(response) {
45
45
  const callId = String(response?.id || "");
46
- if (this.send(response)) {
47
- if (callId) this.pendingResults.delete(callId);
48
- return true;
49
- }
50
- if (callId && this.isRecoverable()) {
51
- this.pendingResults.set(callId, response);
52
- this.scheduleExpiry();
53
- this.logger.event?.("debug", "relay.tool_result.queued", {
54
- call_id: shortCallId(callId), queued_results: this.pendingResults.size,
55
- }, "Queued a completed tool result while the relay reconnects");
46
+ if (!callId) return this.send(response);
47
+ if (!this.isRecoverable()) {
48
+ this.logger.event?.("debug", "relay.tool_result.discarded", {
49
+ call_id: shortCallId(callId), reason: "transport_unavailable",
50
+ }, "Discarded a tool result because the relay is no longer recoverable");
56
51
  return false;
57
52
  }
58
- this.logger.event?.("debug", "relay.tool_result.discarded", {
59
- call_id: shortCallId(callId), reason: "transport_unavailable",
60
- }, "Discarded a tool result because the relay is no longer recoverable");
53
+
54
+ // Retain sent results until the Worker commits and acknowledges them.
55
+ this.pendingResults.set(callId, response);
56
+ const sent = this.send(response);
57
+ if (sent) {
58
+ this.logger.event?.("debug", "relay.tool_result.awaiting_ack", {
59
+ call_id: shortCallId(callId), unacknowledged_results: this.pendingResults.size,
60
+ }, "Delivered a tool result and retained it until Worker acknowledgement");
61
+ return true;
62
+ }
63
+
64
+ this.scheduleExpiry();
65
+ this.logger.event?.("debug", "relay.tool_result.queued", {
66
+ call_id: shortCallId(callId), queued_results: this.pendingResults.size,
67
+ }, "Queued a completed tool result while the relay reconnects");
61
68
  return false;
62
69
  }
63
70
 
64
71
  /** @param {unknown} callId */
65
- discard(callId) {
66
- return this.pendingResults.delete(String(callId));
67
- }
72
+ acknowledge(callId) { return this.pendingResults.delete(String(callId)); }
73
+
74
+ /** @param {unknown} callId */
75
+ discard(callId) { return this.pendingResults.delete(String(callId)); }
68
76
 
69
77
  /** @param {Iterable<string>} resumedCallIds @param {(callId: string) => boolean} cancelCall */
70
78
  reconcile(resumedCallIds, cancelCall) {
@@ -94,18 +102,29 @@ export class RelayCallRecovery {
94
102
 
95
103
  ready() {
96
104
  this.clearTimer();
105
+ this.retryUnacknowledged("reconnected");
106
+ }
107
+
108
+ pulse() {
109
+ this.retryUnacknowledged("heartbeat");
110
+ }
111
+
112
+ /** @param {string} reason */
113
+ retryUnacknowledged(reason) {
97
114
  let delivered = 0;
98
- for (const [callId, response] of [...this.pendingResults]) {
115
+ for (const response of this.pendingResults.values()) {
99
116
  if (!this.send(response)) {
100
117
  this.scheduleExpiry();
101
118
  break;
102
119
  }
103
- this.pendingResults.delete(callId);
104
120
  delivered += 1;
105
121
  }
106
122
  if (delivered > 0) {
107
- this.logger.event?.("info", "relay.tool_results.replayed", { delivered_results: delivered },
108
- "Delivered completed tool results after the relay reconnected");
123
+ this.logger.event?.(reason === "reconnected" ? "info" : "debug", "relay.tool_results.replayed", {
124
+ delivered_results: delivered,
125
+ reason,
126
+ unacknowledged_results: this.pendingResults.size,
127
+ }, "Replayed completed tool results awaiting Worker acknowledgement");
109
128
  }
110
129
  }
111
130
 
@@ -122,7 +141,7 @@ export class RelayCallRecovery {
122
141
  const cancelled = this.cancelOrigin("remote relay reconnect grace expired");
123
142
  const discarded = this.pendingResults.size;
124
143
  this.pendingResults.clear();
125
- this.terminate();
144
+ if (cancelled > 0) this.terminate();
126
145
  if (cancelled > 0 || discarded > 0) {
127
146
  this.logger.warn?.(`remote relay did not recover within ${this.graceMs / 1000} seconds; cancelled ${cancelled} call(s) and discarded ${discarded} queued result(s)`);
128
147
  }