@bridge_gpt/mcp-server 0.2.67 → 0.2.69

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 (127) hide show
  1. package/README.md +272 -51
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +1362 -0
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-settings.js +102 -2
  9. package/build/cli-help.js +27 -0
  10. package/build/codex-home.js +9 -1
  11. package/build/codex-launcher-pin.js +598 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +9 -9
  15. package/build/conductor/bridge-api-client.js +714 -0
  16. package/build/conductor/command-drift-production.js +92 -0
  17. package/build/conductor/command-drift.js +89 -0
  18. package/build/conductor/doctor.js +69 -26
  19. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  20. package/build/conductor/epic-implementer-cli.js +76 -22
  21. package/build/conductor/gate-discovery.js +890 -0
  22. package/build/conductor/install-doctor.js +15 -3
  23. package/build/conductor/json-output.js +63 -0
  24. package/build/conductor/readiness-cli.js +95 -10
  25. package/build/conductor/readiness-sections.js +369 -3
  26. package/build/conductor/readiness.js +211 -8
  27. package/build/conductor/recovery-cli.js +110 -4
  28. package/build/conductor/recovery-operations.js +99 -1
  29. package/build/connect-bitbucket-api.js +41 -6
  30. package/build/connect-bitbucket.js +106 -33
  31. package/build/connect-github-api.js +33 -14
  32. package/build/credential-store.js +207 -47
  33. package/build/docs.generated.js +57 -3
  34. package/build/doctor.js +198 -4
  35. package/build/drive-epic.js +45 -8
  36. package/build/epic-coherence-input.test-fixtures.js +4 -3
  37. package/build/epic-implementer-bin.js +1369 -25
  38. package/build/epic-workarounds-path.js +39 -0
  39. package/build/executor/cli.js +30 -0
  40. package/build/executor/completion-contract.js +20 -5
  41. package/build/executor/deps.js +4 -1
  42. package/build/executor/gate-qualification-job.js +499 -0
  43. package/build/executor/guarded-fast-forward-push.js +129 -0
  44. package/build/executor/http-client.js +43 -4
  45. package/build/executor/integration-job.js +122 -8
  46. package/build/executor/job-errors.js +8 -1
  47. package/build/executor/job-runner.js +589 -47
  48. package/build/executor/likely-tests.js +290 -0
  49. package/build/executor/live-worker-registry.js +1 -1
  50. package/build/executor/observation.js +31 -2
  51. package/build/executor/packaged-recipe.js +106 -0
  52. package/build/executor/permissions.js +115 -28
  53. package/build/executor/platform.js +1 -1
  54. package/build/executor/preflight.js +61 -12
  55. package/build/executor/process-containment.js +36 -12
  56. package/build/executor/process.js +15 -7
  57. package/build/executor/prompt-spec.js +33 -1
  58. package/build/executor/recently-failed-tests-file.js +71 -0
  59. package/build/executor/reconcile-overlap-job.js +41 -23
  60. package/build/executor/required-verification.js +1 -1
  61. package/build/executor/required-work-ledger.js +105 -17
  62. package/build/executor/retirement.js +69 -0
  63. package/build/executor/runner.js +114 -11
  64. package/build/executor/test-clock.js +15 -0
  65. package/build/executor/types.js +32 -0
  66. package/build/executor/viewer-tabs.js +58 -8
  67. package/build/executor/worker-command.js +23 -7
  68. package/build/executor/worker-diagnostics.js +37 -0
  69. package/build/executor/worker-finalization.js +31 -5
  70. package/build/executor/worker-guard-hook.js +179 -25
  71. package/build/executor/worker-log.js +4 -3
  72. package/build/executor/worker-verification-guidance.js +37 -0
  73. package/build/executor/worktree.js +88 -11
  74. package/build/first-build-advisory.js +97 -0
  75. package/build/generated-assets-advisory.js +14 -0
  76. package/build/generated-assets-catalog.js +66 -0
  77. package/build/generated-assets-context.js +46 -0
  78. package/build/generated-assets-dependencies.js +26 -0
  79. package/build/generated-assets-doctor.js +50 -0
  80. package/build/generated-assets-health.js +65 -0
  81. package/build/generated-assets-host.js +17 -0
  82. package/build/generated-assets-inventory.js +17 -0
  83. package/build/generated-assets-paths.js +60 -0
  84. package/build/generated-assets-publisher.js +133 -0
  85. package/build/generated-assets-remedy.js +25 -0
  86. package/build/generated-assets-repair.js +72 -0
  87. package/build/generated-assets-session.js +71 -0
  88. package/build/generated-assets-test-support.js +35 -0
  89. package/build/index.js +1109 -695
  90. package/build/init.js +54 -177
  91. package/build/install-bridge-conductor-gate.js +1372 -0
  92. package/build/install-bridge-conductor.js +289 -36
  93. package/build/install-bridge.js +722 -252
  94. package/build/launcher-config-inspection.js +184 -19
  95. package/build/mcp-host-targets.js +4 -0
  96. package/build/mcp-identity.js +7 -0
  97. package/build/mcp-install-state.js +50 -4
  98. package/build/merge-workflow-install.js +3 -4
  99. package/build/pipeline-utils.js +6 -2
  100. package/build/pipelines.generated.js +22 -142
  101. package/build/plan-execution-ledger.js +2 -32
  102. package/build/plane/manifest.js +16 -1
  103. package/build/plane/preflight.js +54 -1
  104. package/build/printed-remedy-commands.js +16 -1
  105. package/build/readme.generated.js +1 -1
  106. package/build/repair-assets.js +93 -0
  107. package/build/run-unit-tests-launcher.js +130 -49
  108. package/build/secret-redaction.js +33 -0
  109. package/build/session-upgrade-notice.js +86 -0
  110. package/build/setup-epic.js +108 -3
  111. package/build/start-tickets-prereqs.js +70 -16
  112. package/build/start-tickets.js +44 -5
  113. package/build/tool-call-advisory.js +162 -0
  114. package/build/tool-surface-gating.js +32 -57
  115. package/build/update-check.js +97 -16
  116. package/build/update-status.js +61 -71
  117. package/build/upgrade-advice.js +15 -11
  118. package/build/upgrade-cli.js +398 -17
  119. package/build/version.generated.js +3 -3
  120. package/build/worker-guard-hook-bin.js +3 -2
  121. package/build/worker-guard-hook-main.js +1 -1
  122. package/build/worktree-core.js +15 -2
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  124. package/docs/EPIC-IMPLEMENTER.md +68 -2
  125. package/package.json +3 -3
  126. package/pipelines/greenfield-setup.json +4 -128
  127. package/pipelines/implement-ticket.json +3 -2
@@ -875,7 +875,33 @@ export function createRequiredWorkLedger(init) {
875
875
  if (!liveOptional.includes(tid))
876
876
  liveOptional.push(tid);
877
877
  const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
878
- return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
878
+ const bashBgIds = [];
879
+ let onlyBashBg = true;
880
+ for (const o of obligations.values()) {
881
+ if (!isLive(o) || !o.required)
882
+ continue;
883
+ if (o.kind === "bash_bg")
884
+ bashBgIds.push(o.taskId ?? o.toolUseId);
885
+ else
886
+ onlyBashBg = false;
887
+ }
888
+ for (const tid of snap.required) {
889
+ const o = findByTaskId(tid);
890
+ if (!o || o.kind !== "bash_bg")
891
+ onlyBashBg = false;
892
+ else if (!bashBgIds.includes(o.taskId ?? o.toolUseId))
893
+ bashBgIds.push(o.taskId ?? o.toolUseId);
894
+ }
895
+ return {
896
+ readyToClose,
897
+ liveRequired,
898
+ liveOptional,
899
+ activitySeq,
900
+ latestIsResult,
901
+ turnOpen,
902
+ finalResultNoOpenTurn: resultsSeen > 0 && latestIsResult && !turnOpen,
903
+ liveRequiredBashBg: onlyBashBg ? bashBgIds : [],
904
+ };
879
905
  };
880
906
  // One predicate for "a required task is still pending", shared by the envelope
881
907
  // and the observe-only query so the two cannot drift apart.
@@ -1003,6 +1029,13 @@ export function createRequiredWorkLedger(init) {
1003
1029
  }
1004
1030
  /** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
1005
1031
  export const CLOSURE_DEBOUNCE_MS = 1500;
1032
+ /**
1033
+ * The absolute post-result window (BAPI-1395). Once a final result is in, no
1034
+ * turn is open, and the only live required work is the worker's own background
1035
+ * Bash, stdin is closed at most this long after the window opens — whatever the
1036
+ * worker does meanwhile. Normal early closure is unchanged.
1037
+ */
1038
+ export const COMPLETION_WINDOW_MS = 30 * 60 * 1000;
1006
1039
  /**
1007
1040
  * Hold stdin open until the closure rule holds for a full debounce window, then
1008
1041
  * close it exactly once.
@@ -1017,6 +1050,13 @@ export const CLOSURE_DEBOUNCE_MS = 1500;
1017
1050
  * optional watchers are live. It is never sent while required work is live and
1018
1051
  * never asks for a broad stop — cancelling required work is not the executor's
1019
1052
  * call.
1053
+ *
1054
+ * The completion window (BAPI-1395): when the final result is in and only
1055
+ * required `bash_bg` work is live, one fixed end `min(opened + window, deadline)`
1056
+ * is set. A new worker turn cancels it; background activity does not. The one
1057
+ * nudge (shared with the optional-watcher nudge, so a job gets at most one)
1058
+ * asks the worker to stop only its own tasks that are merely waiting. Closing
1059
+ * at the end resolves nothing: the live tasks stay unresolved in the ledger.
1020
1060
  */
1021
1061
  export function createClosureSupervisor(opts) {
1022
1062
  const { ledger, deps } = opts;
@@ -1029,6 +1069,14 @@ export function createClosureSupervisor(opts) {
1029
1069
  let debounceTimer = null;
1030
1070
  let armedSeq = -1;
1031
1071
  let pending = Promise.resolve();
1072
+ const windowMs = opts.windowMs ?? COMPLETION_WINDOW_MS;
1073
+ let windowOpened = false;
1074
+ let windowTimer = null;
1075
+ const clearWindow = () => {
1076
+ if (windowTimer !== null)
1077
+ deps.clearTimer(windowTimer);
1078
+ windowTimer = null;
1079
+ };
1032
1080
  const clearDebounce = () => {
1033
1081
  if (debounceTimer !== null)
1034
1082
  deps.clearTimer(debounceTimer);
@@ -1041,6 +1089,7 @@ export function createClosureSupervisor(opts) {
1041
1089
  closed = true;
1042
1090
  closedReason = reason;
1043
1091
  clearDebounce();
1092
+ clearWindow();
1044
1093
  if (deadlineTimer !== null)
1045
1094
  deps.clearTimer(deadlineTimer);
1046
1095
  deadlineTimer = null;
@@ -1054,6 +1103,48 @@ export function createClosureSupervisor(opts) {
1054
1103
  // then reports `stdout_not_read_to_eof`, never success.
1055
1104
  }
1056
1105
  });
1106
+ if (reason === "window") {
1107
+ try {
1108
+ opts.onWindowExpiry?.();
1109
+ }
1110
+ catch {
1111
+ /* the caller's termination scheduling must never break closure */
1112
+ }
1113
+ }
1114
+ };
1115
+ const sendNudge = (line, onUndelivered) => {
1116
+ nudged = true;
1117
+ nudgeAwaitingReply = true;
1118
+ pending = pending.then(async () => {
1119
+ let ok = false;
1120
+ try {
1121
+ ok = (await opts.writeMessage(line)).ok;
1122
+ }
1123
+ catch {
1124
+ ok = false;
1125
+ }
1126
+ if (!ok && !closed && !disposed)
1127
+ onUndelivered();
1128
+ });
1129
+ };
1130
+ const openWindow = (bashBgIds) => {
1131
+ windowOpened = true;
1132
+ const now = deps.now();
1133
+ const windowEnd = Math.min(now + windowMs, opts.deadlineAtMs);
1134
+ // The deadline timer already closes at `deadlineAtMs`; a second timer there
1135
+ // would only race it, so the window schedules its own close only when it
1136
+ // ends strictly earlier.
1137
+ if (windowEnd < opts.deadlineAtMs) {
1138
+ windowTimer = deps.setTimer(() => {
1139
+ windowTimer = null;
1140
+ close("window");
1141
+ }, Math.max(0, windowEnd - now));
1142
+ }
1143
+ if (!nudged && opts.buildWindowNudge) {
1144
+ sendNudge(opts.buildWindowNudge([...bashBgIds]), () => {
1145
+ nudgeAwaitingReply = false;
1146
+ });
1147
+ }
1057
1148
  };
1058
1149
  let deadlineTimer = deps.setTimer(() => {
1059
1150
  deadlineTimer = null;
@@ -1065,30 +1156,26 @@ export function createClosureSupervisor(opts) {
1065
1156
  const s = ledger.closureState();
1066
1157
  if (armedSeq !== s.activitySeq)
1067
1158
  clearDebounce();
1159
+ if (s.turnOpen && windowOpened) {
1160
+ clearWindow();
1161
+ windowOpened = false;
1162
+ }
1068
1163
  if (!s.latestIsResult) {
1069
1164
  // The nudge was answered (or other activity began): the next result decides again.
1070
1165
  nudgeAwaitingReply = false;
1071
1166
  return;
1072
1167
  }
1168
+ if (!windowOpened && s.finalResultNoOpenTurn && s.liveRequiredBashBg.length > 0) {
1169
+ openWindow(s.liveRequiredBashBg);
1170
+ return;
1171
+ }
1073
1172
  if (!s.readyToClose)
1074
1173
  return;
1075
1174
  if (!nudged && s.liveOptional.length > 0) {
1076
- nudged = true;
1077
- nudgeAwaitingReply = true;
1078
- const line = opts.buildNudge([...s.liveOptional]);
1079
- pending = pending.then(async () => {
1080
- let ok = false;
1081
- try {
1082
- ok = (await opts.writeMessage(line)).ok;
1083
- }
1084
- catch {
1085
- ok = false;
1086
- }
1087
- // An undelivered nudge will never be answered: fall back to the rule.
1088
- if (!ok && !closed && !disposed) {
1089
- nudgeAwaitingReply = false;
1090
- onActivity();
1091
- }
1175
+ // An undelivered nudge will never be answered: fall back to the rule.
1176
+ sendNudge(opts.buildNudge([...s.liveOptional]), () => {
1177
+ nudgeAwaitingReply = false;
1178
+ onActivity();
1092
1179
  });
1093
1180
  return;
1094
1181
  }
@@ -1117,6 +1204,7 @@ export function createClosureSupervisor(opts) {
1117
1204
  settled: () => pending,
1118
1205
  dispose: () => {
1119
1206
  clearDebounce();
1207
+ clearWindow();
1120
1208
  if (deadlineTimer !== null)
1121
1209
  deps.clearTimer(deadlineTimer);
1122
1210
  deadlineTimer = null;
@@ -0,0 +1,69 @@
1
+ /** Process-local, pure retirement bookkeeping for a single-run executor. */
2
+ export const SUSPEND_GAP_SLACK_MS = 60_000;
3
+ export function initialRetirementState() {
4
+ return { unreachableMs: 0, idleMs: 0, definitiveReason: null, definitiveReads: 0, definitiveMs: 0, failedCallEvidence: false };
5
+ }
6
+ /** Bounds prevent accidental years-long values and unsafe number conversions. */
7
+ export function parseRetirementSetting(raw, defaultValue, maximum) {
8
+ if (raw === undefined)
9
+ return { value: defaultValue, warning: false };
10
+ if (!/^(0|[1-9][0-9]*)$/.test(raw))
11
+ return { value: defaultValue, warning: true };
12
+ const value = Number(raw);
13
+ return Number.isSafeInteger(value) && value <= maximum
14
+ ? { value, warning: false }
15
+ : { value: defaultValue, warning: true };
16
+ }
17
+ export function transitionRetirement(prior, cycle, config) {
18
+ const state = { ...prior };
19
+ const elapsed = cycle.sleep.completed && cycle.sleep.elapsedMs >= 0 &&
20
+ cycle.sleep.elapsedMs <= 3 * config.pollIntervalMs + SUSPEND_GAP_SLACK_MS
21
+ ? cycle.sleep.elapsedMs : 0;
22
+ const success = cycle.successfulServerCall || cycle.claimedJob || cycle.claimedAction || cycle.scopedRead === "ok";
23
+ const failure = cycle.failedServerCall || ["network", "timeout", "server", "malformed", "unexpected"].includes(cycle.scopedRead);
24
+ if (success) {
25
+ state.unreachableMs = 0;
26
+ state.failedCallEvidence = false;
27
+ }
28
+ else if (failure) {
29
+ state.failedCallEvidence = true;
30
+ }
31
+ if (cycle.claimedJob || cycle.claimedAction || cycle.activeCount > 0)
32
+ state.idleMs = 0;
33
+ else if ((cycle.claimOutcome === "none" || cycle.claimOutcome === "preflight-refused") && elapsed > 0)
34
+ state.idleMs += elapsed;
35
+ if (cycle.activeCount === 0 && !success && state.failedCallEvidence)
36
+ state.unreachableMs += elapsed;
37
+ if (cycle.scopedRead === "not-found" || cycle.scopedRead === "unauthorized") {
38
+ if (state.definitiveReason === cycle.scopedRead) {
39
+ state.definitiveReads += 1;
40
+ state.definitiveMs += elapsed;
41
+ }
42
+ else {
43
+ state.definitiveReason = cycle.scopedRead;
44
+ state.definitiveReads = 1;
45
+ state.definitiveMs = 0;
46
+ }
47
+ }
48
+ else if (cycle.scopedRead === "ok" || cycle.scopedRead === "unavailable" || cycle.scopedRead === "network" || cycle.scopedRead === "timeout" || cycle.scopedRead === "server" || cycle.scopedRead === "malformed" || cycle.scopedRead === "unexpected") {
49
+ state.definitiveReason = null;
50
+ state.definitiveReads = 0;
51
+ state.definitiveMs = 0;
52
+ }
53
+ else if (state.definitiveReason !== null) {
54
+ state.definitiveMs += elapsed;
55
+ }
56
+ let decision = { kind: "continue" };
57
+ if (cycle.activeCount === 0) {
58
+ if (state.definitiveReason !== null && state.definitiveReads >= 3 && state.definitiveMs >= 5 * 60_000) {
59
+ decision = { kind: "retire", reason: state.definitiveReason === "not-found" ? "run_not_found" : "run_unauthorized", awakeMinutes: Math.floor(state.definitiveMs / 60_000) };
60
+ }
61
+ else if (config.unreachableMinutes > 0 && state.unreachableMs >= config.unreachableMinutes * 60_000) {
62
+ decision = { kind: "retire", reason: "server_unreachable", awakeMinutes: Math.floor(state.unreachableMs / 60_000) };
63
+ }
64
+ else if (config.idleHours > 0 && state.idleMs >= config.idleHours * 3_600_000) {
65
+ decision = { kind: "retire", reason: "idle", awakeMinutes: Math.floor(state.idleMs / 60_000) };
66
+ }
67
+ }
68
+ return { state, decision };
69
+ }
@@ -18,6 +18,7 @@ import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
18
18
  import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
19
19
  import { validateBranchName } from "../base-ref.js";
20
20
  import { isTerminalEpicRunStatus } from "../epic-run-status.js";
21
+ import { initialRetirementState, parseRetirementSetting, transitionRetirement } from "./retirement.js";
21
22
  import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
22
23
  /**
23
24
  * Signals that request a graceful executor shutdown (BAPI-828).
@@ -109,7 +110,10 @@ export function formatProcessHeartbeatRecoveredDiagnostic() {
109
110
  */
110
111
  export function formatRunTerminalReadFailureDiagnostic(reason) {
111
112
  return (`executor: could not read the scoped run's state (reason=${reason}); ` +
112
- "staying up and claiming until it can be read.");
113
+ "continuing to poll during the bounded retirement window.");
114
+ }
115
+ export function formatExecutorRetirement(reason, awakeMinutes) {
116
+ return `executor: retiring (reason=${reason}, awake_minutes=${awakeMinutes}); restart with drive-epic <EPIC> or the printed manual command.`;
113
117
  }
114
118
  /** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
115
119
  function correctionTimeoutSeconds(deadlineAt, nowMs) {
@@ -227,6 +231,20 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
227
231
  // Suppresses the missing-read-capability advisory to one line per
228
232
  // invocation, mirroring the dispatcher-liveness suppression above.
229
233
  let missingReadCapabilityWarned = false;
234
+ let cycleSuccessfulCall = false;
235
+ let cycleFailedCall = false;
236
+ let cycleClaimedJob = false;
237
+ let cycleClaimedAction = false;
238
+ let cycleClaimOutcome = "not-attempted";
239
+ let heartbeatDelivered = false;
240
+ let pendingSleep = { completed: false, elapsedMs: 0 };
241
+ const unreachableSetting = parseRetirementSetting(deps.env.BAPI_EXECUTOR_UNREACHABLE_RETIRE_MINUTES, 60, 10_080);
242
+ const idleSetting = parseRetirementSetting(deps.env.BAPI_EXECUTOR_IDLE_RETIRE_HOURS, 24, 8_760);
243
+ if (unreachableSetting.warning)
244
+ deps.errorLog("executor: invalid BAPI_EXECUTOR_UNREACHABLE_RETIRE_MINUTES; using 60");
245
+ if (idleSetting.warning)
246
+ deps.errorLog("executor: invalid BAPI_EXECUTOR_IDLE_RETIRE_HOURS; using 24");
247
+ let retirementState = initialRetirementState();
230
248
  /**
231
249
  * Announce a dispatcher availability CHANGE, and only a change.
232
250
  *
@@ -323,9 +341,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
323
341
  try {
324
342
  const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
325
343
  if (!result.ok) {
326
- deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
344
+ cycleFailedCall = true;
345
+ if (shouldReport(result.reason, lastCompletionReadFailure, lastCompletionReadFailureAt)) {
346
+ deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
347
+ lastCompletionReadFailureAt = deps.now();
348
+ }
349
+ lastCompletionReadFailure = result.reason;
327
350
  return;
328
351
  }
352
+ cycleSuccessfulCall = true;
353
+ lastCompletionReadFailure = null;
329
354
  if (result.state.status !== "done")
330
355
  return;
331
356
  const featureBranch = result.state.featureBranch;
@@ -369,6 +394,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
369
394
  // The last failure reason reported, so a long outage logs once per change
370
395
  // rather than once per poll.
371
396
  let lastRunStateFailure = null;
397
+ let lastRunStateFailureAt = -Infinity;
398
+ let lastPreflightRefusal = null;
399
+ let lastPreflightRefusalAt = -Infinity;
400
+ let lastCompletionReadFailure = null;
401
+ let lastCompletionReadFailureAt = -Infinity;
402
+ const shouldReport = (value, previous, at) => value !== previous || deps.now() - at >= 15 * 60_000;
372
403
  /**
373
404
  * Is the scoped run finished? Called only between claim cycles while this
374
405
  * process holds NO job. The wind-down observer runs FIRST, so a run that just
@@ -378,17 +409,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
378
409
  */
379
410
  const scopedRunIsTerminal = async () => {
380
411
  if (!exitWhenRunTerminal || observedEpicRunId === null)
381
- return false;
412
+ return { terminal: false, read: "none" };
382
413
  await observeEpicRunCompletion();
383
414
  const readState = httpClient.readEpicRunState;
384
415
  if (typeof readState !== "function")
385
- return false;
416
+ return { terminal: false, read: "unavailable" };
386
417
  let reason = null;
387
418
  let terminal = false;
388
419
  try {
389
420
  const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
390
- if (result.ok)
421
+ if (result.ok) {
422
+ cycleSuccessfulCall = true;
391
423
  terminal = isTerminalEpicRunStatus(result.state.status);
424
+ }
392
425
  else
393
426
  reason = result.reason;
394
427
  }
@@ -396,14 +429,17 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
396
429
  reason = "unexpected";
397
430
  }
398
431
  if (reason !== null) {
399
- if (reason !== lastRunStateFailure) {
432
+ if (shouldReport(reason, lastRunStateFailure, lastRunStateFailureAt)) {
400
433
  deps.errorLog(formatRunTerminalReadFailureDiagnostic(reason));
434
+ lastRunStateFailureAt = deps.now();
401
435
  }
402
436
  lastRunStateFailure = reason;
403
- return false;
437
+ cycleFailedCall = true;
438
+ const read = reason === "not-found" || reason === "unauthorized" || reason === "network" || reason === "timeout" || reason === "server" || reason === "malformed" ? reason : "unexpected";
439
+ return { terminal: false, read };
404
440
  }
405
441
  lastRunStateFailure = null;
406
- return terminal;
442
+ return { terminal, read: "ok" };
407
443
  };
408
444
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
409
445
  // the claim loop below — that scope is the whole feature. A cache created inside
@@ -623,6 +659,8 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
623
659
  deps.errorLog(formatProcessHeartbeatRecoveredDiagnostic());
624
660
  }
625
661
  lastFailed = outcome === "failed";
662
+ if (outcome === "delivered")
663
+ heartbeatDelivered = true;
626
664
  if (heartbeatStopRequested || shutdownRequested)
627
665
  return;
628
666
  // Woken by the same waiter list a real shutdown fires, so process exit does
@@ -674,6 +712,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
674
712
  }
675
713
  }
676
714
  function dispatch(job, report) {
715
+ cycleClaimedJob = true;
677
716
  const promise = (async () => {
678
717
  try {
679
718
  // `undefined` for the behavior seams (the job runner defaults them), then
@@ -711,6 +750,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
711
750
  },
712
751
  }));
713
752
  function dispatchIntegrationAction(action) {
753
+ cycleClaimedAction = true;
714
754
  const key = --integrationSeq;
715
755
  const promise = (async () => {
716
756
  try {
@@ -747,10 +787,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
747
787
  : { repo_wide: true }),
748
788
  });
749
789
  if (result.kind === "claimed") {
790
+ cycleSuccessfulCall = true;
750
791
  dispatchIntegrationAction(result.action);
751
792
  continue;
752
793
  }
753
794
  if (result.kind === "fatal") {
795
+ cycleFailedCall = true;
754
796
  if (result.authHttpStatus === 401 || result.authHttpStatus === 403) {
755
797
  control.reportAuthFatal?.({
756
798
  repoName,
@@ -766,6 +808,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
766
808
  return;
767
809
  }
768
810
  if (result.kind === "retryable") {
811
+ cycleFailedCall = true;
769
812
  deps.errorLog(`executor integration action claim retryable: ${result.error}`);
770
813
  }
771
814
  break;
@@ -789,11 +832,32 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
789
832
  }
790
833
  }
791
834
  }
835
+ const rawParentPid = deps.env.BAPI_EXECUTOR_PARENT_WATCH_PID;
836
+ if (rawParentPid !== undefined) {
837
+ const pid = Number(rawParentPid);
838
+ if (!/^[1-9][0-9]*$/.test(rawParentPid) || !Number.isSafeInteger(pid)) {
839
+ deps.errorLog("executor: invalid BAPI_EXECUTOR_PARENT_WATCH_PID; ignoring parent watch");
840
+ }
841
+ else if (seams.watchParent) {
842
+ unsubscribers.push(seams.watchParent(pid, () => {
843
+ if (shutdownRequested)
844
+ return;
845
+ deps.errorLog(`executor: watched parent process ${pid} exited; shutting down.`);
846
+ beginShutdown();
847
+ }));
848
+ }
849
+ }
792
850
  // Started after the FIRST successful preflight and joined before the runner
793
851
  // returns. Declared here so the `finally` below can await it.
794
852
  let processHeartbeats = null;
795
853
  try {
796
854
  for (;;) {
855
+ cycleSuccessfulCall = heartbeatDelivered;
856
+ heartbeatDelivered = false;
857
+ cycleFailedCall = false;
858
+ cycleClaimedJob = false;
859
+ cycleClaimedAction = false;
860
+ cycleClaimOutcome = "not-attempted";
797
861
  const report = await collectPreflight(options, deps, preflightSeams);
798
862
  latestCorrectionQualification = report.correctionIntentQualified === true &&
799
863
  report.correctionQualificationEvidence?.hook_installed === true &&
@@ -826,7 +890,13 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
826
890
  deps.errorLog(`executor preflight: ${notice}`);
827
891
  }
828
892
  if (!report.ok) {
829
- deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
893
+ cycleClaimOutcome = "preflight-refused";
894
+ const finding = report.fatalFindings.join("; ");
895
+ if (shouldReport(finding, lastPreflightRefusal, lastPreflightRefusalAt)) {
896
+ deps.errorLog(`executor preflight refused claiming: ${finding}`);
897
+ lastPreflightRefusalAt = deps.now();
898
+ }
899
+ lastPreflightRefusal = finding;
830
900
  }
831
901
  else if (shutdownRequested) {
832
902
  // Shutdown arrived during preflight. Claiming now would take on work this
@@ -835,6 +905,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
835
905
  deps.errorLog("executor: shutdown requested; not claiming new work");
836
906
  }
837
907
  else {
908
+ lastPreflightRefusal = null;
838
909
  let claiming = true;
839
910
  while (claiming && !shutdownRequested && options.maxConcurrent - active.size > 0) {
840
911
  const freeSlots = options.maxConcurrent - active.size;
@@ -844,10 +915,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
844
915
  // ahead of the branch below. An error result carries no verdict and is
845
916
  // not evidence about the dispatcher, so it is deliberately not observed.
846
917
  if (result.kind === "claimed" || result.kind === "none") {
918
+ cycleSuccessfulCall = true;
847
919
  observeDispatcherLiveness(result.reconcilerLiveness);
848
920
  await observeEpicRunCompletion();
849
921
  }
850
922
  if (result.kind === "claimed") {
923
+ cycleClaimOutcome = "claimed";
851
924
  const currentEpicRunId = result.job.epic_run_id ?? null;
852
925
  if (lastClaimedEpicRunId !== null &&
853
926
  currentEpicRunId !== null &&
@@ -876,9 +949,13 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
876
949
  dispatch(result.job, report);
877
950
  }
878
951
  else if (result.kind === "none") {
952
+ if (cycleClaimOutcome !== "claimed")
953
+ cycleClaimOutcome = "none";
879
954
  claiming = false;
880
955
  }
881
956
  else {
957
+ cycleClaimOutcome = "error";
958
+ cycleFailedCall = true;
882
959
  deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
883
960
  claiming = false;
884
961
  // BAPI-1021 (AC-5): a categorized 401/403 fatal claim halts the
@@ -919,11 +996,35 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
919
996
  // keeps refusing still holds a sleep assertion and a record, and must not
920
997
  // outlive its run. `break` takes the SAME path a `--once` pass does:
921
998
  // drain, stop the heartbeat loop, unsubscribe, exit 0.
922
- if (!shutdownRequested && active.size === 0 && (await scopedRunIsTerminal())) {
999
+ const scoped = !shutdownRequested && active.size === 0
1000
+ ? await scopedRunIsTerminal()
1001
+ : { terminal: false, read: "none" };
1002
+ if (scoped.terminal) {
923
1003
  runTerminal = true;
924
1004
  deps.errorLog(`executor: run ${observedEpicRunId} is finished and no job is held; exiting.`);
925
1005
  break;
926
1006
  }
1007
+ if (exitWhenRunTerminal && !shutdownRequested) {
1008
+ const transition = transitionRetirement(retirementState, {
1009
+ sleep: pendingSleep,
1010
+ scopedRead: scoped.read,
1011
+ successfulServerCall: cycleSuccessfulCall,
1012
+ failedServerCall: cycleFailedCall,
1013
+ claimedJob: cycleClaimedJob,
1014
+ claimedAction: cycleClaimedAction,
1015
+ claimOutcome: cycleClaimOutcome,
1016
+ activeCount: active.size,
1017
+ }, {
1018
+ pollIntervalMs: options.pollIntervalMs,
1019
+ unreachableMinutes: unreachableSetting.value,
1020
+ idleHours: idleSetting.value,
1021
+ });
1022
+ retirementState = transition.state;
1023
+ if (transition.decision.kind === "retire") {
1024
+ deps.errorLog(formatExecutorRetirement(transition.decision.reason, transition.decision.awakeMinutes));
1025
+ break;
1026
+ }
1027
+ }
927
1028
  if (options.once || shutdownRequested)
928
1029
  break;
929
1030
  // Sample the clock immediately around the ACTUAL sleep, so the measured gap
@@ -932,7 +1033,9 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
932
1033
  const wake = await sleepUntilPollOrShutdown(options.pollIntervalMs);
933
1034
  if (wake === "shutdown")
934
1035
  break;
935
- await reportSuspendGap(deps.now() - sleepStartedAt);
1036
+ const elapsedMs = deps.now() - sleepStartedAt;
1037
+ pendingSleep = { completed: true, elapsedMs };
1038
+ await reportSuspendGap(elapsedMs);
936
1039
  }
937
1040
  // Drain every dispatched job — on the `once` path and the shutdown path alike.
938
1041
  // Terminated workers still have to finish supervision, flush final telemetry,
@@ -250,6 +250,21 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
250
250
  }
251
251
  };
252
252
  }
253
+ // Explicit safe answers for the new preparation reservation and routing reads.
254
+ // Existing fixtures still own every legacy Git invocation.
255
+ const fixtureRun = merged.runCommand;
256
+ merged.runCommand = async (file, args, options) => {
257
+ if (file === "git" && args.join(" ") === "rev-parse --path-format=absolute --git-common-dir") {
258
+ return { stdout: overrides.cwd ? `${overrides.cwd}/.git` : "/fake-common.git", stderr: "", exitCode: 0 };
259
+ }
260
+ if (file === "git" && args[0] === "config" && args[1] === "--get-all") {
261
+ return args[2] === "remote.origin.url" ? { stdout: "/fake-origin", stderr: "", exitCode: 0 }
262
+ : { stdout: "", stderr: "", exitCode: 1 };
263
+ }
264
+ if (file === "git" && args[0] === "config" && args[1] === "--local")
265
+ return { stdout: "", stderr: "", exitCode: 0 };
266
+ return fixtureRun(file, args, options);
267
+ };
253
268
  return merged;
254
269
  }
255
270
  /** Sensible default executor options for tests. */
@@ -24,6 +24,11 @@ export const EXECUTOR_JOB_TYPE_VALUES = [
24
24
  // this union does not carry fails as `ContractError.UnsupportedJobType`,
25
25
  // which is the single most likely way this ticket could ship half-landed.
26
26
  "ci_rerun",
27
+ // BAPI-1391 — the run-less gate qualification: run a repository owner's
28
+ // confirmed local gate commands once on a clean, detached checkout of the
29
+ // default branch. Deterministic like `ci_rerun` (no worker, no worktree), and
30
+ // implemented in `gate-qualification-job.ts`.
31
+ "gate_qualification",
27
32
  ];
28
33
  /**
29
34
  * Server-authoritative completion classification (T2 `ExecutorClassification`,
@@ -42,6 +47,12 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
42
47
  "no_progress",
43
48
  "lease_expired",
44
49
  ];
50
+ /**
51
+ * BAPI-1391 — the capability a REPOSITORY-WIDE executor declares when it can run
52
+ * a run-less `gate_qualification` job. Mirrors the server's stamped
53
+ * `required_capabilities`, so only a claim declaring it can take such a job.
54
+ */
55
+ export const GATE_QUALIFICATION_CAPABILITY = "gate_qualification.v1";
45
56
  /**
46
57
  * BAPI-1311 — the capability an executor declares when it can run a FOCUSED
47
58
  * merge-stage `rebase`: validate `payload.focused_rebase` before any worker
@@ -73,6 +84,27 @@ export const TERMINAL_CAPACITY_CAUSE_VALUES = [
73
84
  "quota_exhausted",
74
85
  "provider_capacity_unavailable",
75
86
  ];
87
+ /**
88
+ * BAPI-1392 — the customized command names carried on a claimed job's payload.
89
+ *
90
+ * Returns an empty list for an absent or malformed value, and the usable
91
+ * non-empty string names (first-seen order, de-duplicated) otherwise. Never
92
+ * mutates the payload and never throws. Shape-only: catalog membership is
93
+ * checked by the packaged-recipe resolver against this executor's own bundled
94
+ * catalog.
95
+ */
96
+ export function readCustomizedCommands(payload) {
97
+ const raw = payload && typeof payload === "object" ? payload.customized_commands : undefined;
98
+ if (!Array.isArray(raw))
99
+ return [];
100
+ const names = [];
101
+ for (const entry of raw) {
102
+ if (typeof entry === "string" && entry.trim().length > 0 && !names.includes(entry)) {
103
+ names.push(entry);
104
+ }
105
+ }
106
+ return names;
107
+ }
76
108
  /**
77
109
  * Outcome of a heartbeat/complete/fail mutation (T2 status mapping):
78
110
  * - `updated` 2xx `{ ok: true }`