@bridge_gpt/mcp-server 0.2.67 → 0.2.68

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 (114) hide show
  1. package/README.md +178 -15
  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 +829 -0
  7. package/build/claude-settings.js +18 -0
  8. package/build/cli-help.js +21 -0
  9. package/build/command-assets-doctor.js +24 -70
  10. package/build/command-provisioning.js +29 -5
  11. package/build/commands.generated.js +8 -8
  12. package/build/conductor/bridge-api-client.js +714 -0
  13. package/build/conductor/command-drift-production.js +92 -0
  14. package/build/conductor/command-drift.js +89 -0
  15. package/build/conductor/doctor.js +69 -26
  16. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  17. package/build/conductor/epic-implementer-cli.js +76 -22
  18. package/build/conductor/gate-discovery.js +890 -0
  19. package/build/conductor/install-doctor.js +15 -3
  20. package/build/conductor/json-output.js +63 -0
  21. package/build/conductor/readiness-cli.js +95 -10
  22. package/build/conductor/readiness-sections.js +369 -3
  23. package/build/conductor/readiness.js +211 -8
  24. package/build/conductor/recovery-cli.js +110 -4
  25. package/build/conductor/recovery-operations.js +99 -1
  26. package/build/connect-bitbucket-api.js +41 -6
  27. package/build/connect-bitbucket.js +106 -33
  28. package/build/credential-store.js +207 -47
  29. package/build/docs.generated.js +57 -3
  30. package/build/doctor.js +92 -1
  31. package/build/drive-epic.js +45 -8
  32. package/build/epic-coherence-input.test-fixtures.js +4 -3
  33. package/build/epic-implementer-bin.js +1374 -25
  34. package/build/epic-workarounds-path.js +39 -0
  35. package/build/executor/cli.js +30 -0
  36. package/build/executor/completion-contract.js +18 -5
  37. package/build/executor/deps.js +4 -1
  38. package/build/executor/gate-qualification-job.js +499 -0
  39. package/build/executor/guarded-fast-forward-push.js +129 -0
  40. package/build/executor/integration-job.js +122 -8
  41. package/build/executor/job-errors.js +8 -1
  42. package/build/executor/job-runner.js +589 -47
  43. package/build/executor/likely-tests.js +290 -0
  44. package/build/executor/live-worker-registry.js +1 -1
  45. package/build/executor/observation.js +31 -2
  46. package/build/executor/packaged-recipe.js +106 -0
  47. package/build/executor/permissions.js +115 -28
  48. package/build/executor/platform.js +1 -1
  49. package/build/executor/preflight.js +61 -12
  50. package/build/executor/process-containment.js +36 -12
  51. package/build/executor/process.js +15 -7
  52. package/build/executor/prompt-spec.js +33 -1
  53. package/build/executor/recently-failed-tests-file.js +71 -0
  54. package/build/executor/reconcile-overlap-job.js +41 -23
  55. package/build/executor/required-verification.js +1 -1
  56. package/build/executor/required-work-ledger.js +105 -17
  57. package/build/executor/retirement.js +69 -0
  58. package/build/executor/runner.js +114 -11
  59. package/build/executor/test-clock.js +15 -0
  60. package/build/executor/types.js +32 -0
  61. package/build/executor/viewer-tabs.js +58 -8
  62. package/build/executor/worker-command.js +23 -7
  63. package/build/executor/worker-diagnostics.js +37 -0
  64. package/build/executor/worker-finalization.js +31 -5
  65. package/build/executor/worker-guard-hook.js +179 -25
  66. package/build/executor/worker-log.js +4 -3
  67. package/build/executor/worker-verification-guidance.js +37 -0
  68. package/build/executor/worktree.js +88 -11
  69. package/build/first-build-advisory.js +97 -0
  70. package/build/generated-assets-advisory.js +14 -0
  71. package/build/generated-assets-catalog.js +66 -0
  72. package/build/generated-assets-context.js +46 -0
  73. package/build/generated-assets-dependencies.js +26 -0
  74. package/build/generated-assets-doctor.js +50 -0
  75. package/build/generated-assets-health.js +65 -0
  76. package/build/generated-assets-host.js +17 -0
  77. package/build/generated-assets-inventory.js +17 -0
  78. package/build/generated-assets-paths.js +60 -0
  79. package/build/generated-assets-publisher.js +133 -0
  80. package/build/generated-assets-remedy.js +25 -0
  81. package/build/generated-assets-repair.js +72 -0
  82. package/build/generated-assets-session.js +71 -0
  83. package/build/generated-assets-test-support.js +35 -0
  84. package/build/index.js +964 -768
  85. package/build/init.js +54 -177
  86. package/build/install-bridge-conductor-gate.js +1372 -0
  87. package/build/install-bridge-conductor.js +289 -36
  88. package/build/install-bridge.js +544 -37
  89. package/build/mcp-install-state.js +50 -4
  90. package/build/merge-workflow-install.js +3 -4
  91. package/build/pipeline-utils.js +6 -2
  92. package/build/pipelines.generated.js +22 -142
  93. package/build/plan-execution-ledger.js +2 -32
  94. package/build/plane/manifest.js +16 -1
  95. package/build/plane/preflight.js +54 -1
  96. package/build/printed-remedy-commands.js +16 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/repair-assets.js +93 -0
  99. package/build/run-unit-tests-launcher.js +130 -49
  100. package/build/secret-redaction.js +33 -0
  101. package/build/setup-epic.js +108 -3
  102. package/build/start-tickets-prereqs.js +70 -16
  103. package/build/start-tickets.js +29 -5
  104. package/build/tool-surface-gating.js +36 -0
  105. package/build/upgrade-cli.js +55 -1
  106. package/build/version.generated.js +3 -3
  107. package/build/worker-guard-hook-bin.js +3 -2
  108. package/build/worker-guard-hook-main.js +1 -1
  109. package/build/worktree-core.js +15 -2
  110. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  111. package/docs/EPIC-IMPLEMENTER.md +68 -2
  112. package/package.json +3 -3
  113. package/pipelines/greenfield-setup.json +4 -128
  114. package/pipelines/implement-ticket.json +3 -2
@@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process";
12
12
  import { defaultResolveBinPath } from "../start-tickets-conductor.js";
13
13
  import { CORRECTION_INTENT_CAPABILITY } from "./correction-dispatch.js";
14
14
  import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
15
- import { FOCUSED_REBASE_CAPABILITY, SCOPE_WAIT_CAPABILITY } from "./types.js";
15
+ import { FOCUSED_REBASE_CAPABILITY, GATE_QUALIFICATION_CAPABILITY, SCOPE_WAIT_CAPABILITY, } from "./types.js";
16
16
  /**
17
17
  * Environment override for the deny probe's headless timeout (BAPI-722).
18
18
  *
@@ -68,7 +68,7 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
68
68
  * hang, a control refusal, and a degraded guess all report `layer: "none"` for the
69
69
  * posture they happened under, and none of them is cached.
70
70
  */
71
- function isCacheableDenyResult(result) {
71
+ function isVerifiedDenyResult(result) {
72
72
  if (result.enforced !== true || result.layer === "none")
73
73
  return false;
74
74
  const postures = result.postures;
@@ -76,17 +76,38 @@ function isCacheableDenyResult(result) {
76
76
  return true;
77
77
  return postures.every((p) => p.enforced === true && p.layer !== "none");
78
78
  }
79
+ function isCacheableDenyResult(result) {
80
+ return result.degraded !== true && isVerifiedDenyResult(result);
81
+ }
79
82
  /** Create a fresh, empty executor-scoped deny-probe cache. */
80
83
  export function createDenyProbeCache() {
81
84
  let cached;
85
+ let failedProbe = false;
86
+ let retryAt = 0;
87
+ let failures = 0;
88
+ const schedule = (nowMs) => {
89
+ failures += 1;
90
+ retryAt = nowMs + Math.min(30, 2 ** Math.min(failures - 1, 5)) * 60_000;
91
+ };
82
92
  return {
83
- get: () => cached,
84
- set: (result) => {
85
- if (isCacheableDenyResult(result))
86
- cached = result;
93
+ get: (nowMs = Date.now()) => cached && (isCacheableDenyResult(cached) || nowMs < retryAt) ? cached : undefined,
94
+ failedProbeDue: (nowMs) => failedProbe && nowMs < retryAt,
95
+ recordFailedProbe: (nowMs) => { failedProbe = true; schedule(nowMs); },
96
+ set: (result, nowMs = Date.now()) => {
97
+ failedProbe = false;
98
+ cached = result;
99
+ if (isCacheableDenyResult(result)) {
100
+ failures = 0;
101
+ retryAt = 0;
102
+ }
103
+ else
104
+ schedule(nowMs);
87
105
  },
88
106
  reset: () => {
89
107
  cached = undefined;
108
+ failedProbe = false;
109
+ failures = 0;
110
+ retryAt = 0;
90
111
  },
91
112
  };
92
113
  }
@@ -412,7 +433,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
412
433
  let denyEnforced = false;
413
434
  try {
414
435
  const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
415
- const cachedDeny = cache?.get();
436
+ if (cache?.failedProbeDue?.(deps.now?.() ?? Date.now())) {
437
+ fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
438
+ throw new Error("cached deny probe failure");
439
+ }
440
+ const cachedDeny = cache?.get(deps.now?.() ?? Date.now());
416
441
  let deny;
417
442
  if (cachedDeny !== undefined) {
418
443
  deny = cachedDeny;
@@ -421,7 +446,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
421
446
  // An explicitly injected probe still wins, so every existing test seam
422
447
  // keeps working unchanged across the extraction.
423
448
  deny = await seams.runDenyPreflight({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
424
- seams.denyProbeCache?.set(deny);
449
+ seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
425
450
  }
426
451
  else {
427
452
  // Otherwise the ADAPTER's declared deny hook probes. The verdict is mapped
@@ -452,10 +477,10 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
452
477
  };
453
478
  // `set` itself refuses anything that is not a genuine success, so a failure
454
479
  // cannot become sticky even if this call site were later moved.
455
- seams.denyProbeCache?.set(deny);
480
+ seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
456
481
  }
457
- denyEnforced = deny.enforced === true;
458
- if (!deny.enforced) {
482
+ denyEnforced = isVerifiedDenyResult(deny);
483
+ if (!denyEnforced) {
459
484
  fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
460
485
  }
461
486
  else if (deny.layer === "none") {
@@ -465,7 +490,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
465
490
  }
466
491
  }
467
492
  catch {
468
- fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
493
+ if (!fatalFindings.includes("deny-layer enforcement probe failed; refusing to claim")) {
494
+ fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
495
+ if (!seams.bypassDenyProbeCache)
496
+ seams.denyProbeCache?.recordFailedProbe?.(deps.now?.() ?? Date.now());
497
+ }
469
498
  }
470
499
  // --- Advisory MCP-shadowing inspection (BAPI-727 → BAPI-790 → BAPI-781) --
471
500
  // This was a refusal under BAPI-727, because a `bridge-api` entry in the
@@ -584,6 +613,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
584
613
  * once ordinary preflight permits claiming — independently of completion-evidence
585
614
  * qualification. The completion tokens keep their exact conditional behavior and
586
615
  * order; the focused token is appended after them.
616
+ *
617
+ * BAPI-1391 adds two things. `gate_qualification.v1` is appended to
618
+ * `capabilities` ONLY on a repository-wide executor (see the comment at the
619
+ * token). And `job_types` is emitted, by conditional spread under the same
620
+ * "truly absent" rule, ONLY when `options.jobTypes` is set and non-empty; with
621
+ * it unset every manifest keeps its exact pre-BAPI-1391 shape apart from that
622
+ * one capability token on repository-wide executors.
587
623
  */
588
624
  export function buildClaimManifest(report, options, freeSlots) {
589
625
  return {
@@ -616,6 +652,19 @@ export function buildClaimManifest(report, options, freeSlots) {
616
652
  SCOPE_WAIT_CAPABILITY,
617
653
  ...(report.correctionIntentQualified === true ? [CORRECTION_INTENT_CAPABILITY] : []),
618
654
  ...(report.correctionIntentQualified === true ? ["ci_evidence.v1"] : []),
655
+ // BAPI-1391: repository-wide executors only. A `gate_qualification` row is
656
+ // run-less (no `epic_run_id`), and run-less rows pass the server's
657
+ // run-scope filter, so a run-scoped executor advertising this token could
658
+ // pick up repository-level qualification work it was never dedicated to.
659
+ // Gating the token on `repoWide` keeps that work on the executor whose
660
+ // operator chose the whole repository.
661
+ ...(options.repoWide === true ? [GATE_QUALIFICATION_CAPABILITY] : []),
619
662
  ],
663
+ // BAPI-1391: an optional job-type filter, present only when configured. An
664
+ // older server rejects the key with a 422, so it must never appear by
665
+ // default.
666
+ ...(options.jobTypes !== undefined && options.jobTypes.length > 0
667
+ ? { job_types: [...options.jobTypes] }
668
+ : {}),
620
669
  };
621
670
  }
@@ -266,6 +266,15 @@ export function buildCleanupVerdict(verdict, survivors, reasons) {
266
266
  export function unconfirmedCleanup(reason) {
267
267
  return buildCleanupVerdict("unconfirmed", 0, [reason]);
268
268
  }
269
+ /**
270
+ * The verdict when no sweep was attempted because ownership could not be
271
+ * proven (invalid token, malformed or unreadable ownership record). The extra
272
+ * `not_run` reason keeps this shape distinct from a completed sweep that merely
273
+ * could not verify ownership, so it is never treated as continuable.
274
+ */
275
+ function noSweepCleanup() {
276
+ return buildCleanupVerdict("unconfirmed", 0, ["ownership_unverifiable", "not_run"]);
277
+ }
269
278
  /** Hard cap on distinct processes tracked by one sweep or one descendant tracker. */
270
279
  const MAX_TRACKED_PROCESSES = 1024;
271
280
  /** Confirmation enumerations after SIGKILL (a killed process can take a moment to leave). */
@@ -302,8 +311,14 @@ export function createDescendantTracker(options) {
302
311
  else
303
312
  childrenOf.set(proc.ppid, [proc]);
304
313
  }
305
- // Seeds: the root (when its identity matches) and every tracked member
306
- // still live with the SAME start time. A reused pid is not a seed.
314
+ // Prune first: a member survives only while its exact (pid, startedAt)
315
+ // is still in this successful listing. Exited descendants must not
316
+ // exhaust the cap, and a reused pid loses its old identity.
317
+ for (const [key, member] of members) {
318
+ const live = byPid.get(member.pid);
319
+ if (!live || live.startedAt !== member.startedAt)
320
+ members.delete(key);
321
+ }
307
322
  const frontier = [];
308
323
  const rootNow = byPid.get(rootPid);
309
324
  if (rootNow) {
@@ -312,12 +327,11 @@ export function createDescendantTracker(options) {
312
327
  if (rootNow.startedAt === rootStartedAt)
313
328
  frontier.push(rootPid);
314
329
  }
315
- for (const member of members.values()) {
316
- const live = byPid.get(member.pid);
317
- if (live && live.startedAt === member.startedAt)
318
- frontier.push(member.pid);
319
- }
330
+ for (const member of members.values())
331
+ frontier.push(member.pid);
320
332
  const visited = new Set(frontier);
333
+ let liveCount = members.size;
334
+ let capHit = false;
321
335
  while (frontier.length > 0) {
322
336
  const parent = frontier.pop();
323
337
  for (const child of childrenOf.get(parent) ?? []) {
@@ -326,8 +340,9 @@ export function createDescendantTracker(options) {
326
340
  visited.add(child.pid);
327
341
  const key = keyOf(child);
328
342
  if (!members.has(key)) {
343
+ liveCount += 1;
329
344
  if (members.size >= MAX_TRACKED_PROCESSES) {
330
- overflow = true;
345
+ capHit = true;
331
346
  continue;
332
347
  }
333
348
  members.set(key, { pid: child.pid, startedAt: child.startedAt });
@@ -335,6 +350,15 @@ export function createDescendantTracker(options) {
335
350
  frontier.push(child.pid);
336
351
  }
337
352
  }
353
+ if (capHit && !overflow) {
354
+ overflow = true;
355
+ try {
356
+ options.onOverflow?.(liveCount);
357
+ }
358
+ catch {
359
+ /* a diagnostic failure never changes tracking or the verdict */
360
+ }
361
+ }
338
362
  }
339
363
  catch {
340
364
  /* sampling is best-effort; the sweep's own verdict stays authoritative */
@@ -399,7 +423,7 @@ export async function runOwnedProcessSweep(options) {
399
423
  const maxRounds = Math.max(quietRounds, options.maxRounds ?? 200);
400
424
  const roundDelayMs = Math.max(0, options.roundDelayMs ?? 15);
401
425
  if (!isOwnershipToken(token))
402
- return unconfirmedCleanup("ownership_unverifiable");
426
+ return noSweepCleanup();
403
427
  const reasons = new Set();
404
428
  if (options.trackerOverflowed)
405
429
  reasons.add("ownership_unverifiable");
@@ -700,8 +724,8 @@ async function ownerMayBeAlive(inspector, pid) {
700
724
  * interrupted before its own sweep finished. The record holds the exact token,
701
725
  * so ownership is verified and the full sweep runs. Only the job id and the
702
726
  * verdict are logged — never the token. A malformed record cannot prove
703
- * ownership: it is reported `unconfirmed / ownership_unverifiable` and removed
704
- * without signalling anything. Never throws.
727
+ * ownership: it is reported `unconfirmed` with `ownership_unverifiable` and
728
+ * `not_run` (no sweep ran) and removed without signalling anything. Never throws.
705
729
  */
706
730
  export async function recoverInterruptedOwnership(options) {
707
731
  const { stateDir, inspector, deps, fsDeps, deadlineMs } = options;
@@ -712,7 +736,7 @@ export async function recoverInterruptedOwnership(options) {
712
736
  let verdict;
713
737
  try {
714
738
  if (record === null) {
715
- verdict = unconfirmedCleanup("ownership_unverifiable");
739
+ verdict = noSweepCleanup();
716
740
  }
717
741
  else if (options.hostDigest !== undefined && record.hostDigest !== options.hostDigest) {
718
742
  continue;
@@ -11,17 +11,19 @@ export const DEFAULT_TERM_GRACE_MS = 10_000;
11
11
  */
12
12
  export function createProcessTerminationController(proc, deps, termGraceMs = DEFAULT_TERM_GRACE_MS) {
13
13
  let requested = false;
14
+ let cause = null;
14
15
  let escalated = false;
15
16
  let disposed = false;
16
17
  let graceTimer;
17
18
  const killListeners = [];
18
19
  return {
19
- requestTermination() {
20
+ requestTermination(endCause = "executor_abort") {
20
21
  // A disposed controller belongs to a settled child: signalling it would
21
22
  // either hit nothing or, worse, a recycled pid.
22
23
  if (requested || disposed)
23
24
  return;
24
25
  requested = true;
26
+ cause = endCause;
25
27
  proc.kill("SIGTERM");
26
28
  graceTimer = deps.setTimer(() => {
27
29
  graceTimer = undefined;
@@ -42,6 +44,9 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
42
44
  isTerminationRequested() {
43
45
  return requested;
44
46
  },
47
+ endCause() {
48
+ return cause;
49
+ },
45
50
  dispose() {
46
51
  if (disposed)
47
52
  return;
@@ -125,7 +130,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
125
130
  // shutdown, timeout, and MCP containment converge on a single `SIGTERM` and a
126
131
  // single grace timer rather than racing three of each.
127
132
  const controller = options.terminationController ?? createProcessTerminationController(proc, deps, termGraceMs);
128
- const terminate = () => controller.requestTermination();
133
+ const terminate = (cause) => controller.requestTermination(cause);
129
134
  // BAPI-1301 (D4a): the authoritative consumer, when supplied. `drainClosed`
130
135
  // flips once the bounded post-exit drain gives up, so a chunk arriving after
131
136
  // the verdict was taken can never mutate the ledger behind the caller's back.
@@ -160,18 +165,16 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
160
165
  stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
161
166
  if (options.onStdoutTerminationCheck?.(chunk) === true && !terminationRequested) {
162
167
  terminationRequested = true;
163
- terminate();
168
+ terminate("executor_abort");
164
169
  }
165
170
  });
166
171
  const pumpStderr = pump(proc.stderr, (chunk) => {
167
172
  stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
168
173
  });
169
- let timedOut = false;
170
174
  const timeoutTimer = deps.setTimer(() => {
171
- timedOut = true;
172
175
  // Same escalation the assertion uses; the guard inside makes a timeout that
173
176
  // races an already-requested termination a no-op rather than a double signal.
174
- terminate();
177
+ terminate("job_timeout");
175
178
  }, timeoutSeconds * 1000);
176
179
  // BAPI-1301 (D4a): bounded waits, only when a drain deadline was supplied.
177
180
  // Every timer created here is cleared before returning.
@@ -226,9 +229,13 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
226
229
  deps.clearTimer(handle);
227
230
  }
228
231
  let classification;
229
- if (timedOut) {
232
+ const endCause = controller.endCause?.() ?? null;
233
+ if (endCause === "job_timeout") {
230
234
  classification = "timeout";
231
235
  }
236
+ else if (endCause !== null) {
237
+ classification = "killed";
238
+ }
232
239
  else if (exitCode === 0) {
233
240
  classification = "clean_exit";
234
241
  }
@@ -241,6 +248,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
241
248
  signal,
242
249
  stdoutExcerpt,
243
250
  stderrExcerpt,
251
+ endCause: endCause ?? "worker_exit",
244
252
  ...(terminationRequested ? { terminationRequested: true } : {}),
245
253
  // BAPI-1301 fields appear only for callers that opted in, so every legacy
246
254
  // result keeps its exact shape.
@@ -1,3 +1,4 @@
1
+ import { renderWorkerVerificationGuidance } from "./worker-verification-guidance.js";
1
2
  /**
2
3
  * Executor-side structured prompt-spec renderer (BAPI-550, TDD §6/§7).
3
4
  *
@@ -18,6 +19,35 @@
18
19
  * or reasons.
19
20
  */
20
21
  import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
22
+ import { EPIC_WORKAROUNDS_DIR, epicWorkaroundRecordPath } from "../epic-workarounds-path.js";
23
+ /**
24
+ * BAPI-1399 — the workaround contract for a PR-producing Epic Implementer worker.
25
+ *
26
+ * A requirement the worker cannot meet unattended (a missing credential, an
27
+ * external setting, or access) is either MINOR — the ticket's essential
28
+ * behavior still works without it — or CORE. A minor gap gets a guarded
29
+ * workaround and a committed record at `.bridge/epic-workarounds/<TICKET>.md`;
30
+ * while that folder exists on the epic head the integration pull request stays
31
+ * in draft. A core gap is never worked around: the worker leaves the required
32
+ * work unfinished, which parks the ticket as `required_work_incomplete` through
33
+ * the unchanged completion contract.
34
+ *
35
+ * The worker decides from the ticket and the code alone; no supervisor scan
36
+ * result is injected here. Kept SINGLE-LINE and free of `;`, quotes, and
37
+ * apostrophes, like `buildPrBaseContractLaunchInstruction`, because it travels
38
+ * in the same launch prompt.
39
+ */
40
+ export function buildEpicWorkaroundWorkerInstruction(ticketKey) {
41
+ const record = (ticketKey ? epicWorkaroundRecordPath(ticketKey) : null) ?? `${EPIC_WORKAROUNDS_DIR}<TICKET>.md`;
42
+ return ("Workaround contract: if a requirement of this ticket cannot be met unattended, such as a missing " +
43
+ "credential, external setting, or access, decide whether it is minor or core. It is minor only when " +
44
+ "the essential behavior of the ticket still works without it. For a minor gap, deliver a guarded " +
45
+ "workaround: guard exactly the deferred behavior (for example, check for a missing setting and skip " +
46
+ "only that feature), keep the core path working, and write " + record + " naming the requirement, " +
47
+ "the guarded substitute you delivered, and the step a human must still take. Commit that record on " +
48
+ "this ticket branch with git add -f " + record + ". For a core gap, never write a workaround or a " +
49
+ "record: leave the required work unfinished so the run parks this ticket for a human.");
50
+ }
21
51
  /** Stable named-error kind used for every prompt-spec contract failure. */
22
52
  const PROMPT_CONTRACT_ERROR = "ContractError.Prompt";
23
53
  /** The prompt-spec version this renderer understands. */
@@ -647,5 +677,7 @@ export async function renderPromptSpecPrompt(job, context) {
647
677
  const values = await buildPromptSpecPlaceholderValues(job, spec, context);
648
678
  const systemPrompt = buildPromptSpecSystemPrompt(spec, values);
649
679
  const userPrompt = buildPromptSpecUserPrompt(spec, values);
650
- return `${systemPrompt}\n\n${userPrompt}`;
680
+ return `${systemPrompt}\n\n${userPrompt}` +
681
+ (!context.deferComparisonGuidance && ["implement", "resume", "ci_fix"].includes(job.job_type)
682
+ ? "\n\n" + renderWorkerVerificationGuidance(context.comparisonSha) : "");
651
683
  }
@@ -0,0 +1,71 @@
1
+ /** Atomic resolver refresh. Caller retains the exclusive worktree lock. */
2
+ import path from "node:path";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { parseRecentlyFailedTests, RECENTLY_FAILED_TESTS_PATH } from "./likely-tests.js";
7
+ export function validatedRecentFailures(value) {
8
+ if (value === undefined)
9
+ return { version: 1, tests: [], truncated: false };
10
+ const parsed = parseRecentlyFailedTests(value);
11
+ if (!parsed.available || parsed.skipped || JSON.stringify(value).length > 16 * 1024 ||
12
+ value.tests.length > 20)
13
+ throw new Error("recent_failure_schema_invalid");
14
+ return parsed.value;
15
+ }
16
+ export async function writeRecentlyFailedTestsFile(worktree, value) {
17
+ const selection = validatedRecentFailures(value);
18
+ const root = await realpath(worktree);
19
+ const directory = path.join(root, ".conductor");
20
+ try {
21
+ await mkdir(directory);
22
+ }
23
+ catch (error) {
24
+ if (error.code !== "EEXIST")
25
+ throw new Error("recent_failure_directory_unavailable");
26
+ }
27
+ const directoryStat = await lstat(directory);
28
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
29
+ throw new Error("recent_failure_directory_unsafe");
30
+ const destination = path.join(root, RECENTLY_FAILED_TESTS_PATH);
31
+ const safeDestination = async () => {
32
+ if (await realpath(directory) !== directory || !(await lstat(directory)).isDirectory() ||
33
+ (await lstat(directory)).isSymbolicLink())
34
+ throw new Error("recent_failure_directory_changed");
35
+ try {
36
+ const stat = await lstat(destination);
37
+ if (!stat.isFile() || stat.isSymbolicLink())
38
+ throw new Error("recent_failure_destination_unsafe");
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "ENOENT")
42
+ throw error;
43
+ }
44
+ };
45
+ await safeDestination();
46
+ const temporary = path.join(directory, `.recent-failures-${randomUUID()}.tmp`);
47
+ let created = false;
48
+ try {
49
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
50
+ created = true;
51
+ try {
52
+ await handle.writeFile(JSON.stringify(selection) + "\n", "utf8");
53
+ await handle.sync();
54
+ }
55
+ finally {
56
+ await handle.close();
57
+ }
58
+ if (await realpath(temporary) !== temporary || !(await lstat(temporary)).isFile())
59
+ throw new Error("recent_failure_temporary_unsafe");
60
+ await safeDestination();
61
+ const current = await lstat(directory);
62
+ if (current.ino !== directoryStat.ino || current.dev !== directoryStat.dev)
63
+ throw new Error("recent_failure_directory_changed");
64
+ await rename(temporary, destination);
65
+ created = false;
66
+ }
67
+ finally {
68
+ if (created && await realpath(directory).catch(() => "") === directory)
69
+ await unlink(temporary).catch(() => { });
70
+ }
71
+ }
@@ -1,3 +1,4 @@
1
+ import { guardedFastForwardPush, PUSH_AUTH_FAILURE_RE } from "./guarded-fast-forward-push.js";
1
2
  /**
2
3
  * The `reconcile_overlap` executor job (BAPI-1152).
3
4
  *
@@ -71,7 +72,6 @@ const CONFLICT_REPORT_DIR = ".conductor/";
71
72
  const CONDUCTOR_ARTIFACT_DIR = ".conductor/";
72
73
  /** Command bounds. */
73
74
  const GIT_TIMEOUT_MS = 120_000;
74
- const PUSH_TIMEOUT_MS = 180_000;
75
75
  const COMMAND_OUTPUT_TAIL_CHARS = 4_000;
76
76
  /** Left in the job budget when the wrapper waits for provider checks. */
77
77
  const CI_WAIT_MARGIN_MS = 90_000;
@@ -91,8 +91,6 @@ const WRAPPER_STAGE_EXCLUDES = [
91
91
  ":(exclude).claude/settings.local.json",
92
92
  ];
93
93
  const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
94
- const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
95
- const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
96
94
  class PayloadError extends Error {
97
95
  }
98
96
  function asRecord(value, field) {
@@ -339,9 +337,13 @@ export function createReconcileOverlapDriver(job, payload, seams) {
339
337
  const git = async (ctx, args, timeoutMs = GIT_TIMEOUT_MS, env) => {
340
338
  if (ctx.signal?.aborted)
341
339
  throw new ReconcileOverlapAborted("reconciliation phase aborted");
340
+ const remaining = "remainingMs" in ctx ? ctx.remainingMs() : timeoutMs;
341
+ if (remaining <= 0)
342
+ throw new ReconcileOverlapAborted("reconciliation deadline expired");
342
343
  return seams.runCommand("git", args, {
343
344
  cwd: ctx.worktreePath,
344
- timeoutMs,
345
+ timeoutMs: Math.min(timeoutMs, remaining),
346
+ signal: ctx.signal,
345
347
  ...(env ? { env } : {}),
346
348
  });
347
349
  };
@@ -374,8 +376,12 @@ export function createReconcileOverlapDriver(job, payload, seams) {
374
376
  result: envelope(extra),
375
377
  ...(lastCommitSha ? { lastCommitSha } : {}),
376
378
  });
379
+ let preserveHead = false;
380
+ let cleanupHead = payload.childHead;
377
381
  /** Reset to the recorded child head. Never throws. */
378
382
  const resetToChildHead = async (ctx) => {
383
+ if (preserveHead)
384
+ return;
379
385
  try {
380
386
  const mergeHead = await seams.runCommand("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
381
387
  cwd: ctx.worktreePath,
@@ -384,7 +390,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
384
390
  if (mergeHead.exitCode === 0) {
385
391
  await seams.runCommand("git", ["merge", "--abort"], { cwd: ctx.worktreePath, timeoutMs: GIT_TIMEOUT_MS });
386
392
  }
387
- await seams.runCommand("git", ["reset", "--hard", payload.childHead], {
393
+ await seams.runCommand("git", ["reset", "--hard", cleanupHead], {
388
394
  cwd: ctx.worktreePath,
389
395
  timeoutMs: GIT_TIMEOUT_MS,
390
396
  });
@@ -595,35 +601,33 @@ export function createReconcileOverlapDriver(job, payload, seams) {
595
601
  }
596
602
  return complete({ observed_epic_head: heads.epic });
597
603
  }
598
- // (2) The ONE push: a fast-forward of the child branch, leased on the
599
- // recorded child head. Never plain --force, never a lease-less lease.
600
- const destination = `refs/heads/${payload.expectedBranch}`;
601
- const push = await git(ctx, [
602
- "push",
603
- "--no-verify",
604
- "--porcelain",
605
- `--force-with-lease=${destination}:${payload.childHead}`,
606
- "origin",
607
- `HEAD:${destination}`,
608
- ], PUSH_TIMEOUT_MS, { ...seams.env, GIT_TERMINAL_PROMPT: "0" });
609
- if (push.exitCode !== 0) {
610
- const text = `${push.stdout}\n${push.stderr}`;
611
- if (PUSH_AUTH_FAILURE_RE.test(text)) {
604
+ const push = await guardedFastForwardPush({
605
+ branch: payload.expectedBranch, expectedOriginSha: payload.childHead,
606
+ candidateSha: candidate, trustedExcludedShas: [payload.requiredEpicSha],
607
+ worktreePath: ctx.worktreePath, runCommand: seams.runCommand, env: seams.env,
608
+ remainingMs: ctx.remainingMs, signal: ctx.signal, mayMutate: () => !ctx.signal.aborted,
609
+ });
610
+ if (push.status !== "published") {
611
+ if (push.reason === "auth_failed") {
612
612
  await resetToChildHead(ctx);
613
613
  return fail(ReconcileOverlapPushUnauthorized, "pushed", "the executor's git credential helper refused the push");
614
614
  }
615
- if (PUSH_LEASE_FAILURE_RE.test(text)) {
615
+ if (push.reason === "lease_rejected") {
616
616
  const reread = await readRemoteHeads(ctx);
617
617
  await resetToChildHead(ctx);
618
- if (reread.ok && reread.child !== null && reread.child !== payload.childHead) {
618
+ if (reread.ok && reread.child !== null && reread.child !== payload.childHead)
619
619
  return complete({ observed_child_head: reread.child });
620
- }
621
620
  }
622
- else {
621
+ else if (push.reason !== "timeout" && !ctx.signal.aborted) {
623
622
  await resetToChildHead(ctx);
624
623
  }
624
+ else {
625
+ preserveHead = true;
626
+ }
625
627
  return fail(ReconcileOverlapPushRejected, "pushed", "the remote refused the guarded push");
626
628
  }
629
+ preserveHead = true;
630
+ ctx.recordPublication?.(push);
627
631
  // (3) Finalization runs ONCE, after the wrapper's push, against the pushed
628
632
  // head: origin must carry it and the PR must be attached at it.
629
633
  const finalize = seams.finalize ?? validateWorkerFinalization;
@@ -633,6 +637,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
633
637
  worktreePath: ctx.worktreePath,
634
638
  result: {},
635
639
  runCommand: seams.runCommand,
640
+ callTimeoutMs: () => Math.min(GIT_TIMEOUT_MS, ctx.remainingMs()),
636
641
  headSha: candidate,
637
642
  expectedBaseBranch: ctx.effectiveBaseBranch,
638
643
  originResolveAttempts: FINALIZATION_ATTEMPTS,
@@ -658,6 +663,16 @@ export function createReconcileOverlapDriver(job, payload, seams) {
658
663
  if (ctx.branch !== payload.expectedBranch) {
659
664
  return finish(fail(ReconcileOverlapBranchMismatch, "validated", "the prepared worktree is not on the job's expected branch"));
660
665
  }
666
+ if (ctx.preparationPublication) {
667
+ preserveHead = true;
668
+ const heads = await readRemoteHeads(ctx);
669
+ if (!heads.ok)
670
+ return finish(heads.outcome);
671
+ if (heads.child === null)
672
+ return finish(fail(ReconcileOverlapPushRejected, "validated", "the child branch no longer exists on origin"));
673
+ cleanupHead = heads.child;
674
+ return finish(complete({ observed_child_head: heads.child }));
675
+ }
661
676
  // Fetch the exact server-supplied inputs. The job never infers a head.
662
677
  await git(ctx, ["fetch", "--no-tags", "origin", payload.epicBranch], GIT_TIMEOUT_MS, {
663
678
  ...seams.env,
@@ -831,6 +846,9 @@ export function createReconcileOverlapDriver(job, payload, seams) {
831
846
  return pushFlow(ctx);
832
847
  },
833
848
  async abandon(ctx) {
849
+ // Wrapper-created merges are not retained worker progress until their
850
+ // verification/publication gate succeeds. Published or ambiguous pushes,
851
+ // including preparation publication, already set preserveHead above.
834
852
  await resetToChildHead(ctx);
835
853
  },
836
854
  };
@@ -236,7 +236,7 @@ export function createSupervisedCommandLauncher(input) {
236
236
  let cancelled = false;
237
237
  const onAbort = () => {
238
238
  cancelled = true;
239
- controller.requestTermination();
239
+ controller.requestTermination("executor_abort");
240
240
  };
241
241
  request.signal.addEventListener("abort", onAbort, { once: true });
242
242
  try {