@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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 (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -0,0 +1,658 @@
1
+ /**
2
+ * The run-scoped executor launcher (BAPI-1279).
3
+ *
4
+ * `drive-epic` used to start an executor only as a lane of a LOCAL plane. A
5
+ * launch against production — or a second run in any repository, since plane
6
+ * lanes serve one run — created a run that nothing would ever claim. This module
7
+ * gives every such run its own executor, started in the background from the
8
+ * launching checkout.
9
+ *
10
+ * ## Two phases, split around run creation
11
+ *
12
+ * - {@link prepareRunExecutor} runs BEFORE `setup-epic` creates the run. It is
13
+ * read-only: it resolves the Git top level and the package entrypoint, and a
14
+ * failure here refuses the launch while nothing exists yet.
15
+ * - {@link ensureRunExecutor} runs AFTER the run exists. It starts (or reuses)
16
+ * exactly one executor scoped to that run and waits for its own heartbeat.
17
+ * A failure here leaves the run untouched and server-side; the operator gets
18
+ * the log path and the exact command to start it by hand.
19
+ *
20
+ * ## Files
21
+ *
22
+ * Everything lives under `<git top level>/.bridge/executors/`:
23
+ *
24
+ * - `<runId>.json` — the non-secret record: pid, executor id, run id, base
25
+ * URL, start time, and the log path.
26
+ * - `<runId>.log` — the executor's stdout and stderr, redacted.
27
+ * - `<runId>.lock` — held only while one launch decides whether to start or
28
+ * reuse, so two launches for one run cannot both spawn.
29
+ *
30
+ * ## Secrets
31
+ *
32
+ * The API key travels in the child's ENVIRONMENT only — never argv (readable
33
+ * through `ps`), never the record, never the log. The executor's output passes
34
+ * through a small detached relay that redacts known credential values, line by
35
+ * line so a value split across two pipe chunks is still caught. The relay, not
36
+ * the launcher, owns the pipes: the launcher exits right after the heartbeat
37
+ * wait, and a pipe to a dead parent would kill the executor on its next write.
38
+ *
39
+ * ## What "reuse" requires
40
+ *
41
+ * A live pid is not proof. Pids are recycled, so a record is reused only when
42
+ * the pid is alive, its command line still names this executor id and run, and
43
+ * that executor instance's own heartbeat is fresh. Anything short of that and
44
+ * uncertain is refused rather than signalled or duplicated.
45
+ */
46
+ import { spawn as nodeSpawn } from "node:child_process";
47
+ import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
48
+ import os from "node:os";
49
+ import path from "node:path";
50
+ import { shellQuote } from "./epic-launch-profile.js";
51
+ import { isValidExecutorEpicRunId } from "./executor/claim-scope.js";
52
+ import { ensureGitInfoExcluded } from "./git-ignore-utils.js";
53
+ import { createPlaneExecFile, createPlaneProcessDeps, probeAutomationHealth, resolvePackageEntrypoint, } from "./plane/defaults.js";
54
+ import { buildPlaneRedactor } from "./plane/supervisor.js";
55
+ /** Directory, relative to the Git top level, holding every launcher file. */
56
+ export const RUN_EXECUTOR_DIR = path.join(".bridge", "executors");
57
+ /** The ignore line for {@link RUN_EXECUTOR_DIR}. */
58
+ export const RUN_EXECUTOR_IGNORE_ENTRY = ".bridge/executors/";
59
+ /** Default bound on the heartbeat wait. */
60
+ export const RUN_EXECUTOR_HEARTBEAT_TIMEOUT_MS = 90_000;
61
+ /** Heartbeat poll interval during the wait. */
62
+ export const RUN_EXECUTOR_HEARTBEAT_POLL_MS = 2_000;
63
+ /** A launch lock older than this, with no readable owner, is abandoned. */
64
+ export const RUN_EXECUTOR_LOCK_STALE_MS = 5 * 60_000;
65
+ /** Record schema version. A record with any other version is not trusted. */
66
+ const RUN_EXECUTOR_RECORD_VERSION = 1;
67
+ /**
68
+ * The relay the executor runs under.
69
+ *
70
+ * `node -e` with discrete arguments: `<logPath> <command> <args...>`. It owns
71
+ * the executor's pipes for the executor's whole life, redacts every credential
72
+ * value it can see in its OWN environment (which is the executor's), buffers to
73
+ * a line boundary so a value split across two chunks is still whole when it is
74
+ * checked, and forwards a stop signal so the executor drains gracefully. It is
75
+ * CommonJS on purpose: `-e` input is CommonJS regardless of the package type.
76
+ */
77
+ export const RUN_EXECUTOR_RELAY_SOURCE = [
78
+ 'const { spawn } = require("node:child_process");',
79
+ 'const fs = require("node:fs");',
80
+ "const [logPath, command, ...args] = process.argv.slice(1);",
81
+ "const secrets = [process.env.BAPI_API_KEY, process.env.ANTHROPIC_API_KEY, process.env.CLAUDE_CODE_OAUTH_TOKEN]",
82
+ ' .filter((s) => typeof s === "string" && s.length >= 8).sort((a, b) => b.length - a.length);',
83
+ "const longest = secrets.length > 0 ? secrets[0].length : 0;",
84
+ 'const redact = (t) => { for (const s of secrets) t = t.split(s).join("[redacted]"); return t; };',
85
+ 'const fd = fs.openSync(logPath, "a", 0o600);',
86
+ "const write = (t) => { try { fs.writeSync(fd, redact(t)); } catch {} };",
87
+ 'const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });',
88
+ "const pump = (stream) => {",
89
+ ' let buf = ""; stream.setEncoding("utf8");',
90
+ ' stream.on("data", (c) => {',
91
+ " buf += c;",
92
+ ' let cut = buf.lastIndexOf("\\n") + 1;',
93
+ // An over-long line with no newline is cut early. Redact the whole buffer
94
+ // FIRST: a key straddling the cut would otherwise be written in two pieces,
95
+ // neither of which matches. After redaction only an incomplete key prefix
96
+ // (shorter than `longest`) can remain, and the kept tail is that long.
97
+ " if (cut === 0 && buf.length > 65536) { buf = redact(buf); cut = Math.max(0, buf.length - longest); }",
98
+ " if (cut > 0) { write(buf.slice(0, cut)); buf = buf.slice(cut); }",
99
+ " });",
100
+ ' stream.on("end", () => { if (buf) write(buf); buf = ""; });',
101
+ "};",
102
+ "pump(child.stdout); pump(child.stderr);",
103
+ 'for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {',
104
+ ' process.on(sig, () => { try { child.kill(sig === "SIGHUP" ? "SIGTERM" : sig); } catch {} });',
105
+ "}",
106
+ 'child.on("error", () => { write("executor relay: the executor process could not be started\\n"); process.exit(1); });',
107
+ 'child.on("close", (code, signal) => {',
108
+ ' write("executor relay: executor exited (code=" + code + " signal=" + signal + ")\\n");',
109
+ " process.exit(code === null ? 1 : code);",
110
+ "});",
111
+ ].join("\n");
112
+ export function createDefaultRunExecutorLaunchDeps() {
113
+ const execFile = createPlaneExecFile();
114
+ const proc = createPlaneProcessDeps();
115
+ return {
116
+ execFile,
117
+ resolveEntrypoint: () => resolvePackageEntrypoint(),
118
+ mkdir: async (dirPath) => {
119
+ await mkdir(dirPath, { recursive: true });
120
+ },
121
+ readFile: (filePath) => readFile(filePath, "utf-8"),
122
+ createExclusive: async (filePath, data) => {
123
+ const handle = await open(filePath, "wx", 0o600);
124
+ try {
125
+ await handle.writeFile(data, "utf-8");
126
+ }
127
+ finally {
128
+ await handle.close();
129
+ }
130
+ },
131
+ writeFile: async (filePath, data) => {
132
+ const handle = await open(filePath, "w", 0o600);
133
+ try {
134
+ await handle.writeFile(data, "utf-8");
135
+ }
136
+ finally {
137
+ await handle.close();
138
+ }
139
+ },
140
+ unlink: (filePath) => unlink(filePath),
141
+ mtimeMs: async (filePath) => {
142
+ try {
143
+ return (await stat(filePath)).mtimeMs;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ },
149
+ ensureIgnored: (repoRoot) => ensureGitInfoExcluded(repoRoot, RUN_EXECUTOR_IGNORE_ENTRY, {
150
+ readFile: (filePath) => readFile(filePath, "utf-8"),
151
+ writeFile: async (filePath, data) => {
152
+ const handle = await open(filePath, "w");
153
+ try {
154
+ await handle.writeFile(data, "utf-8");
155
+ }
156
+ finally {
157
+ await handle.close();
158
+ }
159
+ },
160
+ mkdir: (dirPath, options) => mkdir(dirPath, options),
161
+ runCommand: async (file, args, options) => {
162
+ const result = await execFile(file, args, { cwd: options?.cwd ?? repoRoot });
163
+ return result.ok
164
+ ? { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }
165
+ : { stdout: "", stderr: "", exitCode: 1 };
166
+ },
167
+ platform: process.platform,
168
+ }),
169
+ spawn: (command, args, options) => {
170
+ const child = nodeSpawn(command, args, {
171
+ cwd: options.cwd,
172
+ env: options.env,
173
+ detached: true,
174
+ shell: false,
175
+ windowsHide: true,
176
+ // The relay owns the executor's pipes; the relay itself writes nothing
177
+ // anywhere but its log, so nothing ties it to this process.
178
+ stdio: "ignore",
179
+ });
180
+ return child;
181
+ },
182
+ isAlive: (pid) => proc.isAlive(pid),
183
+ readHeartbeat: ({ baseUrl, repoName, apiKey, instanceId }) => probeAutomationHealth({ baseUrl, repoName, apiKey, component: "executor", instanceId }),
184
+ now: () => Date.now(),
185
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
186
+ hostname: () => os.hostname(),
187
+ nodeExecutable: process.execPath,
188
+ parentEnv: process.env,
189
+ selfPid: process.pid,
190
+ };
191
+ }
192
+ /** Paths for one run's launcher files. */
193
+ export function runExecutorPaths(repoRoot, epicRunId) {
194
+ const dir = path.join(repoRoot, RUN_EXECUTOR_DIR);
195
+ return {
196
+ dir,
197
+ record: path.join(dir, `${epicRunId}.json`),
198
+ log: path.join(dir, `${epicRunId}.log`),
199
+ lock: path.join(dir, `${epicRunId}.lock`),
200
+ };
201
+ }
202
+ /**
203
+ * A stable, non-secret executor id: `drive-epic-<first 8 of run id>-<host>`.
204
+ *
205
+ * Stable so a relaunch for the same run on the same host reports under the same
206
+ * name. It is not a per-instance identity on its own — the heartbeat wait also
207
+ * requires a heartbeat NEWER than the spawn, so an earlier instance's last
208
+ * heartbeat never satisfies it.
209
+ */
210
+ export function runExecutorIdFor(epicRunId, hostname) {
211
+ const host = hostname.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 64) || "host";
212
+ return `drive-epic-${epicRunId.slice(0, 8)}-${host}`;
213
+ }
214
+ /** The executor argv, after the entrypoint. Discrete arguments; no shell. */
215
+ export function runExecutorArgs(args) {
216
+ return [
217
+ "executor",
218
+ "--repo",
219
+ args.repoName,
220
+ "--epic-run-id",
221
+ args.epicRunId,
222
+ "--base-url",
223
+ args.baseUrl,
224
+ "--executor-id",
225
+ args.executorId,
226
+ "--exit-when-run-terminal",
227
+ ];
228
+ }
229
+ /**
230
+ * The exact command to start the executor by hand. Shell-quoted, and free of
231
+ * any credential: the executor reads its key from `BAPI_API_KEY` or the stored
232
+ * `bapi:<repo>` credential, never from argv.
233
+ */
234
+ export function renderRunExecutorManualCommand(args) {
235
+ const argv = [args.nodeExecutable, args.entrypoint, ...runExecutorArgs(args)];
236
+ return `cd ${shellQuote(args.repoRoot)} && ${argv.map(shellQuote).join(" ")}`;
237
+ }
238
+ /**
239
+ * Phase one, before the run exists: resolve the checkout and the build.
240
+ *
241
+ * Read-only. Uses the plane preflight's `git rev-parse` pattern, so a launch
242
+ * from outside a Git work tree refuses here and creates nothing.
243
+ */
244
+ export async function prepareRunExecutor(cwd, deps) {
245
+ const top = await deps.execFile("git", ["rev-parse", "--show-toplevel"], { cwd });
246
+ const repoRoot = top.ok ? top.stdout.trim() : "";
247
+ if (!top.ok || repoRoot.length === 0) {
248
+ return {
249
+ ok: false,
250
+ reason: "not-a-git-work-tree",
251
+ message: `${cwd} is not inside a Git work tree, so no executor can be started from it. ` +
252
+ "Run drive-epic from inside the target repository's checkout.",
253
+ };
254
+ }
255
+ const entry = deps.resolveEntrypoint();
256
+ if (!entry.ok) {
257
+ return {
258
+ ok: false,
259
+ reason: "entrypoint-unresolvable",
260
+ message: `the Bridge MCP server's own entrypoint could not be located (${entry.candidatesChecked} ` +
261
+ "candidate(s) checked), so no executor can be started. Reinstall or rebuild the package.",
262
+ };
263
+ }
264
+ return { ok: true, prepared: { repoRoot, entrypoint: entry.entrypoint } };
265
+ }
266
+ /** Parse a record, or `null` when it is not one this build trusts. */
267
+ export function parseRunExecutorRecord(raw) {
268
+ let value;
269
+ try {
270
+ value = JSON.parse(raw);
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ if (typeof value !== "object" || value === null)
276
+ return null;
277
+ const r = value;
278
+ if (r.version !== RUN_EXECUTOR_RECORD_VERSION)
279
+ return null;
280
+ if (typeof r.pid !== "number" || !Number.isInteger(r.pid) || r.pid <= 0)
281
+ return null;
282
+ for (const key of ["executorId", "epicRunId", "baseUrl", "startedAt", "logPath"]) {
283
+ if (typeof r[key] !== "string" || r[key].length === 0)
284
+ return null;
285
+ }
286
+ if (!isValidExecutorEpicRunId(r.epicRunId))
287
+ return null;
288
+ return {
289
+ version: RUN_EXECUTOR_RECORD_VERSION,
290
+ pid: r.pid,
291
+ executorId: r.executorId,
292
+ epicRunId: r.epicRunId,
293
+ baseUrl: r.baseUrl,
294
+ startedAt: r.startedAt,
295
+ logPath: r.logPath,
296
+ };
297
+ }
298
+ /**
299
+ * Is the recorded pid still THIS executor?
300
+ *
301
+ * Signal 0 first — a dead pid is settled. A live pid is then checked against the
302
+ * process table: its command line must still name the recorded executor id and
303
+ * run. A recycled pid fails that (`mismatch`); a table that cannot be read is
304
+ * `unknown`, which every caller treats as "do not signal, do not duplicate".
305
+ */
306
+ export async function verifyRunExecutorProcess(record, deps, cwd) {
307
+ const liveness = deps.isAlive(record.pid);
308
+ if (liveness === "dead")
309
+ return "dead";
310
+ if (liveness !== "alive")
311
+ return "unknown";
312
+ // `-ww`: never truncate. The relay's `-e` source comes before the executor's
313
+ // flags, so a width-limited listing would hide the very flags checked here.
314
+ const ps = await deps.execFile("ps", ["-ww", "-o", "command=", "-p", String(record.pid)], {
315
+ cwd,
316
+ });
317
+ if (!ps.ok)
318
+ return "unknown";
319
+ const command = ps.stdout.trim();
320
+ if (command.length === 0)
321
+ return "dead";
322
+ const namesExecutor = command.includes(` --executor-id ${record.executorId}`);
323
+ const namesRun = command.includes(` --epic-run-id ${record.epicRunId}`);
324
+ return namesExecutor && namesRun ? "verified" : "mismatch";
325
+ }
326
+ /**
327
+ * Every run-scoped executor record in this checkout.
328
+ *
329
+ * `plane down` reads these. Malformed files are listed by name and never
330
+ * parsed loosely: a kill list is the last thing to guess at.
331
+ */
332
+ export async function listRunExecutorRecords(repoRoot, deps) {
333
+ const dir = path.join(repoRoot, RUN_EXECUTOR_DIR);
334
+ let entries;
335
+ try {
336
+ entries = await deps.readdir(dir);
337
+ }
338
+ catch {
339
+ return { records: [], malformed: [] };
340
+ }
341
+ const records = [];
342
+ const malformed = [];
343
+ for (const entry of entries) {
344
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
345
+ continue;
346
+ let raw;
347
+ try {
348
+ raw = await deps.readFile(path.join(dir, entry.name));
349
+ }
350
+ catch {
351
+ malformed.push(entry.name);
352
+ continue;
353
+ }
354
+ const record = parseRunExecutorRecord(raw);
355
+ if (record === null || `${record.epicRunId}.json` !== entry.name) {
356
+ malformed.push(entry.name);
357
+ continue;
358
+ }
359
+ records.push(record);
360
+ }
361
+ return { records, malformed };
362
+ }
363
+ /**
364
+ * Delete a run's record on the executor's terminal exit — only when the record
365
+ * still names THIS executor instance (its id, and its own or its relay's pid).
366
+ * A record a later launch has since replaced is left alone.
367
+ */
368
+ export async function removeRunExecutorRecordIfOwned(args, deps) {
369
+ if (!isValidExecutorEpicRunId(args.epicRunId))
370
+ return false;
371
+ const { record: recordPath } = runExecutorPaths(args.repoRoot, args.epicRunId);
372
+ let record;
373
+ try {
374
+ record = parseRunExecutorRecord(await deps.readFile(recordPath));
375
+ }
376
+ catch {
377
+ return false;
378
+ }
379
+ if (record === null)
380
+ return false;
381
+ if (record.executorId !== args.executorId || !args.pids.includes(record.pid))
382
+ return false;
383
+ try {
384
+ await deps.unlink(recordPath);
385
+ return true;
386
+ }
387
+ catch {
388
+ return false;
389
+ }
390
+ }
391
+ function errnoCode(err) {
392
+ return err?.code;
393
+ }
394
+ /**
395
+ * Take the per-run launch lock. Only one launch decides start-or-reuse for a run
396
+ * at a time. A lock whose owner is gone — or that is unreadable and old — is
397
+ * replaced once; a live owner means another launch is deciding right now.
398
+ */
399
+ async function acquireLaunchLock(lockPath, deps) {
400
+ const body = JSON.stringify({ pid: deps.selfPid, createdAt: new Date(deps.now()).toISOString() });
401
+ for (let attempt = 0; attempt < 2; attempt++) {
402
+ try {
403
+ await deps.createExclusive(lockPath, body);
404
+ return true;
405
+ }
406
+ catch (err) {
407
+ if (errnoCode(err) !== "EEXIST")
408
+ return false;
409
+ }
410
+ let ownerPid = null;
411
+ try {
412
+ const parsed = JSON.parse(await deps.readFile(lockPath));
413
+ ownerPid = typeof parsed.pid === "number" && parsed.pid > 0 ? parsed.pid : null;
414
+ }
415
+ catch {
416
+ ownerPid = null;
417
+ }
418
+ let abandoned;
419
+ if (ownerPid !== null) {
420
+ abandoned = deps.isAlive(ownerPid) === "dead";
421
+ }
422
+ else {
423
+ const mtime = await deps.mtimeMs(lockPath);
424
+ abandoned = mtime !== null && deps.now() - mtime > RUN_EXECUTOR_LOCK_STALE_MS;
425
+ }
426
+ if (!abandoned)
427
+ return false;
428
+ try {
429
+ await deps.unlink(lockPath);
430
+ }
431
+ catch {
432
+ return false;
433
+ }
434
+ }
435
+ return false;
436
+ }
437
+ /**
438
+ * Wait for the executor instance's OWN heartbeat, newer than `notBeforeMs`.
439
+ *
440
+ * The instance-scoped read is the same signal plane lanes are gated on. Another
441
+ * executor's heartbeat can never satisfy it, and `notBeforeMs` keeps an earlier
442
+ * instance under the same id from satisfying it either. Returns early, false,
443
+ * once the process is gone.
444
+ */
445
+ async function awaitExecutorHeartbeat(args) {
446
+ const { deps } = args;
447
+ const timeoutMs = deps.heartbeatTimeoutMs ?? RUN_EXECUTOR_HEARTBEAT_TIMEOUT_MS;
448
+ const pollMs = deps.heartbeatPollMs ?? RUN_EXECUTOR_HEARTBEAT_POLL_MS;
449
+ const deadline = deps.now() + timeoutMs;
450
+ for (;;) {
451
+ let observation = null;
452
+ try {
453
+ observation = await deps.readHeartbeat({
454
+ baseUrl: args.access.baseUrl,
455
+ repoName: args.access.repoName,
456
+ apiKey: args.access.apiKey,
457
+ instanceId: args.executorId,
458
+ });
459
+ }
460
+ catch {
461
+ observation = null;
462
+ }
463
+ if (observation !== null &&
464
+ observation.result.kind === "state" &&
465
+ observation.result.state === "fresh" &&
466
+ (args.notBeforeMs === null ||
467
+ (observation.observedAtMs !== null && observation.observedAtMs >= args.notBeforeMs))) {
468
+ return true;
469
+ }
470
+ if (deps.isAlive(args.pid) === "dead")
471
+ return false;
472
+ if (deps.now() >= deadline)
473
+ return false;
474
+ await deps.sleep(Math.min(pollMs, Math.max(0, deadline - deps.now())));
475
+ }
476
+ }
477
+ /**
478
+ * Phase two, after the run exists: start or reuse exactly one executor scoped to
479
+ * it, and confirm it through its own heartbeat.
480
+ *
481
+ * Never stops, rolls back, or retries the run. A failure returns the log path
482
+ * and the manual command; the run stays server-side and recoverable.
483
+ */
484
+ export async function ensureRunExecutor(args, deps) {
485
+ const { prepared, repoName, epicRunId, access } = args;
486
+ const paths = runExecutorPaths(prepared.repoRoot, epicRunId);
487
+ const executorId = runExecutorIdFor(epicRunId, deps.hostname());
488
+ const manualCommand = renderRunExecutorManualCommand({
489
+ repoRoot: prepared.repoRoot,
490
+ nodeExecutable: deps.nodeExecutable,
491
+ entrypoint: prepared.entrypoint,
492
+ repoName,
493
+ epicRunId,
494
+ baseUrl: access.baseUrl,
495
+ executorId,
496
+ });
497
+ const fail = (reason, message, executorMayBeRunning = false) => ({
498
+ ok: false,
499
+ reason,
500
+ message: redactAccess(access, message),
501
+ logPath: paths.log,
502
+ manualCommand,
503
+ executorMayBeRunning,
504
+ });
505
+ // The run id becomes a file name; only a server-shaped id may.
506
+ if (!isValidExecutorEpicRunId(epicRunId)) {
507
+ return fail("invalid-run-id", `run id '${epicRunId}' is not a valid epic run id`);
508
+ }
509
+ try {
510
+ await deps.mkdir(paths.dir);
511
+ }
512
+ catch {
513
+ return fail("spawn-failed", `could not create ${paths.dir}`);
514
+ }
515
+ try {
516
+ await deps.ensureIgnored(prepared.repoRoot);
517
+ }
518
+ catch {
519
+ // Advisory: an unignored scratch directory is untidy, not unsafe.
520
+ }
521
+ if (!(await acquireLaunchLock(paths.lock, deps))) {
522
+ return fail("launch-in-progress", `another launch is starting the executor for run ${epicRunId} right now (${paths.lock}). ` +
523
+ "Wait for it to finish, then check the log.", true);
524
+ }
525
+ try {
526
+ // --- Reuse, when a recorded executor is provably this run's and alive ---
527
+ let existingRaw = null;
528
+ try {
529
+ existingRaw = await deps.readFile(paths.record);
530
+ }
531
+ catch (err) {
532
+ if (errnoCode(err) !== "ENOENT") {
533
+ return fail("record-unreadable", `the executor record ${paths.record} could not be read`);
534
+ }
535
+ }
536
+ if (existingRaw !== null) {
537
+ const existing = parseRunExecutorRecord(existingRaw);
538
+ if (existing === null || existing.epicRunId !== epicRunId) {
539
+ // It may name a live process, so nothing is signalled and nothing is
540
+ // started beside it. A human reads it once and removes it.
541
+ return fail("record-unreadable", `the executor record ${paths.record} is not one this build can trust, so it is ` +
542
+ "unknown whether an executor is already running for this run. Check the process " +
543
+ "it names, remove the file, and start the executor with the command below.", true);
544
+ }
545
+ const state = await verifyRunExecutorProcess(existing, deps, prepared.repoRoot);
546
+ if (state === "verified") {
547
+ const live = await awaitExecutorHeartbeat({
548
+ deps,
549
+ access,
550
+ executorId: existing.executorId,
551
+ pid: existing.pid,
552
+ notBeforeMs: null,
553
+ });
554
+ if (live) {
555
+ return {
556
+ ok: true,
557
+ outcome: "reused",
558
+ executorId: existing.executorId,
559
+ pid: existing.pid,
560
+ logPath: existing.logPath,
561
+ recordPath: paths.record,
562
+ };
563
+ }
564
+ return fail("existing-executor-unconfirmed", `an executor for run ${epicRunId} is already running (pid ${existing.pid}), but its ` +
565
+ "heartbeat could not be confirmed. A second one was NOT started beside it.", true);
566
+ }
567
+ if (state === "unknown") {
568
+ return fail("existing-executor-unconfirmed", `the executor recorded for run ${epicRunId} (pid ${existing.pid}) could not be ` +
569
+ "verified, so a second one was NOT started beside it.", true);
570
+ }
571
+ // `dead` or `mismatch` (a recycled pid): the record is stale. Replace it.
572
+ try {
573
+ await deps.unlink(paths.record);
574
+ }
575
+ catch {
576
+ return fail("record-unreadable", `the stale executor record ${paths.record} could not be removed`);
577
+ }
578
+ }
579
+ // --- Start ---------------------------------------------------------------
580
+ const childEnv = {};
581
+ for (const [key, value] of Object.entries(deps.parentEnv)) {
582
+ if (value !== undefined)
583
+ childEnv[key] = value;
584
+ }
585
+ // Environment only, never argv: this satisfies the executor's ambient-key
586
+ // rule, exactly as `launchPlaneSupervisor` does for plane members.
587
+ childEnv.BAPI_API_KEY = access.apiKey;
588
+ childEnv.BAPI_BASE_URL = access.baseUrl;
589
+ childEnv.BAPI_REPO_NAME = repoName;
590
+ const spawnedAtMs = deps.now();
591
+ let child;
592
+ try {
593
+ child = deps.spawn(deps.nodeExecutable, [
594
+ "-e",
595
+ RUN_EXECUTOR_RELAY_SOURCE,
596
+ paths.log,
597
+ deps.nodeExecutable,
598
+ prepared.entrypoint,
599
+ ...runExecutorArgs({ repoName, epicRunId, baseUrl: access.baseUrl, executorId }),
600
+ ], { cwd: prepared.repoRoot, env: childEnv, detached: true });
601
+ }
602
+ catch {
603
+ // Fixed prose: a thrown spawn error can carry the environment.
604
+ return fail("spawn-failed", "the executor process could not be started");
605
+ }
606
+ child.on("error", () => undefined);
607
+ const pid = child.pid;
608
+ if (typeof pid !== "number" || pid <= 0) {
609
+ return fail("spawn-failed", "the executor process started without a pid");
610
+ }
611
+ child.unref();
612
+ const record = {
613
+ version: RUN_EXECUTOR_RECORD_VERSION,
614
+ pid,
615
+ executorId,
616
+ epicRunId,
617
+ baseUrl: access.baseUrl,
618
+ startedAt: new Date(spawnedAtMs).toISOString(),
619
+ logPath: paths.log,
620
+ };
621
+ try {
622
+ await deps.writeFile(paths.record, `${JSON.stringify(record, null, 2)}\n`);
623
+ }
624
+ catch {
625
+ // The process is running; without a record `plane down` cannot find it,
626
+ // but the run is still served. Said, not hidden.
627
+ return fail("spawn-failed", `the executor started (pid ${pid}) but its record ${paths.record} could not be written; ` +
628
+ "stop it by pid if you need to", true);
629
+ }
630
+ const live = await awaitExecutorHeartbeat({
631
+ deps,
632
+ access,
633
+ executorId,
634
+ pid,
635
+ notBeforeMs: spawnedAtMs,
636
+ });
637
+ if (!live) {
638
+ const timeoutSeconds = Math.round((deps.heartbeatTimeoutMs ?? RUN_EXECUTOR_HEARTBEAT_TIMEOUT_MS) / 1000);
639
+ const exited = deps.isAlive(pid) === "dead";
640
+ return fail("heartbeat-timeout", exited
641
+ ? `the executor (pid ${pid}) exited before it sent a heartbeat`
642
+ : `no heartbeat from the executor (pid ${pid}) arrived within ${timeoutSeconds}s`, !exited);
643
+ }
644
+ return { ok: true, outcome: "started", executorId, pid, logPath: paths.log, recordPath: paths.record };
645
+ }
646
+ finally {
647
+ try {
648
+ await deps.unlink(paths.lock);
649
+ }
650
+ catch {
651
+ /* a leftover lock is replaced once its owner is gone */
652
+ }
653
+ }
654
+ }
655
+ /** Redact the one credential this module holds from any text it returns. */
656
+ function redactAccess(access, text) {
657
+ return buildPlaneRedactor([access.apiKey])(text);
658
+ }