@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
@@ -1,3 +1,10 @@
1
+ import { writeRecentlyFailedTestsFile } from "./recently-failed-tests-file.js";
2
+ import { guardedFastForwardPush } from "./guarded-fast-forward-push.js";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import path from "node:path";
5
+ import { findWorktreeByBranch, parseGitWorktreePorcelain } from "./worktree-inspection.js";
6
+ import { resolveExecutorBranch, configureExecutorBranch } from "./worktree.js";
7
+ import { renderWorkerVerificationGuidance, renderWorkerPrBaseGuidance } from "./worker-verification-guidance.js";
1
8
  /**
2
9
  * Claimed-job execution engine (BAPI-534 core, BAPI-535 job behaviors; TDD §7/§8/§9).
3
10
  *
@@ -14,12 +21,13 @@
14
21
  * `spec_review`, plus per-job worker-log tee, watch registry, and optional
15
22
  * read-only viewer tabs wired around every spawned worker.
16
23
  */
17
- import { rm, readdir, lstat, unlink } from "node:fs/promises";
24
+ import { rm, readdir, lstat, unlink, realpath } from "node:fs/promises";
18
25
  import os from "node:os";
19
26
  import { runHeartbeatLoop } from "./heartbeat.js";
20
- import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, WorkerEndedWithPendingBackgroundWork, WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, CiFixContext, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
27
+ import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, MissingVerdictArtifact, PackagedRecipeUndeliverable, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, WorkerEndedWithPendingBackgroundWork, WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, CiFixContext, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
21
28
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
29
  import { createProductionCiRerunDeps, runCiRerunJob, } from "./ci-rerun-job.js";
30
+ import { createProductionGateQualificationDeps, runGateQualificationJob, GATE_QUALIFICATION_FAILED, GATE_QUALIFICATION_INTERRUPTED, } from "./gate-qualification-job.js";
23
31
  import { createObservationState } from "./observation.js";
24
32
  import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
25
33
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
@@ -27,13 +35,15 @@ import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js"
27
35
  import { resolveExecutorAgentId } from "./agent-identity.js";
28
36
  import { closeStdinBounded, createProcessTerminationController, runProcessWithTimeout, writeStdinBounded, } from "./process.js";
29
37
  import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, COMPLETION_CONTRACT_MARKER_VALUES, isCoveredJobType, MAX_EVENT_BYTES, runtimeMatchesSupported, successRefusalReason, verificationVerdictFromFacts, } from "./completion-contract.js";
30
- import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, } from "./completion-supervision.js";
38
+ import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, STDIN_CLOSE_GRACE_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, } from "./completion-supervision.js";
31
39
  import { compareFinalArtifact, createRequiredVerificationRunner, createSupervisedCommandLauncher, observeFinalArtifact, OWNERSHIP_POLL_MS, preCheckArtifactState, toCompletionCheckFact, } from "./required-verification.js";
32
40
  import { createClosureSupervisor, createRequiredWorkLedger } from "./required-work-ledger.js";
33
41
  import { buildCleanupVerdict, CLEANUP_RESIDUALS_NOT_COVERED, createDefaultOwnershipRecordFs, createDescendantTracker, createMacProcessInspector, createOwnershipToken, hostIdentityDigest, ownershipTokenDigest, removeOwnershipRecord, runOwnedProcessSweep, withOwnershipTag, writeOwnershipRecord, } from "./process-containment.js";
34
42
  import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
35
43
  import { autoCheckpointDirtyState, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE, prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, verifyContinuationHeadLineage, } from "./resume-pre-spawn.js";
36
- import { ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
44
+ import { buildEpicWorkaroundWorkerInstruction, ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
45
+ /** BAPI-1399: the job type whose worker implements ticket requirements from scratch. */
46
+ const WORKAROUND_CONTRACT_JOB_TYPES = new Set(["implement"]);
37
47
  import { defaultResolveBinPath } from "../start-tickets-conductor.js";
38
48
  /** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
39
49
  const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
@@ -45,7 +55,7 @@ import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
45
55
  import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
46
56
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
47
57
  import { resolveExecutorStateDir, activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
48
- import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
58
+ import { EXECUTOR_JOB_END_LOG_CHUNK, executorViewerTabsEnabled, openExecutorViewerTab, } from "./viewer-tabs.js";
49
59
  import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
50
60
  import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
51
61
  import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
@@ -54,11 +64,11 @@ import { resolveExecutorJobIndexScope } from "./index-scope.js";
54
64
  import { readFocusedPullRequest, validateWorkerFinalization } from "./worker-finalization.js";
55
65
  import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseContext, validateFocusedRebaseContext, waitForFocusedRebase, } from "./rebase-finalization.js";
56
66
  import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
57
- import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
58
67
  import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
59
68
  import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
60
69
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
61
- import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
70
+ import { readCustomizedCommands, TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
71
+ import { resolvePackagedRecipes } from "./packaged-recipe.js";
62
72
  import { probeInstalledCorrectionHook } from "./preflight.js";
63
73
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
64
74
  /**
@@ -89,7 +99,10 @@ function classificationToErrorKind(c) {
89
99
  case "timeout":
90
100
  return "Timeout";
91
101
  case "killed":
92
- return "Killed";
102
+ // Legacy terminal disposition retries WorkerCrashed but has no Killed
103
+ // branch. Keep the established retry signal while the classification
104
+ // records that the executor deliberately ended the process.
105
+ return "WorkerCrashed";
93
106
  default:
94
107
  return "WorkerCrashed";
95
108
  }
@@ -591,11 +604,22 @@ function createLedgerLineFeeder(ledger, onLine) {
591
604
  */
592
605
  async function superviseProcess(params) {
593
606
  const isDoneRef = { value: false };
607
+ const heartbeatStop = new AbortController();
608
+ const heartbeatDeps = params.cancelHeartbeatSleepOnExit ? { ...params.deps, sleep: (ms) => new Promise((resolve) => {
609
+ if (heartbeatStop.signal.aborted) {
610
+ resolve();
611
+ return;
612
+ }
613
+ let timer;
614
+ const done = () => { params.deps.clearTimer(timer); heartbeatStop.signal.removeEventListener("abort", done); resolve(); };
615
+ timer = params.deps.setTimer(done, ms);
616
+ heartbeatStop.signal.addEventListener("abort", done, { once: true });
617
+ }) } : params.deps;
594
618
  const hb = runHeartbeatLoop({
595
619
  job: params.job,
596
620
  httpClient: params.httpClient,
597
621
  options: params.options,
598
- deps: params.deps,
622
+ deps: heartbeatDeps,
599
623
  ownership: params.ownership,
600
624
  proc: params.proc,
601
625
  observation: params.observation,
@@ -623,8 +647,10 @@ async function superviseProcess(params) {
623
647
  : {}),
624
648
  });
625
649
  isDoneRef.value = true;
650
+ heartbeatStop.abort();
626
651
  const loopOutcome = await hb;
627
- params.observation.setExitCode(result.exitCode);
652
+ if (params.recordProcessExit !== false)
653
+ params.observation.setExitCode(result.exitCode);
628
654
  if (loopOutcome.kind === "auth_fatal") {
629
655
  // BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
630
656
  // identifiers — never carried onto `result` itself, so the process's own
@@ -785,6 +811,13 @@ control) {
785
811
  if (job.job_type === "ci_rerun") {
786
812
  return runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
787
813
  }
814
+ // BAPI-1391: `gate_qualification` is the run-less, deterministic gate
815
+ // qualification, dispatched here beside `ci_rerun` for the same reasons: no
816
+ // worktrunk worktree, no worker, and no fallback to one. `job-types.ts` keeps
817
+ // it out of `SPAWN_JOB_TYPES` and `RECOVERY_JOB_TYPES`.
818
+ if (job.job_type === "gate_qualification") {
819
+ return runGateQualificationExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
820
+ }
788
821
  // BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
789
822
  // and before the generic spawn path. It needs a prepared, locked worktree like
790
823
  // a spawn job, but its body is the reconcile module's state machine: the
@@ -822,6 +855,124 @@ control) {
822
855
  });
823
856
  return { status: "failed", reason: "unsupported_job_type" };
824
857
  }
858
+ /**
859
+ * Bound on waiting for a killed `gate_qualification` body to finish its own
860
+ * teardown (terminating a running command, removing the detached checkout)
861
+ * before the terminal report is sent.
862
+ */
863
+ const GATE_QUALIFICATION_SETTLE_MAX_MS = 180_000;
864
+ /**
865
+ * Run a deterministic `gate_qualification` job (BAPI-1391).
866
+ *
867
+ * The same lifecycle as {@link runCiRerunExecutorJob}: the payload timeout is
868
+ * resolved first, the body runs as a flow under {@link superviseProcess} (the
869
+ * one ownership, heartbeat, dead-man, and timeout protocol), and every terminal
870
+ * report goes through {@link sendTerminalMutationWithAuthReport}.
871
+ *
872
+ * The completion carries only the bounded result envelope from
873
+ * `gate-qualification-job.ts`. An inability goes to `/fail` with a closed
874
+ * error kind and fixed text; command output never reaches either.
875
+ */
876
+ async function runGateQualificationExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control) {
877
+ const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
878
+ if (!timeout.ok) {
879
+ await httpClient.fail(job, {
880
+ error_kind: "ContractError.Timeout",
881
+ error_message: timeout.error,
882
+ classification: "crashed",
883
+ });
884
+ return { status: "failed", reason: "timeout_contract" };
885
+ }
886
+ const timeoutSeconds = timeout.timeoutSeconds;
887
+ const runGateQualification = seams.runGateQualification ??
888
+ ((claimed, input) => runGateQualificationJob(claimed, input, createProductionGateQualificationDeps({
889
+ deps,
890
+ options,
891
+ registerLive: (controller) => control?.liveWorkers.register(controller),
892
+ ownershipHeld: () => !ownership.abandoned,
893
+ })));
894
+ const flow = createFlowProcess((signal) => runGateQualification(job, { timeoutSeconds, signal }), (result) => result.ok);
895
+ const procResult = await superviseProcess({
896
+ job,
897
+ httpClient,
898
+ options,
899
+ deps,
900
+ ownership,
901
+ observation,
902
+ proc: flow.proc,
903
+ timeoutSeconds,
904
+ collectTelemetry: async () => ({}),
905
+ control,
906
+ });
907
+ // A killed flow keeps running until its own `finally` has terminated any
908
+ // command and removed the detached checkout. Wait for that, bounded, so the
909
+ // terminal report never races the teardown of a checkout it describes.
910
+ let settleTimer;
911
+ try {
912
+ await Promise.race([
913
+ flow.settled,
914
+ new Promise((resolve) => {
915
+ settleTimer = deps.setTimer(resolve, GATE_QUALIFICATION_SETTLE_MAX_MS);
916
+ }),
917
+ ]);
918
+ }
919
+ finally {
920
+ if (settleTimer !== undefined)
921
+ deps.clearTimer(settleTimer);
922
+ }
923
+ if (ownership.abandoned) {
924
+ return { status: "abandoned", reason: ownership.abandonReason };
925
+ }
926
+ const sendFail = async (errorKind, message, classification) => {
927
+ const terminal = await sendTerminalMutationWithAuthReport({
928
+ kind: "fail",
929
+ send: () => httpClient.fail(job, {
930
+ error_kind: errorKind,
931
+ error_message: message,
932
+ classification,
933
+ telemetry: observation.snapshot(),
934
+ }),
935
+ deps,
936
+ options,
937
+ ownership,
938
+ log: deps.log,
939
+ }, job, control);
940
+ return terminalToRunResult(terminal, "failed");
941
+ };
942
+ if (procResult.classification === "timeout") {
943
+ return sendFail(GATE_QUALIFICATION_FAILED, "gate_qualification exceeded its job timeout", "timeout");
944
+ }
945
+ const outcome = flow.getOutcome();
946
+ if (outcome === undefined) {
947
+ // Killed without a timeout (a server stop) — the commands were cut short —
948
+ // or the body threw, which it is built never to do.
949
+ return procResult.serverStopRequested
950
+ ? sendFail(GATE_QUALIFICATION_INTERRUPTED, "gate qualification was stopped before it finished", "killed")
951
+ : sendFail(GATE_QUALIFICATION_FAILED, "gate_qualification completed without an outcome", "crashed");
952
+ }
953
+ if (outcome.ok) {
954
+ const completion = {
955
+ job_type: "gate_qualification",
956
+ exit_code: 0,
957
+ classification: "clean_exit",
958
+ // The bounded result envelope, verbatim. The backend model is
959
+ // `extra="forbid"` and recomputes the outcome against the STORED payload.
960
+ result: outcome.result,
961
+ telemetry: observation.snapshot(),
962
+ };
963
+ const terminal = await sendTerminalMutationWithAuthReport({
964
+ kind: "complete",
965
+ send: () => httpClient.complete(job, completion),
966
+ deps,
967
+ options,
968
+ ownership,
969
+ log: deps.log,
970
+ }, job, control);
971
+ return terminalToRunResult(terminal, "completed");
972
+ }
973
+ // An inability: a closed error kind and fixed text, never command output.
974
+ return sendFail(outcome.failure.errorKind, outcome.failure.message, "crashed");
975
+ }
825
976
  /**
826
977
  * Run a deterministic `ci_rerun` job (BAPI-1160).
827
978
  *
@@ -1240,6 +1391,8 @@ async function superviseDriverPhase(params) {
1240
1391
  ownership: params.ownership,
1241
1392
  observation: params.observation,
1242
1393
  proc: flow.proc,
1394
+ cancelHeartbeatSleepOnExit: true,
1395
+ recordProcessExit: false,
1243
1396
  timeoutSeconds: Math.max(1, params.timeoutSeconds),
1244
1397
  collectTelemetry: params.collectTelemetry,
1245
1398
  control: params.control,
@@ -1404,7 +1557,19 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
1404
1557
  return { ok: false, error: failure.error_message, failure };
1405
1558
  }
1406
1559
  }
1407
- async function prepareSpawn(job, httpClient, options, deps, seams) {
1560
+ /** Advisory comparison capture; never reads a payload-provided replacement. */
1561
+ async function captureComparisonSha(worktreePath, deps) {
1562
+ try {
1563
+ const result = await deps.runCommand("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
1564
+ const sha = result.stdout.trim();
1565
+ if (result.exitCode === 0 && /^[a-fA-F0-9]{40}$/.test(sha))
1566
+ return sha.toLowerCase();
1567
+ }
1568
+ catch { /* advisory only */ }
1569
+ deps.errorLog("[executor] immutable comparison SHA unavailable; disclose the discovery gap in the attempt transcript.");
1570
+ return undefined;
1571
+ }
1572
+ async function prepareSpawn(job, httpClient, options, deps, seams, preparation) {
1408
1573
  // A continuation passes no local head here (`head === undefined`): its retained
1409
1574
  // worktree was already proven by `prepareRetainedContinuationWorktree` →
1410
1575
  // `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
@@ -1412,12 +1577,23 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1412
1577
  // `correction_intent.head_sha`, including one the server derived for a first
1413
1578
  // attempt with no bound ticket head (BAPI-1344). The spawn source is always
1414
1579
  // that retained worktree, never an origin checkout.
1415
- const refuseCorrectionDrift = async (branch, head) => {
1580
+ const refuseCorrectionDrift = async (branch, head, publication, worktreePath) => {
1416
1581
  if (!isRemainingWork(job.job_type, job.payload))
1417
1582
  return null;
1418
1583
  const intent = job.payload?.correction_intent;
1419
1584
  if (branch === intent.branch && (head === undefined || head === intent.head_sha))
1420
1585
  return null;
1586
+ if (preparation && publication && worktreePath && publication.jobId === job.id &&
1587
+ publication.preparationId === preparation.preparationId && publication.branch === branch &&
1588
+ branch === intent.branch && head === publication.to_sha && head !== intent.head_sha &&
1589
+ typeof intent.head_sha === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(intent.head_sha)) {
1590
+ try {
1591
+ const proof = await deps.runCommand("git", ["merge-base", "--is-ancestor", intent.head_sha, head], { cwd: worktreePath });
1592
+ if (proof.exitCode === 0)
1593
+ return null;
1594
+ }
1595
+ catch { /* fail closed */ }
1596
+ }
1421
1597
  const reason = "correction_context_missing";
1422
1598
  await httpClient.fail(job, {
1423
1599
  error_kind: "ContractError.CorrectionLegacy",
@@ -1508,13 +1684,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1508
1684
  }
1509
1685
  const wt = await ensureJobWorktree(job, options, deps, seams, {
1510
1686
  reuseExistingBranch: isRecoveryJobType(job.job_type),
1687
+ preparation,
1511
1688
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1512
1689
  });
1513
1690
  if (!wt.ok) {
1514
1691
  await httpClient.fail(job, jobWorktreeFailure(wt));
1515
1692
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1516
1693
  }
1517
- const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1694
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha, wt.publication, wt.worktreePath);
1518
1695
  if (drift)
1519
1696
  return drift;
1520
1697
  return {
@@ -1523,6 +1700,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1523
1700
  branch: wt.branch,
1524
1701
  prompt: "",
1525
1702
  expectedHeadSha: wt.expectedHeadSha,
1703
+ ...(wt.publication ? { publication: wt.publication } : {}),
1526
1704
  ciFix,
1527
1705
  };
1528
1706
  }
@@ -1587,6 +1765,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1587
1765
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
1588
1766
  const wt = await ensureJobWorktree(job, options, deps, seams, {
1589
1767
  reuseExistingBranch,
1768
+ preparation,
1590
1769
  // BAPI-862: hand the guard the reconciler's own reading of why this branch
1591
1770
  // may already carry commits. Read from the SERVER-minted payload field and
1592
1771
  // validated against a closed set — never inferred locally, and never taken
@@ -1601,7 +1780,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1601
1780
  await httpClient.fail(job, jobWorktreeFailure(wt));
1602
1781
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1603
1782
  }
1604
- const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1783
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha, wt.publication, wt.worktreePath);
1605
1784
  if (drift)
1606
1785
  return drift;
1607
1786
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
@@ -1614,6 +1793,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1614
1793
  branch: wt.branch,
1615
1794
  prompt: explicit.prompt,
1616
1795
  expectedHeadSha: wt.expectedHeadSha,
1796
+ ...(wt.publication ? { publication: wt.publication } : {}),
1617
1797
  ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1618
1798
  ...(focusedRebase ? { focusedRebase } : {}),
1619
1799
  };
@@ -1630,6 +1810,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1630
1810
  workBranch: wt.branch,
1631
1811
  baseBranch: options.baseBranch,
1632
1812
  runCommand: deps.runCommand,
1813
+ deferComparisonGuidance: true,
1633
1814
  });
1634
1815
  return {
1635
1816
  ok: true,
@@ -1637,6 +1818,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1637
1818
  branch: wt.branch,
1638
1819
  prompt: rendered,
1639
1820
  expectedHeadSha: wt.expectedHeadSha,
1821
+ ...(wt.publication ? { publication: wt.publication } : {}),
1640
1822
  ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1641
1823
  };
1642
1824
  }
@@ -1772,7 +1954,15 @@ async function renderVerifiedCiFixPrompt(params) {
1772
1954
  return { ok: false, error: ciFixContextError(job, "head_unreadable") };
1773
1955
  }
1774
1956
  if (head !== ciFix.headSha) {
1775
- return { ok: false, error: ciFixContextError(job, "stale_head") };
1957
+ const fact = params.publication;
1958
+ const context = params.preparation;
1959
+ if (!context || !fact || fact.jobId !== job.id || fact.preparationId !== context.preparationId ||
1960
+ fact.branch !== branch || fact.to_sha !== head || fact.from_sha !== ciFix.headSha) {
1961
+ return { ok: false, error: ciFixContextError(job, "stale_head") };
1962
+ }
1963
+ const proof = await deps.runCommand("git", ["merge-base", "--is-ancestor", ciFix.headSha, head], { cwd: worktreePath });
1964
+ if (proof.exitCode !== 0)
1965
+ return { ok: false, error: ciFixContextError(job, "stale_head") };
1776
1966
  }
1777
1967
  const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
1778
1968
  try {
@@ -1781,6 +1971,7 @@ async function renderVerifiedCiFixPrompt(params) {
1781
1971
  workBranch: branch,
1782
1972
  baseBranch,
1783
1973
  runCommand: deps.runCommand,
1974
+ comparisonSha: ciFix.headSha,
1784
1975
  });
1785
1976
  return { ok: true, prompt };
1786
1977
  }
@@ -1944,13 +2135,106 @@ async function superviseFocusedRebaseWait(params) {
1944
2135
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
1945
2136
  * touches a worktree that will not become a runnable worker.
1946
2137
  */
1947
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
2138
+ /** Reserve the branch before Worktrunk can mutate it; lock an existing tree before preparation. */
2139
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver) {
2140
+ const originalHttp = httpClient;
2141
+ let diagnosticWarningSent = false;
2142
+ const failWithTelemetry = async (failedJob, body) => {
2143
+ let telemetry = { ...body.telemetry, ...observation.snapshot() };
2144
+ try {
2145
+ const diagnostics = observation.workerDiagnostics();
2146
+ if (diagnostics)
2147
+ telemetry = { ...telemetry, worker_diagnostics: diagnostics };
2148
+ }
2149
+ catch {
2150
+ if (!diagnosticWarningSent) {
2151
+ diagnosticWarningSent = true;
2152
+ try {
2153
+ deps.errorLog(`[executor] job ${job.id} ${new Date(deps.now()).toISOString()}: diagnostics unavailable (collection_failed)`);
2154
+ }
2155
+ catch { /* supplemental sink */ }
2156
+ }
2157
+ }
2158
+ return originalHttp.fail(failedJob, { ...body, telemetry });
2159
+ };
2160
+ httpClient = new Proxy(originalHttp, { get(target, key) {
2161
+ if (key === "fail")
2162
+ return failWithTelemetry;
2163
+ const value = Reflect.get(target, key);
2164
+ return typeof value === "function" ? value.bind(target) : value;
2165
+ } });
2166
+ const originalDeps = deps;
2167
+ deps = { ...deps, errorLog: (message) => {
2168
+ try {
2169
+ originalDeps.errorLog(`[executor] job ${job.id} ${new Date(originalDeps.now()).toISOString()}: ${message}`);
2170
+ }
2171
+ catch { /* supplemental logging cannot fail the job */ }
2172
+ } };
2173
+ const resolved = resolveExecutorBranch(job);
2174
+ if (!resolved.ok)
2175
+ return runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
2176
+ const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
2177
+ if (!timeout.ok)
2178
+ return runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
2179
+ const deadline = deps.now() + timeout.timeoutSeconds * 1000;
2180
+ const boundedDeps = { ...deps, runCommand: async (file, args, opts) => {
2181
+ const remaining = deadline - deps.now();
2182
+ if (remaining <= 0 || ownership.abandoned)
2183
+ throw new Error("preparation unavailable");
2184
+ return deps.runCommand(file, args, { ...opts, timeoutMs: Math.min(opts?.timeoutMs ?? 120_000, remaining) });
2185
+ } };
2186
+ const acquire = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
2187
+ let reservation;
2188
+ let retainedLock;
2189
+ const reserve = async () => {
2190
+ try {
2191
+ const common = await boundedDeps.runCommand("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: deps.cwd });
2192
+ if (common.exitCode !== 0 || !path.isAbsolute(common.stdout.trim()))
2193
+ throw new Error("reservation unavailable");
2194
+ const reservationPath = path.join(common.stdout.trim(), "executor-preparation", createHash("sha256").update(resolved.branch).digest("hex"));
2195
+ const request = (worktreePath) => ({ worktreePath, jobId: job.id, claimToken: job.claim_token,
2196
+ executorId: options.executorId, ownerPid: process.pid, acquiredAt: nowIso(deps) });
2197
+ const reserved = await acquire(request(reservationPath), buildWorktreeLockSeams(deps));
2198
+ if (!reserved.acquired) {
2199
+ deps.errorLog(`[executor] worktree already held job_id=${job.id} held_by=job_id=${reserved.owner?.jobId ?? "unknown"}`);
2200
+ return null;
2201
+ }
2202
+ reservation = reserved;
2203
+ const entries = await boundedDeps.runCommand("git", ["worktree", "list", "--porcelain"], { cwd: deps.cwd });
2204
+ if (entries.exitCode !== 0)
2205
+ throw new Error("worktree discovery unavailable");
2206
+ const retained = findWorktreeByBranch(parseGitWorktreePorcelain(entries.stdout), resolved.branch);
2207
+ if (retained) {
2208
+ const held = await acquire(request(retained), buildWorktreeLockSeams(deps));
2209
+ if (!held.acquired)
2210
+ throw new Error("worktree busy");
2211
+ retainedLock = held;
2212
+ }
2213
+ const preparationId = randomUUID();
2214
+ const preparation = { jobId: job.id, preparationId, branch: resolved.branch,
2215
+ remainingMs: () => Math.max(0, deadline - deps.now()), mayMutate: () => !ownership.abandoned,
2216
+ recordPublication: (fact) => observation.recordPublication(fact) };
2217
+ return { retainedLock, preparation, deps: boundedDeps };
2218
+ }
2219
+ catch {
2220
+ return null;
2221
+ }
2222
+ };
2223
+ try {
2224
+ return await runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver, undefined, undefined, reserve);
2225
+ }
2226
+ finally {
2227
+ await retainedLock?.release();
2228
+ await reservation?.release();
2229
+ }
2230
+ }
2231
+ async function runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
1948
2232
  /**
1949
2233
  * BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
1950
2234
  * the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
1951
2235
  * adds no second worktree entry and no second worker launcher.
1952
2236
  */
1953
- driver) {
2237
+ driver, retainedLock, preparation, reserve) {
1954
2238
  // A server-owned read precedes every worktree reset/cut and worker launch.
1955
2239
  // Only this boundary can authorize a no-charge pre-work wait; a later plan
1956
2240
  // retrieval failure is ordinary bounded worker failure.
@@ -2071,11 +2355,94 @@ driver) {
2071
2355
  });
2072
2356
  return { status: "failed", reason: "permission_posture_contract" };
2073
2357
  }
2358
+ const executionDeps = deps;
2359
+ // Focused payload admission also precedes every lock/filesystem mutation.
2360
+ if (job.job_type === CI_FIX_JOB_TYPE) {
2361
+ try {
2362
+ (seams.readCiFixDispatchContext ?? readCiFixDispatchContext)(job);
2363
+ }
2364
+ catch (err) {
2365
+ const failure = isExecutorNamedError(err) ? toExecutorFailure(err) : { error_kind: CiFixContext, error_message: secretFreeErrorMessage(err), classification: "crashed" };
2366
+ deps.errorLog(failure.error_message);
2367
+ await httpClient.fail(job, failure);
2368
+ return { status: "failed", reason: "ci_fix_context" };
2369
+ }
2370
+ }
2371
+ if (job.job_type === FOCUSED_REBASE_JOB_TYPE && hasFocusedRebaseContext(job)) {
2372
+ const validation = validateFocusedRebaseContext(job);
2373
+ if (!validation.ok) {
2374
+ const failure = toExecutorFailure(focusedRebaseContextError(job, validation.reason));
2375
+ deps.errorLog(failure.error_message);
2376
+ await httpClient.fail(job, failure);
2377
+ return { status: "failed", reason: "focused_rebase_context" };
2378
+ }
2379
+ }
2380
+ // Acquire ownership only after read-only admission and contract validation.
2381
+ if (reserve) {
2382
+ const held = await reserve();
2383
+ if (!held) {
2384
+ if (ownership.abandoned)
2385
+ return { status: "abandoned", reason: ownership.abandonReason };
2386
+ await httpClient.fail(job, { error_kind: WorktreeBusy, error_message: WORKTREE_BUSY_MESSAGE,
2387
+ classification: "crashed", telemetry: observation.snapshot() });
2388
+ return { status: "failed", reason: "worktree_busy" };
2389
+ }
2390
+ retainedLock = held.retainedLock;
2391
+ preparation = held.preparation;
2392
+ deps = held.deps;
2393
+ }
2074
2394
  // --- Worktree + prompt (resume pre-spawn protocol or standard) -------
2075
- const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
2395
+ let prep;
2396
+ if (preparation) {
2397
+ const phase = await superviseDriverPhase({ job, httpClient, options: jobOptions, deps, ownership, observation, control,
2398
+ timeoutSeconds: Math.max(1, Math.ceil(preparation.remainingMs() / 1000)), collectTelemetry: async () => ({}),
2399
+ run: async (signal) => {
2400
+ const allowed = preparation.mayMutate;
2401
+ const context = { ...preparation, mayMutate: () => allowed() && !signal.aborted };
2402
+ const phaseDeps = { ...deps, runCommand: (file, args, opts) => deps.runCommand(file, args, { ...opts, signal }) };
2403
+ return prepareSpawn(job, httpClient, jobOptions, phaseDeps, seams, context);
2404
+ } });
2405
+ if (phase.kind !== "result") {
2406
+ if (phase.kind === "abandoned")
2407
+ return { status: "abandoned", reason: ownership.abandonReason };
2408
+ await httpClient.fail(job, { error_kind: phase.kind === "server_stop" ? "ServerStopRequested" : phase.kind === "timeout" ? "Timeout" : "WorktreeError",
2409
+ error_message: "executor preparation stopped; retained work preserved", classification: phase.kind === "timeout" ? "timeout" : phase.kind === "server_stop" ? "killed" : "crashed" });
2410
+ if (phase.kind === "server_stop") {
2411
+ ownership.abandoned = true;
2412
+ ownership.abandonReason = "server_stop";
2413
+ return { status: "abandoned", reason: "server_stop" };
2414
+ }
2415
+ return { status: "failed", reason: "preparation_stopped" };
2416
+ }
2417
+ prep = phase.value;
2418
+ }
2419
+ else
2420
+ prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
2076
2421
  if (!prep.ok)
2077
2422
  return prep.result;
2423
+ deps = executionDeps;
2424
+ if (preparation)
2425
+ prep = { ...prep, deadlineMs: deps.now() + preparation.remainingMs() };
2078
2426
  const { worktreePath, branch } = prep;
2427
+ let retainedPathMatches = !retainedLock || path.resolve(retainedLock.owner.worktreePath) === path.resolve(worktreePath);
2428
+ if (!retainedPathMatches && retainedLock) {
2429
+ try {
2430
+ retainedPathMatches = await realpath(retainedLock.owner.worktreePath) === await realpath(worktreePath);
2431
+ }
2432
+ catch { /* unreadable identity fails closed */ }
2433
+ }
2434
+ if (!retainedPathMatches) {
2435
+ await httpClient.fail(job, { error_kind: WorktreeBusy, error_message: WORKTREE_BUSY_MESSAGE, classification: "crashed" });
2436
+ return { status: "failed", reason: "worktree_lock_path_changed" };
2437
+ }
2438
+ if (preparation) {
2439
+ const routing = await configureExecutorBranch(worktreePath, branch, deps);
2440
+ if (routing) {
2441
+ await httpClient.fail(job, { error_kind: "WorktreeError", error_message: routing,
2442
+ classification: "crashed", telemetry: observation.snapshot() });
2443
+ return { status: "failed", reason: "push_routing" };
2444
+ }
2445
+ }
2079
2446
  // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
2080
2447
  // The FIRST thing that happens once a canonical worktree path exists, and
2081
2448
  // ahead of every executor-owned write to it: the deny layer, MCP
@@ -2086,15 +2453,10 @@ driver) {
2086
2453
  // are now created exclusively, and refusal still returns from HERE rather
2087
2454
  // than anywhere downstream.
2088
2455
  //
2089
- // Known residual window, stated rather than papered over: `prepareSpawn`
2090
- // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
2091
- // Its path comes back FROM Worktrunk, so there is no path to lock before it
2092
- // without duplicating Worktrunk's layout rules here — a copy that would go
2093
- // wrong silently. Dispatch exclusion and same-ticket claim serialization
2094
- // (the Python half of this ticket) are what shrink that window; this lock is
2095
- // what makes everything after it safe.
2456
+ // The branch reservation covers not-yet-created worktrees; retained trees
2457
+ // already hold this same worktree lock before preparation.
2096
2458
  const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
2097
- const worktreeLock = await acquireLock({
2459
+ const worktreeLock = retainedLock ?? await acquireLock({
2098
2460
  worktreePath,
2099
2461
  jobId: job.id,
2100
2462
  claimToken: job.claim_token,
@@ -2143,6 +2505,7 @@ driver) {
2143
2505
  const rendered = await renderVerifiedCiFixPrompt({
2144
2506
  job,
2145
2507
  ciFix: prep.ciFix,
2508
+ publication: prep.publication, preparation,
2146
2509
  worktreePath,
2147
2510
  branch,
2148
2511
  baseBranch: effectiveBaseBranch,
@@ -2157,6 +2520,22 @@ driver) {
2157
2520
  }
2158
2521
  readyPrep = { ...prep, prompt: rendered.prompt };
2159
2522
  }
2523
+ if (["implement", "resume", "remediate", "rebase", "ci_fix"].includes(job.job_type)) {
2524
+ readyPrep = { ...readyPrep, comparisonSha: prep.ciFix?.headSha ??
2525
+ await captureComparisonSha(worktreePath, deps) };
2526
+ if (!prep.ciFix && isRemainingWork(job.job_type, job.payload)) {
2527
+ if (job.job_type === "resume" || prep.priorWork) {
2528
+ // Resume preparation owns retained continuation text, including seam-
2529
+ // supplied text. Do not reconstruct it from the earlier payload.
2530
+ readyPrep = { ...readyPrep, prompt: readyPrep.prompt + "\n\n" + renderWorkerVerificationGuidance(readyPrep.comparisonSha) };
2531
+ }
2532
+ else {
2533
+ const final = resolveExecutorPrompt(job, { comparisonSha: readyPrep.comparisonSha, diagnostic: deps.errorLog });
2534
+ readyPrep = { ...readyPrep, prompt: final.ok ? final.prompt :
2535
+ readyPrep.prompt + "\n\n" + renderWorkerVerificationGuidance(readyPrep.comparisonSha) };
2536
+ }
2537
+ }
2538
+ }
2160
2539
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
2161
2540
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
2162
2541
  // so every job type routed through `runSpawnJob` is covered — `implement`,
@@ -2189,6 +2568,15 @@ driver) {
2189
2568
  });
2190
2569
  return { status: "failed", reason: "stale_artifact_cleanup" };
2191
2570
  }
2571
+ try {
2572
+ await (seams.writeRecentlyFailedTestsFile ?? writeRecentlyFailedTestsFile)(worktreePath, job.payload?.recently_failed_tests);
2573
+ }
2574
+ catch {
2575
+ await httpClient.fail(job, { error_kind: "ContractError.RecentlyFailedTests",
2576
+ error_message: "Recent failure resolver file could not be safely confirmed",
2577
+ classification: "crashed" });
2578
+ return { status: "failed", reason: "recently_failed_tests_file" };
2579
+ }
2192
2580
  return await runPreparedSpawn({
2193
2581
  job,
2194
2582
  httpClient,
@@ -2231,6 +2619,32 @@ driver) {
2231
2619
  await worktreeLock.release();
2232
2620
  }
2233
2621
  }
2622
+ function retainedHeadPublisher(params) {
2623
+ return async () => {
2624
+ const { job, branch, worktreePath, prep, deps, ownership, observation, remainingMs } = params;
2625
+ if (!prep.publicationBaseline)
2626
+ return { status: "refused", reason: "invalid_input" };
2627
+ const phase = await superviseDriverPhase({ job, httpClient: params.httpClient, options: params.options, deps, ownership, observation, control: params.control,
2628
+ timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)), collectTelemetry: async () => ({}),
2629
+ run: async (signal) => {
2630
+ const result = await guardedFastForwardPush({ branch, expectedOriginSha: prep.publicationBaseline,
2631
+ worktreePath, runCommand: deps.runCommand, env: deps.env, remainingMs, signal,
2632
+ mayMutate: () => !ownership.abandoned && !signal.aborted });
2633
+ if (result.status === "published")
2634
+ observation.recordPublication({ ...result,
2635
+ jobId: job.id, preparationId: prep.publication?.preparationId ?? "finalization", branch });
2636
+ return result;
2637
+ } });
2638
+ if (phase.kind !== "result") {
2639
+ if (phase.kind === "server_stop") {
2640
+ ownership.abandoned = true;
2641
+ ownership.abandonReason = "server_stop";
2642
+ }
2643
+ return { status: "refused", reason: phase.kind === "error" ? "remote_rejected" : "timeout" };
2644
+ }
2645
+ return phase.value;
2646
+ };
2647
+ }
2234
2648
  /**
2235
2649
  * Run a spawn job whose worktree is already prepared AND exclusively locked.
2236
2650
  *
@@ -2257,8 +2671,18 @@ async function runPreparedSpawn(params) {
2257
2671
  // base contract's "when you open the pull request" instruction would re-add the
2258
2672
  // new-PR step the focused prompt deliberately excludes.
2259
2673
  let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
2260
- ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
2674
+ ? `${prep.prompt} ${renderWorkerPrBaseGuidance()}`
2261
2675
  : prep.prompt;
2676
+ // BAPI-1399: a worker that implements ticket requirements is told the
2677
+ // minor-versus-core workaround contract in the same launch prompt. Repair jobs
2678
+ // (`ci_fix`, a focused rebase, `reconcile_overlap`) and a focused remaining-work
2679
+ // correction implement no new requirement, so their prompts stay as they are.
2680
+ if (WORKAROUND_CONTRACT_JOB_TYPES.has(job.job_type) &&
2681
+ !prep.ciFix &&
2682
+ !prep.focusedRebase &&
2683
+ !isRemainingWork(job.job_type, job.payload)) {
2684
+ prompt = `${prompt} ${buildEpicWorkaroundWorkerInstruction(job.ticket_key)}`;
2685
+ }
2262
2686
  // --- Timeout contract ------------------------------------------------
2263
2687
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
2264
2688
  if (!timeout.ok) {
@@ -2290,12 +2714,26 @@ async function runPreparedSpawn(params) {
2290
2714
  // finish the whole job here (a clean, green reconciliation pushes with no
2291
2715
  // worker at all), or hand back the ONE worker it needs and the pre-spawn state
2292
2716
  // it has already proven. The job's overall timeout budget spans every phase.
2293
- const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
2717
+ const jobDeadlineMs = prep.deadlineMs ?? deps.now() + timeout.timeoutSeconds * 1000;
2294
2718
  const remainingMs = () => Math.max(0, jobDeadlineMs - deps.now());
2719
+ if (!driver) {
2720
+ try {
2721
+ const remote = await deps.runCommand("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branch}`], { cwd: worktreePath, timeoutMs: Math.min(120_000, remainingMs()) });
2722
+ const fields = remote.stdout.trim().split(/\s+/);
2723
+ if (remote.exitCode === 0 && fields.length === 2 && fields[1] === `refs/heads/${branch}` && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(fields[0])) {
2724
+ prep.publicationBaseline = isRecoveryJobType(job.job_type) ? prep.expectedHeadSha : fields[0];
2725
+ }
2726
+ }
2727
+ catch { /* no eligible lease baseline */ }
2728
+ }
2295
2729
  const driverBaseContext = {
2296
2730
  worktreePath,
2297
2731
  branch,
2298
2732
  preparedHeadSha: prep.expectedHeadSha,
2733
+ preparationPublication: prep.publication,
2734
+ recordPublication: (fact) => observation.recordPublication({
2735
+ ...fact, jobId: job.id, preparationId: prep.publication?.preparationId ?? "finalization", branch,
2736
+ }),
2299
2737
  effectiveBaseBranch,
2300
2738
  };
2301
2739
  const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
@@ -2328,6 +2766,37 @@ async function runPreparedSpawn(params) {
2328
2766
  // launch instruction would tell a worker with no push authority to open a PR.
2329
2767
  prompt = driverStep.prompt;
2330
2768
  }
2769
+ // --- Packaged recipe delivery (BAPI-1392) ------------------------------
2770
+ // Applied ONCE, to the final prompt, so a command-bearing prompt makes the worker
2771
+ // follow the CURRENT packaged recipe instead of a stale committed copy (command
2772
+ // provisioning is fill-only, BAPI-664). Prompt expansion reaches both launch
2773
+ // transports because each carries exactly this string. Nothing is written under
2774
+ // the worktree. Only the two command-bearing prompts are eligible — the initial
2775
+ // `implement` synthesis and the server-built `spec_review` prompt. Every focused,
2776
+ // driver-rendered and remaining-work prompt is left byte-identical, as is any
2777
+ // prompt that names no recognized, non-customized Bridge command.
2778
+ if (!driverStep &&
2779
+ !prep.ciFix &&
2780
+ !prep.focusedRebase &&
2781
+ (job.job_type === "implement" || job.job_type === "spec_review") &&
2782
+ !isRemainingWork(job.job_type, job.payload)) {
2783
+ const recipes = resolvePackagedRecipes(prompt, readCustomizedCommands(job.payload));
2784
+ if (!recipes.ok) {
2785
+ deps.errorLog(`[executor] job ${job.id}: ${recipes.error}`);
2786
+ await httpClient.fail(job, {
2787
+ error_kind: PackagedRecipeUndeliverable,
2788
+ error_message: recipes.error,
2789
+ classification: "crashed",
2790
+ });
2791
+ return { status: "failed", reason: "packaged_recipe_undeliverable" };
2792
+ }
2793
+ prompt = recipes.prompt;
2794
+ }
2795
+ // Trusted starter context is appended AFTER packaged-command expansion, never
2796
+ // inserted into command arguments or scanned through untrusted server evidence.
2797
+ if (job.job_type === "implement" && !isRemainingWork(job.job_type, job.payload)) {
2798
+ prompt += "\n\n" + renderWorkerVerificationGuidance(prep.comparisonSha);
2799
+ }
2331
2800
  // --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
2332
2801
  // FIRST of the pre-spawn contracts, and deliberately BEFORE every
2333
2802
  // executor-owned write to the worktree (deny layer, MCP provisioning, worker
@@ -2517,6 +2986,7 @@ async function runPreparedSpawn(params) {
2517
2986
  if (logClaimId !== null) {
2518
2987
  try {
2519
2988
  tee = await createTee(worktreePath, {
2989
+ now: deps.now,
2520
2990
  mkdir: deps.mkdir,
2521
2991
  writeFile: deps.writeFile,
2522
2992
  appendFile: deps.appendFile,
@@ -2535,6 +3005,8 @@ async function runPreparedSpawn(params) {
2535
3005
  // this attempt has no transcript, which is said once, without detail.
2536
3006
  deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
2537
3007
  }
3008
+ if (tee)
3009
+ observation.allocateAttemptLog(tee.identity, tee.available);
2538
3010
  const teeClaimId = tee?.identity.claimId;
2539
3011
  // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
2540
3012
  // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
@@ -2552,6 +3024,22 @@ async function runPreparedSpawn(params) {
2552
3024
  // THIS attempt's entry is removed (BAPI-1330).
2553
3025
  if (teeClaimId !== undefined)
2554
3026
  deactivateJobLog(job.id, teeClaimId);
3027
+ // BAPI-1397 (F2): one fixed end-of-job line, AFTER deactivation so nothing
3028
+ // else can append behind it, on every terminal path (this closure is the
3029
+ // single idempotent close every branch and the `finally` backstop share).
3030
+ // The read-only viewer tab stops following the log when it reads it. Written
3031
+ // straight to the attempt's own file rather than through the tee, so a failed
3032
+ // write cannot mark the transcript unavailable; it is never fatal.
3033
+ if (tee &&
3034
+ deps.appendFile &&
3035
+ (tee.unavailableReason === undefined || tee.unavailableReason === "append_failed")) {
3036
+ try {
3037
+ await deps.appendFile(tee.logPath, EXECUTOR_JOB_END_LOG_CHUNK);
3038
+ }
3039
+ catch {
3040
+ deps.errorLog(`executor viewer: could not mark job ${job.id}'s worker log as ended (non-fatal)`);
3041
+ }
3042
+ }
2555
3043
  await closeWorkerLogTee(tee);
2556
3044
  };
2557
3045
  lifecycle.closeWorkerLog = closeWorkerLog;
@@ -2594,6 +3082,8 @@ async function runPreparedSpawn(params) {
2594
3082
  workerLogPath: tee.logPath,
2595
3083
  worktreePath,
2596
3084
  ticketKey: job.ticket_key ?? undefined,
3085
+ // BAPI-1397 (F2): the tab is titled `<TICKET> · <job type>`.
3086
+ jobType: job.job_type,
2597
3087
  runCommand: deps.runCommand,
2598
3088
  cwd: deps.cwd,
2599
3089
  });
@@ -2882,6 +3372,9 @@ async function runPreparedSpawn(params) {
2882
3372
  plan,
2883
3373
  runtime,
2884
3374
  nudge: (ids) => capability.value.buildWatcherNudge(ids),
3375
+ ...(capability.value.buildCompletionWindowNudge
3376
+ ? { windowNudge: (ids) => capability.value.buildCompletionWindowNudge(ids) }
3377
+ : {}),
2885
3378
  dispatchHeadSha,
2886
3379
  stripEnvFiles: stripWorktreeEnvFiles,
2887
3380
  });
@@ -2957,7 +3450,7 @@ async function runPreparedSpawn(params) {
2957
3450
  proc: supervisedProc,
2958
3451
  // BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
2959
3452
  // pre-worker phase has already spent some of it.
2960
- timeoutSeconds: driver ? Math.max(1, Math.ceil(remainingMs() / 1000)) : timeout.timeoutSeconds,
3453
+ timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
2961
3454
  collectTelemetry,
2962
3455
  collectRemoteMarker,
2963
3456
  mcpSurface,
@@ -3066,7 +3559,7 @@ async function runPreparedSpawn(params) {
3066
3559
  await httpClient.fail(job, {
3067
3560
  error_kind: McpSurfaceMismatch,
3068
3561
  error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
3069
- classification: "crashed",
3562
+ classification: toWireClassification(procResult.classification),
3070
3563
  ...commitResidue(finalTelemetry),
3071
3564
  telemetry: observation.snapshot(),
3072
3565
  });
@@ -3273,8 +3766,10 @@ async function runPreparedSpawn(params) {
3273
3766
  branch,
3274
3767
  worktreePath,
3275
3768
  result: genericResult,
3769
+ publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, remainingMs, httpClient, options: jobOptions, control }),
3276
3770
  runCommand: deps.runCommand,
3277
3771
  headSha: git.last_commit_sha,
3772
+ callTimeoutMs: () => Math.max(1, Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs())),
3278
3773
  expectedBaseBranch: effectiveBaseBranch,
3279
3774
  // BAPI-1311: a focused rebase's finalization reads are bounded by the
3280
3775
  // claim's remaining absolute deadline; every other job keeps the legacy,
@@ -3378,7 +3873,8 @@ async function runPreparedSpawn(params) {
3378
3873
  // BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
3379
3874
  // `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
3380
3875
  // the next job's reset fail. Removed for EVERY spawn job, never with a hard
3381
- // reset; only a driver (`reconcile_overlap`) additionally resets to its anchor.
3876
+ // reset; reconcile_overlap additionally discards unpublished wrapper merge
3877
+ // state, while preserving successful or ambiguous publication.
3382
3878
  if (procResult.classification === "timeout" || procResult.classification === "killed") {
3383
3879
  await cleanupInterruptedWorktree(worktreePath, {
3384
3880
  runCommand: deps.runCommand,
@@ -3430,6 +3926,15 @@ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
3430
3926
  const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3431
3927
  /** Bound on closing the held stdin once the closure rule fires. */
3432
3928
  const STDIN_CLOSE_BOUND_MS = 5_000;
3929
+ /**
3930
+ * BAPI-1395 — how long a worker may keep running after the completion window
3931
+ * closed its stdin before the one termination path is requested. Equal to the
3932
+ * grace the allowance plan already leaves between the hold deadline and the
3933
+ * process timer (the measured CLI stops its own tasks within ~0.4 s of EOF).
3934
+ * The request is also capped at the worker deadline, so the existing 20 s
3935
+ * cleanup reservation that follows the worker's exit is never eaten into.
3936
+ */
3937
+ const POST_WINDOW_EXIT_GRACE_MS = STDIN_CLOSE_GRACE_MS;
3433
3938
  const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3434
3939
  /**
3435
3940
  * BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
@@ -3699,7 +4204,14 @@ async function runCoveredAttempt(params) {
3699
4204
  // --- Ownership lifetime: one heartbeat for the whole attempt ---------------
3700
4205
  let childLive = true;
3701
4206
  let workerStartedAt = null;
3702
- const tracker = proc.pid !== undefined ? createDescendantTracker({ inspector, rootPid: proc.pid }) : null;
4207
+ const tracker = proc.pid !== undefined
4208
+ ? createDescendantTracker({
4209
+ inspector,
4210
+ rootPid: proc.pid,
4211
+ // Only the job id and the live count: never the token, a command, or stdout.
4212
+ onOverflow: (liveDescendants) => deps.errorLog(`descendant tracker overflow job=${job.id} live_descendants=${liveDescendants}; cleanup cannot be confirmed`),
4213
+ })
4214
+ : null;
3703
4215
  if (proc.pid !== undefined) {
3704
4216
  void inspector
3705
4217
  .identity(proc.pid)
@@ -3764,9 +4276,20 @@ async function runCoveredAttempt(params) {
3764
4276
  void tracker.sample().finally(scheduleSample);
3765
4277
  // --- Held-stdin closure supervision -----------------------------------------
3766
4278
  const stdinDeps = { now: deps.now, setTimer: deps.setTimer, clearTimer: deps.clearTimer };
4279
+ // BAPI-1395: a worker that ignores the stdin EOF the completion window sent is
4280
+ // ended through the existing termination controller, not a second kill path.
4281
+ let postWindowExitTimer;
3767
4282
  const supervisor = createClosureSupervisor({
3768
4283
  ledger,
3769
4284
  deps: stdinDeps,
4285
+ ...(params.windowNudge ? { buildWindowNudge: params.windowNudge } : {}),
4286
+ onWindowExpiry: () => {
4287
+ const graceMs = Math.max(0, Math.min(POST_WINDOW_EXIT_GRACE_MS, plan.workerDeadlineAtMs - deps.now()));
4288
+ postWindowExitTimer = deps.setTimer(() => {
4289
+ postWindowExitTimer = undefined;
4290
+ terminationController.requestTermination("completion_window");
4291
+ }, graceMs);
4292
+ },
3770
4293
  writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
3771
4294
  closeStdin: async () => {
3772
4295
  await closeStdinBounded(proc, {
@@ -3804,12 +4327,25 @@ async function runCoveredAttempt(params) {
3804
4327
  // Transport failed before any work: nothing the worker did can be
3805
4328
  // evidence. Stop it through the one termination path.
3806
4329
  ledger.recordTransportFailure(true);
3807
- terminationController.requestTermination();
4330
+ terminationController.requestTermination("executor_abort");
3808
4331
  }
3809
4332
  const procResult = await runPromise;
4333
+ const closedReason = supervisor.state().closedReason;
4334
+ // A termination request has already latched the effective cause. Closing
4335
+ // stdin later must not overwrite an earlier shutdown or containment stop.
4336
+ const processEndCause = procResult.endCause && procResult.endCause !== "worker_exit"
4337
+ ? procResult.endCause
4338
+ : closedReason === "window" ? "completion_window"
4339
+ : closedReason === "deadline" ? "job_timeout"
4340
+ : closedReason === "abort" ? "executor_abort"
4341
+ : "worker_exit";
4342
+ console.error(`[executor] End cause ${processEndCause}`);
3810
4343
  childLive = false;
3811
4344
  if (sampleTimer !== undefined)
3812
4345
  deps.clearTimer(sampleTimer);
4346
+ if (postWindowExitTimer !== undefined)
4347
+ deps.clearTimer(postWindowExitTimer);
4348
+ postWindowExitTimer = undefined;
3813
4349
  supervisor.dispose();
3814
4350
  lineFeeder.flush();
3815
4351
  ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
@@ -3855,18 +4391,21 @@ async function runCoveredAttempt(params) {
3855
4391
  const verificationRead = readRequiredVerification(job.payload);
3856
4392
  const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
3857
4393
  const checks = declaration?.checks ?? [];
3858
- const envelopeWith = (facts) => ledger.buildEnvelope({
3859
- runtime: params.runtime,
3860
- finalHeadSha,
3861
- finalization: facts.finalization,
3862
- verification: facts.verification,
3863
- cleanup,
3864
- missingCapabilities: facts.missing,
3865
- satisfiedObligations: [
3866
- ...(facts.pushSatisfied ? [obligations[0]] : []),
3867
- ...(facts.prSatisfied ? [obligations[1]] : []),
3868
- ],
3869
- ...(facts.verificationFacts ? { verificationFacts: facts.verificationFacts } : {}),
4394
+ const envelopeWith = (facts) => ({
4395
+ ...ledger.buildEnvelope({
4396
+ runtime: params.runtime,
4397
+ finalHeadSha,
4398
+ finalization: facts.finalization,
4399
+ verification: facts.verification,
4400
+ cleanup,
4401
+ missingCapabilities: facts.missing,
4402
+ satisfiedObligations: [
4403
+ ...(facts.pushSatisfied ? [obligations[0]] : []),
4404
+ ...(facts.prSatisfied ? [obligations[1]] : []),
4405
+ ],
4406
+ ...(facts.verificationFacts ? { verificationFacts: facts.verificationFacts } : {}),
4407
+ }),
4408
+ end_cause: processEndCause,
3870
4409
  });
3871
4410
  const unverifiedEnvelope = () => {
3872
4411
  const unlaunched = unlaunchedVerification(verificationRead);
@@ -3947,7 +4486,7 @@ async function runCoveredAttempt(params) {
3947
4486
  error_kind: "ServerStopRequested",
3948
4487
  error_message: "the server requested this run stop; the owned worker was terminated",
3949
4488
  classification: "killed",
3950
- }, unverifiedEnvelope());
4489
+ }, { ...unverifiedEnvelope(), end_cause: "server_stop" });
3951
4490
  ownership.abandoned = true;
3952
4491
  ownership.abandonReason = "server_stop";
3953
4492
  return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
@@ -3962,7 +4501,7 @@ async function runCoveredAttempt(params) {
3962
4501
  return finish(await failWith({
3963
4502
  error_kind: McpSurfaceMismatch,
3964
4503
  error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
3965
- classification: "crashed",
4504
+ classification: toWireClassification(procResult.classification),
3966
4505
  }, unverifiedEnvelope()));
3967
4506
  }
3968
4507
  if (surfaceVerdict.kind === "absent" &&
@@ -4123,6 +4662,9 @@ async function runCoveredAttempt(params) {
4123
4662
  branch,
4124
4663
  worktreePath,
4125
4664
  result: genericResult,
4665
+ publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, httpClient, options: jobOptions, control,
4666
+ remainingMs: () => Math.max(0, plan.deadlineAtMs - deps.now()) }),
4667
+ callTimeoutMs: () => Math.min(120_000, Math.max(0, plan.deadlineAtMs - deps.now())),
4126
4668
  runCommand: deps.runCommand,
4127
4669
  headSha: git.last_commit_sha,
4128
4670
  expectedBaseBranch: effectiveBaseBranch,