machine-bridge-mcp 0.11.1 → 0.12.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.
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, join, resolve } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import { executionEnv } from "./shell.mjs";
6
6
  import { terminateProcessTree } from "./process-sessions.mjs";
7
- import { replaceFileSync } from "./atomic-fs.mjs";
7
+ import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
+ import { currentProcessStartTimeMs } from "./process-identity.mjs";
8
9
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
10
 
10
11
  const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
@@ -22,6 +23,8 @@ if (!jobDirInput) throw new Error("--job-dir is required");
22
23
  const jobDir = resolve(jobDirInput);
23
24
  if (!JOB_ID.test(basename(jobDir))) throw new Error("--job-dir must name a managed job directory");
24
25
  const recover = options.recover === true;
26
+ const recoveryLockToken = typeof process.env.MBM_RECOVERY_LOCK_TOKEN === "string" ? process.env.MBM_RECOVERY_LOCK_TOKEN : "";
27
+ delete process.env.MBM_RECOVERY_LOCK_TOKEN;
25
28
  const planFile = join(jobDir, "plan.json");
26
29
  const statusFile = join(jobDir, "status.json");
27
30
  const resultFile = join(jobDir, "result.json");
@@ -30,6 +33,7 @@ const runtimeDir = join(jobDir, "runtime");
30
33
  const resourcesDir = join(runtimeDir, "resources");
31
34
  const temporaryFilesDir = join(runtimeDir, "files");
32
35
  const runnerPidFile = join(jobDir, "runner.pid");
36
+ const RUNNER_PROCESS_STARTED_AT = new Date(currentProcessStartTimeMs()).toISOString();
33
37
 
34
38
  class JobCancelledError extends Error {
35
39
  constructor() {
@@ -49,7 +53,7 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
49
53
 
50
54
  const initial = readJson(statusFile, MAX_STATUS_BYTES);
51
55
  assertLaunchState(initial);
52
- writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
56
+ createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
53
57
  if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
54
58
  try {
55
59
  const plan = readJson(planFile, 1024 * 1024);
@@ -59,10 +63,22 @@ try {
59
63
  recordFatalRunnerError(error);
60
64
  }
61
65
 
66
+ async function releaseRecoveryClaim() {
67
+ if (!/^[a-f0-9]{32}$/.test(recoveryLockToken)) throw new Error("recovery runner is missing its ownership token");
68
+ const file = join(jobDir, "recovery.lock");
69
+ const deadline = Date.now() + 5000;
70
+ while (Date.now() < deadline) {
71
+ if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
72
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
73
+ }
74
+ throw new Error("recovery runner could not verify ownership of the recovery lock");
75
+ }
76
+
62
77
  async function main(plan, initial) {
63
78
  const status = {
64
79
  ...initial,
65
80
  runner_pid: process.pid,
81
+ runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
66
82
  started_at: initial.started_at || new Date().toISOString(),
67
83
  updated_at: new Date().toISOString(),
68
84
  status: recover ? "cleaning" : "running",
@@ -182,6 +198,7 @@ function recordFatalRunnerError(error) {
182
198
  current_phase: null,
183
199
  current_step: null,
184
200
  runner_pid: process.pid,
201
+ runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
185
202
  updated_at: now,
186
203
  finished_at: now,
187
204
  error_class: result.error_class,
@@ -259,7 +276,6 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
259
276
  timedOut = true;
260
277
  terminateProcessTree(child, "SIGTERM");
261
278
  killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
262
- killTimer.unref?.();
263
279
  }, timeoutMs);
264
280
  timer.unref?.();
265
281
  const cancellationPoll = setInterval(() => {
@@ -300,12 +316,14 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
300
316
  if (closed) return;
301
317
  closed = true;
302
318
  clearTimeout(timer);
303
- if (killTimer) clearTimeout(killTimer);
319
+ if (killTimer && !timedOut) clearTimeout(killTimer);
304
320
  clearInterval(cancellationPoll);
305
321
  activeChild = null;
306
322
  activeChildCancellationAware = false;
307
- if (cancellationEscalation) clearTimeout(cancellationEscalation);
308
- cancellationEscalation = null;
323
+ if (cancellationEscalation && !isCancellationRequested()) {
324
+ clearTimeout(cancellationEscalation);
325
+ cancellationEscalation = null;
326
+ }
309
327
  callback();
310
328
  }
311
329
  });
@@ -440,7 +458,7 @@ function redactOutput(buffer, context) {
440
458
  }
441
459
 
442
460
  function updateStatus(status, changes) {
443
- Object.assign(status, changes, { runner_pid: process.pid, updated_at: new Date().toISOString() });
461
+ Object.assign(status, changes, { runner_pid: process.pid, runner_process_started_at: RUNNER_PROCESS_STARTED_AT, updated_at: new Date().toISOString() });
444
462
  writeJson(statusFile, status, MAX_STATUS_BYTES);
445
463
  }
446
464
 
@@ -451,9 +469,9 @@ function requestCancellation() {
451
469
  terminateProcessTree(child, "SIGTERM");
452
470
  if (cancellationEscalation) return;
453
471
  cancellationEscalation = setTimeout(() => {
454
- if (activeChild === child && activeChildCancellationAware) terminateProcessTree(child, "SIGKILL");
472
+ terminateProcessTree(child, "SIGKILL");
473
+ cancellationEscalation = null;
455
474
  }, 2000);
456
- cancellationEscalation.unref?.();
457
475
  }
458
476
 
459
477
  function isCancellationRequested() {
@@ -474,12 +492,10 @@ function appendLimited(current, chunk, limit, budget) {
474
492
  }
475
493
 
476
494
  function writeJson(file, value, maxBytes) {
477
- const text = `${JSON.stringify(value, null, 2)}\n`;
495
+ const text = `${JSON.stringify(value, null, 2)}
496
+ `;
478
497
  if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
479
- const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
480
- writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
481
- replaceFileSync(temp, file);
482
- chmodSync(file, 0o600);
498
+ replaceFileAtomicallySync(file, text, { mode: 0o600 });
483
499
  }
484
500
 
485
501
  function readJson(file, maxBytes) {
package/src/local/log.mjs CHANGED
@@ -22,8 +22,15 @@ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
22
22
  const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
23
23
  const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
24
24
  const GITHUB_TOKEN = /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g;
25
+ const GITLAB_TOKEN = /\bglpat-[A-Za-z0-9_-]{20,}\b/g;
26
+ const NPM_TOKEN = /\bnpm_[A-Za-z0-9]{30,}\b/g;
27
+ const SLACK_TOKEN = /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g;
28
+ const GOOGLE_API_KEY = /\bAIza[A-Za-z0-9_-]{30,}\b/g;
29
+ const PAYMENT_API_KEY = /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g;
30
+ const JWT_VALUE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
31
+ const URL_CREDENTIALS = /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi;
25
32
  const API_SECRET = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g;
26
- const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g;
33
+ const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g;
27
34
  const HOME_PATHS = [...new Set([process.env.HOME, process.env.USERPROFILE, safeHomeDirectory()].filter(value => typeof value === "string" && value.length > 1))]
28
35
  .sort((left, right) => right.length - left.length);
29
36
 
@@ -54,6 +61,7 @@ export function createLogger(options = {}) {
54
61
  error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
55
62
  debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
56
63
  plain(message = "") { if (!quiet) process.stdout.write(`${String(message)}\n`); },
64
+ safePlain(message = "") { if (!quiet) process.stdout.write(`${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}\n`); },
57
65
  json(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
58
66
  };
59
67
  }
@@ -123,6 +131,13 @@ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
123
131
  .replace(BEARER_VALUE, "Bearer <redacted>")
124
132
  .replace(AWS_ACCESS_KEY, "<redacted-cloud-key>")
125
133
  .replace(GITHUB_TOKEN, "<redacted-access-token>")
134
+ .replace(GITLAB_TOKEN, "<redacted-access-token>")
135
+ .replace(NPM_TOKEN, "<redacted-access-token>")
136
+ .replace(SLACK_TOKEN, "<redacted-access-token>")
137
+ .replace(GOOGLE_API_KEY, "<redacted-cloud-key>")
138
+ .replace(PAYMENT_API_KEY, "<redacted-api-secret>")
139
+ .replace(JWT_VALUE, "<redacted-bearer-token>")
140
+ .replace(URL_CREDENTIALS, "<redacted-credential-url>")
126
141
  .replace(API_SECRET, "<redacted-api-secret>")
127
142
  .replace(PRIVATE_KEY_HEADER, "<redacted-private-key-header>")
128
143
  .replace(EMAIL_VALUE, "<redacted-email>");
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
- import { replaceFileSync } from "./atomic-fs.mjs";
6
+ import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
+ import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
8
9
  import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
9
10
 
10
11
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
@@ -23,7 +24,7 @@ const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted
23
24
  const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
24
25
 
25
26
  export class ManagedJobManager {
26
- constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", logger = console, recover = true }) {
27
+ constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", stateRoot = "", logger = console, recover = true }) {
27
28
  const jobRootInput = resolve(jobRoot);
28
29
  ensureOwnerOnlyDir(jobRootInput);
29
30
  this.jobRoot = realpathSync.native ? realpathSync.native(jobRootInput) : realpathSync(jobRootInput);
@@ -32,12 +33,15 @@ export class ManagedJobManager {
32
33
  this.policy = policy;
33
34
  this.resources = normalizeResourceRegistry(resources);
34
35
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
36
+ this.stateRoot = stateRoot ? resolve(stateRoot) : "";
35
37
  this.logger = logger;
38
+ this.assertMaintenanceAvailable();
36
39
  this.prune();
37
40
  if (recover) this.recoverInterruptedJobs();
38
41
  }
39
42
 
40
43
  status() {
44
+ this.assertMaintenanceAvailable();
41
45
  const jobs = this.list({ limit: MAX_JOBS }).jobs;
42
46
  return {
43
47
  active: jobs.filter((job) => ACTIVE_JOB_STATES.has(job.status)).length,
@@ -48,6 +52,7 @@ export class ManagedJobManager {
48
52
  }
49
53
 
50
54
  resourceInfo() {
55
+ this.assertMaintenanceAvailable();
51
56
  const resources = this.currentResources();
52
57
  return {
53
58
  count: Object.keys(resources).length,
@@ -57,6 +62,7 @@ export class ManagedJobManager {
57
62
  }
58
63
 
59
64
  listResources() {
65
+ this.assertMaintenanceAvailable();
60
66
  const resources = [];
61
67
  for (const [name, resource] of Object.entries(this.currentResources()).sort(([a], [b]) => a.localeCompare(b))) {
62
68
  try {
@@ -70,6 +76,7 @@ export class ManagedJobManager {
70
76
  }
71
77
 
72
78
  diagnoseStorage() {
79
+ this.assertMaintenanceAvailable();
73
80
  const probe = join(this.jobRoot, `.probe-${process.pid}-${randomBytes(6).toString("hex")}`);
74
81
  try {
75
82
  writeFileSync(probe, "ok\n", { mode: 0o600, flag: "wx" });
@@ -84,16 +91,19 @@ export class ManagedJobManager {
84
91
 
85
92
 
86
93
  stage(args = {}) {
94
+ this.assertMaintenanceAvailable();
87
95
  if (this.policy.allowWrite !== true) throw new Error("stage_job is disabled by daemon policy");
88
96
  return this.createJob(args, { launch: false });
89
97
  }
90
98
 
91
99
  start(args = {}) {
100
+ this.assertMaintenanceAvailable();
92
101
  this.assertEnabled("start_job");
93
102
  return this.createJob(args, { launch: true });
94
103
  }
95
104
 
96
105
  approve(args = {}, { localOperator = false } = {}) {
106
+ this.assertMaintenanceAvailable();
97
107
  if (!localOperator) this.assertEnabled("approve_job");
98
108
  const dir = this.jobDir(args.job_id);
99
109
  const transition = acquireJobTransitionLock(dir);
@@ -203,22 +213,29 @@ export class ManagedJobManager {
203
213
 
204
214
 
205
215
  list(args = {}) {
216
+ this.assertMaintenanceAvailable();
206
217
  this.prune();
207
218
  const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
208
219
  const jobs = [];
209
220
  for (const entry of safeReadDir(this.jobRoot)) {
210
221
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
211
222
  const dir = join(this.jobRoot, entry.name);
212
- this.reconcileStatus(dir);
213
- const status = readJson(join(dir, "status.json"), 256 * 1024);
214
- if (!status) continue;
215
- jobs.push(publicStatus(status));
223
+ try {
224
+ this.reconcileStatus(dir);
225
+ const status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
226
+ if (!status) continue;
227
+ jobs.push(publicStatus(status));
228
+ } catch (error) {
229
+ this.logger.warn?.("managed job status is unreadable; retaining it for inspection", { error_class: resourceErrorClass(error) });
230
+ jobs.push({ job_id: entry.name, name: "unavailable", status: "unreadable", error_class: resourceErrorClass(error) });
231
+ }
216
232
  }
217
233
  jobs.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
218
234
  return { jobs: jobs.slice(0, limit), retained: jobs.length, maximum: MAX_JOBS };
219
235
  }
220
236
 
221
237
  read(args = {}) {
238
+ this.assertMaintenanceAvailable();
222
239
  const dir = this.jobDir(args.job_id);
223
240
  this.reconcileStatus(dir);
224
241
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
@@ -230,6 +247,7 @@ export class ManagedJobManager {
230
247
  }
231
248
 
232
249
  inspectLocal(args = {}) {
250
+ this.assertMaintenanceAvailable();
233
251
  const dir = this.jobDir(args.job_id);
234
252
  this.reconcileStatus(dir);
235
253
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
@@ -243,6 +261,7 @@ export class ManagedJobManager {
243
261
  }
244
262
 
245
263
  cancel(args = {}) {
264
+ this.assertMaintenanceAvailable();
246
265
  const dir = this.jobDir(args.job_id);
247
266
  const transition = acquireJobTransitionLock(dir);
248
267
  if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
@@ -286,12 +305,18 @@ export class ManagedJobManager {
286
305
  }
287
306
 
288
307
  currentResources() {
308
+ this.assertMaintenanceAvailable();
289
309
  if (!this.resourceStatePath) return this.resources;
290
- const state = readJson(this.resourceStatePath, 2 * 1024 * 1024);
291
- if (!state || typeof state !== "object" || Array.isArray(state)) return existsSync(this.resourceStatePath) ? {} : this.resources;
310
+ const state = readJson(this.resourceStatePath, 2 * 1024 * 1024, "resource state");
311
+ if (!state) return this.resources;
312
+ if (typeof state !== "object" || Array.isArray(state)) throw new Error("resource state is not a JSON object");
292
313
  return normalizeResourceRegistry(state.resources);
293
314
  }
294
315
 
316
+ assertMaintenanceAvailable() {
317
+ if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
318
+ }
319
+
295
320
  assertEnabled(tool) {
296
321
  if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") {
297
322
  throw new Error(`${tool} is disabled by daemon policy`);
@@ -307,14 +332,14 @@ export class ManagedJobManager {
307
332
  }
308
333
 
309
334
  reconcileStatus(dir) {
335
+ this.assertMaintenanceAvailable();
310
336
  const file = join(dir, "status.json");
311
337
  const initial = readJson(file, 256 * 1024);
312
338
  if (!initial || !ACTIVE_JOB_STATES.has(initial.status)) {
313
339
  if (initial) scrubFinishedPlan(dir, initial);
314
340
  return;
315
341
  }
316
- const initialPid = Number(initial.runner_pid) || readRunnerPid(dir);
317
- if (Number.isInteger(initialPid) && initialPid > 0 && isPidAlive(initialPid)) return;
342
+ if (runnerProcessIsCurrent(initial, dir)) return;
318
343
  const updated = Date.parse(initial.updated_at || initial.created_at || "");
319
344
  if (Number.isFinite(updated) && Date.now() - updated < 10_000) return;
320
345
 
@@ -324,14 +349,13 @@ export class ManagedJobManager {
324
349
  try {
325
350
  const status = readJson(file, 256 * 1024);
326
351
  if (!status || !ACTIVE_JOB_STATES.has(status.status)) return;
327
- const pid = Number(status.runner_pid) || readRunnerPid(dir);
328
- if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
352
+ if (runnerProcessIsCurrent(status, dir)) return;
329
353
  const recoveryAttempts = Number(status.recovery_attempts || 0);
330
354
  if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
331
355
  markRecoveryExhausted(dir, file, status, recoveryAttempts);
332
356
  return;
333
357
  }
334
- const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts);
358
+ const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token);
335
359
  recoveryLock.handoff(runnerPid);
336
360
  handedOff = true;
337
361
  } finally {
@@ -340,22 +364,35 @@ export class ManagedJobManager {
340
364
  }
341
365
 
342
366
  recoverInterruptedJobs() {
367
+ this.assertMaintenanceAvailable();
343
368
  for (const entry of safeReadDir(this.jobRoot)) {
344
369
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
345
- this.reconcileStatus(join(this.jobRoot, entry.name));
370
+ try {
371
+ this.reconcileStatus(join(this.jobRoot, entry.name));
372
+ } catch (error) {
373
+ this.logger.warn?.("managed job recovery skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
374
+ }
346
375
  }
347
376
  }
348
377
 
349
378
  prune() {
379
+ this.assertMaintenanceAvailable();
350
380
  const entries = [];
351
381
  for (const entry of safeReadDir(this.jobRoot)) {
352
382
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
353
383
  const dir = join(this.jobRoot, entry.name);
354
- const status = readJson(join(dir, "status.json"), 256 * 1024);
355
- const mtime = safeMtime(dir);
384
+ let status;
385
+ let mtime;
386
+ try {
387
+ status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
388
+ mtime = statSync(dir).mtimeMs;
389
+ } catch (error) {
390
+ this.logger.warn?.("managed job pruning skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
391
+ continue;
392
+ }
356
393
  if (!status) {
357
- const pid = readRunnerPid(dir);
358
- if ((!pid || !isPidAlive(pid)) && Date.now() - mtime > 60_000) {
394
+ const runner = readRunnerOwner(dir, { startedAt: new Date(mtime).toISOString() });
395
+ if (!runnerProcessIsCurrent(runner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
359
396
  rmSync(dir, { recursive: true, force: true });
360
397
  continue;
361
398
  }
@@ -384,13 +421,19 @@ export class ManagedJobManager {
384
421
 
385
422
  export function activeManagedJobs(jobRoot) {
386
423
  const root = resolve(jobRoot);
424
+ if (!existsSync(root)) return [];
387
425
  const jobs = [];
388
426
  for (const entry of safeReadDir(root)) {
389
427
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
390
428
  const dir = join(root, entry.name);
391
- const status = readJson(join(dir, "status.json"), 256 * 1024);
392
- const pid = Number(status?.runner_pid) || readRunnerPid(dir);
393
- const runnerAlive = Number.isInteger(pid) && pid > 0 && isPidAlive(pid);
429
+ let status;
430
+ try {
431
+ status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
432
+ } catch (error) {
433
+ jobs.push({ job_id: entry.name, status: "unreadable", runner_alive: true, error_class: resourceErrorClass(error) });
434
+ continue;
435
+ }
436
+ const runnerAlive = runnerProcessIsCurrent(status, dir);
394
437
  const lifecycleActive = status && ACTIVE_JOB_STATES.has(status.status);
395
438
  if (runnerAlive || lifecycleActive) {
396
439
  jobs.push({
@@ -673,7 +716,7 @@ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
673
716
  scrubFinishedPlan(dir, status);
674
717
  }
675
718
 
676
- function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
719
+ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken) {
677
720
  status.status = "interrupted";
678
721
  status.updated_at = new Date().toISOString();
679
722
  status.finished_at = status.updated_at;
@@ -682,7 +725,7 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
682
725
  atomicWriteJson(statusFile, status, 256 * 1024);
683
726
  rmSync(join(dir, "runtime"), { recursive: true, force: true });
684
727
  rmSync(join(dir, "runner.pid"), { force: true });
685
- return launchRunner(dir, true);
728
+ return launchRunner(dir, true, recoveryToken);
686
729
  }
687
730
 
688
731
  function acquireRecoveryLock(dir) {
@@ -707,32 +750,102 @@ function acquireJobTransitionLock(dir) {
707
750
  }
708
751
 
709
752
  function acquirePidLock(file, { allowHandoff = false } = {}) {
710
- for (let attempt = 0; attempt < 2; attempt += 1) {
753
+ for (let attempt = 0; attempt < 3; attempt += 1) {
754
+ const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
711
755
  try {
712
- writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
756
+ createExclusiveFileSync(file, `${JSON.stringify(owner)}
757
+ `, { mode: 0o600 });
713
758
  return {
714
759
  ...(allowHandoff ? {
715
760
  handoff(pid) {
716
- if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
761
+ if (!Number.isInteger(pid) || pid <= 0) return;
762
+ const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
763
+ replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}
764
+ `);
717
765
  },
718
766
  } : {}),
719
- release() { rmSync(file, { force: true }); },
767
+ token: owner.token,
768
+ release() { removePidLockOwnedBy(file, owner.token); },
720
769
  };
721
770
  } catch (error) {
722
771
  if (error?.code !== "EEXIST") throw error;
723
- let owner = 0;
724
- try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
725
- const age = Date.now() - safeMtime(file);
726
- const ownerAlive = owner > 0 && isPidAlive(owner);
727
- const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
772
+ const snapshot = readPidLockSnapshot(file);
773
+ if (!snapshot) continue;
774
+ const age = Date.now() - snapshot.info.mtimeMs;
775
+ const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
776
+ const definitelyStale = !snapshot.owner
777
+ ? age >= 60_000
778
+ : identity.reclaimable === true;
728
779
  if (!definitelyStale) return null;
729
- rmSync(file, { force: true });
780
+ removePidLockSnapshot(file, snapshot);
730
781
  }
731
782
  }
732
783
  return null;
733
784
  }
734
785
 
735
- function launchRunner(dir, recover = false) {
786
+ function pidLockOwner(pid, startedAtMs) {
787
+ return {
788
+ pid,
789
+ token: randomBytes(16).toString("hex"),
790
+ startedAt: new Date().toISOString(),
791
+ processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
792
+ };
793
+ }
794
+
795
+ function readPidLockOwner(file) {
796
+ return readPidLockSnapshot(file)?.owner || null;
797
+ }
798
+
799
+ function readPidLockSnapshot(file) {
800
+ let info;
801
+ try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
802
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
803
+ let owner = null;
804
+ try {
805
+ const text = readBoundedFile(file, 1024).toString("utf8").trim();
806
+ if (/^\d+$/.test(text)) owner = { pid: Number(text), startedAt: new Date(info.mtimeMs).toISOString() };
807
+ else {
808
+ const parsed = JSON.parse(text);
809
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
810
+ }
811
+ } catch (error) {
812
+ if (error?.code === "ENOENT") return null;
813
+ }
814
+ return { owner, info: pidLockIdentity(info) };
815
+ }
816
+
817
+ function removePidLockOwnedBy(file, token) {
818
+ const snapshot = readPidLockSnapshot(file);
819
+ if (!snapshot || snapshot.owner?.token !== token) return false;
820
+ return removePidLockSnapshot(file, snapshot);
821
+ }
822
+
823
+ function removePidLockSnapshot(file, snapshot) {
824
+ let current;
825
+ try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
826
+ if (current.isSymbolicLink() || !current.isFile()) return false;
827
+ if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
828
+ if (snapshot.owner?.token) {
829
+ const currentOwner = readPidLockSnapshot(file)?.owner;
830
+ if (currentOwner?.token !== snapshot.owner.token) return false;
831
+ }
832
+ try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
833
+ }
834
+
835
+ function pidLockIdentity(info) {
836
+ return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
837
+ }
838
+
839
+ function samePidLockIdentity(left, right) {
840
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
841
+ }
842
+
843
+ function replacePrivateTextFile(file, content) {
844
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
845
+ ownerOnlyFile(file);
846
+ }
847
+
848
+ function launchRunner(dir, recover = false, recoveryToken = "") {
736
849
  const args = [RUNNER_PATH, "--job-dir", dir];
737
850
  if (recover) args.push("--recover");
738
851
  const stdoutFile = join(dir, "runner.out.log");
@@ -749,6 +862,7 @@ function launchRunner(dir, recover = false) {
749
862
  detached: true,
750
863
  stdio: ["ignore", stdoutFd, stderrFd],
751
864
  windowsHide: true,
865
+ env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
752
866
  });
753
867
  } finally {
754
868
  if (stdoutFd !== undefined) closeSync(stdoutFd);
@@ -761,15 +875,30 @@ function launchRunner(dir, recover = false) {
761
875
  }
762
876
 
763
877
 
764
- function readRunnerPid(dir) {
878
+ function readRunnerOwner(dir, fallback = {}) {
765
879
  try {
766
- const value = Number.parseInt(readBoundedFile(join(dir, "runner.pid"), 64).toString("utf8").trim(), 10);
767
- return Number.isInteger(value) && value > 0 ? value : 0;
880
+ const text = readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8").trim();
881
+ if (/^\d+$/.test(text)) return { pid: Number(text), ...fallback };
882
+ const parsed = JSON.parse(text);
883
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
884
+ return { ...fallback, ...parsed };
768
885
  } catch {
769
- return 0;
886
+ return { ...fallback };
770
887
  }
771
888
  }
772
889
 
890
+ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
891
+ const fallback = ownerOnly ? status : {
892
+ pid: Number(status?.runner_pid) || undefined,
893
+ processStartedAt: status?.runner_process_started_at,
894
+ startedAt: status?.started_at || status?.updated_at || status?.created_at,
895
+ };
896
+ const owner = readRunnerOwner(dir, fallback);
897
+ if (!owner.pid) return false;
898
+ const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
899
+ return identity.current || (identity.alive && !identity.reclaimable);
900
+ }
901
+
773
902
  function scrubFinishedPlan(dir, status) {
774
903
  if (PLAN_RETAINING_STATES.has(status.status)) return;
775
904
  rmSync(join(dir, "plan.json"), { force: true });
@@ -816,20 +945,30 @@ function publicStatus(status) {
816
945
 
817
946
  function atomicWriteJson(file, value, maxBytes) {
818
947
  ensureOwnerOnlyDir(dirname(file));
819
- const text = `${JSON.stringify(value, null, 2)}\n`;
948
+ const text = `${JSON.stringify(value, null, 2)}
949
+ `;
820
950
  if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
821
- const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
822
- writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
823
- replaceFileSync(temp, file);
951
+ replaceFileAtomicallySync(file, text, { mode: 0o600 });
824
952
  ownerOnlyFile(file);
825
953
  }
826
954
 
827
- function readJson(file, maxBytes) {
828
- try { return JSON.parse(readBoundedFile(file, maxBytes).toString("utf8")); } catch { return null; }
955
+ function readJson(file, maxBytes, label = "JSON") {
956
+ let buffer;
957
+ try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
958
+ if (error?.code === "ENOENT") return null;
959
+ throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
960
+ }
961
+ let text;
962
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
963
+ throw new Error(`${label} is not valid UTF-8`);
964
+ }
965
+ try { return JSON.parse(text); } catch {
966
+ throw new Error(`${label} is not valid JSON`);
967
+ }
829
968
  }
830
969
 
831
970
  function readRequiredJson(file, maxBytes, label) {
832
- const value = readJson(file, maxBytes);
971
+ const value = readJson(file, maxBytes, label);
833
972
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
834
973
  return value;
835
974
  }
@@ -900,15 +1039,7 @@ function resourceErrorClass(error) {
900
1039
  }
901
1040
 
902
1041
  function safeReadDir(dir) {
903
- try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
904
- }
905
-
906
- function safeMtime(path) {
907
- try { return statSync(path).mtimeMs; } catch { return 0; }
908
- }
909
-
910
- function isPidAlive(pid) {
911
- try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
1042
+ return readdirSync(dir, { withFileTypes: true });
912
1043
  }
913
1044
 
914
1045
  function boundedString(value, maxBytes, label) {