@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.
- package/README.md +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -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/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- 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 +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- 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 +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- 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 +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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;
|