@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
@@ -24,7 +24,7 @@ import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration
24
24
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
25
25
  import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
26
26
  import { resolveExecutorAgentId } from "./agent-identity.js";
27
- import { runProcessWithTimeout } from "./process.js";
27
+ import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
28
28
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
29
29
  import { renderPromptSpecPrompt } from "./prompt-spec.js";
30
30
  import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
@@ -33,15 +33,17 @@ import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
33
33
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
34
34
  import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
35
35
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
36
- import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
36
+ import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
37
37
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
38
38
  import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
39
39
  import { resolveExecutorJobBaseBranch } from "./base-branch.js";
40
+ import { resolveExecutorJobIndexScope } from "./index-scope.js";
40
41
  import { validateWorkerFinalization } from "./worker-finalization.js";
41
42
  import { ensureExecutorWorktree } from "./worktree.js";
42
43
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
43
44
  import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
44
- import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, normalizeMcpServerNames, } from "./observation.js";
45
+ import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
46
+ import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
45
47
  /** Default runtime for the no-op smoke process (ms). */
46
48
  const DEFAULT_SMOKE_DURATION_MS = 100;
47
49
  /** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
@@ -249,7 +251,7 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
249
251
  * Only server NAMES are read. The command, args, and env of each registration are
250
252
  * never retained, so nothing that could carry a secret leaves this function.
251
253
  */
252
- export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = REQUIRED_MCP_SERVER_NAME) {
254
+ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = MCP_SERVER_NAME) {
253
255
  // Normalize through the SAME path semantics the provisioner used to write the
254
256
  // file, so the absolute path handed to `--mcp-config` is the one that actually
255
257
  // exists on disk rather than a differently-spelled equivalent.
@@ -275,7 +277,17 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
275
277
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
276
278
  }
277
279
  const names = normalizeMcpServerNames(Object.keys(servers));
278
- if (!names.includes(requiredServerName)) {
280
+ // BAPI-807: when the required name is Bridge's OWN registration, a
281
+ // permanently-supported legacy key satisfies it. A worktree provisioned
282
+ // before the rename (or re-entered from one) registers `bridge-api`, and
283
+ // refusing to spawn into it would turn the rename into an outage for exactly
284
+ // the users the legacy compatibility exists to protect. A non-Bridge
285
+ // `requiredServerName` (a future adapter's own runtime) is matched exactly,
286
+ // as before.
287
+ const satisfied = requiredServerName === MCP_SERVER_NAME
288
+ ? names.some((name) => isRecognizedServerName(name))
289
+ : names.includes(requiredServerName);
290
+ if (!satisfied) {
279
291
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
280
292
  }
281
293
  return {
@@ -289,12 +301,6 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
289
301
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
290
302
  }
291
303
  }
292
- /**
293
- * Default required registration name, retained only as the parameter default so
294
- * existing direct callers keep working. The SPAWN PATH always passes the
295
- * adapter's declared name explicitly — it never relies on this default.
296
- */
297
- const REQUIRED_MCP_SERVER_NAME = "bridge-api";
298
304
  /**
299
305
  * Refuse a spawn shape that is not internally consistent (BAPI-781).
300
306
  *
@@ -336,7 +342,7 @@ function assertUsableSpawnShape(shape, adapter) {
336
342
  }
337
343
  }
338
344
  /** Fixed, secret-free refusal text (no path, no file contents). */
339
- const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "the required 'bridge-api' MCP registration is missing or unreadable in the prepared " +
345
+ const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = `the required '${MCP_SERVER_NAME}' MCP registration is missing or unreadable in the prepared ` +
340
346
  "worktree; a worker loads MCP servers strictly from that file, so refusing to spawn a " +
341
347
  "worker that would run without Bridge tools";
342
348
  /**
@@ -471,6 +477,9 @@ async function superviseProcess(params) {
471
477
  const mcpSurface = params.mcpSurface;
472
478
  const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
473
479
  termGraceMs: params.options.termGraceMs,
480
+ ...(params.terminationController
481
+ ? { terminationController: params.terminationController }
482
+ : {}),
474
483
  onStdout: (chunk) => params.observation.recordStdout(chunk),
475
484
  ...(mcpSurface
476
485
  ? {
@@ -503,12 +512,33 @@ function terminalToRunResult(terminal, successStatus) {
503
512
  }
504
513
  }
505
514
  /** Dispatch and run a single claimed job. */
506
- export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {}) {
515
+ export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
516
+ /**
517
+ * BAPI-828: the runner-scoped control object, carrying the live worker
518
+ * registry this job's worker registers with.
519
+ *
520
+ * A SEVENTH positional parameter, appended after the existing seams argument so
521
+ * every current caller — the runner's default `runJob`, the many tests that
522
+ * invoke this directly with four to six arguments — keeps compiling unchanged.
523
+ * Absent means no executor-level shutdown control, which is exactly right for a
524
+ * caller that has no executor loop around it.
525
+ */
526
+ control) {
507
527
  const ownership = {
508
528
  abandoned: false,
509
529
  lastSuccessfulHeartbeatAt: deps.now(),
510
530
  };
511
- const observation = createObservationState({ now: deps.now }, { advisoryParserEnabled: options.advisoryParserEnabled });
531
+ const observation = createObservationState({ now: deps.now }, {
532
+ advisoryParserEnabled: options.advisoryParserEnabled,
533
+ // BAPI-828: ONE line per job, from the per-job observation's first-wins
534
+ // retention rather than from a counter here. A worker nearing a weekly
535
+ // ceiling repeats the same event on every turn, and the operator needs the
536
+ // fact once. Emitting is all this does — no claim, retry, lease, or
537
+ // classification decision reads it.
538
+ onWorkerRateLimitAdvisory: (advisory) => {
539
+ deps.errorLog(formatWorkerRateLimitAdvisory(advisory));
540
+ },
541
+ });
512
542
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
513
543
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
514
544
  // worker (TDD §5/§9).
@@ -530,7 +560,7 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
530
560
  // are stateless with respect to the host, which is why the whole
531
561
  // establish/dispose lifetime this branch used to manage simply stopped
532
562
  // existing rather than moving somewhere else.
533
- return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
563
+ return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control);
534
564
  }
535
565
  // Unsupported job type — fail server-side; never spawn.
536
566
  await httpClient.fail(job, {
@@ -915,7 +945,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
915
945
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
916
946
  * touches a worktree that will not become a runnable worker.
917
947
  */
918
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
948
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control) {
919
949
  // --- Per-job base branch (BAPI-586) ----------------------------------
920
950
  // Resolve the effective logical base BEFORE any side effect: the persisted
921
951
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -971,6 +1001,23 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
971
1001
  }
972
1002
  const effectiveBaseBranch = baseResolution.baseBranch;
973
1003
  const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
1004
+ // --- Declared index scope (BAPI-844) ---------------------------------
1005
+ // Resolved here, alongside the base branch and for the same reason: a job that
1006
+ // declares a scope it cannot be routed by must fail before a worktree is cut, a
1007
+ // deny layer is written, MCP is provisioned, a log is opened, or a process is
1008
+ // spawned. An ABSENT declaration is the ordinary unscoped job and adds nothing
1009
+ // anywhere downstream. The error text names the field only — the declared value
1010
+ // is opaque and never reaches `/fail`.
1011
+ const indexScopeResolution = resolveExecutorJobIndexScope(job);
1012
+ if (!indexScopeResolution.ok) {
1013
+ await httpClient.fail(job, {
1014
+ error_kind: "ContractError.IndexScope",
1015
+ error_message: indexScopeResolution.error,
1016
+ classification: "crashed",
1017
+ });
1018
+ return { status: "failed", reason: "index_scope_contract" };
1019
+ }
1020
+ const indexScope = indexScopeResolution.indexScope;
974
1021
  // --- Permission posture (BAPI-725) -----------------------------------
975
1022
  // Resolved alongside the base branch, BEFORE any side effect, for the same
976
1023
  // reason: an unsupported posture is a contract failure, and failing it here
@@ -1032,6 +1079,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1032
1079
  });
1033
1080
  return { status: "failed", reason: "worktree_busy" };
1034
1081
  }
1082
+ // BAPI-828: the post-spawn lifecycle handles this job's worker owns, filled in
1083
+ // by `runPreparedSpawn` as each resource comes into existence and unwound in
1084
+ // the `finally` below. A shared mutable record rather than return values,
1085
+ // because the resources must be released even when `runPreparedSpawn` throws —
1086
+ // which is exactly the path that has no return value to inspect.
1087
+ const spawnLifecycle = {};
1035
1088
  try {
1036
1089
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
1037
1090
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
@@ -1078,14 +1131,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1078
1131
  branch,
1079
1132
  prep,
1080
1133
  effectiveBaseBranch,
1134
+ indexScope,
1081
1135
  permissionPosture,
1082
1136
  adapter,
1137
+ control,
1138
+ lifecycle: spawnLifecycle,
1083
1139
  });
1084
1140
  }
1085
1141
  finally {
1086
- // Released only here after supervision has settled and every terminal
1087
- // mutation above has been attempted so the worktree is never advertised as
1088
- // free while this job's worker could still be touching it.
1142
+ // BAPI-828 cleanup ORDER is the contract, and it runs strictly after
1143
+ // `runPreparedSpawn` has settled (normally or by throwing), which means
1144
+ // strictly after process supervision has settled:
1145
+ //
1146
+ // 1. Deregister the worker. It is no longer live, so a shutdown arriving
1147
+ // now must not try to signal it — the registry should only ever hold
1148
+ // children that might still be running.
1149
+ // 2. Close and deactivate the worker log. Idempotent: the terminal branches
1150
+ // inside `runPreparedSpawn` already close it on their way out, and this
1151
+ // is the backstop for the exceptional paths that do not.
1152
+ // 3. Release the worktree lock — LAST, and still through the same
1153
+ // ownership-checked `release()` as before this ticket. The worktree must
1154
+ // never be advertised as free while this job's worker could still be
1155
+ // touching it, and the two steps above are what establish that it cannot.
1156
+ spawnLifecycle.deregisterLiveWorker?.();
1157
+ spawnLifecycle.deregisterLiveWorker = undefined;
1158
+ await spawnLifecycle.closeWorkerLog?.();
1089
1159
  await worktreeLock.release();
1090
1160
  }
1091
1161
  }
@@ -1100,7 +1170,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1100
1170
  * rather than a 500-line indentation nobody can verify by eye.
1101
1171
  */
1102
1172
  async function runPreparedSpawn(params) {
1103
- const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
1173
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
1104
1174
  // BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
1105
1175
  // are told to open the PR against the injected run base. Verdict-only
1106
1176
  // `spec_review` produces no PR, so it is excluded from the instruction while
@@ -1247,7 +1317,31 @@ async function runPreparedSpawn(params) {
1247
1317
  catch {
1248
1318
  tee = null;
1249
1319
  }
1320
+ // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
1321
+ // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
1322
+ // directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
1323
+ // the paths that throw. Before this ticket the close was open-coded at eleven
1324
+ // separate return sites, which is why the active-log deactivation is folded in
1325
+ // here rather than added as a twelfth thing each branch had to remember.
1326
+ let workerLogClosed = false;
1327
+ const closeWorkerLog = async () => {
1328
+ if (workerLogClosed)
1329
+ return;
1330
+ workerLogClosed = true;
1331
+ // Deactivate FIRST: once the tee is closing, an append from the suspend
1332
+ // diagnostic would be writing to a log nobody is reading any more.
1333
+ deactivateJobLog(job.id);
1334
+ await closeWorkerLogTee(tee);
1335
+ };
1336
+ lifecycle.closeWorkerLog = closeWorkerLog;
1250
1337
  if (tee) {
1338
+ // BAPI-828 — mark the log appendable as soon as the tee EXISTS, and
1339
+ // deliberately BEFORE the watch-registry persistence below. The two are
1340
+ // independent on purpose: `registerLog` swallows its own failures (it is a
1341
+ // visibility nicety for a separate `executor watch` process), and chaining
1342
+ // the active marker to it would mean a failed registry write silently
1343
+ // disables suspend annotation for a worker whose tee is perfectly healthy.
1344
+ activateJobLog(job.id, tee.logPath);
1251
1345
  const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
1252
1346
  try {
1253
1347
  await registerLog({
@@ -1312,9 +1406,16 @@ async function runPreparedSpawn(params) {
1312
1406
  //
1313
1407
  // BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
1314
1408
  // worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
1409
+ //
1410
+ // BAPI-844: the validated declared index scope travels the same way, as an
1411
+ // explicit option rather than through the environment this process happens to
1412
+ // hold — the executor's own `BAPI_INDEX_SCOPE`, if an operator exported one,
1413
+ // is denied at the env builder and cannot reach `spawnShape.env`. It stays out
1414
+ // of `argv` entirely: it is a routing declaration, not a command-line input,
1415
+ // and argv is world-readable via `ps`.
1315
1416
  const invocation = adapter.headlessInvocation;
1316
1417
  if (invocation?.supported !== true) {
1317
- await closeWorkerLogTee(tee);
1418
+ await closeWorkerLog();
1318
1419
  await finalizeRegistry();
1319
1420
  await httpClient.fail(job, {
1320
1421
  error_kind: ExecutorAdapterUnavailable,
@@ -1332,6 +1433,7 @@ async function runPreparedSpawn(params) {
1332
1433
  posture: permissionPosture,
1333
1434
  mcpConfigPath: mcpContract.configPath,
1334
1435
  effectiveBaseBranch,
1436
+ indexScope,
1335
1437
  parentEnv: deps.env,
1336
1438
  });
1337
1439
  assertUsableSpawnShape(spawnShape, adapter);
@@ -1342,7 +1444,7 @@ async function runPreparedSpawn(params) {
1342
1444
  // a missing argv from Claude's flags is precisely the defaulting this
1343
1445
  // extraction removes, and it would spawn a real worker under a shape nobody
1344
1446
  // declared.
1345
- await closeWorkerLogTee(tee);
1447
+ await closeWorkerLog();
1346
1448
  await finalizeRegistry();
1347
1449
  await httpClient.fail(job, {
1348
1450
  error_kind: ExecutorAdapterUnavailable,
@@ -1360,7 +1462,7 @@ async function runPreparedSpawn(params) {
1360
1462
  // certify its own containment.
1361
1463
  const initParsing = adapter.mcpInitParsing;
1362
1464
  if (initParsing?.supported !== true) {
1363
- await closeWorkerLogTee(tee);
1465
+ await closeWorkerLog();
1364
1466
  await finalizeRegistry();
1365
1467
  await httpClient.fail(job, {
1366
1468
  error_kind: ExecutorAdapterUnavailable,
@@ -1378,7 +1480,7 @@ async function runPreparedSpawn(params) {
1378
1480
  });
1379
1481
  }
1380
1482
  catch (err) {
1381
- await closeWorkerLogTee(tee);
1483
+ await closeWorkerLog();
1382
1484
  await finalizeRegistry();
1383
1485
  // Redact through the adapter's declared secret names before bounding. A
1384
1486
  // spawn error can echo the environment it failed to apply, and that
@@ -1394,6 +1496,22 @@ async function runPreparedSpawn(params) {
1394
1496
  });
1395
1497
  return { status: "failed", reason: "spawn_failed" };
1396
1498
  }
1499
+ // --- Live worker registration (BAPI-828) ------------------------------
1500
+ // Immediately after the ONE successful `deps.spawnProcess` above, and strictly
1501
+ // before any asynchronous supervision begins. That ordering is the whole
1502
+ // guarantee: between the spawn returning and supervision starting there is real
1503
+ // async work (the tee wrapper, the telemetry closures), and a `SIGTERM` landing
1504
+ // in that window used to find a live child that nothing owned.
1505
+ //
1506
+ // The controller is created against the RAW `proc`, not the tee-wrapped view
1507
+ // below: signals go to the actual child, while the wrapper exists only to
1508
+ // duplicate its output.
1509
+ const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
1510
+ // Registration may terminate the worker SYNCHRONOUSLY when shutdown was already
1511
+ // requested — the sticky case, where the signal arrived while this job was still
1512
+ // preparing its worktree. That is intended: the child is signalled here, and
1513
+ // supervision below still runs normally to observe its exit and report it.
1514
+ lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
1397
1515
  // Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
1398
1516
  const supervisedProc = tee
1399
1517
  ? {
@@ -1419,6 +1537,10 @@ async function runPreparedSpawn(params) {
1419
1537
  collectTelemetry,
1420
1538
  collectRemoteMarker,
1421
1539
  mcpSurface,
1540
+ // The SAME controller the registry holds, so an executor shutdown, a timeout,
1541
+ // and an MCP containment refusal all converge on one `SIGTERM` and one grace
1542
+ // timer for this child (BAPI-828).
1543
+ terminationController,
1422
1544
  });
1423
1545
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
1424
1546
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
@@ -1458,7 +1580,7 @@ async function runPreparedSpawn(params) {
1458
1580
  // `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
1459
1581
  // the terminal mutation is attempted, so the report goes out under a live claim.
1460
1582
  if (procResult.serverStopRequested) {
1461
- await closeWorkerLogTee(tee);
1583
+ await closeWorkerLog();
1462
1584
  await finalizeRegistry();
1463
1585
  await httpClient.fail(job, {
1464
1586
  error_kind: "ServerStopRequested",
@@ -1472,7 +1594,7 @@ async function runPreparedSpawn(params) {
1472
1594
  return { status: "abandoned", reason: "server_stop" };
1473
1595
  }
1474
1596
  // Finalize the log tee WITHOUT signaling the worker (it has already exited).
1475
- await closeWorkerLogTee(tee);
1597
+ await closeWorkerLog();
1476
1598
  // --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
1477
1599
  // "`.conductor/worker.log` for any completed job contains exactly one
1478
1600
  // `session_id`" — the cheap, durable invariant from the ticket that would have
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Runner-scoped live worker registry (BAPI-828).
3
+ *
4
+ * WHAT THIS FIXES. Before this module the executor had no handle on its own
5
+ * children. `SIGTERM` to the executor killed the executor and left the Claude
6
+ * worker it had spawned running, still holding the worktree, still committing
7
+ * into it, with the worktree lock left behind by a process that no longer
8
+ * existed. The operator runbook's answer was to run `pkill -TERM -P <executor
9
+ * pid>` BEFORE stopping the executor — a manual step that is exactly as
10
+ * reliable as remembering it.
11
+ *
12
+ * SCOPE IS PER `runExecutor` INVOCATION, NOT MODULE-GLOBAL. A module-level set
13
+ * would outlive the invocation that filled it, so a second `runExecutor` call in
14
+ * the same process (the `--once` path, an embedded runner, a test) would inherit
15
+ * another run's children and could terminate them. The registry is created by
16
+ * `runExecutor` and travels to each dispatched job through
17
+ * {@link ExecutorRunControl}; when the invocation returns, the registry is
18
+ * garbage, which is the correct lifetime.
19
+ *
20
+ * SHUTDOWN IS STICKY, AND THAT IS THE POINT. A signal can arrive while a claimed
21
+ * job is still preparing its worktree — seconds before it spawns anything. A
22
+ * plain "terminate everyone registered right now" would miss that worker
23
+ * entirely and leave precisely the orphan this module exists to prevent. So the
24
+ * shutdown flag persists: a controller registered AFTER shutdown was requested
25
+ * is terminated immediately, on registration.
26
+ *
27
+ * TERMINATION IS REQUESTED EXACTLY ONCE PER CONTROLLER. The controllers
28
+ * themselves are idempotent, but the registry does not lean on that — it tracks
29
+ * what it has already asked for, so repeated or mixed `SIGTERM`/`SIGINT` cannot
30
+ * produce a second request even against a controller implementation that would
31
+ * have honored one.
32
+ *
33
+ * SECRET-FREE. The registry holds termination controllers and nothing else — no
34
+ * job payload, no claim token, no argv, no environment.
35
+ */
36
+ /** Create an empty, runner-scoped live worker registry. */
37
+ export function createLiveWorkerRegistry() {
38
+ const live = new Set();
39
+ const alreadyRequested = new Set();
40
+ let shutdownRequested = false;
41
+ const terminateOnce = (controller) => {
42
+ if (alreadyRequested.has(controller))
43
+ return;
44
+ alreadyRequested.add(controller);
45
+ try {
46
+ controller.requestTermination();
47
+ }
48
+ catch {
49
+ // Fail-open, and deliberately silent. A controller that throws is a defect
50
+ // in one worker's teardown; letting it escape would abort the shutdown
51
+ // drain and strand every OTHER live worker — turning one bad teardown into
52
+ // the exact orphan situation this registry prevents.
53
+ }
54
+ };
55
+ return {
56
+ register(controller) {
57
+ live.add(controller);
58
+ if (shutdownRequested)
59
+ terminateOnce(controller);
60
+ return () => {
61
+ live.delete(controller);
62
+ };
63
+ },
64
+ deregister(controller) {
65
+ live.delete(controller);
66
+ },
67
+ requestShutdown() {
68
+ shutdownRequested = true;
69
+ // Snapshot before iterating: `terminateOnce` cannot mutate `live`, but a
70
+ // controller's own termination path may synchronously reach code that
71
+ // deregisters, and mutating a Set mid-iteration is not a behavior to rely
72
+ // on.
73
+ for (const controller of [...live])
74
+ terminateOnce(controller);
75
+ },
76
+ isShutdownRequested() {
77
+ return shutdownRequested;
78
+ },
79
+ size() {
80
+ return live.size;
81
+ },
82
+ };
83
+ }
@@ -166,9 +166,93 @@ 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
+ * Recognize a QUALIFYING `rate_limit_event`, or `null` for everything else.
205
+ *
206
+ * Qualification is deliberately two-sided: utilization at or above the warning
207
+ * threshold, OR a status that is not exactly `allowed`. Either alone would miss a
208
+ * real case — a hard `blocked` at low utilization is the most actionable event
209
+ * there is, and a provider that keeps saying `allowed` right up to the ceiling
210
+ * still deserves a warning at 90%.
211
+ *
212
+ * Everything unrecognized returns `null` rather than throwing: this is the
213
+ * advisory parser, and R5 says a parser that throws is a bug.
214
+ */
215
+ function parseRateLimitEvent(obj) {
216
+ if (obj.type !== "rate_limit_event")
217
+ return null;
218
+ const info = obj.rate_limit_info;
219
+ if (!info || typeof info !== "object" || Array.isArray(info))
220
+ return null;
221
+ const record = info;
222
+ const rateLimitType = sanitizeRateLimitField(record.rateLimitType);
223
+ const status = sanitizeRateLimitField(record.status);
224
+ if (rateLimitType === null || status === null)
225
+ return null;
226
+ const utilization = record.utilization;
227
+ // `Number.isFinite` rejects NaN and both infinities as well as non-numbers, so a
228
+ // malformed value can never reach the percentage arithmetic below.
229
+ if (typeof utilization !== "number" || !Number.isFinite(utilization))
230
+ return null;
231
+ const qualifies = utilization >= RATE_LIMIT_WARNING_UTILIZATION || status !== "allowed";
232
+ if (!qualifies)
233
+ return null;
234
+ return { rate_limit_type: rateLimitType, status, utilization };
235
+ }
236
+ /**
237
+ * Render the operator-facing rate-limit line.
238
+ *
239
+ * Exported so the stderr text has ONE definition rather than being rebuilt at the
240
+ * call site — the job runner emits it, and the tests pin it, from here.
241
+ */
242
+ export function formatWorkerRateLimitAdvisory(advisory) {
243
+ const percent = Math.round(advisory.utilization * 100);
244
+ return (`executor: worker reports ${advisory.rate_limit_type} rate limit at ` +
245
+ `${percent}% (${advisory.status})`);
246
+ }
247
+ /**
248
+ * Tolerantly parse one Claude stream-json line for advisory signals.
170
249
  * Catches ALL parse/schema errors and never throws; returns `{}` when nothing
171
250
  * usable is found.
251
+ *
252
+ * BAPI-828: a line may now yield BOTH a `phase_hint` and a `rate_limit` record.
253
+ * They are independent observations of the same event, so neither suppresses the
254
+ * other — a `rate_limit_event` still contributes its type as a phase hint exactly
255
+ * as it did before this ticket.
172
256
  */
173
257
  export function parseClaudeStreamJsonLine(line) {
174
258
  try {
@@ -179,11 +263,17 @@ export function parseClaudeStreamJsonLine(line) {
179
263
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
180
264
  return {};
181
265
  const obj = parsed;
266
+ const out = {};
182
267
  // Advisory only: prefer an explicit phase/subtype, else the stream event type.
183
268
  const hint = pickString(obj.phase) ??
184
269
  pickString(obj.subtype) ??
185
270
  pickString(obj.type);
186
- return hint ? { phase_hint: hint } : {};
271
+ if (hint)
272
+ out.phase_hint = hint;
273
+ const rateLimit = parseRateLimitEvent(obj);
274
+ if (rateLimit)
275
+ out.rate_limit = rateLimit;
276
+ return out;
187
277
  }
188
278
  catch {
189
279
  return {};
@@ -357,6 +447,13 @@ export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
357
447
  },
358
448
  };
359
449
  }
450
+ /**
451
+ * Cap on the partial advisory line the observation state will buffer, in
452
+ * characters. Mirrors {@link MAX_BUFFERED_SURFACE_LINE_CHARS} and exists for the
453
+ * same reason: hold one complete stream-json line intact so a chunk boundary
454
+ * cannot hide an event, while still refusing to grow without bound.
455
+ */
456
+ export const MAX_BUFFERED_ADVISORY_LINE_CHARS = 1_048_576;
360
457
  /**
361
458
  * Create the observation state. When `advisoryParserEnabled` is false, stdout is
362
459
  * still timestamped (`last_stdout_at`) but never parsed — proving the advisory
@@ -368,16 +465,75 @@ export function createObservationState(deps, options) {
368
465
  let exitCode;
369
466
  let attemptStartSha;
370
467
  let attemptEndSha;
468
+ /** BAPI-828: the FIRST qualifying advisory wins; later ones never overwrite it. */
469
+ let rateLimit;
470
+ /** Residual partial line carried across stdout chunks (BAPI-828). */
471
+ let buffer = "";
472
+ /** True while skipping the remainder of a line that blew the size cap. */
473
+ let discardingOversizedLine = false;
474
+ const consumeAdvisoryLine = (line) => {
475
+ const parsed = parseClaudeStreamJsonLine(line);
476
+ if (parsed.phase_hint)
477
+ advisory.phase_hint = parsed.phase_hint;
478
+ if (parsed.rate_limit && rateLimit === undefined) {
479
+ rateLimit = parsed.rate_limit;
480
+ try {
481
+ options.onWorkerRateLimitAdvisory?.(rateLimit);
482
+ }
483
+ catch {
484
+ // The callback only logs. A throwing one must not propagate out of the
485
+ // stdout pump, where it would be swallowed as a stream error and
486
+ // silently disable observation for the rest of the run.
487
+ }
488
+ }
489
+ };
371
490
  return {
372
491
  recordStdout(chunk) {
373
492
  advisory.last_stdout_at = new Date(deps.now()).toISOString();
374
493
  if (!options.advisoryParserEnabled)
375
494
  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;
495
+ if (typeof chunk !== "string" || chunk.length === 0)
496
+ return;
497
+ // BAPI-828: line assembly replaces the previous per-chunk `split("\n")`.
498
+ // stdout arrives in arbitrary chunks, and a `rate_limit_event` split across
499
+ // two reads used to be parsed as two malformed halves and lost. Phase hints
500
+ // tolerated that (the next event supplied another); a rate-limit event may
501
+ // legitimately occur once in a whole run, so losing it loses the signal.
502
+ let input = chunk;
503
+ // Still skipping past a line that already blew the cap. Resync ON THE
504
+ // NEWLINE rather than merely clearing, so the dead line's tail cannot be
505
+ // parsed as though it were a fresh event.
506
+ if (discardingOversizedLine) {
507
+ const resyncAt = input.indexOf("\n");
508
+ if (resyncAt === -1)
509
+ return;
510
+ discardingOversizedLine = false;
511
+ input = input.slice(resyncAt + 1);
512
+ }
513
+ buffer += input;
514
+ let newlineAt = buffer.indexOf("\n");
515
+ while (newlineAt !== -1) {
516
+ consumeAdvisoryLine(buffer.slice(0, newlineAt));
517
+ buffer = buffer.slice(newlineAt + 1);
518
+ newlineAt = buffer.indexOf("\n");
519
+ }
520
+ if (buffer.length > MAX_BUFFERED_ADVISORY_LINE_CHARS) {
521
+ // Unparseable by definition, and not worth carrying. Drop it and resync;
522
+ // a LATER valid line still parses normally, so one pathological line
523
+ // cannot disable advisory observation for the rest of the run.
524
+ buffer = "";
525
+ discardingOversizedLine = true;
526
+ return;
380
527
  }
528
+ // Parse the residual NON-destructively. A worker whose final chunk carries
529
+ // no trailing newline would otherwise strand its last event in the buffer
530
+ // forever — and the pre-BAPI-828 parser DID read that trailing fragment, so
531
+ // skipping it would be a silent regression in phase-hint reporting. Keeping
532
+ // the buffer means the same bytes are re-parsed once the rest arrives, which
533
+ // is harmless: a truncated line does not parse, and the first-wins guard
534
+ // above makes a repeat parse of a complete line a no-op.
535
+ if (buffer.length > 0)
536
+ consumeAdvisoryLine(buffer);
381
537
  },
382
538
  setGitTelemetry(telemetry) {
383
539
  git = telemetry;
@@ -404,6 +560,11 @@ export function createObservationState(deps, options) {
404
560
  residue.phase_hint = advisory.phase_hint;
405
561
  if (advisory.last_stdout_at)
406
562
  residue.last_stdout_at = advisory.last_stdout_at;
563
+ // BAPI-828: rides the existing envelope into heartbeats and terminal
564
+ // mutations. Absent when no qualifying event was seen, so a job that never
565
+ // approached a limit serializes byte-identically to before this ticket.
566
+ if (rateLimit !== undefined)
567
+ residue.rate_limit = rateLimit;
407
568
  if (exitCode !== undefined)
408
569
  residue.exit_code = exitCode;
409
570
  // The attempt boundary pair is independent of `last_commit_sha`: it