@bridge_gpt/mcp-server 0.2.41 → 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.
Files changed (73) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +17 -0
  9. package/build/conduct-epic/cli.js +752 -99
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/spawn.js +14 -2
  12. package/build/conductor/bridge-api-client.js +27 -1
  13. package/build/conductor/cli.js +46 -1
  14. package/build/conductor/doctor.js +101 -16
  15. package/build/conductor/epic-reconcile.js +72 -19
  16. package/build/conductor/epic-runtime.js +15 -3
  17. package/build/conductor/errors.js +47 -0
  18. package/build/conductor/git-hooks.js +205 -11
  19. package/build/conductor/install-doctor.js +230 -1
  20. package/build/conductor/local-merge.js +130 -28
  21. package/build/conductor/tools.js +32 -3
  22. package/build/conductor/worker-ledger-cli.js +27 -1
  23. package/build/conductor-bin.js +15 -15
  24. package/build/credentials-cli.js +3 -2
  25. package/build/doctor.js +107 -41
  26. package/build/executor/cli.js +48 -1
  27. package/build/executor/env.js +21 -0
  28. package/build/executor/index-scope.js +39 -0
  29. package/build/executor/job-log-registry.js +69 -0
  30. package/build/executor/job-runner.js +148 -26
  31. package/build/executor/live-worker-registry.js +83 -0
  32. package/build/executor/observation.js +167 -6
  33. package/build/executor/platform.js +147 -3
  34. package/build/executor/process.js +58 -14
  35. package/build/executor/runner.js +235 -48
  36. package/build/executor/test-clock.js +3 -2
  37. package/build/index-scope-contract.js +96 -0
  38. package/build/index.js +153 -204
  39. package/build/init.js +83 -22
  40. package/build/install-bridge-conductor.js +323 -14
  41. package/build/install-bridge.js +202 -38
  42. package/build/install-doctor.js +23 -9
  43. package/build/install-reexec.js +2 -1
  44. package/build/launcher-config-inspection.js +83 -22
  45. package/build/mcp-host-config.js +331 -67
  46. package/build/mcp-host-targets.js +45 -21
  47. package/build/mcp-identity.js +92 -0
  48. package/build/mcp-install-state.js +94 -1
  49. package/build/mcp-invoke.js +2 -1
  50. package/build/mcp-provisioning.js +45 -12
  51. package/build/mcp-registration-doctor.js +35 -13
  52. package/build/mcp-server-invocation.js +4 -2
  53. package/build/merge-pull-request.js +208 -9
  54. package/build/pipelines.generated.js +3 -3
  55. package/build/plane/defaults.js +4 -1
  56. package/build/plane/preflight.js +81 -10
  57. package/build/plane/test-fakes.js +9 -1
  58. package/build/readme.generated.js +1 -1
  59. package/build/regression-check.js +3 -2
  60. package/build/review-tickets.js +8 -7
  61. package/build/run-unit-tests-launcher.js +74 -1
  62. package/build/schedule-run.js +3 -2
  63. package/build/setup-epic.js +453 -78
  64. package/build/sfcc/tool-wrapper.js +15 -0
  65. package/build/start-tickets-prereqs.js +11 -6
  66. package/build/start-tickets.js +91 -85
  67. package/build/update-check.js +3 -2
  68. package/build/upgrade-advice.js +2 -1
  69. package/build/upgrade-cli.js +50 -18
  70. package/build/version.generated.js +1 -1
  71. package/docs/CONDUCTOR.md +22 -0
  72. package/docs/install/mcp-tool-integrations.md +19 -3
  73. 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. Nothing here reads the filesystem, the environment, or a credential.
20
- * The returned reason is a fixed sentence plus the platform name — never a host
21
- * path, an environment value, or raw exception text.
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
- let terminating = false;
50
- let graceTimer;
51
- /** TERM now, KILL after the grace period. Idempotent never signals twice. */
52
- const terminate = () => {
53
- if (terminating)
54
- return;
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
- if (graceTimer !== undefined)
84
- deps.clearTimer(graceTimer);
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;
@@ -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
- await runJob(job, httpClient, options, deps, report);
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
- for (;;) {
85
- const report = await collectPreflight(options, deps, preflightSeams);
86
- // BAPI-727: preflight warnings were previously collected but never emitted, so
87
- // a non-fatal finding an overridden MCP-shadowing collision, an unreadable
88
- // ~/.claude.json was invisible to the operator. Emit them before the claim
89
- // decision; this only logs and never changes the existing `!report.ok`
90
- // claim-skipping behavior below.
91
- for (const warning of report.warnings) {
92
- deps.errorLog(`executor preflight warning: ${warning}`);
93
- }
94
- if (!report.ok) {
95
- deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
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
- else {
98
- let claiming = true;
99
- while (claiming && options.maxConcurrent - active.size > 0) {
100
- const freeSlots = options.maxConcurrent - active.size;
101
- const manifest = buildClaimManifest(report, options, freeSlots);
102
- const result = await httpClient.claim(manifest);
103
- if (result.kind === "claimed") {
104
- const currentEpicRunId = result.job.epic_run_id ?? null;
105
- if (lastClaimedEpicRunId !== null &&
106
- currentEpicRunId !== null &&
107
- currentEpicRunId !== lastClaimedEpicRunId) {
108
- deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
109
- `(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
110
- `current_epic_run_id=${currentEpicRunId} scoped=${options.epicRunIds !== undefined})`);
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
- // `once`: ensure every dispatched job promise settles before returning.
136
- await Promise.all(active.values());
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: "bridge-api", status: "connected" }],
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: { "bridge-api": { command: "node", args: [] } } });
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).