@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
@@ -82,6 +82,75 @@ export async function markExecutorJobLogFinished(jobId, finishedAt, deps) {
82
82
  const filePath = jobLogRecordPath(stateDir, jobId, deps.platform ?? process.platform);
83
83
  await deps.writeFile(filePath, JSON.stringify(normalizeRecord(updated), null, 2));
84
84
  }
85
+ // ---------------------------------------------------------------------------
86
+ // Active worker logs (BAPI-828)
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * In-memory map of job id → worker-log path for jobs whose tee is currently open.
90
+ *
91
+ * DELIBERATELY SEPARATE FROM THE PERSISTED RECORD ABOVE, and deliberately not
92
+ * derived from it. The persisted record is a visibility nicety for a SEPARATE
93
+ * `executor watch` process, it is written with best-effort error swallowing, and
94
+ * it keeps its row after `finished_at` is stamped. This map answers a different
95
+ * question — "which worker logs can be appended to right now, in THIS process" —
96
+ * and the suspend diagnostic depends on that answer being correct. Deriving it
97
+ * from the persisted record would mean a failed registry write silently disables
98
+ * suspend annotation for a worker whose tee is perfectly healthy.
99
+ *
100
+ * Process-scoped module state rather than runner-scoped, unlike the live worker
101
+ * registry: entries are keyed by server-minted job id, so two concurrent
102
+ * `runExecutor` invocations in one process cannot collide, and each value is a
103
+ * path only its own job ever deactivates.
104
+ *
105
+ * SECRET-FREE: a filesystem path inside the job's own worktree, nothing else.
106
+ */
107
+ const activeJobLogPaths = new Map();
108
+ /**
109
+ * Mark a job's worker log as open and appendable.
110
+ *
111
+ * Called once the tee EXISTS, before any registry persistence — see the map's
112
+ * docstring for why the two are not chained. Re-activating the same job id simply
113
+ * overwrites the path.
114
+ */
115
+ export function activateJobLog(jobId, logPath) {
116
+ if (typeof logPath !== "string" || logPath.length === 0)
117
+ return;
118
+ activeJobLogPaths.set(jobId, logPath);
119
+ }
120
+ /** Stop appending to a job's worker log. Idempotent. */
121
+ export function deactivateJobLog(jobId) {
122
+ activeJobLogPaths.delete(jobId);
123
+ }
124
+ /** The distinct currently-appendable log paths (a snapshot, safe to iterate). */
125
+ export function activeJobLogPathsSnapshot() {
126
+ return [...new Set(activeJobLogPaths.values())];
127
+ }
128
+ /**
129
+ * Append one diagnostic line to every currently active worker log.
130
+ *
131
+ * FAIL-OPEN PER PATH. Each append is contained independently, so one missing,
132
+ * deleted, or unwritable log cannot suppress the diagnostic for the others and
133
+ * cannot propagate an error into the executor's poll loop — this is annotation,
134
+ * never correctness.
135
+ *
136
+ * Paths are DEDUPLICATED (two jobs can legitimately name the same log during a
137
+ * re-entry window) and exactly one trailing newline is written, whether or not
138
+ * the caller supplied one.
139
+ */
140
+ export async function appendToActiveJobLogs(line, deps) {
141
+ const paths = activeJobLogPathsSnapshot();
142
+ if (paths.length === 0)
143
+ return;
144
+ const payload = `${line.replace(/\n+$/, "")}\n`;
145
+ await Promise.all(paths.map(async (logPath) => {
146
+ try {
147
+ await deps.appendFile(logPath, payload);
148
+ }
149
+ catch {
150
+ /* one unwritable log never blocks the others, or the executor */
151
+ }
152
+ }));
153
+ }
85
154
  /**
86
155
  * Load and validate a record for `executor watch <job>`. Returns a structured
87
156
  * missing/invalid result rather than throwing raw filesystem/JSON errors.
@@ -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, {
@@ -787,6 +817,35 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
787
817
  });
788
818
  return terminalToRunResult(terminal, "completed");
789
819
  }
820
+ /**
821
+ * BAPI-862: the payload key the reconciler stamps with the implement gate's last
822
+ * observation reason code. Server-minted and opaque to the executor beyond the
823
+ * closed set below — the executor never derives, defaults, or infers it.
824
+ */
825
+ const IMPLEMENT_LAST_OBSERVATION_KEY = "implement_last_observation_reason";
826
+ /**
827
+ * The one observation reason code that changes the stale-branch guard's advice.
828
+ * Matches `PR_ATTACH_PENDING_REASON_CODE` in the reconciler; the two strings are
829
+ * one vocabulary on purpose, so an operator greps a single term across the job
830
+ * row, the gate observation, and the executor refusal.
831
+ */
832
+ const PR_NOT_ATTACHED_REASON_CODE = "pr_not_attached";
833
+ /**
834
+ * Map the job payload's reconciliation observation onto the guard's narrow
835
+ * classification. Anything absent, malformed, or unrecognized degrades to
836
+ * `"unknown"` — the pre-BAPI-862 delete/rebase guidance. That direction is
837
+ * deliberate: the softened message asserts the branch holds finished work, and
838
+ * asserting that on a guess would talk an operator OUT of cleaning up a genuinely
839
+ * stale branch.
840
+ */
841
+ function resolveStaleBranchClassification(payload) {
842
+ if (!payload || typeof payload !== "object")
843
+ return "unknown";
844
+ const raw = payload[IMPLEMENT_LAST_OBSERVATION_KEY];
845
+ if (typeof raw !== "string")
846
+ return "unknown";
847
+ return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
848
+ }
790
849
  async function prepareSpawn(job, httpClient, options, deps, seams) {
791
850
  if (job.job_type === "resume") {
792
851
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
@@ -853,7 +912,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
853
912
  // `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
854
913
  // here — it has its own preservation protocol above.
855
914
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
856
- const wt = await ensureWorktree(job, options, deps, { reuseExistingBranch });
915
+ const wt = await ensureWorktree(job, options, deps, {
916
+ reuseExistingBranch,
917
+ // BAPI-862: hand the guard the reconciler's own reading of why this branch
918
+ // may already carry commits. Read from the SERVER-minted payload field and
919
+ // validated against a closed set — never inferred locally, and never taken
920
+ // from worker output (R14 rules 3 and 4).
921
+ staleBranchClassification: resolveStaleBranchClassification(job.payload),
922
+ });
857
923
  if (!wt.ok) {
858
924
  await httpClient.fail(job, {
859
925
  error_kind: "WorktreeError",
@@ -915,7 +981,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
915
981
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
916
982
  * touches a worktree that will not become a runnable worker.
917
983
  */
918
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
984
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control) {
919
985
  // --- Per-job base branch (BAPI-586) ----------------------------------
920
986
  // Resolve the effective logical base BEFORE any side effect: the persisted
921
987
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -971,6 +1037,23 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
971
1037
  }
972
1038
  const effectiveBaseBranch = baseResolution.baseBranch;
973
1039
  const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
1040
+ // --- Declared index scope (BAPI-844) ---------------------------------
1041
+ // Resolved here, alongside the base branch and for the same reason: a job that
1042
+ // declares a scope it cannot be routed by must fail before a worktree is cut, a
1043
+ // deny layer is written, MCP is provisioned, a log is opened, or a process is
1044
+ // spawned. An ABSENT declaration is the ordinary unscoped job and adds nothing
1045
+ // anywhere downstream. The error text names the field only — the declared value
1046
+ // is opaque and never reaches `/fail`.
1047
+ const indexScopeResolution = resolveExecutorJobIndexScope(job);
1048
+ if (!indexScopeResolution.ok) {
1049
+ await httpClient.fail(job, {
1050
+ error_kind: "ContractError.IndexScope",
1051
+ error_message: indexScopeResolution.error,
1052
+ classification: "crashed",
1053
+ });
1054
+ return { status: "failed", reason: "index_scope_contract" };
1055
+ }
1056
+ const indexScope = indexScopeResolution.indexScope;
974
1057
  // --- Permission posture (BAPI-725) -----------------------------------
975
1058
  // Resolved alongside the base branch, BEFORE any side effect, for the same
976
1059
  // reason: an unsupported posture is a contract failure, and failing it here
@@ -1032,6 +1115,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1032
1115
  });
1033
1116
  return { status: "failed", reason: "worktree_busy" };
1034
1117
  }
1118
+ // BAPI-828: the post-spawn lifecycle handles this job's worker owns, filled in
1119
+ // by `runPreparedSpawn` as each resource comes into existence and unwound in
1120
+ // the `finally` below. A shared mutable record rather than return values,
1121
+ // because the resources must be released even when `runPreparedSpawn` throws —
1122
+ // which is exactly the path that has no return value to inspect.
1123
+ const spawnLifecycle = {};
1035
1124
  try {
1036
1125
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
1037
1126
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
@@ -1078,14 +1167,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1078
1167
  branch,
1079
1168
  prep,
1080
1169
  effectiveBaseBranch,
1170
+ indexScope,
1081
1171
  permissionPosture,
1082
1172
  adapter,
1173
+ control,
1174
+ lifecycle: spawnLifecycle,
1083
1175
  });
1084
1176
  }
1085
1177
  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.
1178
+ // BAPI-828 cleanup ORDER is the contract, and it runs strictly after
1179
+ // `runPreparedSpawn` has settled (normally or by throwing), which means
1180
+ // strictly after process supervision has settled:
1181
+ //
1182
+ // 1. Deregister the worker. It is no longer live, so a shutdown arriving
1183
+ // now must not try to signal it — the registry should only ever hold
1184
+ // children that might still be running.
1185
+ // 2. Close and deactivate the worker log. Idempotent: the terminal branches
1186
+ // inside `runPreparedSpawn` already close it on their way out, and this
1187
+ // is the backstop for the exceptional paths that do not.
1188
+ // 3. Release the worktree lock — LAST, and still through the same
1189
+ // ownership-checked `release()` as before this ticket. The worktree must
1190
+ // never be advertised as free while this job's worker could still be
1191
+ // touching it, and the two steps above are what establish that it cannot.
1192
+ spawnLifecycle.deregisterLiveWorker?.();
1193
+ spawnLifecycle.deregisterLiveWorker = undefined;
1194
+ await spawnLifecycle.closeWorkerLog?.();
1089
1195
  await worktreeLock.release();
1090
1196
  }
1091
1197
  }
@@ -1100,7 +1206,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1100
1206
  * rather than a 500-line indentation nobody can verify by eye.
1101
1207
  */
1102
1208
  async function runPreparedSpawn(params) {
1103
- const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
1209
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
1104
1210
  // BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
1105
1211
  // are told to open the PR against the injected run base. Verdict-only
1106
1212
  // `spec_review` produces no PR, so it is excluded from the instruction while
@@ -1247,7 +1353,31 @@ async function runPreparedSpawn(params) {
1247
1353
  catch {
1248
1354
  tee = null;
1249
1355
  }
1356
+ // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
1357
+ // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
1358
+ // directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
1359
+ // the paths that throw. Before this ticket the close was open-coded at eleven
1360
+ // separate return sites, which is why the active-log deactivation is folded in
1361
+ // here rather than added as a twelfth thing each branch had to remember.
1362
+ let workerLogClosed = false;
1363
+ const closeWorkerLog = async () => {
1364
+ if (workerLogClosed)
1365
+ return;
1366
+ workerLogClosed = true;
1367
+ // Deactivate FIRST: once the tee is closing, an append from the suspend
1368
+ // diagnostic would be writing to a log nobody is reading any more.
1369
+ deactivateJobLog(job.id);
1370
+ await closeWorkerLogTee(tee);
1371
+ };
1372
+ lifecycle.closeWorkerLog = closeWorkerLog;
1250
1373
  if (tee) {
1374
+ // BAPI-828 — mark the log appendable as soon as the tee EXISTS, and
1375
+ // deliberately BEFORE the watch-registry persistence below. The two are
1376
+ // independent on purpose: `registerLog` swallows its own failures (it is a
1377
+ // visibility nicety for a separate `executor watch` process), and chaining
1378
+ // the active marker to it would mean a failed registry write silently
1379
+ // disables suspend annotation for a worker whose tee is perfectly healthy.
1380
+ activateJobLog(job.id, tee.logPath);
1251
1381
  const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
1252
1382
  try {
1253
1383
  await registerLog({
@@ -1312,9 +1442,16 @@ async function runPreparedSpawn(params) {
1312
1442
  //
1313
1443
  // BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
1314
1444
  // worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
1445
+ //
1446
+ // BAPI-844: the validated declared index scope travels the same way, as an
1447
+ // explicit option rather than through the environment this process happens to
1448
+ // hold — the executor's own `BAPI_INDEX_SCOPE`, if an operator exported one,
1449
+ // is denied at the env builder and cannot reach `spawnShape.env`. It stays out
1450
+ // of `argv` entirely: it is a routing declaration, not a command-line input,
1451
+ // and argv is world-readable via `ps`.
1315
1452
  const invocation = adapter.headlessInvocation;
1316
1453
  if (invocation?.supported !== true) {
1317
- await closeWorkerLogTee(tee);
1454
+ await closeWorkerLog();
1318
1455
  await finalizeRegistry();
1319
1456
  await httpClient.fail(job, {
1320
1457
  error_kind: ExecutorAdapterUnavailable,
@@ -1332,6 +1469,7 @@ async function runPreparedSpawn(params) {
1332
1469
  posture: permissionPosture,
1333
1470
  mcpConfigPath: mcpContract.configPath,
1334
1471
  effectiveBaseBranch,
1472
+ indexScope,
1335
1473
  parentEnv: deps.env,
1336
1474
  });
1337
1475
  assertUsableSpawnShape(spawnShape, adapter);
@@ -1342,7 +1480,7 @@ async function runPreparedSpawn(params) {
1342
1480
  // a missing argv from Claude's flags is precisely the defaulting this
1343
1481
  // extraction removes, and it would spawn a real worker under a shape nobody
1344
1482
  // declared.
1345
- await closeWorkerLogTee(tee);
1483
+ await closeWorkerLog();
1346
1484
  await finalizeRegistry();
1347
1485
  await httpClient.fail(job, {
1348
1486
  error_kind: ExecutorAdapterUnavailable,
@@ -1360,7 +1498,7 @@ async function runPreparedSpawn(params) {
1360
1498
  // certify its own containment.
1361
1499
  const initParsing = adapter.mcpInitParsing;
1362
1500
  if (initParsing?.supported !== true) {
1363
- await closeWorkerLogTee(tee);
1501
+ await closeWorkerLog();
1364
1502
  await finalizeRegistry();
1365
1503
  await httpClient.fail(job, {
1366
1504
  error_kind: ExecutorAdapterUnavailable,
@@ -1378,7 +1516,7 @@ async function runPreparedSpawn(params) {
1378
1516
  });
1379
1517
  }
1380
1518
  catch (err) {
1381
- await closeWorkerLogTee(tee);
1519
+ await closeWorkerLog();
1382
1520
  await finalizeRegistry();
1383
1521
  // Redact through the adapter's declared secret names before bounding. A
1384
1522
  // spawn error can echo the environment it failed to apply, and that
@@ -1394,6 +1532,22 @@ async function runPreparedSpawn(params) {
1394
1532
  });
1395
1533
  return { status: "failed", reason: "spawn_failed" };
1396
1534
  }
1535
+ // --- Live worker registration (BAPI-828) ------------------------------
1536
+ // Immediately after the ONE successful `deps.spawnProcess` above, and strictly
1537
+ // before any asynchronous supervision begins. That ordering is the whole
1538
+ // guarantee: between the spawn returning and supervision starting there is real
1539
+ // async work (the tee wrapper, the telemetry closures), and a `SIGTERM` landing
1540
+ // in that window used to find a live child that nothing owned.
1541
+ //
1542
+ // The controller is created against the RAW `proc`, not the tee-wrapped view
1543
+ // below: signals go to the actual child, while the wrapper exists only to
1544
+ // duplicate its output.
1545
+ const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
1546
+ // Registration may terminate the worker SYNCHRONOUSLY when shutdown was already
1547
+ // requested — the sticky case, where the signal arrived while this job was still
1548
+ // preparing its worktree. That is intended: the child is signalled here, and
1549
+ // supervision below still runs normally to observe its exit and report it.
1550
+ lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
1397
1551
  // Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
1398
1552
  const supervisedProc = tee
1399
1553
  ? {
@@ -1419,6 +1573,10 @@ async function runPreparedSpawn(params) {
1419
1573
  collectTelemetry,
1420
1574
  collectRemoteMarker,
1421
1575
  mcpSurface,
1576
+ // The SAME controller the registry holds, so an executor shutdown, a timeout,
1577
+ // and an MCP containment refusal all converge on one `SIGTERM` and one grace
1578
+ // timer for this child (BAPI-828).
1579
+ terminationController,
1422
1580
  });
1423
1581
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
1424
1582
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
@@ -1458,7 +1616,7 @@ async function runPreparedSpawn(params) {
1458
1616
  // `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
1459
1617
  // the terminal mutation is attempted, so the report goes out under a live claim.
1460
1618
  if (procResult.serverStopRequested) {
1461
- await closeWorkerLogTee(tee);
1619
+ await closeWorkerLog();
1462
1620
  await finalizeRegistry();
1463
1621
  await httpClient.fail(job, {
1464
1622
  error_kind: "ServerStopRequested",
@@ -1472,7 +1630,7 @@ async function runPreparedSpawn(params) {
1472
1630
  return { status: "abandoned", reason: "server_stop" };
1473
1631
  }
1474
1632
  // Finalize the log tee WITHOUT signaling the worker (it has already exited).
1475
- await closeWorkerLogTee(tee);
1633
+ await closeWorkerLog();
1476
1634
  // --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
1477
1635
  // "`.conductor/worker.log` for any completed job contains exactly one
1478
1636
  // `session_id`" — the cheap, durable invariant from the ticket that would have
@@ -1621,7 +1779,7 @@ async function runPreparedSpawn(params) {
1621
1779
  // the verdict-artifact path above stays limited to verdict jobs like
1622
1780
  // `spec_review`. Recovery jobs complete through this same generic envelope.
1623
1781
  const artifacts = await readCompletionArtifacts(worktreePath, deps);
1624
- const result = buildGenericSuccessResult({
1782
+ const genericResult = buildGenericSuccessResult({
1625
1783
  summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
1626
1784
  branch,
1627
1785
  headSha: git.last_commit_sha,
@@ -1635,7 +1793,7 @@ async function runPreparedSpawn(params) {
1635
1793
  job,
1636
1794
  branch,
1637
1795
  worktreePath,
1638
- result,
1796
+ result: genericResult,
1639
1797
  runCommand: deps.runCommand,
1640
1798
  headSha: git.last_commit_sha,
1641
1799
  expectedBaseBranch: effectiveBaseBranch,
@@ -1656,6 +1814,17 @@ async function runPreparedSpawn(params) {
1656
1814
  });
1657
1815
  return terminalToRunResult(terminal, "failed");
1658
1816
  }
1817
+ // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
1818
+ // populated on this path before — `buildGenericSuccessResult` has no PR field
1819
+ // at all — which is why job 1397's stored `result.pr_url` was null even though
1820
+ // the reconciler's whole implement gate is waiting on that identity. The value
1821
+ // comes strictly from the authoritative `gh` observation finalization just
1822
+ // made; worker prose is never admissible here (R14 rule 3). It is omitted
1823
+ // rather than set to null when finalization had no PR to verify (a
1824
+ // non-implementation job), so the field's presence always means "verified".
1825
+ const result = typeof finalization.prUrl === "string"
1826
+ ? { ...genericResult, pr_url: finalization.prUrl }
1827
+ : genericResult;
1659
1828
  const completion = {
1660
1829
  job_type: job.job_type,
1661
1830
  exit_code: procResult.exitCode ?? 0,
@@ -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
+ }