@bridge_gpt/mcp-server 0.2.66 → 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 (171) hide show
  1. package/README.md +421 -83
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +70 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +22 -2
  6. package/build/bitbucket-workspace-remote.js +136 -0
  7. package/build/bootstrap-project-identity.js +24 -0
  8. package/build/bounded-wait.js +67 -10
  9. package/build/bridge-permissions.js +829 -0
  10. package/build/claude-settings.js +18 -0
  11. package/build/cli-help.js +21 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +12 -12
  15. package/build/conductor/bridge-api-client.js +857 -1
  16. package/build/conductor/bring-up-facts.js +1 -1
  17. package/build/conductor/command-drift-production.js +92 -0
  18. package/build/conductor/command-drift.js +89 -0
  19. package/build/conductor/doctor.js +72 -29
  20. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  21. package/build/conductor/epic-implementer-cli.js +98 -28
  22. package/build/conductor/errors.js +4 -3
  23. package/build/conductor/gate-discovery.js +890 -0
  24. package/build/conductor/guided-repair.js +6 -2
  25. package/build/conductor/hook-runtime-qualification.js +1 -1
  26. package/build/conductor/install-doctor.js +15 -3
  27. package/build/conductor/json-output.js +63 -0
  28. package/build/conductor/merge-execution-contract.js +27 -8
  29. package/build/conductor/merge-execution-eligibility.js +3 -2
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +113 -12
  32. package/build/conductor/readiness-sections.js +410 -9
  33. package/build/conductor/readiness.js +388 -33
  34. package/build/conductor/recovery-cli.js +310 -12
  35. package/build/conductor/recovery-operations.js +167 -3
  36. package/build/conductor/repair-contract.js +54 -3
  37. package/build/conductor/repository-workflow-remedy.js +125 -0
  38. package/build/conductor/tools.js +2 -2
  39. package/build/conductor-bundle-artifacts.js +8 -0
  40. package/build/connect-bitbucket-api.js +41 -6
  41. package/build/connect-bitbucket.js +106 -33
  42. package/build/credential-store.js +207 -47
  43. package/build/docs.generated.js +62 -3
  44. package/build/doctor.js +92 -1
  45. package/build/drive-epic-key.js +6 -5
  46. package/build/drive-epic.js +129 -61
  47. package/build/epic-coherence-input.js +2 -1
  48. package/build/epic-coherence-input.test-fixtures.js +4 -3
  49. package/build/epic-implementer-bin.js +1379 -28
  50. package/build/epic-implementer-bundle-cli.js +2 -2
  51. package/build/epic-workarounds-path.js +39 -0
  52. package/build/executor/cli.js +33 -2
  53. package/build/executor/completion-contract.js +66 -10
  54. package/build/executor/completion-supervision.js +4 -0
  55. package/build/executor/deps.js +4 -1
  56. package/build/executor/env.js +19 -0
  57. package/build/executor/gate-qualification-job.js +499 -0
  58. package/build/executor/guarded-fast-forward-push.js +129 -0
  59. package/build/executor/heartbeat.js +1 -1
  60. package/build/executor/http-client.js +20 -0
  61. package/build/executor/integration-job.js +122 -8
  62. package/build/executor/job-errors.js +33 -1
  63. package/build/executor/job-runner.js +813 -87
  64. package/build/executor/likely-tests.js +290 -0
  65. package/build/executor/live-worker-registry.js +1 -1
  66. package/build/executor/observation.js +37 -3
  67. package/build/executor/packaged-recipe.js +106 -0
  68. package/build/executor/permissions.js +115 -28
  69. package/build/executor/platform.js +1 -1
  70. package/build/executor/preflight.js +112 -26
  71. package/build/executor/process-containment.js +36 -12
  72. package/build/executor/process.js +15 -7
  73. package/build/executor/prompt-spec.js +33 -1
  74. package/build/executor/recently-failed-tests-file.js +71 -0
  75. package/build/executor/reconcile-overlap-job.js +41 -23
  76. package/build/executor/required-verification.js +1 -1
  77. package/build/executor/required-work-ledger.js +111 -18
  78. package/build/executor/resume-pre-spawn.js +2 -0
  79. package/build/executor/retirement.js +69 -0
  80. package/build/executor/runner.js +122 -13
  81. package/build/executor/service-unit.js +7 -1
  82. package/build/executor/test-clock.js +15 -0
  83. package/build/executor/types.js +32 -0
  84. package/build/executor/viewer-tabs.js +58 -8
  85. package/build/executor/worker-command.js +23 -7
  86. package/build/executor/worker-diagnostics.js +37 -0
  87. package/build/executor/worker-finalization.js +113 -34
  88. package/build/executor/worker-guard-hook.js +316 -58
  89. package/build/executor/worker-log.js +4 -3
  90. package/build/executor/worker-verification-guidance.js +37 -0
  91. package/build/executor/worktree.js +166 -8
  92. package/build/first-build-advisory.js +97 -0
  93. package/build/generated-assets-advisory.js +14 -0
  94. package/build/generated-assets-catalog.js +66 -0
  95. package/build/generated-assets-context.js +46 -0
  96. package/build/generated-assets-dependencies.js +26 -0
  97. package/build/generated-assets-doctor.js +50 -0
  98. package/build/generated-assets-health.js +65 -0
  99. package/build/generated-assets-host.js +17 -0
  100. package/build/generated-assets-inventory.js +17 -0
  101. package/build/generated-assets-paths.js +60 -0
  102. package/build/generated-assets-publisher.js +133 -0
  103. package/build/generated-assets-remedy.js +25 -0
  104. package/build/generated-assets-repair.js +72 -0
  105. package/build/generated-assets-session.js +71 -0
  106. package/build/generated-assets-test-support.js +35 -0
  107. package/build/index.js +1224 -1054
  108. package/build/init.js +54 -177
  109. package/build/install-bridge-conductor-gate.js +1372 -0
  110. package/build/install-bridge-conductor.js +296 -41
  111. package/build/install-bridge.js +547 -40
  112. package/build/mcp-install-state.js +50 -4
  113. package/build/merge-workflow-install.js +3 -4
  114. package/build/phase-context-git-probe.js +68 -0
  115. package/build/phase-result-artifacts.js +173 -5
  116. package/build/pipeline-utils.js +6 -2
  117. package/build/pipelines.generated.js +34 -160
  118. package/build/plan-execution-ledger.js +2 -32
  119. package/build/plane/alembic-head.js +12 -3
  120. package/build/plane/base-worktree-guard.js +147 -0
  121. package/build/plane/cli.js +217 -17
  122. package/build/plane/code-snapshot.js +468 -0
  123. package/build/plane/defaults.js +48 -3
  124. package/build/plane/endpoint.js +41 -0
  125. package/build/plane/manifest.js +136 -2
  126. package/build/plane/member-roster.js +94 -19
  127. package/build/plane/preflight.js +83 -2
  128. package/build/plane/process-cwd.js +67 -0
  129. package/build/plane/restart.js +542 -0
  130. package/build/plane/shutdown.js +31 -0
  131. package/build/plane/status.js +143 -8
  132. package/build/plane/supervisor.js +586 -179
  133. package/build/plane/types.js +54 -2
  134. package/build/printed-remedy-commands.js +52 -0
  135. package/build/readiness-check.js +1 -1
  136. package/build/readme.generated.js +1 -1
  137. package/build/repair-assets.js +93 -0
  138. package/build/review-generation.js +22 -0
  139. package/build/run-unit-tests-launcher.js +130 -49
  140. package/build/secret-redaction.js +33 -0
  141. package/build/setup-epic.js +152 -22
  142. package/build/sfcc/config.js +9 -0
  143. package/build/sfcc/credentials.js +100 -38
  144. package/build/sfcc/hostname.js +67 -0
  145. package/build/sfcc/permissions.js +17 -13
  146. package/build/sfcc/register.js +1 -1
  147. package/build/sfcc/tool-wrapper.js +39 -43
  148. package/build/sfcc/write-grants.js +17 -11
  149. package/build/sfcc/write-result.js +4 -3
  150. package/build/sfcc/writes-site-preference.js +20 -8
  151. package/build/start-tickets-prereqs.js +70 -16
  152. package/build/start-tickets.js +208 -36
  153. package/build/tool-error-envelope.js +61 -0
  154. package/build/tool-surface-gating.js +36 -0
  155. package/build/upgrade-cli.js +55 -1
  156. package/build/version.generated.js +3 -3
  157. package/build/visual-diff.js +22 -1
  158. package/build/worker-guard-hook-bin.js +4 -6
  159. package/build/worker-guard-hook-main.js +47 -0
  160. package/build/worktree-core.js +145 -6
  161. package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
  162. package/docs/EPIC-IMPLEMENTER.md +728 -0
  163. package/docs/install/mcp-tool-integrations.md +24 -15
  164. package/docs/install/sfcc-integration.md +12 -4
  165. package/package.json +3 -3
  166. package/pipelines/greenfield-setup.json +4 -128
  167. package/pipelines/implement-ticket.json +3 -2
  168. package/pipelines/plan-epic.json +0 -5
  169. package/pipelines/review-ticket.json +1 -1
  170. package/docs/CONDUCTOR.md +0 -605
  171. package/pipelines/idea-to-pr.json +0 -49
@@ -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 {
@@ -875,8 +875,38 @@ 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
  };
906
+ // One predicate for "a required task is still pending", shared by the envelope
907
+ // and the observe-only query so the two cannot drift apart.
908
+ const hasPendingRequiredWork = () => effectiveTasks().some(({ o, state }) => o.required && state === "pending");
909
+ const observeLiveRequiredWork = () => hasPendingRequiredWork() ? "live_required_work" : null;
880
910
  const buildEnvelope = (facts) => {
881
911
  const reasons = new Set(insufficient);
882
912
  if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
@@ -897,7 +927,7 @@ export function createRequiredWorkLedger(init) {
897
927
  reasons.add("forced_kill_no_cli_teardown");
898
928
  const effective = effectiveTasks();
899
929
  // R6: a required task with no terminal after the stream ended is missing evidence.
900
- if (effective.some(({ o, state }) => o.required && state === "pending"))
930
+ if (hasPendingRequiredWork())
901
931
  reasons.add("live_required_work");
902
932
  // R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
903
933
  const snap = liveSnapshotIds();
@@ -993,11 +1023,19 @@ export function createRequiredWorkLedger(init) {
993
1023
  recordDeclarationRefused: () => {
994
1024
  insufficient.add("declaration_refused");
995
1025
  },
1026
+ observeLiveRequiredWork,
996
1027
  buildEnvelope,
997
1028
  };
998
1029
  }
999
1030
  /** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
1000
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;
1001
1039
  /**
1002
1040
  * Hold stdin open until the closure rule holds for a full debounce window, then
1003
1041
  * close it exactly once.
@@ -1012,6 +1050,13 @@ export const CLOSURE_DEBOUNCE_MS = 1500;
1012
1050
  * optional watchers are live. It is never sent while required work is live and
1013
1051
  * never asks for a broad stop — cancelling required work is not the executor's
1014
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.
1015
1060
  */
1016
1061
  export function createClosureSupervisor(opts) {
1017
1062
  const { ledger, deps } = opts;
@@ -1024,6 +1069,14 @@ export function createClosureSupervisor(opts) {
1024
1069
  let debounceTimer = null;
1025
1070
  let armedSeq = -1;
1026
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
+ };
1027
1080
  const clearDebounce = () => {
1028
1081
  if (debounceTimer !== null)
1029
1082
  deps.clearTimer(debounceTimer);
@@ -1036,6 +1089,7 @@ export function createClosureSupervisor(opts) {
1036
1089
  closed = true;
1037
1090
  closedReason = reason;
1038
1091
  clearDebounce();
1092
+ clearWindow();
1039
1093
  if (deadlineTimer !== null)
1040
1094
  deps.clearTimer(deadlineTimer);
1041
1095
  deadlineTimer = null;
@@ -1049,6 +1103,48 @@ export function createClosureSupervisor(opts) {
1049
1103
  // then reports `stdout_not_read_to_eof`, never success.
1050
1104
  }
1051
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
+ }
1052
1148
  };
1053
1149
  let deadlineTimer = deps.setTimer(() => {
1054
1150
  deadlineTimer = null;
@@ -1060,30 +1156,26 @@ export function createClosureSupervisor(opts) {
1060
1156
  const s = ledger.closureState();
1061
1157
  if (armedSeq !== s.activitySeq)
1062
1158
  clearDebounce();
1159
+ if (s.turnOpen && windowOpened) {
1160
+ clearWindow();
1161
+ windowOpened = false;
1162
+ }
1063
1163
  if (!s.latestIsResult) {
1064
1164
  // The nudge was answered (or other activity began): the next result decides again.
1065
1165
  nudgeAwaitingReply = false;
1066
1166
  return;
1067
1167
  }
1168
+ if (!windowOpened && s.finalResultNoOpenTurn && s.liveRequiredBashBg.length > 0) {
1169
+ openWindow(s.liveRequiredBashBg);
1170
+ return;
1171
+ }
1068
1172
  if (!s.readyToClose)
1069
1173
  return;
1070
1174
  if (!nudged && s.liveOptional.length > 0) {
1071
- nudged = true;
1072
- nudgeAwaitingReply = true;
1073
- const line = opts.buildNudge([...s.liveOptional]);
1074
- pending = pending.then(async () => {
1075
- let ok = false;
1076
- try {
1077
- ok = (await opts.writeMessage(line)).ok;
1078
- }
1079
- catch {
1080
- ok = false;
1081
- }
1082
- // An undelivered nudge will never be answered: fall back to the rule.
1083
- if (!ok && !closed && !disposed) {
1084
- nudgeAwaitingReply = false;
1085
- onActivity();
1086
- }
1175
+ // An undelivered nudge will never be answered: fall back to the rule.
1176
+ sendNudge(opts.buildNudge([...s.liveOptional]), () => {
1177
+ nudgeAwaitingReply = false;
1178
+ onActivity();
1087
1179
  });
1088
1180
  return;
1089
1181
  }
@@ -1112,6 +1204,7 @@ export function createClosureSupervisor(opts) {
1112
1204
  settled: () => pending,
1113
1205
  dispose: () => {
1114
1206
  clearDebounce();
1207
+ clearWindow();
1115
1208
  if (deadlineTimer !== null)
1116
1209
  deps.clearTimer(deadlineTimer);
1117
1210
  deadlineTimer = null;
@@ -149,6 +149,8 @@ export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
149
149
  // ---------------------------------------------------------------------------
150
150
  /** The deterministic WIP checkpoint message for a continuation. */
151
151
  export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
152
+ /** BAPI-1367: the WIP checkpoint message when a retry resumes its prior attempt's work. */
153
+ export const PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before retry resume";
152
154
  const FULL_SHA = /^[0-9a-f]{40}$/;
153
155
  /** Read the continuation binding from a claimed job's payload (pure). */
154
156
  export function readContinuationBinding(job) {
@@ -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
+ }