@bridge_gpt/mcp-server 0.2.41 → 0.2.43

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 (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -166,9 +166,165 @@ export async function collectRemoteTrackingSha(deps, worktreePath, branch) {
166
166
  }
167
167
  }
168
168
  /**
169
- * Tolerantly parse one Claude stream-json line for an advisory `phase_hint`.
169
+ * Utilization at which a still-`allowed` rate limit becomes worth reporting
170
+ * (BAPI-828).
171
+ *
172
+ * The comparison is `>=`, not `>`: a provider that reports
173
+ * `surpassedThreshold: 0.75` alongside `utilization: 0.75` is telling us the
174
+ * threshold was reached, and treating exactly-at-threshold as uninteresting would
175
+ * drop the very first warning the provider bothered to send.
176
+ */
177
+ export const RATE_LIMIT_WARNING_UTILIZATION = 0.75;
178
+ /**
179
+ * Character cap on each worker-supplied rate-limit string.
180
+ *
181
+ * SMALL ON PURPOSE, unlike the surface observer's line buffer above. These two
182
+ * values are persisted into job telemetry and interpolated into a stderr line, so
183
+ * the bound is a content limit rather than a stop-unbounded-growth limit. Real
184
+ * values (`seven_day`, `allowed_warning`) are an order of magnitude under it.
185
+ */
186
+ export const MAX_RATE_LIMIT_FIELD_CHARS = 64;
187
+ /**
188
+ * Normalize one worker-controlled rate-limit string, or `null` when unusable.
189
+ *
190
+ * Newlines and tabs collapse to spaces BEFORE truncation. That ordering matters:
191
+ * the sanitized value is interpolated into a single-line stderr diagnostic, and a
192
+ * worker that embedded `\n executor: ...` could otherwise forge an executor
193
+ * diagnostic line in the operator's terminal and in the worker log.
194
+ */
195
+ function sanitizeRateLimitField(value) {
196
+ if (typeof value !== "string")
197
+ return null;
198
+ const collapsed = value.replace(/[\r\n\t]+/g, " ").trim();
199
+ if (collapsed.length === 0)
200
+ return null;
201
+ return collapsed.slice(0, MAX_RATE_LIMIT_FIELD_CHARS);
202
+ }
203
+ /**
204
+ * Safely derive an ISO-8601 UTC reset timestamp from worker-controlled input, or
205
+ * `undefined` when the value is unusable (BAPI-898).
206
+ *
207
+ * Stateless and private: a boundary helper that accepts an unknown value plus
208
+ * the current epoch time and returns a derived string, never the raw input.
209
+ * Only a finite number is accepted. A value strictly greater than `1e12` is
210
+ * interpreted as milliseconds since epoch; everything else — including exactly
211
+ * `1e12` — is interpreted as seconds. A resolved timestamp outside ten years
212
+ * before or after `nowMs` is rejected as an implausible worker-reported value.
213
+ *
214
+ * The result is emitted EXCLUSIVELY through `new Date(timestampMs).toISOString()`
215
+ * so raw worker input can never enter telemetry or diagnostics.
216
+ */
217
+ function normalizeRateLimitResetAt(value, nowMs) {
218
+ if (typeof value !== "number" || !Number.isFinite(value))
219
+ return undefined;
220
+ const timestampMs = value > 1e12 ? value : value * 1000;
221
+ if (!Number.isFinite(timestampMs))
222
+ return undefined;
223
+ const now = new Date(nowMs);
224
+ const min = new Date(now);
225
+ min.setUTCFullYear(min.getUTCFullYear() - 10);
226
+ const max = new Date(now);
227
+ max.setUTCFullYear(max.getUTCFullYear() + 10);
228
+ if (timestampMs < min.getTime() || timestampMs > max.getTime())
229
+ return undefined;
230
+ return new Date(timestampMs).toISOString();
231
+ }
232
+ /**
233
+ * Rank a rate-limit status by severity for most-severe-wins retention
234
+ * (BAPI-898): `allowed` is least severe, `allowed_warning` is next, and every
235
+ * other status (`rejected`, `blocked`, or anything unrecognized) is most severe.
236
+ *
237
+ * Pinned independently of stream parsing so retention logic (BAPI-898 Step 2)
238
+ * can compare severities without re-deriving this mapping.
239
+ */
240
+ export function rateLimitSeverity(status) {
241
+ if (status === "allowed")
242
+ return 0;
243
+ if (status === "allowed_warning")
244
+ return 1;
245
+ return 2;
246
+ }
247
+ /**
248
+ * Recognize a QUALIFYING `rate_limit_event`, or `null` for everything else.
249
+ *
250
+ * Qualification is deliberately two-sided: utilization at or above the warning
251
+ * threshold, OR a status that is not exactly `allowed`. Either alone would miss a
252
+ * real case — a hard `blocked` at low utilization is the most actionable event
253
+ * there is, and a provider that keeps saying `allowed` right up to the ceiling
254
+ * still deserves a warning at 90%.
255
+ *
256
+ * Everything unrecognized returns `null` rather than throwing: this is the
257
+ * advisory parser, and R5 says a parser that throws is a bug.
258
+ */
259
+ function parseRateLimitEvent(obj) {
260
+ if (obj.type !== "rate_limit_event")
261
+ return null;
262
+ const info = obj.rate_limit_info;
263
+ if (!info || typeof info !== "object" || Array.isArray(info))
264
+ return null;
265
+ const record = info;
266
+ const rateLimitType = sanitizeRateLimitField(record.rateLimitType);
267
+ const status = sanitizeRateLimitField(record.status);
268
+ if (rateLimitType === null || status === null)
269
+ return null;
270
+ const utilization = record.utilization;
271
+ // `Number.isFinite` rejects NaN and both infinities as well as non-numbers, so a
272
+ // malformed value can never reach the percentage arithmetic below.
273
+ if (typeof utilization !== "number" || !Number.isFinite(utilization))
274
+ return null;
275
+ const qualifies = utilization >= RATE_LIMIT_WARNING_UTILIZATION || status !== "allowed";
276
+ if (!qualifies)
277
+ return null;
278
+ const advisory = { rate_limit_type: rateLimitType, status, utilization };
279
+ // Invalid or missing reset data never disqualifies an otherwise-qualifying
280
+ // advisory — an absent `resets_at` is neutral, not an error.
281
+ const resetsAt = normalizeRateLimitResetAt(record.resetsAt, Date.now());
282
+ if (resetsAt !== undefined)
283
+ advisory.resets_at = resetsAt;
284
+ return advisory;
285
+ }
286
+ /**
287
+ * Fixed consequence clause appended to every rate-limit diagnostic (BAPI-898).
288
+ * Stated exactly once per line so an operator reading it cold understands what
289
+ * happens next without cross-referencing the runbook.
290
+ */
291
+ const RATE_LIMIT_CONSEQUENCE_TEXT = "The executor keeps claiming: a worker that hits the ceiling fails fast and its " +
292
+ "job classifies as a worker failure under the normal retry budget.";
293
+ /** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
294
+ const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-conductor-v2-operator-runbook.md §8.";
295
+ /**
296
+ * Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
297
+ *
298
+ * Exported so the stderr text has ONE definition rather than being rebuilt at the
299
+ * call site — the job runner emits it, and the tests pin it, from here.
300
+ *
301
+ * One calm, scannable line: the sanitized limit type, rounded utilization, and
302
+ * status; whether a reset time is known; the fixed executor consequence; and the
303
+ * runbook reference. Punctuation shape:
304
+ * `executor: worker reports … at … (…); <reset clause>. <consequence> <reference>`
305
+ *
306
+ * `resets_at` is rendered ONLY from the parser-derived field — never from raw
307
+ * worker input — and its absence is neutral operational information, not an
308
+ * error. A non-finite `utilization` reaching this formatter at runtime (outside
309
+ * the parser's own finite-utilization qualification contract) falls back to
310
+ * `100%` rather than rendering `NaN%` or `undefined%`.
311
+ */
312
+ export function formatWorkerRateLimitAdvisory(advisory) {
313
+ const percent = Number.isFinite(advisory.utilization) ? Math.round(advisory.utilization * 100) : 100;
314
+ const resetClause = advisory.resets_at !== undefined ? `; resets at ${advisory.resets_at}` : "; reset time not reported";
315
+ return (`executor: worker reports ${advisory.rate_limit_type} rate limit at ` +
316
+ `${percent}% (${advisory.status})${resetClause}. ` +
317
+ `${RATE_LIMIT_CONSEQUENCE_TEXT} ${RATE_LIMIT_RUNBOOK_REFERENCE}`);
318
+ }
319
+ /**
320
+ * Tolerantly parse one Claude stream-json line for advisory signals.
170
321
  * Catches ALL parse/schema errors and never throws; returns `{}` when nothing
171
322
  * usable is found.
323
+ *
324
+ * BAPI-828: a line may now yield BOTH a `phase_hint` and a `rate_limit` record.
325
+ * They are independent observations of the same event, so neither suppresses the
326
+ * other — a `rate_limit_event` still contributes its type as a phase hint exactly
327
+ * as it did before this ticket.
172
328
  */
173
329
  export function parseClaudeStreamJsonLine(line) {
174
330
  try {
@@ -179,11 +335,17 @@ export function parseClaudeStreamJsonLine(line) {
179
335
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
180
336
  return {};
181
337
  const obj = parsed;
338
+ const out = {};
182
339
  // Advisory only: prefer an explicit phase/subtype, else the stream event type.
183
340
  const hint = pickString(obj.phase) ??
184
341
  pickString(obj.subtype) ??
185
342
  pickString(obj.type);
186
- return hint ? { phase_hint: hint } : {};
343
+ if (hint)
344
+ out.phase_hint = hint;
345
+ const rateLimit = parseRateLimitEvent(obj);
346
+ if (rateLimit)
347
+ out.rate_limit = rateLimit;
348
+ return out;
187
349
  }
188
350
  catch {
189
351
  return {};
@@ -357,6 +519,13 @@ export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
357
519
  },
358
520
  };
359
521
  }
522
+ /**
523
+ * Cap on the partial advisory line the observation state will buffer, in
524
+ * characters. Mirrors {@link MAX_BUFFERED_SURFACE_LINE_CHARS} and exists for the
525
+ * same reason: hold one complete stream-json line intact so a chunk boundary
526
+ * cannot hide an event, while still refusing to grow without bound.
527
+ */
528
+ export const MAX_BUFFERED_ADVISORY_LINE_CHARS = 1_048_576;
360
529
  /**
361
530
  * Create the observation state. When `advisoryParserEnabled` is false, stdout is
362
531
  * still timestamped (`last_stdout_at`) but never parsed — proving the advisory
@@ -368,16 +537,95 @@ export function createObservationState(deps, options) {
368
537
  let exitCode;
369
538
  let attemptStartSha;
370
539
  let attemptEndSha;
540
+ /**
541
+ * BAPI-828/BAPI-898: the MOST SEVERE qualifying advisory observed so far wins,
542
+ * ranked by {@link rateLimitSeverity}. An advisory at equal or lower severity —
543
+ * including a repeat of the same event or a warning observed after a rejection
544
+ * — leaves this unchanged.
545
+ */
546
+ let rateLimit;
547
+ /** Residual partial line carried across stdout chunks (BAPI-828). */
548
+ let buffer = "";
549
+ /** True while skipping the remainder of a line that blew the size cap. */
550
+ let discardingOversizedLine = false;
551
+ const consumeAdvisoryLine = (line) => {
552
+ const parsed = parseClaudeStreamJsonLine(line);
553
+ if (parsed.phase_hint)
554
+ advisory.phase_hint = parsed.phase_hint;
555
+ if (parsed.rate_limit) {
556
+ const observedAdvisory = parsed.rate_limit;
557
+ const isFirstObservation = rateLimit === undefined;
558
+ // BAPI-898: strict severity comparison replaces the old "first advisory
559
+ // wins" rule. Equal or lower severity — a repeat event or a warning
560
+ // observed after a rejected/blocked status — leaves the stored advisory
561
+ // unchanged.
562
+ if (isFirstObservation ||
563
+ rateLimitSeverity(observedAdvisory.status) > rateLimitSeverity(rateLimit.status)) {
564
+ rateLimit = observedAdvisory;
565
+ }
566
+ // The callback preserves one stderr line per job: it fires ONLY for the
567
+ // first qualifying advisory, while a later severity escalation is still
568
+ // retained above for `observation.snapshot()` and the existing terminal
569
+ // telemetry path.
570
+ if (isFirstObservation) {
571
+ try {
572
+ options.onWorkerRateLimitAdvisory?.(observedAdvisory);
573
+ }
574
+ catch {
575
+ // The callback only logs. A throwing one must not propagate out of the
576
+ // stdout pump, where it would be swallowed as a stream error and
577
+ // silently disable observation for the rest of the run.
578
+ }
579
+ }
580
+ }
581
+ };
371
582
  return {
372
583
  recordStdout(chunk) {
373
584
  advisory.last_stdout_at = new Date(deps.now()).toISOString();
374
585
  if (!options.advisoryParserEnabled)
375
586
  return;
376
- for (const line of chunk.split("\n")) {
377
- const hint = parseClaudeStreamJsonLine(line);
378
- if (hint.phase_hint)
379
- advisory.phase_hint = hint.phase_hint;
587
+ if (typeof chunk !== "string" || chunk.length === 0)
588
+ return;
589
+ // BAPI-828: line assembly replaces the previous per-chunk `split("\n")`.
590
+ // stdout arrives in arbitrary chunks, and a `rate_limit_event` split across
591
+ // two reads used to be parsed as two malformed halves and lost. Phase hints
592
+ // tolerated that (the next event supplied another); a rate-limit event may
593
+ // legitimately occur once in a whole run, so losing it loses the signal.
594
+ let input = chunk;
595
+ // Still skipping past a line that already blew the cap. Resync ON THE
596
+ // NEWLINE rather than merely clearing, so the dead line's tail cannot be
597
+ // parsed as though it were a fresh event.
598
+ if (discardingOversizedLine) {
599
+ const resyncAt = input.indexOf("\n");
600
+ if (resyncAt === -1)
601
+ return;
602
+ discardingOversizedLine = false;
603
+ input = input.slice(resyncAt + 1);
604
+ }
605
+ buffer += input;
606
+ let newlineAt = buffer.indexOf("\n");
607
+ while (newlineAt !== -1) {
608
+ consumeAdvisoryLine(buffer.slice(0, newlineAt));
609
+ buffer = buffer.slice(newlineAt + 1);
610
+ newlineAt = buffer.indexOf("\n");
611
+ }
612
+ if (buffer.length > MAX_BUFFERED_ADVISORY_LINE_CHARS) {
613
+ // Unparseable by definition, and not worth carrying. Drop it and resync;
614
+ // a LATER valid line still parses normally, so one pathological line
615
+ // cannot disable advisory observation for the rest of the run.
616
+ buffer = "";
617
+ discardingOversizedLine = true;
618
+ return;
380
619
  }
620
+ // Parse the residual NON-destructively. A worker whose final chunk carries
621
+ // no trailing newline would otherwise strand its last event in the buffer
622
+ // forever — and the pre-BAPI-828 parser DID read that trailing fragment, so
623
+ // skipping it would be a silent regression in phase-hint reporting. Keeping
624
+ // the buffer means the same bytes are re-parsed once the rest arrives, which
625
+ // is harmless: a truncated line does not parse, and the first-wins guard
626
+ // above makes a repeat parse of a complete line a no-op.
627
+ if (buffer.length > 0)
628
+ consumeAdvisoryLine(buffer);
381
629
  },
382
630
  setGitTelemetry(telemetry) {
383
631
  git = telemetry;
@@ -404,6 +652,11 @@ export function createObservationState(deps, options) {
404
652
  residue.phase_hint = advisory.phase_hint;
405
653
  if (advisory.last_stdout_at)
406
654
  residue.last_stdout_at = advisory.last_stdout_at;
655
+ // BAPI-828: rides the existing envelope into heartbeats and terminal
656
+ // mutations. Absent when no qualifying event was seen, so a job that never
657
+ // approached a limit serializes byte-identically to before this ticket.
658
+ if (rateLimit !== undefined)
659
+ residue.rate_limit = rateLimit;
407
660
  if (exitCode !== undefined)
408
661
  residue.exit_code = exitCode;
409
662
  // The attempt boundary pair is independent of `last_commit_sha`: it
@@ -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;