@bridge_gpt/mcp-server 0.2.39 → 0.2.42
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 +10 -10
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +4 -4
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +75 -2
- package/build/conduct-epic/cli.js +795 -109
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/pr-state.js +113 -24
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +27 -1
- package/build/conductor/cli.js +46 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +15 -15
- package/build/credentials-cli.js +3 -2
- package/build/doctor.js +107 -41
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +148 -26
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +167 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +235 -48
- package/build/executor/test-clock.js +3 -2
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +153 -204
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +202 -38
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +3 -3
- package/build/plane/defaults.js +4 -1
- package/build/plane/preflight.js +81 -10
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +74 -1
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +453 -78
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
|
@@ -16,10 +16,19 @@
|
|
|
16
16
|
* `CLAUDE_CODE_OAUTH_TOKEN` before an otherwise-runnable host may claim work
|
|
17
17
|
* would reintroduce exactly the onboarding barrier this epic removes.
|
|
18
18
|
*
|
|
19
|
-
* PURE
|
|
20
|
-
*
|
|
21
|
-
* path, an environment value, or
|
|
19
|
+
* PURE (the gate). `evaluateExecutorPlatform` and its message renderer read no
|
|
20
|
+
* filesystem, no environment, and no credential. The returned reason is a fixed
|
|
21
|
+
* sentence plus the platform name — never a host path, an environment value, or
|
|
22
|
+
* raw exception text.
|
|
23
|
+
*
|
|
24
|
+
* BAPI-828 adds a SECOND, deliberately impure concern to this module: the Darwin
|
|
25
|
+
* sleep assertion at the bottom. It lives here because it answers the same kind
|
|
26
|
+
* of question — "what does this executor do differently because of the platform
|
|
27
|
+
* it is on" — and a competing `platform-ish` module is how two places end up
|
|
28
|
+
* disagreeing about what `darwin` means. It spawns a child and reads the
|
|
29
|
+
* executor's own environment, so the purity claim above is scoped to the gate.
|
|
22
30
|
*/
|
|
31
|
+
import { createProcessTerminationController } from "./process.js";
|
|
23
32
|
/**
|
|
24
33
|
* Platforms on which a conductor worker may be spawned.
|
|
25
34
|
*
|
|
@@ -52,3 +61,138 @@ export function evaluateExecutorPlatform(platform) {
|
|
|
52
61
|
}
|
|
53
62
|
return { supported: false, platform: name, message: formatUnsupportedExecutorPlatform(name) };
|
|
54
63
|
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Darwin sleep assertion (BAPI-828)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Executor-only opt-out for the Darwin sleep assertion.
|
|
69
|
+
*
|
|
70
|
+
* READ FROM THE EXECUTOR'S OWN ENVIRONMENT ONLY, and never forwarded to a
|
|
71
|
+
* worker. `env.ts`'s `ALLOWED_ENV_KEYS` is a strict twelve-key allowlist, so this
|
|
72
|
+
* key is excluded from every worker environment by construction — nothing needed
|
|
73
|
+
* adding to a deny list, and nothing may be added to the allowlist for it. A
|
|
74
|
+
* worker holds no sleep assertion of its own, so letting job-side state reach
|
|
75
|
+
* this decision would invert the trust boundary for no benefit.
|
|
76
|
+
*/
|
|
77
|
+
export const EXECUTOR_NO_SLEEP_ASSERTION_ENV = "BAPI_EXECUTOR_NO_SLEEP_ASSERTION";
|
|
78
|
+
/**
|
|
79
|
+
* Grace before the assertion child is escalated to `SIGKILL`.
|
|
80
|
+
*
|
|
81
|
+
* Much shorter than a worker's ten seconds on purpose: `caffeinate` has no work
|
|
82
|
+
* to flush and no state to commit, so a lingering one is pure delay at shutdown.
|
|
83
|
+
*/
|
|
84
|
+
export const SLEEP_ASSERTION_GRACE_MS = 2_000;
|
|
85
|
+
/**
|
|
86
|
+
* The exact assertion command. `caffeinate -i -s -w <pid>`:
|
|
87
|
+
* - `-i` prevents idle SYSTEM sleep (the failure this ticket exists for),
|
|
88
|
+
* - `-s` prevents sleep while on AC power,
|
|
89
|
+
* - `-w <pid>` ties the assertion's lifetime to the executor process, so even a
|
|
90
|
+
* `SIGKILL`ed executor — the one shutdown path that cannot run cleanup — stops
|
|
91
|
+
* holding the machine awake as soon as it dies.
|
|
92
|
+
*/
|
|
93
|
+
export const SLEEP_ASSERTION_COMMAND = "caffeinate";
|
|
94
|
+
/** Build the fixed assertion argv for `executorPid`. */
|
|
95
|
+
export function buildSleepAssertionArgs(executorPid) {
|
|
96
|
+
return ["-i", "-s", "-w", String(executorPid)];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* FIXED failure text. Deliberately carries no error message, no argv, and no
|
|
100
|
+
* environment: a spawn failure can echo the environment it failed to apply, and
|
|
101
|
+
* that environment may hold the operator's forwarded token. There is nothing an
|
|
102
|
+
* operator can do with the underlying errno that this sentence does not already
|
|
103
|
+
* tell them, so nothing is lost by refusing to interpolate it.
|
|
104
|
+
*/
|
|
105
|
+
export const SLEEP_ASSERTION_FAILED_MESSAGE = "executor could not hold a sleep assertion (caffeinate); continuing without it — " +
|
|
106
|
+
"on a laptop, keep the host awake by other means or the run may stall while it sleeps";
|
|
107
|
+
/** FIXED premature-exit text, secret-free for the same reason. */
|
|
108
|
+
export const SLEEP_ASSERTION_EXITED_MESSAGE = "executor sleep assertion (caffeinate) exited before the executor did; the host may " +
|
|
109
|
+
"idle-sleep for the remainder of this run";
|
|
110
|
+
/** Render the success line, which names only the child's pid. */
|
|
111
|
+
export function formatSleepAssertionHeld(pid) {
|
|
112
|
+
return `executor holding sleep assertion (caffeinate pid ${pid})`;
|
|
113
|
+
}
|
|
114
|
+
/** The shared inert handle shape; `release` resolves immediately. */
|
|
115
|
+
const INERT_ASSERTION = {
|
|
116
|
+
held: false,
|
|
117
|
+
release: async () => { },
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Start a Darwin sleep assertion for the executor's lifetime.
|
|
121
|
+
*
|
|
122
|
+
* FAIL-OPEN, ALWAYS. Every failure mode — a non-Darwin host, the opt-out, a
|
|
123
|
+
* throwing spawn, a child with no usable pid, a `caffeinate` that dies early —
|
|
124
|
+
* produces at most one bounded diagnostic and an inert handle. Not one of them
|
|
125
|
+
* prevents the executor from claiming: an executor that refused to work because
|
|
126
|
+
* it could not stop the laptop sleeping would be a strictly worse outcome than
|
|
127
|
+
* the sleep it is guarding against.
|
|
128
|
+
*
|
|
129
|
+
* GATED BEFORE ANY SPAWN. The platform and opt-out checks run before
|
|
130
|
+
* `spawnProcess` is touched, so a linux host and an opted-out darwin host each
|
|
131
|
+
* perform exactly zero process work — which is what makes "no probe on an
|
|
132
|
+
* unsupported platform" assertable rather than merely likely.
|
|
133
|
+
*/
|
|
134
|
+
export function startExecutorSleepAssertion(deps) {
|
|
135
|
+
if (deps.platform !== "darwin")
|
|
136
|
+
return INERT_ASSERTION;
|
|
137
|
+
if (deps.env[EXECUTOR_NO_SLEEP_ASSERTION_ENV] === "1")
|
|
138
|
+
return INERT_ASSERTION;
|
|
139
|
+
// The executor's own environment, minus the keys Node reports as undefined
|
|
140
|
+
// (`SpawnProcessOptions.env` is `Record<string, string>`). No filtering beyond
|
|
141
|
+
// that: `caffeinate` is a first-party Apple binary that reads no credential,
|
|
142
|
+
// and handing it a stripped environment would only risk breaking its PATH.
|
|
143
|
+
const env = {};
|
|
144
|
+
for (const [key, value] of Object.entries(deps.env)) {
|
|
145
|
+
if (typeof value === "string")
|
|
146
|
+
env[key] = value;
|
|
147
|
+
}
|
|
148
|
+
let child;
|
|
149
|
+
try {
|
|
150
|
+
child = deps.spawnProcess(SLEEP_ASSERTION_COMMAND, buildSleepAssertionArgs(deps.executorPid), {
|
|
151
|
+
cwd: deps.cwd,
|
|
152
|
+
env,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
deps.errorLog(SLEEP_ASSERTION_FAILED_MESSAGE);
|
|
157
|
+
return INERT_ASSERTION;
|
|
158
|
+
}
|
|
159
|
+
// A spawn that "succeeded" without producing a usable child is the same
|
|
160
|
+
// situation as one that threw: we are not holding an assertion, and pretending
|
|
161
|
+
// otherwise would report a guard that does not exist.
|
|
162
|
+
if (!child || typeof child.pid !== "number" || typeof child.kill !== "function") {
|
|
163
|
+
deps.errorLog(SLEEP_ASSERTION_FAILED_MESSAGE);
|
|
164
|
+
return INERT_ASSERTION;
|
|
165
|
+
}
|
|
166
|
+
deps.errorLog(formatSleepAssertionHeld(child.pid));
|
|
167
|
+
// Cleanup routes through the SAME escalation used for workers, so there is one
|
|
168
|
+
// implementation of "ask a child to stop, then insist" in the executor.
|
|
169
|
+
const controller = createProcessTerminationController(child, deps, SLEEP_ASSERTION_GRACE_MS);
|
|
170
|
+
let releasing = false;
|
|
171
|
+
// Watch the child WITHOUT blocking startup: claiming must not wait on this, and
|
|
172
|
+
// an assertion that dies early is a warning, never a job failure. Reporting is
|
|
173
|
+
// suppressed once WE initiated the release, because a child exiting after being
|
|
174
|
+
// asked to is the expected outcome, not a premature death.
|
|
175
|
+
const settled = (async () => {
|
|
176
|
+
try {
|
|
177
|
+
await child.wait();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* an unobservable exit is still an exit */
|
|
181
|
+
}
|
|
182
|
+
if (!releasing)
|
|
183
|
+
deps.errorLog(SLEEP_ASSERTION_EXITED_MESSAGE);
|
|
184
|
+
})();
|
|
185
|
+
return {
|
|
186
|
+
held: true,
|
|
187
|
+
async release() {
|
|
188
|
+
if (releasing)
|
|
189
|
+
return;
|
|
190
|
+
releasing = true;
|
|
191
|
+
controller.requestTermination();
|
|
192
|
+
// Await the child so a caller that releases and then exits does not race the
|
|
193
|
+
// teardown, and so `pmset -g assertions` is observably clean afterwards.
|
|
194
|
+
await settled;
|
|
195
|
+
controller.dispose();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
export const DEFAULT_EXCERPT_BYTES = 8_000;
|
|
3
3
|
/** Default SIGTERM→SIGKILL grace period (ms). */
|
|
4
4
|
export const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
5
|
+
/**
|
|
6
|
+
* Create the shared termination controller for `proc`.
|
|
7
|
+
*
|
|
8
|
+
* `termGraceMs` is captured at creation, so a controller handed to
|
|
9
|
+
* {@link runProcessWithTimeout} keeps the grace period its creator chose rather
|
|
10
|
+
* than silently adopting a second one.
|
|
11
|
+
*/
|
|
12
|
+
export function createProcessTerminationController(proc, deps, termGraceMs = DEFAULT_TERM_GRACE_MS) {
|
|
13
|
+
let requested = false;
|
|
14
|
+
let escalated = false;
|
|
15
|
+
let disposed = false;
|
|
16
|
+
let graceTimer;
|
|
17
|
+
return {
|
|
18
|
+
requestTermination() {
|
|
19
|
+
// A disposed controller belongs to a settled child: signalling it would
|
|
20
|
+
// either hit nothing or, worse, a recycled pid.
|
|
21
|
+
if (requested || disposed)
|
|
22
|
+
return;
|
|
23
|
+
requested = true;
|
|
24
|
+
proc.kill("SIGTERM");
|
|
25
|
+
graceTimer = deps.setTimer(() => {
|
|
26
|
+
graceTimer = undefined;
|
|
27
|
+
if (escalated || disposed)
|
|
28
|
+
return;
|
|
29
|
+
escalated = true;
|
|
30
|
+
proc.kill("SIGKILL");
|
|
31
|
+
}, termGraceMs);
|
|
32
|
+
},
|
|
33
|
+
isTerminationRequested() {
|
|
34
|
+
return requested;
|
|
35
|
+
},
|
|
36
|
+
dispose() {
|
|
37
|
+
if (disposed)
|
|
38
|
+
return;
|
|
39
|
+
disposed = true;
|
|
40
|
+
if (graceTimer !== undefined) {
|
|
41
|
+
deps.clearTimer(graceTimer);
|
|
42
|
+
graceTimer = undefined;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
5
47
|
function byteLength(value) {
|
|
6
48
|
return Buffer.byteLength(value, "utf8");
|
|
7
49
|
}
|
|
@@ -45,19 +87,19 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
45
87
|
const limit = options.excerptLimitBytes ?? DEFAULT_EXCERPT_BYTES;
|
|
46
88
|
let stdoutExcerpt = "";
|
|
47
89
|
let stderrExcerpt = "";
|
|
90
|
+
/**
|
|
91
|
+
* Reserved for the `onStdoutTerminationCheck` containment path ONLY (BAPI-828).
|
|
92
|
+
* An executor `SIGTERM`/`SIGINT` shutdown reaches the same controller below but
|
|
93
|
+
* must NOT set this flag: reporting an operator-initiated stop as an MCP
|
|
94
|
+
* containment refusal would invent a containment failure that never happened.
|
|
95
|
+
*/
|
|
48
96
|
let terminationRequested = false;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
terminating = true;
|
|
56
|
-
proc.kill("SIGTERM");
|
|
57
|
-
graceTimer = deps.setTimer(() => {
|
|
58
|
-
proc.kill("SIGKILL");
|
|
59
|
-
}, termGraceMs);
|
|
60
|
-
};
|
|
97
|
+
// ONE escalation mechanism, whoever asks. Reused when the job runner already
|
|
98
|
+
// created and registered a controller for this child (BAPI-828), so executor
|
|
99
|
+
// shutdown, timeout, and MCP containment converge on a single `SIGTERM` and a
|
|
100
|
+
// single grace timer rather than racing three of each.
|
|
101
|
+
const controller = options.terminationController ?? createProcessTerminationController(proc, deps, termGraceMs);
|
|
102
|
+
const terminate = () => controller.requestTermination();
|
|
61
103
|
const pumpStdout = pump(proc.stdout, (chunk) => {
|
|
62
104
|
// Advisory observation ALWAYS runs first and always runs: the tee and the
|
|
63
105
|
// telemetry must see every chunk regardless of what the assertion decides.
|
|
@@ -80,8 +122,10 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
80
122
|
}, timeoutSeconds * 1000);
|
|
81
123
|
const { exitCode, signal } = await proc.wait();
|
|
82
124
|
deps.clearTimer(timeoutTimer);
|
|
83
|
-
|
|
84
|
-
|
|
125
|
+
// The child has settled, so any outstanding escalation is now aimed at a
|
|
126
|
+
// finished process. Disposal is idempotent and safe for a controller this call
|
|
127
|
+
// did not create — the job runner's `finally` may dispose it again.
|
|
128
|
+
controller.dispose();
|
|
85
129
|
await pumpStdout;
|
|
86
130
|
await pumpStderr;
|
|
87
131
|
let classification;
|
package/build/executor/runner.js
CHANGED
|
@@ -8,7 +8,40 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
|
|
10
10
|
import { runClaimedJob } from "./job-runner.js";
|
|
11
|
+
import { appendToActiveJobLogs } from "./job-log-registry.js";
|
|
12
|
+
import { createLiveWorkerRegistry } from "./live-worker-registry.js";
|
|
11
13
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
14
|
+
/**
|
|
15
|
+
* Signals that request a graceful executor shutdown (BAPI-828).
|
|
16
|
+
*
|
|
17
|
+
* Both, not just `SIGTERM`: an operator stopping a foreground executor types
|
|
18
|
+
* Ctrl-C, so a `SIGINT` that orphaned the worker would be the common case rather
|
|
19
|
+
* than the rare one. `SIGKILL` is deliberately absent — it cannot be trapped, and
|
|
20
|
+
* the worktree lock's stale-owner recovery is what covers it.
|
|
21
|
+
*/
|
|
22
|
+
export const EXECUTOR_SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
23
|
+
/**
|
|
24
|
+
* Extra wall-clock slack, beyond three poll intervals, before a COMPLETED poll
|
|
25
|
+
* sleep is called a host suspend (BAPI-828).
|
|
26
|
+
*
|
|
27
|
+
* The threshold is `3 * pollIntervalMs + SUSPEND_GAP_SLACK_MS`, so it scales with
|
|
28
|
+
* however the operator configured polling instead of pinning a constant that a
|
|
29
|
+
* long interval would trivially exceed and a short one would never reach. Three
|
|
30
|
+
* intervals plus a full minute is far outside anything ordinary scheduling jitter
|
|
31
|
+
* or a slow GC sweep produces, which keeps the diagnostic rare enough to mean
|
|
32
|
+
* something when it does appear.
|
|
33
|
+
*/
|
|
34
|
+
export const SUSPEND_GAP_SLACK_MS = 60_000;
|
|
35
|
+
/**
|
|
36
|
+
* Render the fixed suspend-gap diagnostic.
|
|
37
|
+
*
|
|
38
|
+
* Exported so the stderr line and the worker-log annotation are the same string
|
|
39
|
+
* by construction — an operator correlating one against the other is the whole
|
|
40
|
+
* reason both exist.
|
|
41
|
+
*/
|
|
42
|
+
export function formatSuspendGapDiagnostic(elapsedMs) {
|
|
43
|
+
return `executor: host appears to have been suspended for ~${Math.round(elapsedMs / 1000)} s (poll gap)`;
|
|
44
|
+
}
|
|
12
45
|
/**
|
|
13
46
|
* Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
|
|
14
47
|
* in continuous mode it does not return (the process is long-lived).
|
|
@@ -50,6 +83,92 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
50
83
|
denyProbeCache: createDenyProbeCache(),
|
|
51
84
|
...(seams.preflightSeams ?? {}),
|
|
52
85
|
};
|
|
86
|
+
// --- Graceful shutdown state (BAPI-828) -------------------------------
|
|
87
|
+
// ONE registry per invocation — see `live-worker-registry.ts` for why this is
|
|
88
|
+
// not module-global. Every dispatched job receives it, so a worker spawned by
|
|
89
|
+
// any of them is reachable from the signal handlers installed below.
|
|
90
|
+
const liveWorkers = createLiveWorkerRegistry();
|
|
91
|
+
const control = { liveWorkers };
|
|
92
|
+
let shutdownRequested = false;
|
|
93
|
+
/**
|
|
94
|
+
* Wake functions for whichever poll sleep is currently in flight. A list whose
|
|
95
|
+
* entries are REMOVED when their sleep completes normally, rather than one
|
|
96
|
+
* long-lived promise every cycle races against: the latter would accumulate one
|
|
97
|
+
* pending reaction per poll for the entire life of a long-running executor.
|
|
98
|
+
*/
|
|
99
|
+
const shutdownWaiters = [];
|
|
100
|
+
/**
|
|
101
|
+
* Handle the first shutdown signal. Idempotent across repeated and mixed
|
|
102
|
+
* `SIGTERM`/`SIGINT`: a second signal must not send a second `SIGTERM` to a
|
|
103
|
+
* worker, install a second grace timer, or start a second drain.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately does NOT call `process.exit` and does NOT signal any child
|
|
106
|
+
* directly. The loop below is allowed to unwind normally so worker supervision,
|
|
107
|
+
* worker-log closure, and the ownership-checked worktree-lock release all still
|
|
108
|
+
* run — exiting here is precisely what would leave the lock behind.
|
|
109
|
+
*/
|
|
110
|
+
const beginShutdown = () => {
|
|
111
|
+
if (shutdownRequested)
|
|
112
|
+
return;
|
|
113
|
+
shutdownRequested = true;
|
|
114
|
+
deps.errorLog("executor: shutdown signal received; terminating live workers and draining");
|
|
115
|
+
liveWorkers.requestShutdown();
|
|
116
|
+
for (const wake of shutdownWaiters.splice(0, shutdownWaiters.length))
|
|
117
|
+
wake();
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Sleep one poll interval, or return early once shutdown is requested.
|
|
121
|
+
*
|
|
122
|
+
* The return value is load-bearing: only a `"slept"` result may feed the
|
|
123
|
+
* suspend-gap calculation. A shutdown wake is an INTERRUPTED wait whose elapsed
|
|
124
|
+
* time says nothing about the host, and reporting one as a suspend would
|
|
125
|
+
* manufacture a diagnostic out of an ordinary Ctrl-C.
|
|
126
|
+
*/
|
|
127
|
+
const sleepUntilPollOrShutdown = async (ms) => {
|
|
128
|
+
if (shutdownRequested)
|
|
129
|
+
return "shutdown";
|
|
130
|
+
let wake;
|
|
131
|
+
const interrupted = new Promise((resolve) => {
|
|
132
|
+
wake = resolve;
|
|
133
|
+
});
|
|
134
|
+
shutdownWaiters.push(wake);
|
|
135
|
+
try {
|
|
136
|
+
return await Promise.race([
|
|
137
|
+
deps.sleep(ms).then(() => "slept"),
|
|
138
|
+
interrupted.then(() => "shutdown"),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
const index = shutdownWaiters.indexOf(wake);
|
|
143
|
+
if (index !== -1)
|
|
144
|
+
shutdownWaiters.splice(index, 1);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Annotate an abnormally long COMPLETED poll gap (BAPI-828).
|
|
149
|
+
*
|
|
150
|
+
* ADVISORY BY CONSTRUCTION: it logs and appends, and touches nothing else.
|
|
151
|
+
* Preflight, claim eligibility, heartbeat cadence, leases, active jobs, retries,
|
|
152
|
+
* and worktree handling are all deliberately left alone — the host having slept
|
|
153
|
+
* is a fact for the operator reading the log, never an input to scheduling. The
|
|
154
|
+
* whole point is that an executor whose MacBook idled to sleep mid-run left
|
|
155
|
+
* nothing in its logs saying so, and the freeze was misdiagnosed for hours.
|
|
156
|
+
*/
|
|
157
|
+
const reportSuspendGap = async (elapsedMs) => {
|
|
158
|
+
if (elapsedMs <= 3 * options.pollIntervalMs + SUSPEND_GAP_SLACK_MS)
|
|
159
|
+
return;
|
|
160
|
+
const diagnostic = formatSuspendGapDiagnostic(elapsedMs);
|
|
161
|
+
deps.errorLog(diagnostic);
|
|
162
|
+
const appendFile = deps.appendFile;
|
|
163
|
+
if (!appendFile)
|
|
164
|
+
return;
|
|
165
|
+
try {
|
|
166
|
+
await appendToActiveJobLogs(diagnostic, { appendFile });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* annotation is never allowed to disturb the poll loop */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
53
172
|
/**
|
|
54
173
|
* Run the conservative worktree GC sweep ONLY while the runner owns no active
|
|
55
174
|
* job worktree. Failures are logged to stderr and never abort claiming
|
|
@@ -69,7 +188,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
69
188
|
function dispatch(job, report) {
|
|
70
189
|
const promise = (async () => {
|
|
71
190
|
try {
|
|
72
|
-
|
|
191
|
+
// `undefined` for the behavior seams (the job runner defaults them), then
|
|
192
|
+
// the runner-scoped control object carrying the live-worker registry. A
|
|
193
|
+
// job dispatched after shutdown was requested still runs through here so
|
|
194
|
+
// its worktree lock unwinds through the normal `finally`; the registry's
|
|
195
|
+
// sticky flag terminates whatever it manages to spawn.
|
|
196
|
+
await runJob(job, httpClient, options, deps, report, undefined, control);
|
|
73
197
|
}
|
|
74
198
|
catch (err) {
|
|
75
199
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -81,58 +205,121 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
81
205
|
})();
|
|
82
206
|
active.set(job.id, promise);
|
|
83
207
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for (const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
208
|
+
// Handlers go on BEFORE preflight and before the first claim (BAPI-828).
|
|
209
|
+
// Preflight can take seconds and the first claim can spawn a worker immediately
|
|
210
|
+
// after it, so registering any later would leave a real window in which a
|
|
211
|
+
// `SIGTERM` reached the executor and nothing owned the child it had just made.
|
|
212
|
+
const unsubscribers = [];
|
|
213
|
+
const onSignal = seams.onSignal;
|
|
214
|
+
if (onSignal) {
|
|
215
|
+
for (const signal of EXECUTOR_SHUTDOWN_SIGNALS) {
|
|
216
|
+
try {
|
|
217
|
+
unsubscribers.push(onSignal(signal, beginShutdown));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// A host that refuses a handler for one signal must not stop the executor
|
|
221
|
+
// from running, or from handling the other signal.
|
|
222
|
+
}
|
|
96
223
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
for (;;) {
|
|
227
|
+
const report = await collectPreflight(options, deps, preflightSeams);
|
|
228
|
+
// BAPI-727: preflight warnings were previously collected but never emitted, so
|
|
229
|
+
// a non-fatal finding — an overridden MCP-shadowing collision, an unreadable
|
|
230
|
+
// ~/.claude.json — was invisible to the operator. Emit them before the claim
|
|
231
|
+
// decision; this only logs and never changes the existing `!report.ok`
|
|
232
|
+
// claim-skipping behavior below.
|
|
233
|
+
for (const warning of report.warnings) {
|
|
234
|
+
deps.errorLog(`executor preflight warning: ${warning}`);
|
|
235
|
+
}
|
|
236
|
+
if (!report.ok) {
|
|
237
|
+
deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
|
|
238
|
+
}
|
|
239
|
+
else if (shutdownRequested) {
|
|
240
|
+
// Shutdown arrived during preflight. Claiming now would take on work this
|
|
241
|
+
// executor is about to stop doing, and the server would have to wait out
|
|
242
|
+
// the whole lease before anyone else could have it.
|
|
243
|
+
deps.errorLog("executor: shutdown requested; not claiming new work");
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
let claiming = true;
|
|
247
|
+
while (claiming && !shutdownRequested && options.maxConcurrent - active.size > 0) {
|
|
248
|
+
const freeSlots = options.maxConcurrent - active.size;
|
|
249
|
+
const manifest = buildClaimManifest(report, options, freeSlots);
|
|
250
|
+
const result = await httpClient.claim(manifest);
|
|
251
|
+
if (result.kind === "claimed") {
|
|
252
|
+
const currentEpicRunId = result.job.epic_run_id ?? null;
|
|
253
|
+
if (lastClaimedEpicRunId !== null &&
|
|
254
|
+
currentEpicRunId !== null &&
|
|
255
|
+
currentEpicRunId !== lastClaimedEpicRunId) {
|
|
256
|
+
deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
|
|
257
|
+
`(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
|
|
258
|
+
`current_epic_run_id=${currentEpicRunId} scoped=${options.epicRunIds !== undefined})`);
|
|
259
|
+
}
|
|
260
|
+
if (currentEpicRunId !== null)
|
|
261
|
+
lastClaimedEpicRunId = currentEpicRunId;
|
|
262
|
+
// Dispatched even when the signal landed while this claim was in
|
|
263
|
+
// flight (BAPI-828). The job is OURS the moment the server answered,
|
|
264
|
+
// so dropping it here would strand a claimed row under a live lease
|
|
265
|
+
// and skip the worktree-lock unwind. The registry's sticky shutdown
|
|
266
|
+
// terminates whatever it spawns, immediately, on registration.
|
|
267
|
+
dispatch(result.job, report);
|
|
268
|
+
}
|
|
269
|
+
else if (result.kind === "none") {
|
|
270
|
+
claiming = false;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
|
|
274
|
+
claiming = false;
|
|
111
275
|
}
|
|
112
|
-
if (currentEpicRunId !== null)
|
|
113
|
-
lastClaimedEpicRunId = currentEpicRunId;
|
|
114
|
-
dispatch(result.job, report);
|
|
115
|
-
}
|
|
116
|
-
else if (result.kind === "none") {
|
|
117
|
-
claiming = false;
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
|
|
121
|
-
claiming = false;
|
|
122
276
|
}
|
|
277
|
+
// Conservative worktree GC runs once per cycle, AFTER the claim attempt and
|
|
278
|
+
// only while idle (`active.size === 0` — no owned job worktree). This covers
|
|
279
|
+
// both the startup cycle and between-poll-cycle cadence without ever
|
|
280
|
+
// sweeping while a job is running. Kept off the pre-claim path so it never
|
|
281
|
+
// delays claiming a ready job.
|
|
282
|
+
await maybeSweepWorktrees();
|
|
283
|
+
}
|
|
284
|
+
if (options.once || shutdownRequested)
|
|
285
|
+
break;
|
|
286
|
+
// Sample the clock immediately around the ACTUAL sleep, so the measured gap
|
|
287
|
+
// is the wait itself and not the claim/GC work on either side of it.
|
|
288
|
+
const sleepStartedAt = deps.now();
|
|
289
|
+
const wake = await sleepUntilPollOrShutdown(options.pollIntervalMs);
|
|
290
|
+
if (wake === "shutdown")
|
|
291
|
+
break;
|
|
292
|
+
await reportSuspendGap(deps.now() - sleepStartedAt);
|
|
293
|
+
}
|
|
294
|
+
// Drain every dispatched job — on the `once` path and the shutdown path alike.
|
|
295
|
+
// Terminated workers still have to finish supervision, flush final telemetry,
|
|
296
|
+
// close their worker logs, deregister, and release their worktree locks;
|
|
297
|
+
// returning ahead of that is exactly what would leave a lock owned by a
|
|
298
|
+
// process that has already exited.
|
|
299
|
+
//
|
|
300
|
+
// INSIDE the try, so the handlers are still installed while it runs: a second
|
|
301
|
+
// `SIGTERM` arriving mid-drain must reach `beginShutdown` (an idempotent no-op
|
|
302
|
+
// by then) rather than Node's default terminate action, which would kill the
|
|
303
|
+
// executor in the middle of releasing its locks.
|
|
304
|
+
await Promise.all(active.values());
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
// Unsubscribe in a runner-level `finally`, so a direct or repeated
|
|
308
|
+
// `runExecutor` call — an embedded runner, a `--once` invocation in a loop, a
|
|
309
|
+
// test suite — cannot leave stale listeners bound to a registry that has since
|
|
310
|
+
// gone out of scope.
|
|
311
|
+
for (const unsubscribe of unsubscribers) {
|
|
312
|
+
try {
|
|
313
|
+
unsubscribe();
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* a failed unsubscribe must not mask the runner's own outcome */
|
|
123
317
|
}
|
|
124
|
-
// Conservative worktree GC runs once per cycle, AFTER the claim attempt and
|
|
125
|
-
// only while idle (`active.size === 0` — no owned job worktree). This covers
|
|
126
|
-
// both the startup cycle and between-poll-cycle cadence without ever
|
|
127
|
-
// sweeping while a job is running. Kept off the pre-claim path so it never
|
|
128
|
-
// delays claiming a ready job.
|
|
129
|
-
await maybeSweepWorktrees();
|
|
130
318
|
}
|
|
131
|
-
if (options.once)
|
|
132
|
-
break;
|
|
133
|
-
await deps.sleep(options.pollIntervalMs);
|
|
134
319
|
}
|
|
135
|
-
//
|
|
136
|
-
|
|
320
|
+
// A graceful signal shutdown is a NORMAL executor exit, not an error: the run did
|
|
321
|
+
// exactly what it was asked to do. A distinct nonzero code here would make every
|
|
322
|
+
// deliberate operator stop look like a crash to launchd, systemd, and the CLI
|
|
323
|
+
// boundary alike.
|
|
137
324
|
return 0;
|
|
138
325
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { resolveAgentSpec } from "../agent-registry.js";
|
|
10
10
|
import { createClaudeExecutorAdapter } from "../agent-launchers/claude-executor-adapter.js";
|
|
11
11
|
import { validateExecutorAdapterCapabilities } from "../agent-launchers/executor-adapter.js";
|
|
12
|
+
import { MCP_SERVER_NAME } from "../mcp-identity.js";
|
|
12
13
|
export class VirtualClock {
|
|
13
14
|
t = 0;
|
|
14
15
|
timers = [];
|
|
@@ -70,7 +71,7 @@ async function flushMicrotasks() {
|
|
|
70
71
|
export const DEFAULT_WORKER_INIT_EVENT_LINE = `${JSON.stringify({
|
|
71
72
|
type: "system",
|
|
72
73
|
subtype: "init",
|
|
73
|
-
mcp_servers: [{ name:
|
|
74
|
+
mcp_servers: [{ name: MCP_SERVER_NAME, status: "connected" }],
|
|
74
75
|
})}\n`;
|
|
75
76
|
/** A single-chunk stdout stream carrying {@link DEFAULT_WORKER_INIT_EVENT_LINE}. */
|
|
76
77
|
async function* defaultWorkerStdout() {
|
|
@@ -136,7 +137,7 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
136
137
|
// registration must therefore override this read explicitly — it will not
|
|
137
138
|
// get that state by accident, which is the point.
|
|
138
139
|
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
139
|
-
return JSON.stringify({ mcpServers: {
|
|
140
|
+
return JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: "node", args: [] } } });
|
|
140
141
|
}
|
|
141
142
|
// Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
|
|
142
143
|
// (so BAPI-664 command provisioning treats absent files as fillable).
|