@bridge_gpt/mcp-server 0.2.18 → 0.2.19
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/CONDUCTOR.md +75 -0
- package/README.md +2 -2
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +1 -0
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +97 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +11222 -8544
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/public/css/main.min.css +39 -0
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
package/build/doctor.js
CHANGED
|
@@ -51,6 +51,12 @@ export function getDoctorUsage() {
|
|
|
51
51
|
"bootstrap-field completeness, and repository-indexing state. It performs",
|
|
52
52
|
"read-only GETs only and never affects the exit code.",
|
|
53
53
|
"",
|
|
54
|
+
"Conductor ledger / native-module diagnostics (the SQLite ledger's native",
|
|
55
|
+
"binding load status and Node-version skew) live under a separate command:",
|
|
56
|
+
" conductor doctor",
|
|
57
|
+
"That command is likewise strictly read-only — it does not install, rebuild,",
|
|
58
|
+
"migrate, or write ledger files.",
|
|
59
|
+
"",
|
|
54
60
|
"Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
|
|
55
61
|
].join("\n");
|
|
56
62
|
}
|
|
@@ -171,6 +177,8 @@ export function formatDoctorReport(platform, agent, collection) {
|
|
|
171
177
|
lines.push(anyMissing
|
|
172
178
|
? "Some prerequisites are missing — install the ones above manually, then re-run doctor."
|
|
173
179
|
: "All required prerequisites are present.");
|
|
180
|
+
// Advisory pointer only (BAPI-526): informational, independent of exit code.
|
|
181
|
+
lines.push("For conductor ledger/native-module diagnostics, run: conductor doctor");
|
|
174
182
|
return lines.join("\n");
|
|
175
183
|
}
|
|
176
184
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor CLI subcommand (BAPI-534, TDD §12/§13).
|
|
3
|
+
*
|
|
4
|
+
* `mcp-server executor --repo <name> [...]` — parses args, resolves default deps,
|
|
5
|
+
* credentials, and the HTTP client, then runs the poll/claim loop. Never starts
|
|
6
|
+
* the MCP server; all user-facing diagnostics go to stderr; secrets are never
|
|
7
|
+
* printed. Returns numeric exit codes rather than throwing at the top level.
|
|
8
|
+
*/
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { VERSION } from "../version.generated.js";
|
|
11
|
+
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
12
|
+
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
13
|
+
import { createDefaultExecutorDeps } from "./deps.js";
|
|
14
|
+
import { resolveExecutorApiAccess, } from "./credentials.js";
|
|
15
|
+
import { createExecutorHttpClient } from "./http-client.js";
|
|
16
|
+
import { runExecutor } from "./runner.js";
|
|
17
|
+
import { runExecutorWatchCli } from "./watch-cli.js";
|
|
18
|
+
/** Fixed executor timing/behavior defaults. */
|
|
19
|
+
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
20
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
21
|
+
const DEFAULT_DEADMAN_MS = 120_000;
|
|
22
|
+
const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
23
|
+
const DEFAULT_BASE_BRANCH = "main";
|
|
24
|
+
const DEFAULT_JOB_TIMEOUT_SECONDS = 20 * 60;
|
|
25
|
+
const DEFAULT_MAX_CONCURRENT = 1;
|
|
26
|
+
export function getExecutorUsage() {
|
|
27
|
+
return [
|
|
28
|
+
"Usage: mcp-server executor --repo <name> [--repo <name> ...] [options]",
|
|
29
|
+
"",
|
|
30
|
+
"Runs the Epic Conductor v2 local executor: poll → claim → spawn → heartbeat.",
|
|
31
|
+
"",
|
|
32
|
+
"Options:",
|
|
33
|
+
" --repo <name> Repo to serve (repeatable).",
|
|
34
|
+
" --repos=<a,b> Comma-separated repos.",
|
|
35
|
+
" --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
|
|
36
|
+
" --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
|
|
37
|
+
" --once Run a single preflight/claim cycle and exit.",
|
|
38
|
+
" --poll-interval-ms <n> Poll interval (default 15000).",
|
|
39
|
+
" --heartbeat-interval-ms <n> Heartbeat interval (default 60000).",
|
|
40
|
+
" --deadman-ms <n> Dead-man self-kill window (default 120000).",
|
|
41
|
+
" --base-branch <name> Base branch (default main).",
|
|
42
|
+
" --no-advisory-parser Disable the advisory stream-json parser.",
|
|
43
|
+
" -h, --help Show this help.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
function parseIntArg(value, flag) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return { ok: false, message: `${flag} requires a numeric value` };
|
|
49
|
+
const n = Number(value);
|
|
50
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
51
|
+
return { ok: false, message: `${flag} must be an integer` };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, value: n };
|
|
54
|
+
}
|
|
55
|
+
/** Parse the executor CLI arguments into resolved options (pure, synchronous). */
|
|
56
|
+
export function parseExecutorArgs(argv, context) {
|
|
57
|
+
const repos = [];
|
|
58
|
+
let executorId;
|
|
59
|
+
let maxConcurrent = DEFAULT_MAX_CONCURRENT;
|
|
60
|
+
let once = false;
|
|
61
|
+
let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
|
|
62
|
+
let heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
63
|
+
let deadmanMs = DEFAULT_DEADMAN_MS;
|
|
64
|
+
let baseBranch = DEFAULT_BASE_BRANCH;
|
|
65
|
+
let advisoryParserEnabled = true;
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === "--help" || arg === "-h")
|
|
69
|
+
return { kind: "help" };
|
|
70
|
+
else if (arg === "--repo") {
|
|
71
|
+
const v = argv[++i];
|
|
72
|
+
if (!v)
|
|
73
|
+
return { kind: "error", message: "--repo requires a value" };
|
|
74
|
+
repos.push(v);
|
|
75
|
+
}
|
|
76
|
+
else if (arg.startsWith("--repos=")) {
|
|
77
|
+
repos.push(...arg.slice("--repos=".length).split(",").map((s) => s.trim()).filter(Boolean));
|
|
78
|
+
}
|
|
79
|
+
else if (arg === "--repos") {
|
|
80
|
+
const v = argv[++i];
|
|
81
|
+
if (!v)
|
|
82
|
+
return { kind: "error", message: "--repos requires a value" };
|
|
83
|
+
repos.push(...v.split(",").map((s) => s.trim()).filter(Boolean));
|
|
84
|
+
}
|
|
85
|
+
else if (arg === "--executor-id") {
|
|
86
|
+
executorId = argv[++i];
|
|
87
|
+
if (!executorId)
|
|
88
|
+
return { kind: "error", message: "--executor-id requires a value" };
|
|
89
|
+
}
|
|
90
|
+
else if (arg === "--max-concurrent") {
|
|
91
|
+
const r = parseIntArg(argv[++i], "--max-concurrent");
|
|
92
|
+
if (!r.ok)
|
|
93
|
+
return { kind: "error", message: r.message };
|
|
94
|
+
maxConcurrent = r.value;
|
|
95
|
+
}
|
|
96
|
+
else if (arg === "--once") {
|
|
97
|
+
once = true;
|
|
98
|
+
}
|
|
99
|
+
else if (arg === "--poll-interval-ms") {
|
|
100
|
+
const r = parseIntArg(argv[++i], "--poll-interval-ms");
|
|
101
|
+
if (!r.ok)
|
|
102
|
+
return { kind: "error", message: r.message };
|
|
103
|
+
pollIntervalMs = r.value;
|
|
104
|
+
}
|
|
105
|
+
else if (arg === "--heartbeat-interval-ms") {
|
|
106
|
+
const r = parseIntArg(argv[++i], "--heartbeat-interval-ms");
|
|
107
|
+
if (!r.ok)
|
|
108
|
+
return { kind: "error", message: r.message };
|
|
109
|
+
heartbeatIntervalMs = r.value;
|
|
110
|
+
}
|
|
111
|
+
else if (arg === "--deadman-ms") {
|
|
112
|
+
const r = parseIntArg(argv[++i], "--deadman-ms");
|
|
113
|
+
if (!r.ok)
|
|
114
|
+
return { kind: "error", message: r.message };
|
|
115
|
+
deadmanMs = r.value;
|
|
116
|
+
}
|
|
117
|
+
else if (arg === "--base-branch") {
|
|
118
|
+
const v = argv[++i];
|
|
119
|
+
if (!v)
|
|
120
|
+
return { kind: "error", message: "--base-branch requires a value" };
|
|
121
|
+
baseBranch = v;
|
|
122
|
+
}
|
|
123
|
+
else if (arg === "--no-advisory-parser") {
|
|
124
|
+
advisoryParserEnabled = false;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
return { kind: "error", message: `unknown argument: ${arg}` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (repos.length === 0) {
|
|
131
|
+
return { kind: "error", message: "at least one --repo (or --repos=a,b) is required" };
|
|
132
|
+
}
|
|
133
|
+
if (maxConcurrent < 1) {
|
|
134
|
+
return { kind: "error", message: "--max-concurrent must be >= 1" };
|
|
135
|
+
}
|
|
136
|
+
const executorIdFinal = executorId && executorId.trim().length > 0
|
|
137
|
+
? executorId.trim()
|
|
138
|
+
: `${context.hostname}-${context.pid}`;
|
|
139
|
+
const options = {
|
|
140
|
+
executorId: executorIdFinal,
|
|
141
|
+
repos,
|
|
142
|
+
repoName: repos[0],
|
|
143
|
+
maxConcurrent,
|
|
144
|
+
pollIntervalMs,
|
|
145
|
+
heartbeatIntervalMs,
|
|
146
|
+
deadmanMs,
|
|
147
|
+
termGraceMs: DEFAULT_TERM_GRACE_MS,
|
|
148
|
+
once,
|
|
149
|
+
worktrunkBinary: resolveWorktrunkBinary(context.platform, context.env),
|
|
150
|
+
baseBranch,
|
|
151
|
+
advisoryParserEnabled,
|
|
152
|
+
defaultJobTimeoutSeconds: DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
153
|
+
};
|
|
154
|
+
return { kind: "ok", options };
|
|
155
|
+
}
|
|
156
|
+
function hasRepoFlag(argv) {
|
|
157
|
+
return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
|
|
158
|
+
}
|
|
159
|
+
/** Wire and run the executor CLI. Returns a numeric exit code (never throws). */
|
|
160
|
+
export async function runExecutorCli(argv, overrides = {}) {
|
|
161
|
+
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
162
|
+
// `executor watch <job>` is a READ-ONLY log-attach subcommand (BAPI-535). It
|
|
163
|
+
// owns the rest of argv and never runs the poll/claim loop or resolves executor
|
|
164
|
+
// credentials — it only tails a locally-registered worker log.
|
|
165
|
+
if (argv[0] === "watch") {
|
|
166
|
+
return runExecutorWatchCli(argv.slice(1));
|
|
167
|
+
}
|
|
168
|
+
const deps = overrides.deps ?? createDefaultExecutorDeps();
|
|
169
|
+
const context = overrides.context ?? {
|
|
170
|
+
hostname: os.hostname(),
|
|
171
|
+
pid: process.pid,
|
|
172
|
+
platform: process.platform,
|
|
173
|
+
env: process.env,
|
|
174
|
+
};
|
|
175
|
+
// Repo fallback: when no --repo/--repos was passed, resolve via the shared
|
|
176
|
+
// start-tickets repo resolver (BAPI_REPO_NAME / .bridge/config).
|
|
177
|
+
let effectiveArgv = argv;
|
|
178
|
+
if (!hasRepoFlag(argv)) {
|
|
179
|
+
const fallback = await resolveStartTicketsRepoName({
|
|
180
|
+
env: deps.env,
|
|
181
|
+
cwd: deps.cwd,
|
|
182
|
+
readFile: deps.readFile,
|
|
183
|
+
});
|
|
184
|
+
if (fallback)
|
|
185
|
+
effectiveArgv = ["--repo", fallback, ...argv];
|
|
186
|
+
}
|
|
187
|
+
const parsed = parseExecutorArgs(effectiveArgv, context);
|
|
188
|
+
if (parsed.kind === "help") {
|
|
189
|
+
errorLog(getExecutorUsage());
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
if (parsed.kind === "error") {
|
|
193
|
+
errorLog(`Error: ${parsed.message}\n\n${getExecutorUsage()}`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
const options = parsed.options;
|
|
197
|
+
// Resolve credentials for EVERY configured repo so multi-repo mode sends the
|
|
198
|
+
// correct repo-bound `X-API-Key` per claim/heartbeat/complete/fail — not just
|
|
199
|
+
// the first repo's key. Uses the (injectable) single-repo resolver per repo.
|
|
200
|
+
const resolveApi = overrides.resolveApiAccess ?? resolveExecutorApiAccess;
|
|
201
|
+
const apiKeyByRepo = {};
|
|
202
|
+
let baseUrl = "";
|
|
203
|
+
for (const repo of options.repos) {
|
|
204
|
+
const access = await resolveApi(repo, deps);
|
|
205
|
+
if (!access.ok) {
|
|
206
|
+
errorLog(`Error: ${access.error}`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
apiKeyByRepo[repo] = access.apiKey;
|
|
210
|
+
baseUrl = access.baseUrl;
|
|
211
|
+
}
|
|
212
|
+
const createHttpClient = overrides.createHttpClient ?? createExecutorHttpClient;
|
|
213
|
+
const httpClient = createHttpClient({
|
|
214
|
+
baseUrl,
|
|
215
|
+
apiKey: apiKeyByRepo[options.repoName],
|
|
216
|
+
apiKeyByRepo,
|
|
217
|
+
mcpVersion: VERSION,
|
|
218
|
+
fetch: deps.fetch,
|
|
219
|
+
});
|
|
220
|
+
const run = overrides.runExecutor ?? runExecutor;
|
|
221
|
+
try {
|
|
222
|
+
return await run(options, deps, httpClient);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
+
errorLog(`Error: executor exited unexpectedly: ${message.slice(0, 200)}`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor Bridge API access resolution (BAPI-534, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the `BAPI_API_KEY` + base URL for the executor's HTTP calls ONLY
|
|
5
|
+
* through the shared credential store (`resolveBapiCredentials`) — never by
|
|
6
|
+
* reading worktree-local MCP config, and never leaking secret values into
|
|
7
|
+
* diagnostics/results.
|
|
8
|
+
*/
|
|
9
|
+
import { resolveBapiCredentials } from "../credential-store.js";
|
|
10
|
+
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset/blank. */
|
|
11
|
+
export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
|
|
12
|
+
function resolveBaseUrl(env) {
|
|
13
|
+
const raw = env.BAPI_BASE_URL;
|
|
14
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
15
|
+
return raw.trim().replace(/\/+$/, "");
|
|
16
|
+
}
|
|
17
|
+
return DEFAULT_BAPI_BASE_URL;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve API access for a single repo. Returns a structured, secret-free
|
|
21
|
+
* failure for credential-not-found, read/parse failure, missing key, or an
|
|
22
|
+
* invalid repo name — never throwing, never echoing credential-file contents.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveExecutorApiAccess(repoName, deps) {
|
|
25
|
+
const trimmed = typeof repoName === "string" ? repoName.trim() : "";
|
|
26
|
+
if (trimmed.length === 0) {
|
|
27
|
+
return { ok: false, error: "invalid repo name: repo name is required" };
|
|
28
|
+
}
|
|
29
|
+
const storeDeps = {
|
|
30
|
+
env: deps.env,
|
|
31
|
+
homedir: deps.homedir,
|
|
32
|
+
platform: deps.platform,
|
|
33
|
+
readFile: deps.readFile,
|
|
34
|
+
stat: deps.stat,
|
|
35
|
+
};
|
|
36
|
+
const result = await resolveBapiCredentials(trimmed, storeDeps);
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: `could not resolve Bridge API credentials for repo '${trimmed}' (${result.kind})`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
apiKey: result.credentials.apiKey,
|
|
46
|
+
baseUrl: resolveBaseUrl(deps.env),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve credentials for every configured repo before the executor starts
|
|
51
|
+
* claiming. Each repo resolves independently; failures are reported per-repo.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveAllExecutorApiAccess(repos, deps) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const repo of repos) {
|
|
56
|
+
const access = await resolveExecutorApiAccess(repo, deps);
|
|
57
|
+
if (access.ok) {
|
|
58
|
+
out.push({ ok: true, repoName: repo, apiKey: access.apiKey, baseUrl: access.baseUrl });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
out.push({ ok: false, repoName: repo, error: access.error });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default (production) executor dependencies (BAPI-534, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* Provides real subprocess/HTTP/fs/timer/clock implementations behind the
|
|
5
|
+
* injected `ExecutorDeps` boundary so all executor logic stays unit-testable.
|
|
6
|
+
* Diagnostics route through `console.error` (stderr) because the executor is a
|
|
7
|
+
* CLI subcommand and MUST NOT write to stdout (which would corrupt the MCP
|
|
8
|
+
* server's JSON-RPC channel in adjacent tooling).
|
|
9
|
+
*/
|
|
10
|
+
import { execFile, spawn } from "node:child_process";
|
|
11
|
+
import { readFile, writeFile, appendFile, mkdir, stat, statfs } from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
/** Bounded subprocess output buffer (bytes). */
|
|
16
|
+
const MAX_COMMAND_BUFFER = 10 * 1024 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Hard wall-clock cap on every executor HTTP request. Without it a hung
|
|
19
|
+
* heartbeat/complete/fail (network stall, half-open socket) would block
|
|
20
|
+
* `runHeartbeatLoop`'s await forever, so the dead-man check never runs and the
|
|
21
|
+
* 2-minute self-kill invariant is defeated. 30s is well under the 60s heartbeat
|
|
22
|
+
* interval, so a stuck request fails fast and the loop advances to the dead-man
|
|
23
|
+
* evaluation instead of stalling.
|
|
24
|
+
*/
|
|
25
|
+
const EXECUTOR_HTTP_TIMEOUT_MS = 30_000;
|
|
26
|
+
export function createDefaultExecutorDeps() {
|
|
27
|
+
return {
|
|
28
|
+
async runCommand(file, args, options) {
|
|
29
|
+
try {
|
|
30
|
+
const { stdout, stderr } = await execFileAsync(file, args, {
|
|
31
|
+
cwd: options?.cwd,
|
|
32
|
+
timeout: options?.timeoutMs,
|
|
33
|
+
maxBuffer: MAX_COMMAND_BUFFER,
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
shell: false,
|
|
36
|
+
});
|
|
37
|
+
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const e = err;
|
|
41
|
+
const exitCode = typeof e.code === "number" ? e.code : 1;
|
|
42
|
+
return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", exitCode };
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
spawnProcess(file, args, options) {
|
|
46
|
+
const child = spawn(file, args, {
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
env: options.env,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
shell: false,
|
|
51
|
+
});
|
|
52
|
+
child.stdout?.setEncoding("utf8");
|
|
53
|
+
child.stderr?.setEncoding("utf8");
|
|
54
|
+
return {
|
|
55
|
+
pid: child.pid,
|
|
56
|
+
stdout: child.stdout ?? null,
|
|
57
|
+
stderr: child.stderr ?? null,
|
|
58
|
+
wait() {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
child.on("close", (code, signal) => resolve({ exitCode: code, signal }));
|
|
61
|
+
child.on("error", () => resolve({ exitCode: null, signal: null }));
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
kill(signal) {
|
|
65
|
+
try {
|
|
66
|
+
child.kill(signal);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* already exited */
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
75
|
+
writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
|
|
76
|
+
appendFile: (filePath, data) => appendFile(filePath, data, "utf-8"),
|
|
77
|
+
mkdir: (dirPath, opts) => mkdir(dirPath, opts),
|
|
78
|
+
stat: (filePath) => stat(filePath).then((s) => ({ mode: s.mode })),
|
|
79
|
+
statMtimeMs: (filePath) => stat(filePath)
|
|
80
|
+
.then((s) => s.mtimeMs)
|
|
81
|
+
.catch(() => null),
|
|
82
|
+
statfs: async (path) => {
|
|
83
|
+
const s = await statfs(path);
|
|
84
|
+
return { bavail: Number(s.bavail), bsize: Number(s.bsize) };
|
|
85
|
+
},
|
|
86
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
87
|
+
now: () => Date.now(),
|
|
88
|
+
setTimer: (cb, ms) => setTimeout(cb, ms),
|
|
89
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
90
|
+
env: process.env,
|
|
91
|
+
cwd: process.cwd(),
|
|
92
|
+
platform: process.platform,
|
|
93
|
+
homedir: os.homedir,
|
|
94
|
+
async fetch(url, init) {
|
|
95
|
+
const g = globalThis;
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const timer = setTimeout(() => controller.abort(), EXECUTOR_HTTP_TIMEOUT_MS);
|
|
98
|
+
try {
|
|
99
|
+
const res = await g.fetch(url, {
|
|
100
|
+
method: init.method,
|
|
101
|
+
headers: init.headers,
|
|
102
|
+
body: init.body,
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
// Read the body within the timeout window too, so a stalled body stream
|
|
106
|
+
// is aborted rather than hanging the caller after headers arrive.
|
|
107
|
+
const body = await res.text();
|
|
108
|
+
return { status: res.status, text: async () => body };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
log: (message) => console.error(message),
|
|
115
|
+
errorLog: (message) => console.error(message),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret-free executor worker environment (BAPI-534, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* v2 executor workers get NO Bridge MCP and NO conductor identity: unlike the
|
|
5
|
+
* conductor worker env builder, this one omits `BRIDGE_MCP_PROFILE`, every
|
|
6
|
+
* `BAPI_CONDUCTOR_*` key, `CONDUCTOR_NODE_PATH`, and all secret-bearing keys.
|
|
7
|
+
* It constructs a FRESH object from a strict allowlist of non-secret operational
|
|
8
|
+
* keys — it never copies arbitrary `process.env`, so credentials/tokens/headers
|
|
9
|
+
* cannot leak into the spawned `claude` process.
|
|
10
|
+
*/
|
|
11
|
+
/** Non-secret operational keys forwarded to the worker when present. */
|
|
12
|
+
const ALLOWED_ENV_KEYS = [
|
|
13
|
+
"PATH",
|
|
14
|
+
"HOME",
|
|
15
|
+
"USER",
|
|
16
|
+
"LOGNAME",
|
|
17
|
+
"SHELL",
|
|
18
|
+
"TMPDIR",
|
|
19
|
+
"TMP",
|
|
20
|
+
"TEMP",
|
|
21
|
+
"LANG",
|
|
22
|
+
"LC_ALL",
|
|
23
|
+
"TERM",
|
|
24
|
+
"XDG_CONFIG_HOME",
|
|
25
|
+
"NO_COLOR",
|
|
26
|
+
];
|
|
27
|
+
/** Substrings that mark a key as secret-bearing (case-insensitive). */
|
|
28
|
+
const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
|
|
29
|
+
/** Explicit deny-list of known secret / MCP / conductor keys. */
|
|
30
|
+
const EXPLICIT_DENY_KEYS = [
|
|
31
|
+
"BRIDGE_MCP_PROFILE",
|
|
32
|
+
"CONDUCTOR_NODE_PATH",
|
|
33
|
+
"BAPI_API_KEY",
|
|
34
|
+
"ANTHROPIC_API_KEY",
|
|
35
|
+
"OPENAI_API_KEY",
|
|
36
|
+
"GITHUB_TOKEN",
|
|
37
|
+
"GH_TOKEN",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
41
|
+
* allowlist is locally auditable/testable. A key is allowed iff it is in the
|
|
42
|
+
* allowlist AND is not explicitly denied, does not begin with `BAPI_CONDUCTOR_`,
|
|
43
|
+
* and does not contain a secret-name fragment.
|
|
44
|
+
*/
|
|
45
|
+
export function isExecutorEnvKeyAllowed(key) {
|
|
46
|
+
if (!ALLOWED_ENV_KEYS.includes(key))
|
|
47
|
+
return false;
|
|
48
|
+
if (EXPLICIT_DENY_KEYS.includes(key))
|
|
49
|
+
return false;
|
|
50
|
+
if (key.startsWith("BAPI_CONDUCTOR_"))
|
|
51
|
+
return false;
|
|
52
|
+
const upper = key.toUpperCase();
|
|
53
|
+
if (SECRET_NAME_FRAGMENTS.some((fragment) => upper.includes(fragment)))
|
|
54
|
+
return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the secret-free worker environment: a fresh object containing only the
|
|
59
|
+
* allowlisted keys that are present (and non-undefined) in `parentEnv`, plus the
|
|
60
|
+
* literal `BRIDGE_SKIP_PREPUSH=\"1\"` (BAPI-551). This is a constant, never read
|
|
61
|
+
* from `parentEnv` — the worker's advisory local pre-push suite (~70s) is
|
|
62
|
+
* redundant for a conductor worker, since its PR is CI-gated by the merge gate
|
|
63
|
+
* anyway, and the delay was observed causing a worker to background the push and
|
|
64
|
+
* exit before it landed. `BAPI_CONDUCTOR_*` identity keys remain intentionally
|
|
65
|
+
* absent from the worker env (TDD §7) — this literal does not change that.
|
|
66
|
+
*/
|
|
67
|
+
export function buildExecutorWorkerEnv(parentEnv) {
|
|
68
|
+
const env = {};
|
|
69
|
+
for (const key of ALLOWED_ENV_KEYS) {
|
|
70
|
+
if (!isExecutorEnvKeyAllowed(key))
|
|
71
|
+
continue;
|
|
72
|
+
const value = parentEnv[key];
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
env.BRIDGE_SKIP_PREPUSH = "1";
|
|
78
|
+
return env;
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat loop + dead-man's switch (BAPI-534, TDD §7, §10, R15).
|
|
3
|
+
*
|
|
4
|
+
* Every `heartbeatIntervalMs` (default 60s) POST `/heartbeat` keyed on the
|
|
5
|
+
* fencing `claim_token`, carrying telemetry residue. Timing chain (TDD §10):
|
|
6
|
+
* 60s heartbeat, 3-min lease, 2-min executor self-kill, 4-min server re-queue.
|
|
7
|
+
*
|
|
8
|
+
* - `updated` → advance the dead-man horizon.
|
|
9
|
+
* - `stale_claim` → definitive loss of ownership: SIGKILL + abandon the worktree.
|
|
10
|
+
* - anything else → transient: keep the worker alive UNTIL 2 minutes without a
|
|
11
|
+
* successful heartbeat, then SIGKILL + abandon (dead-man).
|
|
12
|
+
*
|
|
13
|
+
* After abandonment the loop stops all further heartbeat / telemetry / mutation
|
|
14
|
+
* work so `runClaimedJob` never reports success after losing ownership.
|
|
15
|
+
*/
|
|
16
|
+
import { killOwnedProcess } from "./process.js";
|
|
17
|
+
/**
|
|
18
|
+
* Run the heartbeat loop until the worker is done or ownership is abandoned. The
|
|
19
|
+
* FIRST heartbeat fires promptly (no initial interval wait).
|
|
20
|
+
*/
|
|
21
|
+
export async function runHeartbeatLoop(params) {
|
|
22
|
+
const { job, httpClient, options, deps, ownership, proc, observation } = params;
|
|
23
|
+
let first = true;
|
|
24
|
+
while (!ownership.abandoned && !params.isDone()) {
|
|
25
|
+
if (!first) {
|
|
26
|
+
await deps.sleep(options.heartbeatIntervalMs);
|
|
27
|
+
if (ownership.abandoned || params.isDone())
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
first = false;
|
|
31
|
+
const git = await params.collectTelemetry();
|
|
32
|
+
observation.setGitTelemetry(git);
|
|
33
|
+
const residue = observation.snapshot();
|
|
34
|
+
const outcome = await httpClient.heartbeat(job, {
|
|
35
|
+
local_commit_count: git.local_commit_count,
|
|
36
|
+
last_commit_sha: git.last_commit_sha,
|
|
37
|
+
telemetry: residue,
|
|
38
|
+
});
|
|
39
|
+
if (outcome === "updated") {
|
|
40
|
+
ownership.lastSuccessfulHeartbeatAt = deps.now();
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (outcome === "stale_claim") {
|
|
44
|
+
// Definitive dead claim: kill the worker and stop touching the worktree.
|
|
45
|
+
killOwnedProcess(proc, "stale_claim");
|
|
46
|
+
ownership.abandoned = true;
|
|
47
|
+
ownership.abandonReason = "stale_claim";
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
// retry_later / fatal_http_error: keep the worker alive within the dead-man
|
|
51
|
+
// window; SIGKILL + abandon once 2 minutes pass without a successful beat.
|
|
52
|
+
if (deps.now() - ownership.lastSuccessfulHeartbeatAt >= options.deadmanMs) {
|
|
53
|
+
killOwnedProcess(proc, "deadman");
|
|
54
|
+
ownership.abandoned = true;
|
|
55
|
+
ownership.abandonReason = "deadman";
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|