@bridge_gpt/mcp-server 0.2.62 → 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.
- package/README.md +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- 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
|
+
}
|