@bridge_gpt/mcp-server 0.2.50 → 0.2.52

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 (76) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -52,6 +52,40 @@ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree,
52
52
  * carries the whole Bridge-credential guarantee again, exactly as it did before
53
53
  * BAPI-731: the shim resolves the operator's real store in its own process, and
54
54
  * the model cannot read it.
55
+ *
56
+ * BAPI-1020 appends fifteen DESTRUCTIVE / DEPLOYING rules after the original nine.
57
+ * Architecture Miss 28 happened with the original set in place, because it covered
58
+ * credentials, force-push, and `rm -rf` at the filesystem root — and nothing else.
59
+ * Every external or operational system a worker could reach (`gh`, `heroku`,
60
+ * `gcloud`, `aws`, `kubectl`, `terraform`, `docker push`, `npm publish`, a tag
61
+ * push, a push to `main`) was allowed.
62
+ *
63
+ * EVERY new rule is COMMAND-scoped, and that is the Architecture Miss 23
64
+ * constraint restated above, not a stylistic preference: a deny glob must target
65
+ * the ASSET, never a directory that also holds the worker's own working state. A
66
+ * `Bash(<cmd>:...)` rule cannot shadow a path the CLI persists tool results into.
67
+ * Do not widen any of them into a directory or worktree-state path glob.
68
+ *
69
+ * Two rules a reader might expect are deliberately ABSENT because they are already
70
+ * covered: `Bash(git push:*--force*)` above satisfies the force-push clause, and
71
+ * the two `rm:*-rf` rules cover the root and home cases. The hook's
72
+ * worktree-relative `rm -rf` check COMPLEMENTS those two rather than replacing
73
+ * them — a glob cannot decide whether a relative path resolves inside the
74
+ * worktree, and no glob can decide whether a push destination is the job's own
75
+ * branch. That argument-sensitive half lives in `worker-guard-hook.ts`.
76
+ *
77
+ * `Bash(gh:*)` denies `gh` TO WORKERS ONLY, and the distinction is load-bearing —
78
+ * do not "fix" this denial when a conductor merge appears to need `gh`. Conductor
79
+ * merges run in the EXECUTOR PROCESS: `merge-job.ts` spawns no worker, and
80
+ * `conductor/local-merge.ts` runs `gh pr view` / `gh pr merge` in the executor's
81
+ * own process, which reads no worktree `settings.local.json`. The merge path is
82
+ * therefore unaffected by this rule. The mandatory worker paths already avoid `gh`
83
+ * too (push via git's credential helper; PRs via the `create_pull_request` MCP
84
+ * tool — see the note in `env.ts`). `pr-base-contract.ts`'s
85
+ * `buildPrBaseContractLaunchInstruction()` does instruct `gh pr create`, but its
86
+ * only production caller is the INTERACTIVE `start-tickets` path, which is
87
+ * provisioned through `start-tickets-conductor.ts` and never receives these rules.
88
+ * If that ever changes, the instruction — not this rule — is what moves.
55
89
  */
56
90
  export function executorDenyRules(inputs) {
57
91
  return [
@@ -64,8 +98,46 @@ export function executorDenyRules(inputs) {
64
98
  "Bash(git push:*--force*)",
65
99
  "Bash(rm:*-rf /*)",
66
100
  "Bash(rm:*-rf ~*)",
101
+ // BAPI-1020 — destructive / deploying commands, in the ticket's stated order.
102
+ "Bash(gh:*)",
103
+ "Bash(git push:*--tags*)",
104
+ "Bash(heroku:*)",
105
+ "Bash(git push:*refs/tags/*)",
106
+ "Bash(gcloud:*)",
107
+ "Bash(git push:*--delete*)",
108
+ "Bash(aws:*)",
109
+ "Bash(git push:* :*)",
110
+ "Bash(kubectl:*)",
111
+ "Bash(git push:* main*)",
112
+ "Bash(terraform:*)",
113
+ "Bash(git push:* epic/*)",
114
+ "Bash(docker:push*)",
115
+ "Bash(git push:*mcp-server/v*)",
116
+ "Bash(npm:publish*)",
67
117
  ];
68
118
  }
119
+ /**
120
+ * Branch names that a BRANCH-SHAPED deny glob would match (BAPI-1020).
121
+ *
122
+ * Two of the fifteen new rules — `Bash(git push:* main*)` and
123
+ * `Bash(git push:* epic/*)` — deny by branch shape rather than by command. If a
124
+ * worker's OWN branch ever matched one, the static deny would block the push the
125
+ * guard is required to allow, and AC-10 ("it still pushes its own branch") would
126
+ * break at push time with an error naming a rule nobody connected to the branch
127
+ * name. The hook cannot rescue it: a PreToolUse allow does not override a
128
+ * settings-level deny.
129
+ *
130
+ * Today the collision is unreachable — worker branches come from `wt.branch`, the
131
+ * Worktrunk `feature/<KEY>` branch, and `epic/<EPIC>` appears only as a merge base
132
+ * — but nothing wrote that invariant down. This does, and it fails LOUDLY at
133
+ * provisioning time rather than mysteriously at push time.
134
+ */
135
+ export function workerBranchCollidesWithDenyGlob(branch) {
136
+ const trimmed = branch.trim();
137
+ if (trimmed.length === 0)
138
+ return false;
139
+ return trimmed === "main" || trimmed.startsWith("main") || trimmed.startsWith("epic/");
140
+ }
69
141
  function asObject(value) {
70
142
  return value !== null && typeof value === "object" && !Array.isArray(value)
71
143
  ? value
@@ -75,6 +147,12 @@ function asObject(value) {
75
147
  * Merge the executor deny set into a settings object under `permissions.deny`,
76
148
  * preserving all existing settings and appending only rules not already present
77
149
  * (idempotent).
150
+ *
151
+ * Append-if-absent is what keeps the merge safe for a worktree provisioned by an
152
+ * EARLIER executor build: the nine pre-BAPI-1020 rules already sitting in the file
153
+ * are matched and skipped, the fifteen new ones are appended, and any unrelated
154
+ * operator-authored deny entry is preserved in place rather than reordered or
155
+ * replaced.
78
156
  */
79
157
  export function mergeExecutorDenySettings(existing, inputs) {
80
158
  const permissions = { ...asObject(existing.permissions) };
@@ -272,6 +350,12 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
272
350
  * and shield that file from worker commits via `.git/info/exclude` (BAPI-740/F2).
273
351
  * Fails open: a malformed existing file is preserved (not clobbered) and I/O
274
352
  * errors return a warning — the caller still spawns the worker.
353
+ *
354
+ * BAPI-1020: this call now provisions TWO things into the same file — the deny
355
+ * rules and the always-on deterministic worker-guard PreToolUse hook. They are one
356
+ * write because they are one worktree-provisioning boundary, not because they are
357
+ * one mechanism: the rules deny command FAMILIES, the guard denies destructive
358
+ * ARGUMENTS, and neither can express the other's half.
275
359
  */
276
360
  export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
277
361
  const inputs = {
@@ -281,6 +365,12 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
281
365
  };
282
366
  const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
283
367
  let merged = mergeExecutorDenySettings(existing, inputs);
368
+ // BAPI-1020 — the worker guard is registered UNCONDITIONALLY, before and
369
+ // independently of the conditional fallback below. PreToolUse hooks fire in
370
+ // EVERY permission mode, bypass included, which is what makes the guard hold
371
+ // even under the `skip_permissions` revert value. The merge is idempotent
372
+ // per command, so a re-provisioned worktree does not accumulate duplicates.
373
+ merged = mergeClaudeSettingsWithCommandHook(merged, options.workerGuardHookCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
284
374
  if (options.preToolUseFallbackCommand) {
285
375
  merged = mergeClaudeSettingsWithCommandHook(merged, options.preToolUseFallbackCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
286
376
  }
@@ -290,6 +380,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
290
380
  // may already exist from an earlier provisioning, so it runs even when the
291
381
  // merge-write was refused or failed.
292
382
  const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
383
+ // BAPI-1020 — the branch-shaped-glob collision check. Advisory rather than
384
+ // blocking, matching this function's established fail-open posture: the worker
385
+ // can still do most of its job, and killing the spawn would trade a push failure
386
+ // for a total one. It is loud where it matters — the warning names the condition
387
+ // at PROVISIONING time, so an operator reads it before the push fails, instead of
388
+ // decoding a deny rule from a push error twenty minutes later.
389
+ const collidingBranch = typeof options.workerBranch === "string" &&
390
+ workerBranchCollidesWithDenyGlob(options.workerBranch);
293
391
  // `blocking` and `advisory` are kept apart on purpose. A blocking warning
294
392
  // flips `ok` (the established contract, which existing callers and tests
295
393
  // read); an advisory one rides along on `ok: true` so a degraded-but-
@@ -303,6 +401,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
303
401
  blocking.push(exclude.warning);
304
402
  else if (exclude.warning)
305
403
  advisory.push(exclude.warning);
404
+ if (collidingBranch) {
405
+ // The branch NAME is not echoed: this text travels into worker logs, and the
406
+ // condition ("matches a branch-shaped deny glob") is the whole diagnostic.
407
+ advisory.push("the job branch matches a branch-shaped executor deny glob (git push to main/ or " +
408
+ "epic/); the worker's own push would be denied by settings permissions.deny, " +
409
+ "which the PreToolUse guard cannot override — re-cut the branch before relying " +
410
+ "on this worktree");
411
+ }
306
412
  if (blocking.length > 0) {
307
413
  return { ok: false, warning: [...blocking, ...advisory].join("; ") };
308
414
  }
@@ -46,9 +46,25 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
46
46
  }
47
47
  return parsed;
48
48
  }
49
- /** True only for a result safe to reuse: genuinely enforced by a real layer. */
49
+ /**
50
+ * True only for a result safe to reuse: genuinely enforced by a real layer, for
51
+ * EVERY posture that was probed.
52
+ *
53
+ * BAPI-1020 added the per-posture clause. The combined result already refuses to
54
+ * report `enforced: true` unless every required posture passed, but caching is the
55
+ * one place where being wrong is STICKY — a cached result is reused for the rest
56
+ * of the executor's life — so the check is made against the per-posture record
57
+ * directly rather than trusted to the conjunction upstream. A spawn failure, a
58
+ * hang, a control refusal, and a degraded guess all report `layer: "none"` for the
59
+ * posture they happened under, and none of them is cached.
60
+ */
50
61
  function isCacheableDenyResult(result) {
51
- return result.enforced === true && result.layer !== "none";
62
+ if (result.enforced !== true || result.layer === "none")
63
+ return false;
64
+ const postures = result.postures;
65
+ if (postures === undefined)
66
+ return true;
67
+ return postures.every((p) => p.enforced === true && p.layer !== "none");
52
68
  }
53
69
  /** Create a fresh, empty executor-scoped deny-probe cache. */
54
70
  export function createDenyProbeCache() {
@@ -343,18 +359,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
343
359
  };
344
360
  }
345
361
  /**
346
- * Build the HTTP claim manifest — the eight original T2-accepted keys, plus the
347
- * optional ninth `epic_run_ids` key (BAPI-794) when the executor was configured
348
- * with one or more `--epic-run-id` flags. `free_slots` is supplied by the runner
349
- * (dynamic). The internal-only `gh` auth capability is intentionally NOT included
350
- * (not accepted by the T2 request schema in this slice).
362
+ * Build the HTTP claim manifest — the eight original T2-accepted keys, plus at
363
+ * most ONE optional scope-intent key: `epic_run_ids` (BAPI-794) when the
364
+ * executor was configured with `--epic-run-id`, or `repo_wide: true`
365
+ * (BAPI-1026) when it was configured with `--repo-wide`. `free_slots` is
366
+ * supplied by the runner (dynamic). The internal-only `gh` auth capability is
367
+ * intentionally NOT included (not accepted by the T2 request schema).
368
+ *
369
+ * The two scope keys are mutually exclusive by construction: `parseExecutorArgs`
370
+ * refuses to start an executor that supplied both, and the server rejects the
371
+ * combination with a 422 anyway. Exactly one is always present, because since
372
+ * BAPI-1026 the CLI also refuses to start with neither.
351
373
  *
352
- * `epic_run_ids` is added via a conditional spread, not a bare property
353
- * assignment: `{ epic_run_ids: options.epicRunIds }` would still create the key
354
- * (with value `undefined`) when unscoped, which `Object.keys()` — unlike
355
- * `JSON.stringify` — does NOT drop. The eight-key compatibility contract is
356
- * pinned on `Object.keys()`, so the key must be truly absent, not merely
357
- * `undefined`-valued.
374
+ * Both are added via a conditional spread, not a bare property assignment:
375
+ * `{ epic_run_ids: options.epicRunIds }` would still create the key (with value
376
+ * `undefined`) when unscoped, which `Object.keys()` — unlike `JSON.stringify` —
377
+ * does NOT drop. The eight-key compatibility contract is pinned on
378
+ * `Object.keys()`, so an unused key must be truly absent, not merely
379
+ * `undefined`-valued. For the same reason `repo_wide` is never serialized as
380
+ * `false`: `false` is the server's default and means "not an opt-out", so
381
+ * sending it would add a key that carries no information.
358
382
  */
359
383
  export function buildClaimManifest(report, options, freeSlots) {
360
384
  return {
@@ -367,5 +391,6 @@ export function buildClaimManifest(report, options, freeSlots) {
367
391
  free_slots: freeSlots,
368
392
  disk_free_gb: report.diskFreeGb ?? 0,
369
393
  ...(options.epicRunIds !== undefined ? { epic_run_ids: options.epicRunIds } : {}),
394
+ ...(options.repoWide === true ? { repo_wide: true } : {}),
370
395
  };
371
396
  }
@@ -51,7 +51,8 @@ export function resolveResumeExpectedBranch(job, fields) {
51
51
  * Resolve the server-provided prompt template, failing loud with a contract
52
52
  * error when it is missing/empty. Validated BEFORE any worktree resolution or
53
53
  * WIP-checkpoint work so a malformed `resume` payload never spawns a real
54
- * `claude -p "" --dangerously-skip-permissions` worker that would burn the full
54
+ * `claude -p ""` worker under whatever posture the run policy resolved, which
55
+ * since BAPI-1020 defaults to `--permission-mode auto` — that would burn the full
55
56
  * job timeout before failing generically (mirrors the `implement`/`spec_review`
56
57
  * `ContractError.Prompt` fail-fast in `resolveExecutorPrompt`).
57
58
  */
@@ -9,8 +9,10 @@
9
9
  import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
10
10
  import { runClaimedJob } from "./job-runner.js";
11
11
  import { appendToActiveJobLogs } from "./job-log-registry.js";
12
- import { createLiveWorkerRegistry } from "./live-worker-registry.js";
12
+ import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
13
13
  import { sweepExecutorWorktrees } from "./worktree-gc.js";
14
+ import { validateBranchName } from "../base-ref.js";
15
+ import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
14
16
  /**
15
17
  * Signals that request a graceful executor shutdown (BAPI-828).
16
18
  *
@@ -133,6 +135,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
133
135
  // Read-only diagnostics, exactly like `lastClaimedEpicRunId` above: it never
134
136
  // feeds claim eligibility, cadence, retries, dispatch, or exit.
135
137
  let lastDispatcherAvailability = null;
138
+ // BAPI-1010 — one-shot executor wind-down observer state. Declared here,
139
+ // OUTSIDE both the claim loop and the poll loop, so a readiness attempt
140
+ // fires at most once for the life of this `runExecutor` invocation rather
141
+ // than once per poll cycle.
142
+ let markReadyAttempted = false;
143
+ // Suppresses the missing-read-capability advisory to one line per
144
+ // invocation, mirroring the dispatcher-liveness suppression above.
145
+ let missingReadCapabilityWarned = false;
136
146
  /**
137
147
  * Announce a dispatcher availability CHANGE, and only a change.
138
148
  *
@@ -164,6 +174,109 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
164
174
  deps.errorLog(formatDispatcherRecoveredDiagnostic());
165
175
  }
166
176
  };
177
+ /**
178
+ * Resolve the single epic run this executor process is unambiguously scoped
179
+ * to observe for wind-down, or `null` when the scope is absent or ambiguous.
180
+ *
181
+ * BAPI-1010: Slice 1 (`ExecutorOptions.epicRunIds`, BAPI-794) does not
182
+ * guarantee a dedicated executor receives exactly one run id — an operator
183
+ * may repeat `--epic-run-id` for several runs. Observing an arbitrary entry
184
+ * from a multi-run scope would silently pick the wrong epic, so the
185
+ * mutation stays disabled for both an unscoped executor (`epicRunIds`
186
+ * absent) and an ambiguous one (more than one id).
187
+ *
188
+ * BAPI-1026 (R54) DELIBERATELY LEAVES THIS CONDITION UNCHANGED, and that is
189
+ * the whole decision, not an omission.
190
+ *
191
+ * Making run scoping the default raised a real hazard: if the server could
192
+ * hand an executor a single-run scope it had not asked for, this gate would
193
+ * start authorizing a GitHub write — the one-shot epic→base integration-PR
194
+ * mutation — for processes that never had it, invisibly, as a side effect of
195
+ * a claim-scoping change. The decision is to keep the wind-down gated on
196
+ * EXPLICIT OPERATOR SCOPE only.
197
+ *
198
+ * No provenance flag is needed to express that, because R54 removed the only
199
+ * way a non-operator scope could arise: there is no server-derived scope. The
200
+ * server owns no executor-to-run binding to derive one from, so the accepted
201
+ * design makes the scope client-supplied and the CLI refuse to start without
202
+ * an explicit `--epic-run-id` or `--repo-wide`. Every `epicRunIds` value that
203
+ * can reach this line therefore came from an operator's `--epic-run-id`
204
+ * flags, exactly as it did before R54, and `--repo-wide` leaves `epicRunIds`
205
+ * absent so it cannot enable the mutation either.
206
+ *
207
+ * If a future slice ever introduces a server-derived scope, this gate must
208
+ * grow an explicit provenance field at the same time — cardinality would stop
209
+ * being evidence of operator intent the moment that happens.
210
+ */
211
+ const observedEpicRunId = options.epicRunIds !== undefined && options.epicRunIds.length === 1
212
+ ? options.epicRunIds[0]
213
+ : null;
214
+ /**
215
+ * One-shot epic-run completion observer (BAPI-1010).
216
+ *
217
+ * Called after every successful claim response (`claimed` or `none`), next
218
+ * to `observeDispatcherLiveness`. The FIRST time the scoped run's state
219
+ * reads back `status: "done"` on a valid `epic/`-prefixed feature branch,
220
+ * this requests the epic's existing draft integration pull request be
221
+ * marked ready for review — it never creates a merge and never merges
222
+ * anything; only a human merges an epic integration branch (see
223
+ * `epic-integration-pr.ts`). Every failure here — a missing read
224
+ * capability, a sanitized transport/validation failure, an invalid branch,
225
+ * or an unexpected throw — is ADVISORY: it is logged (credential-free,
226
+ * bounded) and never affects claiming, running jobs, or the runner's exit.
227
+ */
228
+ const observeEpicRunCompletion = async () => {
229
+ if (markReadyAttempted || observedEpicRunId === null)
230
+ return;
231
+ const readState = httpClient.readEpicRunState;
232
+ if (typeof readState !== "function") {
233
+ if (!missingReadCapabilityWarned) {
234
+ missingReadCapabilityWarned = true;
235
+ deps.errorLog("executor: HTTP client lacks readEpicRunState; skipping epic wind-down observation.");
236
+ }
237
+ return;
238
+ }
239
+ try {
240
+ const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
241
+ if (!result.ok) {
242
+ deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
243
+ return;
244
+ }
245
+ if (result.state.status !== "done")
246
+ return;
247
+ const featureBranch = result.state.featureBranch;
248
+ if (typeof featureBranch !== "string" || !featureBranch.startsWith(EPIC_BRANCH_PREFIX)) {
249
+ return;
250
+ }
251
+ const epicKey = featureBranch.slice(EPIC_BRANCH_PREFIX.length);
252
+ if (epicKey.length === 0)
253
+ return;
254
+ if (validateBranchName(featureBranch) !== null)
255
+ return;
256
+ if (validateBranchName(options.baseBranch) !== null)
257
+ return;
258
+ // Marked BEFORE constructing/awaiting the PR operation — a slow or
259
+ // failing `gh` call must never let a later poll cycle start a second
260
+ // attempt while this one is still in flight.
261
+ markReadyAttempted = true;
262
+ const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
263
+ const outcome = await ensureEpicIntegrationPullRequest({
264
+ epicKey,
265
+ epicBranch: featureBranch,
266
+ baseBranch: options.baseBranch,
267
+ command: "executor wind-down",
268
+ requestReady: true,
269
+ gh,
270
+ cwd: deps.cwd,
271
+ });
272
+ deps.errorLog(`executor: epic wind-down integration PR: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
273
+ }
274
+ catch {
275
+ // Advisory-only: never allowed to affect claiming, running jobs, or the
276
+ // runner's exit code.
277
+ deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
278
+ }
279
+ };
167
280
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
168
281
  // the claim loop below — that scope is the whole feature. A cache created inside
169
282
  // the loop would be discarded every cycle and re-probe exactly as before; a
@@ -182,7 +295,6 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
182
295
  // not module-global. Every dispatched job receives it, so a worker spawned by
183
296
  // any of them is reachable from the signal handlers installed below.
184
297
  const liveWorkers = createLiveWorkerRegistry();
185
- const control = { liveWorkers };
186
298
  let shutdownRequested = false;
187
299
  /**
188
300
  * Wake functions for whichever poll sleep is currently in flight. A list whose
@@ -210,6 +322,35 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
210
322
  for (const wake of shutdownWaiters.splice(0, shutdownWaiters.length))
211
323
  wake();
212
324
  };
325
+ /**
326
+ * Process-level auth-fatal handler (BAPI-1021, AC-5). A dispatched job
327
+ * (claim, heartbeat, complete, or fail) calls this via
328
+ * `control.reportAuthFatal` on its FIRST 401/403 — a credential that will
329
+ * never become valid again by waiting.
330
+ *
331
+ * Deliberately just `beginShutdown()` underneath: an auth halt is the same
332
+ * kind of stop as a `SIGTERM`/`SIGINT` — terminate live workers gracefully
333
+ * and stop claiming new work — reusing its idempotent flag and drain
334
+ * behavior rather than inventing a parallel shutdown path. That reuse is
335
+ * also what keeps this handler from ever running a worktree
336
+ * reset/recreation or a conservative GC sweep: neither the existing
337
+ * shutdown path nor this one performs either.
338
+ *
339
+ * The ONE bounded, secret-safe log line is guarded SEPARATELY from
340
+ * `beginShutdown`'s own idempotency: two jobs racing an auth failure nearly
341
+ * concurrently must produce exactly one `executor_auth_failure` line, even
342
+ * though `beginShutdown()` itself is safe to call from both.
343
+ */
344
+ let authFailureLogged = false;
345
+ const reportAuthFatal = (notice) => {
346
+ if (!authFailureLogged) {
347
+ authFailureLogged = true;
348
+ deps.errorLog(`executor_auth_failure: category=${notice.category} status=${notice.httpStatus} ` +
349
+ `job_id=${notice.jobId ?? "n/a"} repo=${notice.repoName}`);
350
+ }
351
+ beginShutdown();
352
+ };
353
+ const control = { liveWorkers, reportAuthFatal };
213
354
  /**
214
355
  * Sleep one poll interval, or return early once shutdown is requested.
215
356
  *
@@ -453,15 +594,26 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
453
594
  // not evidence about the dispatcher, so it is deliberately not observed.
454
595
  if (result.kind === "claimed" || result.kind === "none") {
455
596
  observeDispatcherLiveness(result.reconcilerLiveness);
597
+ await observeEpicRunCompletion();
456
598
  }
457
599
  if (result.kind === "claimed") {
458
600
  const currentEpicRunId = result.job.epic_run_id ?? null;
459
601
  if (lastClaimedEpicRunId !== null &&
460
602
  currentEpicRunId !== null &&
461
603
  currentEpicRunId !== lastClaimedEpicRunId) {
604
+ // BAPI-1026 (R54) — RETARGETED, not removed. `scoped=` used to
605
+ // report `epicRunIds !== undefined`, which this ticket could have
606
+ // made universally true and so turned the warning it guards into
607
+ // dead code. It reports the claim-scope MODE instead, which stays
608
+ // genuinely two-valued: a `--repo-wide` executor legitimately
609
+ // claims across runs (the warning is expected there), while a
610
+ // scoped executor seeing a cross-run claim is a real anomaly worth
611
+ // the line. Bounded context only — a mode label and the run IDs
612
+ // already present in this pre-existing message.
462
613
  deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
463
614
  `(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
464
- `current_epic_run_id=${currentEpicRunId} scoped=${options.epicRunIds !== undefined})`);
615
+ `current_epic_run_id=${currentEpicRunId} ` +
616
+ `claim_scope_mode=${options.epicRunIds !== undefined ? "scoped" : "repo_wide"})`);
465
617
  }
466
618
  if (currentEpicRunId !== null)
467
619
  lastClaimedEpicRunId = currentEpicRunId;
@@ -478,6 +630,18 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
478
630
  else {
479
631
  deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
480
632
  claiming = false;
633
+ // BAPI-1021 (AC-5): a categorized 401/403 fatal claim halts the
634
+ // RUNNER through the same handler a heartbeat/complete/fail auth
635
+ // failure reaches — not merely this claim iteration. No job was
636
+ // ever claimed, so the notice names the manifest's repository and
637
+ // omits `jobId`.
638
+ if (result.kind === "fatal" && result.authCategory === "auth_fatal") {
639
+ control.reportAuthFatal?.({
640
+ repoName: manifest.repo_name,
641
+ category: "claim",
642
+ httpStatus: result.authHttpStatus ?? 401,
643
+ });
644
+ }
481
645
  }
482
646
  }
483
647
  // Conservative worktree GC runs once per cycle, AFTER the claim attempt and
@@ -485,7 +649,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
485
649
  // both the startup cycle and between-poll-cycle cadence without ever
486
650
  // sweeping while a job is running. Kept off the pre-claim path so it never
487
651
  // delays claiming a ready job.
488
- await maybeSweepWorktrees();
652
+ //
653
+ // BAPI-1021 (AC-5): also skipped once shutdown was requested DURING this
654
+ // cycle's claim loop (a claim-time auth-fatal is the case that matters —
655
+ // it can flip `shutdownRequested` while `active.size` is still 0). No
656
+ // worktree-mutating sweep may run once the auth-fatal flag is set.
657
+ if (!shutdownRequested) {
658
+ await maybeSweepWorktrees();
659
+ }
489
660
  }
490
661
  if (options.once || shutdownRequested)
491
662
  break;
@@ -255,6 +255,17 @@ export function buildExecutorServiceArguments(input) {
255
255
  for (const repo of input.repos)
256
256
  args.push("--repo", repo);
257
257
  args.push("--executor-id", input.executorId);
258
+ // BAPI-1026 (R54): forward the claim scope. Repeated `--epic-run-id <id>`
259
+ // pairs mirror how the operator supplied them, or the single `--repo-wide`
260
+ // opt-out. Appended last so the pre-existing prefix/repo/executor-id ordering
261
+ // — which several artifact tests pin positionally — is unchanged.
262
+ if (input.epicRunIds !== undefined) {
263
+ for (const runId of input.epicRunIds)
264
+ args.push("--epic-run-id", runId);
265
+ }
266
+ else if (input.repoWide === true) {
267
+ args.push("--repo-wide");
268
+ }
258
269
  return args;
259
270
  }
260
271
  // ---------------------------------------------------------------------------
@@ -845,6 +856,10 @@ export async function runExecutorInstallServiceCli(argv, overrides) {
845
856
  baseUrl,
846
857
  homeDir: deps.homeDir,
847
858
  envPath,
859
+ // Forwarded from the already-validated parse, so the generated unit starts
860
+ // with the same claim scope the operator typed (BAPI-1026).
861
+ ...(options.epicRunIds !== undefined ? { epicRunIds: options.epicRunIds } : {}),
862
+ ...(options.repoWide === true ? { repoWide: true } : {}),
848
863
  };
849
864
  // Windows has no generated user-service format here: print the fully resolved,
850
865
  // secret-free command line plus manual Task Scheduler guidance and stop. No
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Terminal mutation (complete/fail) retry semantics (BAPI-534, TDD §10).
3
+ *
4
+ * - `updated` → terminal success.
5
+ * - `stale_claim` → definitive loss of ownership; stop touching worktree.
6
+ * - `retry_later` / net → retryable at the heartbeat interval WHILE the dead-man
7
+ * window has not elapsed; then abandon.
8
+ * - `invalid_job_result` → (complete only) terminal non-success; the server has
9
+ * already failed the job, so DO NOT then call `/fail`.
10
+ *
11
+ * Retry diagnostics are bounded and never include raw result envelopes, secrets,
12
+ * or unbounded stdout/stderr.
13
+ */
14
+ import { normalizeMutationResult } from "./types.js";
1
15
  /**
2
16
  * Send a terminal mutation with the fencing-aware retry policy. Skips entirely
3
17
  * when ownership was already abandoned.
@@ -7,9 +21,16 @@ export async function sendTerminalMutationWithRetry(params) {
7
21
  if (ownership.abandoned)
8
22
  return { outcome: "skipped" };
9
23
  while (true) {
10
- const outcome = await params.send();
24
+ const mapped = normalizeMutationResult(await params.send());
25
+ const outcome = mapped.outcome;
11
26
  if (outcome === "updated")
12
27
  return { outcome: "success" };
28
+ if (outcome === "auth_fatal") {
29
+ // Return immediately after exactly one send attempt — no dead-man sleep
30
+ // or retry. Deliberately does NOT set `ownership.abandoned` (this is not
31
+ // a lost/stale claim) and never invokes `send()` a second time.
32
+ return { outcome: "auth_fatal", httpStatus: mapped.authHttpStatus ?? 401 };
33
+ }
13
34
  if (outcome === "stale_claim") {
14
35
  ownership.abandoned = true;
15
36
  ownership.abandonReason = "stale_claim";
@@ -1,3 +1,89 @@
1
+ /**
2
+ * Server-authoritative executor job types (T2 `ExecutorJobType` literal,
3
+ * mirrors `EXECUTOR_JOB_TYPE_VALUES` in `api/models/executor_job.py`).
4
+ *
5
+ * BAPI-997 (T4): the runtime array is the single source, and `ExecutorJobType`
6
+ * is DERIVED from it via `(typeof VALUES)[number]` rather than declared
7
+ * independently — adding or removing a value here changes the emitted
8
+ * JavaScript array (visible to a Node contract driver) and the compile-time
9
+ * union in one edit, so the two can never drift apart.
10
+ */
11
+ export const EXECUTOR_JOB_TYPE_VALUES = [
12
+ "implement",
13
+ "resume",
14
+ "spec_review",
15
+ "remediate",
16
+ "rebase",
17
+ "ci_fix",
18
+ "merge",
19
+ "smoke",
20
+ ];
21
+ /**
22
+ * Server-authoritative completion classification (T2 `ExecutorClassification`,
23
+ * mirrors `EXECUTOR_CLASSIFICATION_VALUES` in `api/models/executor_job.py`).
24
+ * The local process owner produces the first four; `no_progress`/`lease_expired`
25
+ * are reserved for server/other paths.
26
+ *
27
+ * BAPI-997 (T4): derived from the runtime array, same rationale as
28
+ * {@link EXECUTOR_JOB_TYPE_VALUES} above.
29
+ */
30
+ export const EXECUTOR_CLASSIFICATION_VALUES = [
31
+ "clean_exit",
32
+ "timeout",
33
+ "killed",
34
+ "crashed",
35
+ "no_progress",
36
+ "lease_expired",
37
+ ];
38
+ /**
39
+ * Outcome of a heartbeat/complete/fail mutation (T2 status mapping):
40
+ * - `updated` 2xx `{ ok: true }`
41
+ * - `stale_claim` 409 STALE_CLAIM — definitive dead claim (dead-man's switch)
42
+ * - `retry_later` 503 RETRY_LATER — transient; keep worker alive, retry
43
+ * - `invalid_job_result` 422 INVALID_JOB_RESULT on /complete — job failed server-side
44
+ * - `auth_fatal` 401/403 — BAPI-1021 (AC-5): a Bridge auth/access failure.
45
+ * Distinct from `fatal_http_error` because it must bypass the dead-man retry
46
+ * loop and halt the executor immediately rather than waiting out the deadman
47
+ * window on a credential that will never become valid again.
48
+ * - `fatal_http_error` parse/other non-retryable, NON-AUTH failure. Reserved
49
+ * for exactly that now that `auth_fatal` exists — 401/403 map to `auth_fatal`
50
+ * before this fallback is ever reached.
51
+ *
52
+ * BAPI-997 (T4): derived from the runtime array, same rationale as
53
+ * {@link EXECUTOR_JOB_TYPE_VALUES} above.
54
+ */
55
+ export const EXECUTOR_MUTATION_OUTCOME_VALUES = [
56
+ "updated",
57
+ "stale_claim",
58
+ "retry_later",
59
+ "invalid_job_result",
60
+ "fatal_http_error",
61
+ "auth_fatal",
62
+ ];
63
+ /** Normalize either mutation response form into the structured shape. */
64
+ export function normalizeMutationResult(response) {
65
+ return typeof response === "string" ? { outcome: response } : response;
66
+ }
67
+ /**
68
+ * Server-minted reconciler/dispatcher liveness, carried on a claim response
69
+ * header (BAPI-871).
70
+ *
71
+ * SERVER-AUTHORED, and that is the whole contract. The backend derives this from
72
+ * the durable `process_heartbeats` row; the executor only reads it. Nothing here
73
+ * may be inferred from HTTP 204, an empty queue, elapsed polling time, a response
74
+ * body, or a local scheduler setting — those are exactly the proxies that made
75
+ * "nothing to do" ambiguous in the first place.
76
+ *
77
+ * `unknown` means the verdict is UNEVALUABLE — the header was absent (an older
78
+ * backend), unrecognized, or the server could not read its own durable source.
79
+ * It is not a claim that the dispatcher is absent.
80
+ *
81
+ * BAPI-997 (T4): derived from the runtime array, same rationale as
82
+ * {@link EXECUTOR_JOB_TYPE_VALUES} above. `http-client.ts`'s
83
+ * `parseReconcilerLivenessHeader` admits values from this projection rather
84
+ * than maintaining an independent literal set.
85
+ */
86
+ export const RECONCILER_LIVENESS_VALUES = ["fresh", "stale", "never_seen", "unknown"];
1
87
  /** Normalize either heartbeat response form into the structured shape. */
2
88
  export function normalizeHeartbeatResult(response) {
3
89
  return typeof response === "string" ? { outcome: response } : response;