@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
@@ -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,26 +21,29 @@
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, 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";
26
34
  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
- import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, SUPPORTED_COMPLETION_RUNTIME, 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";
37
+ import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, COMPLETION_CONTRACT_MARKER_VALUES, isCoveredJobType, MAX_EVENT_BYTES, runtimeMatchesSupported, successRefusalReason, verificationVerdictFromFacts, } from "./completion-contract.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
- import { prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, } from "./resume-pre-spawn.js";
36
- import { ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
43
+ import { autoCheckpointDirtyState, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE, prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, verifyContinuationHeadLineage, } from "./resume-pre-spawn.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
  }
@@ -540,6 +553,46 @@ function createNoopProcess(deps, durationMs, message) {
540
553
  },
541
554
  };
542
555
  }
556
+ /**
557
+ * Bounded newline assembly of worker stdout into whole lines for a
558
+ * {@link RequiredWorkLedger} (BAPI-1301 covered path, BAPI-1364 legacy
559
+ * observation). A line that outgrows the event bound is handed over once (the
560
+ * ledger marks it `stream_oversize`) and the rest of it is discarded up to its
561
+ * newline. `onLine` runs after each completed line is ingested.
562
+ */
563
+ function createLedgerLineFeeder(ledger, onLine) {
564
+ let lineBuffer = "";
565
+ let discardingOversize = false;
566
+ return {
567
+ feed: (chunk) => {
568
+ lineBuffer += chunk;
569
+ let newline;
570
+ while ((newline = lineBuffer.indexOf("\n")) >= 0) {
571
+ const line = lineBuffer.slice(0, newline);
572
+ lineBuffer = lineBuffer.slice(newline + 1);
573
+ if (discardingOversize) {
574
+ discardingOversize = false;
575
+ continue;
576
+ }
577
+ ledger.ingestLine(line);
578
+ onLine?.();
579
+ }
580
+ if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
581
+ ledger.ingestLine(lineBuffer);
582
+ lineBuffer = "";
583
+ discardingOversize = true;
584
+ }
585
+ else if (discardingOversize) {
586
+ lineBuffer = "";
587
+ }
588
+ },
589
+ flush: () => {
590
+ if (!discardingOversize && lineBuffer.length > 0)
591
+ ledger.ingestLine(lineBuffer);
592
+ lineBuffer = "";
593
+ },
594
+ };
595
+ }
543
596
  /**
544
597
  * Run the process and heartbeat loop concurrently; return the process outcome.
545
598
  *
@@ -551,11 +604,22 @@ function createNoopProcess(deps, durationMs, message) {
551
604
  */
552
605
  async function superviseProcess(params) {
553
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;
554
618
  const hb = runHeartbeatLoop({
555
619
  job: params.job,
556
620
  httpClient: params.httpClient,
557
621
  options: params.options,
558
- deps: params.deps,
622
+ deps: heartbeatDeps,
559
623
  ownership: params.ownership,
560
624
  proc: params.proc,
561
625
  observation: params.observation,
@@ -569,7 +633,10 @@ async function superviseProcess(params) {
569
633
  ...(params.terminationController
570
634
  ? { terminationController: params.terminationController }
571
635
  : {}),
572
- onStdout: (chunk) => params.observation.recordStdout(chunk),
636
+ onStdout: (chunk) => {
637
+ params.observation.recordStdout(chunk);
638
+ params.onStdoutObserved?.(chunk);
639
+ },
573
640
  ...(mcpSurface
574
641
  ? {
575
642
  onStdoutTerminationCheck: (chunk) => {
@@ -580,8 +647,10 @@ async function superviseProcess(params) {
580
647
  : {}),
581
648
  });
582
649
  isDoneRef.value = true;
650
+ heartbeatStop.abort();
583
651
  const loopOutcome = await hb;
584
- params.observation.setExitCode(result.exitCode);
652
+ if (params.recordProcessExit !== false)
653
+ params.observation.setExitCode(result.exitCode);
585
654
  if (loopOutcome.kind === "auth_fatal") {
586
655
  // BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
587
656
  // identifiers — never carried onto `result` itself, so the process's own
@@ -717,7 +786,11 @@ control) {
717
786
  // any /fail that has no envelope gets the truthful "not launched" envelope and
718
787
  // keeps its own error kind; reports that already carry one are untouched.
719
788
  if (claimIsCovered(job)) {
720
- httpClient = withNotLaunchedEnvelopeFallback(httpClient, job);
789
+ httpClient = withNotLaunchedEnvelopeFallback(httpClient, job, {
790
+ jobTimeRuntime: () => jobTimeCompletionRuntimes.get(ownership) ?? null,
791
+ preflightRuntime: _report?.completionEvidenceQualified === true ? (_report.completionRuntime ?? null) : null,
792
+ agentId: resolveExecutorAgentId(options.agentId),
793
+ });
721
794
  }
722
795
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
723
796
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
@@ -738,6 +811,13 @@ control) {
738
811
  if (job.job_type === "ci_rerun") {
739
812
  return runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
740
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
+ }
741
821
  // BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
742
822
  // and before the generic spawn path. It needs a prepared, locked worktree like
743
823
  // a spawn job, but its body is the reconcile module's state machine: the
@@ -775,6 +855,124 @@ control) {
775
855
  });
776
856
  return { status: "failed", reason: "unsupported_job_type" };
777
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
+ }
778
976
  /**
779
977
  * Run a deterministic `ci_rerun` job (BAPI-1160).
780
978
  *
@@ -1193,6 +1391,8 @@ async function superviseDriverPhase(params) {
1193
1391
  ownership: params.ownership,
1194
1392
  observation: params.observation,
1195
1393
  proc: flow.proc,
1394
+ cancelHeartbeatSleepOnExit: true,
1395
+ recordProcessExit: false,
1196
1396
  timeoutSeconds: Math.max(1, params.timeoutSeconds),
1197
1397
  collectTelemetry: params.collectTelemetry,
1198
1398
  control: params.control,
@@ -1357,7 +1557,19 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
1357
1557
  return { ok: false, error: failure.error_message, failure };
1358
1558
  }
1359
1559
  }
1360
- 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) {
1361
1573
  // A continuation passes no local head here (`head === undefined`): its retained
1362
1574
  // worktree was already proven by `prepareRetainedContinuationWorktree` →
1363
1575
  // `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
@@ -1365,12 +1577,23 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1365
1577
  // `correction_intent.head_sha`, including one the server derived for a first
1366
1578
  // attempt with no bound ticket head (BAPI-1344). The spawn source is always
1367
1579
  // that retained worktree, never an origin checkout.
1368
- const refuseCorrectionDrift = async (branch, head) => {
1580
+ const refuseCorrectionDrift = async (branch, head, publication, worktreePath) => {
1369
1581
  if (!isRemainingWork(job.job_type, job.payload))
1370
1582
  return null;
1371
1583
  const intent = job.payload?.correction_intent;
1372
1584
  if (branch === intent.branch && (head === undefined || head === intent.head_sha))
1373
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
+ }
1374
1597
  const reason = "correction_context_missing";
1375
1598
  await httpClient.fail(job, {
1376
1599
  error_kind: "ContractError.CorrectionLegacy",
@@ -1461,13 +1684,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1461
1684
  }
1462
1685
  const wt = await ensureJobWorktree(job, options, deps, seams, {
1463
1686
  reuseExistingBranch: isRecoveryJobType(job.job_type),
1687
+ preparation,
1464
1688
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1465
1689
  });
1466
1690
  if (!wt.ok) {
1467
1691
  await httpClient.fail(job, jobWorktreeFailure(wt));
1468
1692
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1469
1693
  }
1470
- 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);
1471
1695
  if (drift)
1472
1696
  return drift;
1473
1697
  return {
@@ -1476,6 +1700,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1476
1700
  branch: wt.branch,
1477
1701
  prompt: "",
1478
1702
  expectedHeadSha: wt.expectedHeadSha,
1703
+ ...(wt.publication ? { publication: wt.publication } : {}),
1479
1704
  ciFix,
1480
1705
  };
1481
1706
  }
@@ -1540,17 +1765,22 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1540
1765
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
1541
1766
  const wt = await ensureJobWorktree(job, options, deps, seams, {
1542
1767
  reuseExistingBranch,
1768
+ preparation,
1543
1769
  // BAPI-862: hand the guard the reconciler's own reading of why this branch
1544
1770
  // may already carry commits. Read from the SERVER-minted payload field and
1545
1771
  // validated against a closed set — never inferred locally, and never taken
1546
1772
  // from worker output (R14 rules 3 and 4).
1547
1773
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1774
+ // BAPI-1367: the server-minted (and client-normalized) prior head. Only an
1775
+ // `implement` retry acts on it; `ensureExecutorWorktree` re-checks that.
1776
+ // Spread only when present so an ordinary job keeps its exact legacy policy.
1777
+ ...(job.prior_attempt_head_sha ? { priorAttemptHeadSha: job.prior_attempt_head_sha } : {}),
1548
1778
  });
1549
1779
  if (!wt.ok) {
1550
1780
  await httpClient.fail(job, jobWorktreeFailure(wt));
1551
1781
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1552
1782
  }
1553
- 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);
1554
1784
  if (drift)
1555
1785
  return drift;
1556
1786
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
@@ -1563,6 +1793,8 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1563
1793
  branch: wt.branch,
1564
1794
  prompt: explicit.prompt,
1565
1795
  expectedHeadSha: wt.expectedHeadSha,
1796
+ ...(wt.publication ? { publication: wt.publication } : {}),
1797
+ ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1566
1798
  ...(focusedRebase ? { focusedRebase } : {}),
1567
1799
  };
1568
1800
  }
@@ -1578,6 +1810,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1578
1810
  workBranch: wt.branch,
1579
1811
  baseBranch: options.baseBranch,
1580
1812
  runCommand: deps.runCommand,
1813
+ deferComparisonGuidance: true,
1581
1814
  });
1582
1815
  return {
1583
1816
  ok: true,
@@ -1585,6 +1818,8 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1585
1818
  branch: wt.branch,
1586
1819
  prompt: rendered,
1587
1820
  expectedHeadSha: wt.expectedHeadSha,
1821
+ ...(wt.publication ? { publication: wt.publication } : {}),
1822
+ ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1588
1823
  };
1589
1824
  }
1590
1825
  catch (err) {
@@ -1599,6 +1834,93 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1599
1834
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1600
1835
  }
1601
1836
  }
1837
+ /** The first twelve characters of a SHA — safe, supporting evidence only. */
1838
+ function shortSha(sha) {
1839
+ return sha.slice(0, 12);
1840
+ }
1841
+ /**
1842
+ * BAPI-1367 — the continuation wording prepended to a retried `implement` prompt.
1843
+ *
1844
+ * States the work state first (this is prior-attempt work, not a fresh start),
1845
+ * says whether uncommitted files survived as a WIP checkpoint or were unavailable
1846
+ * because the worktree was recreated from the pushed branch, and leaves the
1847
+ * resolved prompt untouched after it. Short SHAs are the only identifiers used.
1848
+ */
1849
+ export function buildPriorAttemptContinuationNotice(params) {
1850
+ const { branch, priorWork, checkpointed, ticketKey } = params;
1851
+ const recreated = priorWork.kind === "recreated_from_pushed_branch";
1852
+ const command = ticketKey ? `/implement-ticket ${ticketKey} --auto` : "/implement-ticket";
1853
+ const files = recreated
1854
+ ? "Prior uncommitted files were unavailable on this host: the worktree was recreated from the pushed branch, so only committed work is present."
1855
+ : checkpointed
1856
+ ? "Prior uncommitted files were saved as a WIP checkpoint commit so nothing was lost."
1857
+ : "The retained worktree was clean, so no WIP checkpoint was needed.";
1858
+ return (`CONTINUATION: this is a retry. An earlier attempt of this job left work on branch '${branch}' ` +
1859
+ `(its recorded head was ${shortSha(priorWork.priorHeadSha)}). You are continuing that prior-attempt ` +
1860
+ `work, not starting ${command} from base: read the branch's existing commits first, keep what is ` +
1861
+ `correct, and finish only what is missing. ${files} The original instructions follow unchanged.\n\n`);
1862
+ }
1863
+ /**
1864
+ * BAPI-1367 — finish a prior-attempt resume UNDER THE WORKTREE LOCK.
1865
+ *
1866
+ * Order is the contract: prove the retained HEAD descends from the recorded prior
1867
+ * head, WIP-checkpoint dirty files (a checkpoint commits onto whatever HEAD is, so
1868
+ * lineage must be proven first), then finalize — which pins `expectedHeadSha` to
1869
+ * the POST-checkpoint head and provisions command assets. No destructive Git
1870
+ * command is ever issued. Diagnostics carry the job id, attempt number, and short
1871
+ * SHAs only — never the prompt or a full hash.
1872
+ */
1873
+ async function resumePriorAttemptUnderLock(params) {
1874
+ const { job, prep, options, deps, worktreePath, branch } = params;
1875
+ const priorWork = prep.priorWork;
1876
+ const resumeSeams = buildResumeSeams(deps, options);
1877
+ try {
1878
+ await verifyContinuationHeadLineage(worktreePath, priorWork.priorHeadSha, resumeSeams);
1879
+ const checkpoint = await autoCheckpointDirtyState(worktreePath, resumeSeams, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE);
1880
+ const finalized = await finalizeRetainedExecutorWorktree(worktreePath, branch, deps);
1881
+ if (!finalized.ok) {
1882
+ return {
1883
+ ok: false,
1884
+ failure: {
1885
+ error_kind: "WorktreeError",
1886
+ error_message: finalized.error,
1887
+ classification: "crashed",
1888
+ },
1889
+ };
1890
+ }
1891
+ const notice = buildPriorAttemptContinuationNotice({
1892
+ branch,
1893
+ priorWork,
1894
+ checkpointed: checkpoint.committed,
1895
+ ticketKey: job.ticket_key,
1896
+ });
1897
+ deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} resuming prior-attempt work ` +
1898
+ `(prior head ${shortSha(priorWork.priorHeadSha)}, resumed at ${shortSha(finalized.expectedHeadSha)}` +
1899
+ `${checkpoint.committed ? ", dirty files checkpointed" : ""})`);
1900
+ if (priorWork.kind === "recreated_from_pushed_branch") {
1901
+ deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} worktree recreated from the pushed ` +
1902
+ `branch; prior uncommitted files were unavailable (prior head ${shortSha(priorWork.priorHeadSha)})`);
1903
+ }
1904
+ return {
1905
+ ok: true,
1906
+ prep: {
1907
+ ...prep,
1908
+ prompt: `${notice}${prep.prompt}`,
1909
+ expectedHeadSha: finalized.expectedHeadSha,
1910
+ },
1911
+ };
1912
+ }
1913
+ catch (err) {
1914
+ const failure = isExecutorNamedError(err)
1915
+ ? toExecutorFailure(err)
1916
+ : {
1917
+ error_kind: "WorktreeError",
1918
+ error_message: secretFreeErrorMessage(err),
1919
+ classification: "crashed",
1920
+ };
1921
+ return { ok: false, failure };
1922
+ }
1923
+ }
1602
1924
  /**
1603
1925
  * BAPI-1296 — verify a prepared `ci_fix` checkout against its dispatched CI
1604
1926
  * context and render the focused prompt, UNDER THE WORKTREE LOCK.
@@ -1632,7 +1954,15 @@ async function renderVerifiedCiFixPrompt(params) {
1632
1954
  return { ok: false, error: ciFixContextError(job, "head_unreadable") };
1633
1955
  }
1634
1956
  if (head !== ciFix.headSha) {
1635
- 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") };
1636
1966
  }
1637
1967
  const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
1638
1968
  try {
@@ -1641,6 +1971,7 @@ async function renderVerifiedCiFixPrompt(params) {
1641
1971
  workBranch: branch,
1642
1972
  baseBranch,
1643
1973
  runCommand: deps.runCommand,
1974
+ comparisonSha: ciFix.headSha,
1644
1975
  });
1645
1976
  return { ok: true, prompt };
1646
1977
  }
@@ -1804,13 +2135,106 @@ async function superviseFocusedRebaseWait(params) {
1804
2135
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
1805
2136
  * touches a worktree that will not become a runnable worker.
1806
2137
  */
1807
- 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,
1808
2232
  /**
1809
2233
  * BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
1810
2234
  * the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
1811
2235
  * adds no second worktree entry and no second worker launcher.
1812
2236
  */
1813
- driver) {
2237
+ driver, retainedLock, preparation, reserve) {
1814
2238
  // A server-owned read precedes every worktree reset/cut and worker launch.
1815
2239
  // Only this boundary can authorize a no-charge pre-work wait; a later plan
1816
2240
  // retrieval failure is ordinary bounded worker failure.
@@ -1931,11 +2355,94 @@ driver) {
1931
2355
  });
1932
2356
  return { status: "failed", reason: "permission_posture_contract" };
1933
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
+ }
1934
2394
  // --- Worktree + prompt (resume pre-spawn protocol or standard) -------
1935
- 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);
1936
2421
  if (!prep.ok)
1937
2422
  return prep.result;
2423
+ deps = executionDeps;
2424
+ if (preparation)
2425
+ prep = { ...prep, deadlineMs: deps.now() + preparation.remainingMs() };
1938
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
+ }
1939
2446
  // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
1940
2447
  // The FIRST thing that happens once a canonical worktree path exists, and
1941
2448
  // ahead of every executor-owned write to it: the deny layer, MCP
@@ -1946,15 +2453,10 @@ driver) {
1946
2453
  // are now created exclusively, and refusal still returns from HERE rather
1947
2454
  // than anywhere downstream.
1948
2455
  //
1949
- // Known residual window, stated rather than papered over: `prepareSpawn`
1950
- // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
1951
- // Its path comes back FROM Worktrunk, so there is no path to lock before it
1952
- // without duplicating Worktrunk's layout rules here — a copy that would go
1953
- // wrong silently. Dispatch exclusion and same-ticket claim serialization
1954
- // (the Python half of this ticket) are what shrink that window; this lock is
1955
- // 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.
1956
2458
  const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
1957
- const worktreeLock = await acquireLock({
2459
+ const worktreeLock = retainedLock ?? await acquireLock({
1958
2460
  worktreePath,
1959
2461
  jobId: job.id,
1960
2462
  claimToken: job.claim_token,
@@ -1985,10 +2487,25 @@ driver) {
1985
2487
  // a refused ci_fix leaves the worktree exactly as preparation left it. The
1986
2488
  // verified, rendered prompt replaces preparation's empty placeholder.
1987
2489
  let readyPrep = prep;
2490
+ // --- Prior-attempt resume (BAPI-1367) --------------------------------
2491
+ // Inside the lock, and BEFORE artifact cleanup and every executor-owned write:
2492
+ // prove the retained head's lineage, WIP-checkpoint any dirty files, and only
2493
+ // then pin the pre-spawn expectation to the POST-checkpoint head. Pinning
2494
+ // earlier (to base or to the lineage anchor) would make the BAPI-731
2495
+ // assertion refuse the very checkpoint this step just made.
2496
+ if (prep.priorWork) {
2497
+ const resumed = await resumePriorAttemptUnderLock({ job, prep, options, deps, worktreePath, branch });
2498
+ if (!resumed.ok) {
2499
+ await httpClient.fail(job, resumed.failure);
2500
+ return { status: "failed", reason: "prior_attempt_resume_failed" };
2501
+ }
2502
+ readyPrep = resumed.prep;
2503
+ }
1988
2504
  if (prep.ciFix) {
1989
2505
  const rendered = await renderVerifiedCiFixPrompt({
1990
2506
  job,
1991
2507
  ciFix: prep.ciFix,
2508
+ publication: prep.publication, preparation,
1992
2509
  worktreePath,
1993
2510
  branch,
1994
2511
  baseBranch: effectiveBaseBranch,
@@ -2003,6 +2520,22 @@ driver) {
2003
2520
  }
2004
2521
  readyPrep = { ...prep, prompt: rendered.prompt };
2005
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
+ }
2006
2539
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
2007
2540
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
2008
2541
  // so every job type routed through `runSpawnJob` is covered — `implement`,
@@ -2035,6 +2568,15 @@ driver) {
2035
2568
  });
2036
2569
  return { status: "failed", reason: "stale_artifact_cleanup" };
2037
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
+ }
2038
2580
  return await runPreparedSpawn({
2039
2581
  job,
2040
2582
  httpClient,
@@ -2077,6 +2619,32 @@ driver) {
2077
2619
  await worktreeLock.release();
2078
2620
  }
2079
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
+ }
2080
2648
  /**
2081
2649
  * Run a spawn job whose worktree is already prepared AND exclusively locked.
2082
2650
  *
@@ -2103,8 +2671,18 @@ async function runPreparedSpawn(params) {
2103
2671
  // base contract's "when you open the pull request" instruction would re-add the
2104
2672
  // new-PR step the focused prompt deliberately excludes.
2105
2673
  let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
2106
- ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
2674
+ ? `${prep.prompt} ${renderWorkerPrBaseGuidance()}`
2107
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
+ }
2108
2686
  // --- Timeout contract ------------------------------------------------
2109
2687
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
2110
2688
  if (!timeout.ok) {
@@ -2136,12 +2714,26 @@ async function runPreparedSpawn(params) {
2136
2714
  // finish the whole job here (a clean, green reconciliation pushes with no
2137
2715
  // worker at all), or hand back the ONE worker it needs and the pre-spawn state
2138
2716
  // it has already proven. The job's overall timeout budget spans every phase.
2139
- const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
2717
+ const jobDeadlineMs = prep.deadlineMs ?? deps.now() + timeout.timeoutSeconds * 1000;
2140
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
+ }
2141
2729
  const driverBaseContext = {
2142
2730
  worktreePath,
2143
2731
  branch,
2144
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
+ }),
2145
2737
  effectiveBaseBranch,
2146
2738
  };
2147
2739
  const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
@@ -2174,6 +2766,37 @@ async function runPreparedSpawn(params) {
2174
2766
  // launch instruction would tell a worker with no push authority to open a PR.
2175
2767
  prompt = driverStep.prompt;
2176
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
+ }
2177
2800
  // --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
2178
2801
  // FIRST of the pre-spawn contracts, and deliberately BEFORE every
2179
2802
  // executor-owned write to the worktree (deny layer, MCP provisioning, worker
@@ -2363,6 +2986,7 @@ async function runPreparedSpawn(params) {
2363
2986
  if (logClaimId !== null) {
2364
2987
  try {
2365
2988
  tee = await createTee(worktreePath, {
2989
+ now: deps.now,
2366
2990
  mkdir: deps.mkdir,
2367
2991
  writeFile: deps.writeFile,
2368
2992
  appendFile: deps.appendFile,
@@ -2381,6 +3005,8 @@ async function runPreparedSpawn(params) {
2381
3005
  // this attempt has no transcript, which is said once, without detail.
2382
3006
  deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
2383
3007
  }
3008
+ if (tee)
3009
+ observation.allocateAttemptLog(tee.identity, tee.available);
2384
3010
  const teeClaimId = tee?.identity.claimId;
2385
3011
  // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
2386
3012
  // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
@@ -2398,6 +3024,22 @@ async function runPreparedSpawn(params) {
2398
3024
  // THIS attempt's entry is removed (BAPI-1330).
2399
3025
  if (teeClaimId !== undefined)
2400
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
+ }
2401
3043
  await closeWorkerLogTee(tee);
2402
3044
  };
2403
3045
  lifecycle.closeWorkerLog = closeWorkerLog;
@@ -2440,6 +3082,8 @@ async function runPreparedSpawn(params) {
2440
3082
  workerLogPath: tee.logPath,
2441
3083
  worktreePath,
2442
3084
  ticketKey: job.ticket_key ?? undefined,
3085
+ // BAPI-1397 (F2): the tab is titled `<TICKET> · <job type>`.
3086
+ jobType: job.job_type,
2443
3087
  runCommand: deps.runCommand,
2444
3088
  cwd: deps.cwd,
2445
3089
  });
@@ -2681,6 +3325,9 @@ async function runPreparedSpawn(params) {
2681
3325
  })
2682
3326
  : null;
2683
3327
  const runtime = probed ?? unrecognizedRuntime(adapter);
3328
+ // Carry the probe identity into any later not-launched report for this job.
3329
+ if (probed !== null)
3330
+ jobTimeCompletionRuntimes.set(ownership, probed);
2684
3331
  const refuse = async (reason, message) => {
2685
3332
  await closeWorkerLog();
2686
3333
  await finalizeRegistry();
@@ -2725,10 +3372,24 @@ async function runPreparedSpawn(params) {
2725
3372
  plan,
2726
3373
  runtime,
2727
3374
  nudge: (ids) => capability.value.buildWatcherNudge(ids),
3375
+ ...(capability.value.buildCompletionWindowNudge
3376
+ ? { windowNudge: (ids) => capability.value.buildCompletionWindowNudge(ids) }
3377
+ : {}),
2728
3378
  dispatchHeadSha,
2729
3379
  stripEnvFiles: stripWorktreeEnvFiles,
2730
3380
  });
2731
3381
  }
3382
+ // BAPI-1364: an observe-only ledger for a legacy run of a covered job type. It
3383
+ // never enters `runCoveredAttempt()` and never enables the completion contract;
3384
+ // it only lets a clean exit be checked for required background work still live.
3385
+ const legacyLedger = isCoveredJobType(job.job_type)
3386
+ ? createRequiredWorkLedger({
3387
+ binding: readCompletionBinding(job, null).binding,
3388
+ lifecycleSupported: true,
3389
+ optionalWatchers: readOptionalWatchers(job.payload),
3390
+ })
3391
+ : null;
3392
+ const legacyLineFeeder = legacyLedger ? createLedgerLineFeeder(legacyLedger) : null;
2732
3393
  let proc;
2733
3394
  try {
2734
3395
  proc = spawnWorker({ env: spawnShape.env });
@@ -2789,16 +3450,19 @@ async function runPreparedSpawn(params) {
2789
3450
  proc: supervisedProc,
2790
3451
  // BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
2791
3452
  // pre-worker phase has already spent some of it.
2792
- timeoutSeconds: driver ? Math.max(1, Math.ceil(remainingMs() / 1000)) : timeout.timeoutSeconds,
3453
+ timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
2793
3454
  collectTelemetry,
2794
3455
  collectRemoteMarker,
2795
3456
  mcpSurface,
3457
+ ...(legacyLineFeeder ? { onStdoutObserved: legacyLineFeeder.feed } : {}),
2796
3458
  // The SAME controller the registry holds, so an executor shutdown, a timeout,
2797
3459
  // and an MCP containment refusal all converge on one `SIGTERM` and one grace
2798
3460
  // timer for this child (BAPI-828).
2799
3461
  terminationController,
2800
3462
  control,
2801
3463
  });
3464
+ // Flush a trailing partial stdout line so the observe-only ledger sees it.
3465
+ legacyLineFeeder?.flush();
2802
3466
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
2803
3467
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
2804
3468
  // nothing runs here — which is correct, because BAPI-790 deleted the per-job
@@ -2895,7 +3559,7 @@ async function runPreparedSpawn(params) {
2895
3559
  await httpClient.fail(job, {
2896
3560
  error_kind: McpSurfaceMismatch,
2897
3561
  error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
2898
- classification: "crashed",
3562
+ classification: toWireClassification(procResult.classification),
2899
3563
  ...commitResidue(finalTelemetry),
2900
3564
  telemetry: observation.snapshot(),
2901
3565
  });
@@ -3028,6 +3692,32 @@ async function runPreparedSpawn(params) {
3028
3692
  await finalizeRegistry();
3029
3693
  return terminalToRunResult(terminal, "completed");
3030
3694
  }
3695
+ // BAPI-1364: a legacy worker that ended its (headless) session while the
3696
+ // structured stream still showed a required background command with no
3697
+ // terminal notification can never receive that command's result. Report it
3698
+ // before generic success, the ci_fix artifact merge, or finalization can hide
3699
+ // it. Only the fixed kind and message are sent: no event text, command, path,
3700
+ // or output. A matching terminal task_notification leaves nothing pending.
3701
+ if (legacyLedger?.observeLiveRequiredWork() === "live_required_work") {
3702
+ deps.errorLog(`[executor] job ${job.id}: worker exited cleanly with required background work still live`);
3703
+ await finalizeRegistry();
3704
+ const terminal = await sendTerminalMutationWithAuthReport({
3705
+ kind: "fail",
3706
+ send: () => httpClient.fail(job, {
3707
+ error_kind: WorkerEndedWithPendingBackgroundWork,
3708
+ error_message: WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE,
3709
+ classification: "crashed",
3710
+ ...observedExitCode(procResult.exitCode),
3711
+ ...commitResidue(git),
3712
+ telemetry: observation.snapshot(),
3713
+ }),
3714
+ deps,
3715
+ options,
3716
+ ownership,
3717
+ log: deps.log,
3718
+ }, job, control);
3719
+ return terminalToRunResult(terminal, "failed");
3720
+ }
3031
3721
  // Non-verdict spawn jobs (`implement`, `resume`, and the recovery jobs
3032
3722
  // `remediate`/`ci_fix`/`rebase`) use the generic success-result construction;
3033
3723
  // the verdict-artifact path above stays limited to verdict jobs like
@@ -3076,8 +3766,10 @@ async function runPreparedSpawn(params) {
3076
3766
  branch,
3077
3767
  worktreePath,
3078
3768
  result: genericResult,
3769
+ publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, remainingMs, httpClient, options: jobOptions, control }),
3079
3770
  runCommand: deps.runCommand,
3080
3771
  headSha: git.last_commit_sha,
3772
+ callTimeoutMs: () => Math.max(1, Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs())),
3081
3773
  expectedBaseBranch: effectiveBaseBranch,
3082
3774
  // BAPI-1311: a focused rebase's finalization reads are bounded by the
3083
3775
  // claim's remaining absolute deadline; every other job keeps the legacy,
@@ -3181,7 +3873,8 @@ async function runPreparedSpawn(params) {
3181
3873
  // BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
3182
3874
  // `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
3183
3875
  // the next job's reset fail. Removed for EVERY spawn job, never with a hard
3184
- // 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.
3185
3878
  if (procResult.classification === "timeout" || procResult.classification === "killed") {
3186
3879
  await cleanupInterruptedWorktree(worktreePath, {
3187
3880
  runCommand: deps.runCommand,
@@ -3233,6 +3926,15 @@ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
3233
3926
  const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3234
3927
  /** Bound on closing the held stdin once the closure rule fires. */
3235
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;
3236
3938
  const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3237
3939
  /**
3238
3940
  * BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
@@ -3255,22 +3957,25 @@ function observedExitCode(exitCode) {
3255
3957
  function claimIsCovered(job) {
3256
3958
  return claimRequiresCompletionEvidence(job.required_capabilities);
3257
3959
  }
3258
- /**
3259
- * Wrap the client so a covered claim's /fail without an envelope carries the
3260
- * "not launched" envelope (BAPI-1301). Every other call passes straight through.
3261
- */
3262
- function withNotLaunchedEnvelopeFallback(httpClient, job) {
3960
+ /** Exported for the covered-job runtime tests; not part of the executor's public surface. */
3961
+ export function withNotLaunchedEnvelopeFallback(httpClient, job, runtimeSources) {
3263
3962
  const wrapped = Object.create(httpClient);
3264
3963
  wrapped.fail = (claimed, failure) => {
3265
3964
  if (failure.completion_evidence !== undefined)
3266
3965
  return httpClient.fail(claimed, failure);
3267
3966
  const { binding } = readCompletionBinding(job, typeof job.expected_head_sha === "string" ? job.expected_head_sha : null);
3268
3967
  const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3968
+ // The runtime actually probed, never the measured baseline: the job-time probe
3969
+ // when it ran, then the qualified preflight runtime, then an unrecognized one.
3970
+ // A runtime that does not satisfy the predicate makes the report a truthful
3971
+ // refusal, so the server accepts it and still never reads it as success.
3972
+ const runtime = runtimeSources.jobTimeRuntime() ?? runtimeSources.preflightRuntime ?? unrecognizedRuntimeForAgent(runtimeSources.agentId);
3269
3973
  const envelope = buildNotLaunchedEnvelope({
3270
3974
  binding,
3271
- runtime: { ...SUPPORTED_COMPLETION_RUNTIME },
3975
+ runtime: { ...runtime },
3272
3976
  verification: unlaunched.verification,
3273
3977
  residuals: CLEANUP_RESIDUALS_NOT_COVERED,
3978
+ unsupportedRuntime: !runtimeMatchesSupported(runtime),
3274
3979
  ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3275
3980
  });
3276
3981
  return httpClient.fail(claimed, {
@@ -3281,6 +3986,12 @@ function withNotLaunchedEnvelopeFallback(httpClient, job) {
3281
3986
  };
3282
3987
  return wrapped;
3283
3988
  }
3989
+ /**
3990
+ * The covered-job runtime probe's identity per job, recorded before launch so a
3991
+ * later not-launched report stamps what was actually probed (BAPI-1386). Keyed by
3992
+ * the job's ownership state, which both the wrapper and the spawn path share.
3993
+ */
3994
+ const jobTimeCompletionRuntimes = new WeakMap();
3284
3995
  /**
3285
3996
  * The verification a covered report carries when no declared check ran
3286
3997
  * (BAPI-1302): before launch, after a worker failure, or on a stop.
@@ -3327,8 +4038,11 @@ function declaredOrderFacts(checks, runFacts) {
3327
4038
  }
3328
4039
  /** An identity that cannot match the supported runtime, for truthful refusal envelopes. */
3329
4040
  function unrecognizedRuntime(adapter) {
4041
+ return unrecognizedRuntimeForAgent(adapter.identity.agentId);
4042
+ }
4043
+ function unrecognizedRuntimeForAgent(agentId) {
3330
4044
  return {
3331
- adapter: adapter.identity.agentId,
4045
+ adapter: agentId,
3332
4046
  cli_version: "unrecognized",
3333
4047
  platform: "unrecognized",
3334
4048
  event_schema: "unrecognized",
@@ -3490,7 +4204,14 @@ async function runCoveredAttempt(params) {
3490
4204
  // --- Ownership lifetime: one heartbeat for the whole attempt ---------------
3491
4205
  let childLive = true;
3492
4206
  let workerStartedAt = null;
3493
- 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;
3494
4215
  if (proc.pid !== undefined) {
3495
4216
  void inspector
3496
4217
  .identity(proc.pid)
@@ -3555,9 +4276,20 @@ async function runCoveredAttempt(params) {
3555
4276
  void tracker.sample().finally(scheduleSample);
3556
4277
  // --- Held-stdin closure supervision -----------------------------------------
3557
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;
3558
4282
  const supervisor = createClosureSupervisor({
3559
4283
  ledger,
3560
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
+ },
3561
4293
  writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
3562
4294
  closeStdin: async () => {
3563
4295
  await closeStdinBounded(proc, {
@@ -3568,33 +4300,9 @@ async function runCoveredAttempt(params) {
3568
4300
  buildNudge: params.nudge,
3569
4301
  deadlineAtMs: plan.holdDeadlineAtMs,
3570
4302
  });
3571
- // The authoritative consumer: whole lines into the ledger, bounded. A line
3572
- // that outgrows the event bound is handed over once (the ledger marks it
3573
- // `stream_oversize`) and the rest of it is discarded up to its newline.
3574
- let lineBuffer = "";
3575
- let discardingOversize = false;
3576
- const authoritativeStdout = (chunk) => {
3577
- lineBuffer += chunk;
3578
- let newline;
3579
- while ((newline = lineBuffer.indexOf("\n")) >= 0) {
3580
- const line = lineBuffer.slice(0, newline);
3581
- lineBuffer = lineBuffer.slice(newline + 1);
3582
- if (discardingOversize) {
3583
- discardingOversize = false;
3584
- continue;
3585
- }
3586
- ledger.ingestLine(line);
3587
- supervisor.onActivity();
3588
- }
3589
- if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
3590
- ledger.ingestLine(lineBuffer);
3591
- lineBuffer = "";
3592
- discardingOversize = true;
3593
- }
3594
- else if (discardingOversize) {
3595
- lineBuffer = "";
3596
- }
3597
- };
4303
+ // The authoritative consumer: whole lines into the ledger, bounded.
4304
+ const lineFeeder = createLedgerLineFeeder(ledger, () => supervisor.onActivity());
4305
+ const authoritativeStdout = lineFeeder.feed;
3598
4306
  const runPromise = runProcessWithTimeout(supervisedProc, Math.max(1, Math.ceil(plan.workerWindowMs / 1000)), deps, {
3599
4307
  termGraceMs: jobOptions.termGraceMs,
3600
4308
  terminationController,
@@ -3619,15 +4327,27 @@ async function runCoveredAttempt(params) {
3619
4327
  // Transport failed before any work: nothing the worker did can be
3620
4328
  // evidence. Stop it through the one termination path.
3621
4329
  ledger.recordTransportFailure(true);
3622
- terminationController.requestTermination();
4330
+ terminationController.requestTermination("executor_abort");
3623
4331
  }
3624
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}`);
3625
4343
  childLive = false;
3626
4344
  if (sampleTimer !== undefined)
3627
4345
  deps.clearTimer(sampleTimer);
4346
+ if (postWindowExitTimer !== undefined)
4347
+ deps.clearTimer(postWindowExitTimer);
4348
+ postWindowExitTimer = undefined;
3628
4349
  supervisor.dispose();
3629
- if (!discardingOversize && lineBuffer.length > 0)
3630
- ledger.ingestLine(lineBuffer);
4350
+ lineFeeder.flush();
3631
4351
  ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
3632
4352
  ? "stream_error"
3633
4353
  : (procResult.stdoutEnd ?? "eof"));
@@ -3671,18 +4391,21 @@ async function runCoveredAttempt(params) {
3671
4391
  const verificationRead = readRequiredVerification(job.payload);
3672
4392
  const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
3673
4393
  const checks = declaration?.checks ?? [];
3674
- const envelopeWith = (facts) => ledger.buildEnvelope({
3675
- runtime: params.runtime,
3676
- finalHeadSha,
3677
- finalization: facts.finalization,
3678
- verification: facts.verification,
3679
- cleanup,
3680
- missingCapabilities: facts.missing,
3681
- satisfiedObligations: [
3682
- ...(facts.pushSatisfied ? [obligations[0]] : []),
3683
- ...(facts.prSatisfied ? [obligations[1]] : []),
3684
- ],
3685
- ...(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,
3686
4409
  });
3687
4410
  const unverifiedEnvelope = () => {
3688
4411
  const unlaunched = unlaunchedVerification(verificationRead);
@@ -3763,7 +4486,7 @@ async function runCoveredAttempt(params) {
3763
4486
  error_kind: "ServerStopRequested",
3764
4487
  error_message: "the server requested this run stop; the owned worker was terminated",
3765
4488
  classification: "killed",
3766
- }, unverifiedEnvelope());
4489
+ }, { ...unverifiedEnvelope(), end_cause: "server_stop" });
3767
4490
  ownership.abandoned = true;
3768
4491
  ownership.abandonReason = "server_stop";
3769
4492
  return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
@@ -3778,7 +4501,7 @@ async function runCoveredAttempt(params) {
3778
4501
  return finish(await failWith({
3779
4502
  error_kind: McpSurfaceMismatch,
3780
4503
  error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
3781
- classification: "crashed",
4504
+ classification: toWireClassification(procResult.classification),
3782
4505
  }, unverifiedEnvelope()));
3783
4506
  }
3784
4507
  if (surfaceVerdict.kind === "absent" &&
@@ -3939,6 +4662,9 @@ async function runCoveredAttempt(params) {
3939
4662
  branch,
3940
4663
  worktreePath,
3941
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())),
3942
4668
  runCommand: deps.runCommand,
3943
4669
  headSha: git.last_commit_sha,
3944
4670
  expectedBaseBranch: effectiveBaseBranch,