@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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 (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -17,7 +17,7 @@
17
17
  import { rm, readdir, lstat, unlink } from "node:fs/promises";
18
18
  import os from "node:os";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
- import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.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";
21
21
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
22
  import { createProductionCiRerunDeps, runCiRerunJob, } from "./ci-rerun-job.js";
23
23
  import { createObservationState } from "./observation.js";
@@ -25,32 +25,41 @@ import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration
25
25
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
26
26
  import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
27
27
  import { resolveExecutorAgentId } from "./agent-identity.js";
28
- import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
29
- import { prepareResumeSpawn } from "./resume-pre-spawn.js";
30
- import { renderPromptSpecPrompt } from "./prompt-spec.js";
28
+ 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";
31
+ import { compareFinalArtifact, createRequiredVerificationRunner, createSupervisedCommandLauncher, observeFinalArtifact, OWNERSHIP_POLL_MS, preCheckArtifactState, toCompletionCheckFact, } from "./required-verification.js";
32
+ import { createClosureSupervisor, createRequiredWorkLedger } from "./required-work-ledger.js";
33
+ import { buildCleanupVerdict, CLEANUP_RESIDUALS_NOT_COVERED, createDefaultOwnershipRecordFs, createDescendantTracker, createMacProcessInspector, createOwnershipToken, hostIdentityDigest, ownershipTokenDigest, removeOwnershipRecord, runOwnedProcessSweep, withOwnershipTag, writeOwnershipRecord, } from "./process-containment.js";
34
+ 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";
31
37
  import { defaultResolveBinPath } from "../start-tickets-conductor.js";
32
38
  /** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
33
39
  const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
34
- import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
40
+ import { buildGenericSuccessResult, CI_FIX_CLAIMS_STATUS_KEY, readCiFixResultArtifact, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
35
41
  import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
36
42
  import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
37
43
  import { stripWorkerEnvFiles } from "./env-file-guard.js";
38
44
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
39
- import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
45
+ import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
40
46
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
41
- import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
47
+ import { resolveExecutorStateDir, activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
42
48
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
43
49
  import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
44
50
  import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
45
51
  import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
46
52
  import { resolveExecutorJobBaseBranch } from "./base-branch.js";
47
53
  import { resolveExecutorJobIndexScope } from "./index-scope.js";
48
- import { validateWorkerFinalization } from "./worker-finalization.js";
49
- import { ensureExecutorWorktree } from "./worktree.js";
54
+ import { readFocusedPullRequest, validateWorkerFinalization } from "./worker-finalization.js";
55
+ import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseContext, validateFocusedRebaseContext, waitForFocusedRebase, } from "./rebase-finalization.js";
56
+ import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
50
57
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
51
- import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
58
+ import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
59
+ import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
52
60
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
53
61
  import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
62
+ import { probeInstalledCorrectionHook } from "./preflight.js";
54
63
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
55
64
  /**
56
65
  * Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
@@ -167,6 +176,9 @@ function buildJobLogRegistryDeps(deps) {
167
176
  env: deps.env,
168
177
  tmpdir: () => os.tmpdir(),
169
178
  platform: deps.platform,
179
+ // BAPI-1330: atomic temp-file + rename replacement when the host has it.
180
+ ...(deps.rename ? { rename: deps.rename } : {}),
181
+ errorLog: deps.errorLog,
170
182
  };
171
183
  }
172
184
  /**
@@ -644,6 +656,23 @@ async function sendTerminalMutationWithAuthReport(params, job, control) {
644
656
  * `/executor/jobs`. Integration actions are NOT ordinary executor jobs.
645
657
  */
646
658
  export async function runIntegrationCorrectionSpawn(job, httpClient, options, deps, driver, seams = {}, control) {
659
+ const validated = validateCorrectionIntent(job.payload?.correction_intent, {
660
+ repo_name: job.repo_name,
661
+ epic_run_id: job.epic_run_id ?? null,
662
+ ticket_key: job.ticket_key ?? null,
663
+ expected_branch: job.expected_branch,
664
+ expected_head_sha: job.expected_head_sha,
665
+ });
666
+ if (!validated.ok || validated.intent.kind !== "integration_correction" ||
667
+ (validated.ok && validated.intent.context_ref.action_id !==
668
+ job.payload?.integration_action_id)) {
669
+ const reason = validated.ok ? "correction_contract_invalid" : validated.code;
670
+ await httpClient.fail(job, {
671
+ error_kind: "ContractError.CorrectionLegacy",
672
+ error_message: reason, classification: "crashed",
673
+ });
674
+ return { status: "failed", reason };
675
+ }
647
676
  const { ownership, observation } = createJobRunState(options, deps);
648
677
  return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
649
678
  }
@@ -679,6 +708,17 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
679
708
  */
680
709
  control) {
681
710
  const { ownership, observation } = createJobRunState(options, deps);
711
+ // BAPI-1301: a covered claim must carry an envelope on EVERY terminal report,
712
+ // including the many refusals that happen before a worker exists (worktree
713
+ // preparation, pre-spawn verification, MCP registration, …). Without one the
714
+ // server refuses the report, the row dead-letters and the child parks as
715
+ // "cleanup unconfirmed" — a transient pre-spawn problem would need a human.
716
+ // Wrapping the client ONCE makes that structural rather than a per-site duty:
717
+ // any /fail that has no envelope gets the truthful "not launched" envelope and
718
+ // keeps its own error kind; reports that already carry one are untouched.
719
+ if (claimIsCovered(job)) {
720
+ httpClient = withNotLaunchedEnvelopeFallback(httpClient, job);
721
+ }
682
722
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
683
723
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
684
724
  // worker (TDD §5/§9).
@@ -1157,7 +1197,20 @@ async function superviseDriverPhase(params) {
1157
1197
  collectTelemetry: params.collectTelemetry,
1158
1198
  control: params.control,
1159
1199
  });
1160
- await Promise.race([flow.settled, params.deps.sleep(DRIVER_PHASE_SETTLE_MAX_MS)]);
1200
+ let settleTimer;
1201
+ try {
1202
+ await Promise.race([
1203
+ flow.settled,
1204
+ new Promise((resolve) => {
1205
+ settleTimer = params.deps.setTimer(resolve, DRIVER_PHASE_SETTLE_MAX_MS);
1206
+ }),
1207
+ ]);
1208
+ }
1209
+ finally {
1210
+ // A normal phase must not leave its four-minute bound alive after it settles.
1211
+ if (settleTimer !== undefined)
1212
+ params.deps.clearTimer(settleTimer);
1213
+ }
1161
1214
  if (procResult.serverStopRequested)
1162
1215
  return { kind: "server_stop" };
1163
1216
  if (params.ownership.abandoned)
@@ -1266,11 +1319,95 @@ function resolveStaleBranchClassification(payload) {
1266
1319
  return "unknown";
1267
1320
  return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
1268
1321
  }
1322
+ /** The `/fail` payload for a failed worktree preparation. */
1323
+ function jobWorktreeFailure(wt) {
1324
+ return (wt.failure ?? {
1325
+ error_kind: "WorktreeError",
1326
+ error_message: wt.error,
1327
+ classification: "crashed",
1328
+ });
1329
+ }
1330
+ /**
1331
+ * Prepare a job's worktree. A job the server dispatched as a parked-child
1332
+ * CONTINUATION (BAPI-1300; `payload.continuation.retained_worktree === true`)
1333
+ * follows the retained-work protocol: it reuses the existing local worktree,
1334
+ * proves its branch and head lineage, and WIP-checkpoints it — it is never
1335
+ * freshened onto origin (which hard-resets and would drop unpushed work) and
1336
+ * never recreated. Every other job keeps the ordinary path unchanged.
1337
+ */
1338
+ async function ensureJobWorktree(job, options, deps, seams, policy) {
1339
+ const continuation = readContinuationBinding(job);
1340
+ if (continuation === null) {
1341
+ const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
1342
+ return ensureWorktree(job, options, deps, policy);
1343
+ }
1344
+ const prepareRetained = seams.prepareRetainedContinuationWorktree ?? prepareRetainedContinuationWorktree;
1345
+ try {
1346
+ const retained = await prepareRetained(job, continuation, buildResumeSeams(deps, options));
1347
+ return await finalizeRetainedExecutorWorktree(retained.worktreePath, retained.branch, deps);
1348
+ }
1349
+ catch (err) {
1350
+ const failure = isExecutorNamedError(err)
1351
+ ? toExecutorFailure(err)
1352
+ : {
1353
+ error_kind: "WorktreeError",
1354
+ error_message: secretFreeErrorMessage(err),
1355
+ classification: "crashed",
1356
+ };
1357
+ return { ok: false, error: failure.error_message, failure };
1358
+ }
1359
+ }
1269
1360
  async function prepareSpawn(job, httpClient, options, deps, seams) {
1361
+ // A continuation passes no local head here (`head === undefined`): its retained
1362
+ // worktree was already proven by `prepareRetainedContinuationWorktree` →
1363
+ // `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
1364
+ // descends from `continuation.bound_head_sha` — the same head as
1365
+ // `correction_intent.head_sha`, including one the server derived for a first
1366
+ // attempt with no bound ticket head (BAPI-1344). The spawn source is always
1367
+ // that retained worktree, never an origin checkout.
1368
+ const refuseCorrectionDrift = async (branch, head) => {
1369
+ if (!isRemainingWork(job.job_type, job.payload))
1370
+ return null;
1371
+ const intent = job.payload?.correction_intent;
1372
+ if (branch === intent.branch && (head === undefined || head === intent.head_sha))
1373
+ return null;
1374
+ const reason = "correction_context_missing";
1375
+ await httpClient.fail(job, {
1376
+ error_kind: "ContractError.CorrectionLegacy",
1377
+ error_message: reason,
1378
+ classification: "crashed",
1379
+ });
1380
+ return { ok: false, result: { status: "failed", reason } };
1381
+ };
1382
+ if (isRemainingWork(job.job_type, job.payload)) {
1383
+ const intent = validateCorrectionIntent(job.payload?.correction_intent, {
1384
+ repo_name: job.repo_name,
1385
+ epic_run_id: job.epic_run_id ?? null,
1386
+ ticket_key: job.ticket_key ?? null,
1387
+ expected_branch: job.expected_branch,
1388
+ expected_head_sha: job.expected_head_sha,
1389
+ });
1390
+ const reason = !intent.ok
1391
+ ? intent.code
1392
+ : !job.required_capabilities?.includes(CORRECTION_INTENT_CAPABILITY)
1393
+ ? "correction_contract_invalid"
1394
+ : null;
1395
+ if (reason !== null) {
1396
+ await httpClient.fail(job, {
1397
+ error_kind: "ContractError.CorrectionLegacy",
1398
+ error_message: reason,
1399
+ classification: "crashed",
1400
+ });
1401
+ return { ok: false, result: { status: "failed", reason } };
1402
+ }
1403
+ }
1270
1404
  if (job.job_type === "resume") {
1271
1405
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
1272
1406
  try {
1273
1407
  const prepared = await prepareResume(job, options, buildResumeSeams(deps, options));
1408
+ const drift = await refuseCorrectionDrift(prepared.branch);
1409
+ if (drift)
1410
+ return drift;
1274
1411
  return {
1275
1412
  ok: true,
1276
1413
  worktreePath: prepared.worktreePath,
@@ -1296,8 +1433,78 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1296
1433
  };
1297
1434
  }
1298
1435
  }
1436
+ // BAPI-1296 — the FOCUSED ci_fix route, decided before any worktree effect.
1437
+ // The payload's focused contract (no explicit prompt, a valid ci_fix prompt
1438
+ // spec, a ticket spec, and a head-bound dispatched check set) is validated
1439
+ // here; a failure is a named `ContractError.CiFixContext` refusal and the job
1440
+ // never reaches `ensureWorktree`, let alone the full `/implement-ticket`
1441
+ // recipe it used to re-enter. The recovery classification is unchanged, so the
1442
+ // job still reuses the ticket's bound branch under the same lock and
1443
+ // finalization checks.
1444
+ if (job.job_type === CI_FIX_JOB_TYPE) {
1445
+ const readCiFix = seams.readCiFixDispatchContext ?? readCiFixDispatchContext;
1446
+ let ciFix;
1447
+ try {
1448
+ ciFix = readCiFix(job);
1449
+ }
1450
+ catch (err) {
1451
+ const failure = isExecutorNamedError(err)
1452
+ ? toExecutorFailure(err)
1453
+ : {
1454
+ error_kind: CiFixContext,
1455
+ error_message: secretFreeErrorMessage(err),
1456
+ classification: "crashed",
1457
+ };
1458
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
1459
+ await httpClient.fail(job, failure);
1460
+ return { ok: false, result: { status: "failed", reason: "ci_fix_context" } };
1461
+ }
1462
+ const wt = await ensureJobWorktree(job, options, deps, seams, {
1463
+ reuseExistingBranch: isRecoveryJobType(job.job_type),
1464
+ staleBranchClassification: resolveStaleBranchClassification(job.payload),
1465
+ });
1466
+ if (!wt.ok) {
1467
+ await httpClient.fail(job, jobWorktreeFailure(wt));
1468
+ return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1469
+ }
1470
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1471
+ if (drift)
1472
+ return drift;
1473
+ return {
1474
+ ok: true,
1475
+ worktreePath: wt.worktreePath,
1476
+ branch: wt.branch,
1477
+ prompt: "",
1478
+ expectedHeadSha: wt.expectedHeadSha,
1479
+ ciFix,
1480
+ };
1481
+ }
1482
+ // BAPI-1311 — the FOCUSED merge-stage rebase, decided before any worktree
1483
+ // effect. A `rebase` job that carries `payload.focused_rebase` must validate
1484
+ // against its own claim; a failure is a named `ContractError.FocusedRebaseContext`
1485
+ // refusal and the job never reaches `ensureWorktree`, never spawns, and is never
1486
+ // reinterpreted as a legacy rebase. A `rebase` WITHOUT the context is an older
1487
+ // queued job: it keeps its original prompt, deadline, finalization, and server
1488
+ // completion contract, and says so once on stderr.
1489
+ let focusedRebase;
1490
+ if (job.job_type === FOCUSED_REBASE_JOB_TYPE) {
1491
+ if (hasFocusedRebaseContext(job)) {
1492
+ const validation = validateFocusedRebaseContext(job);
1493
+ if (!validation.ok) {
1494
+ const failure = toExecutorFailure(focusedRebaseContextError(job, validation.reason));
1495
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
1496
+ await httpClient.fail(job, failure);
1497
+ return { ok: false, result: { status: "failed", reason: "focused_rebase_context" } };
1498
+ }
1499
+ focusedRebase = validation.context;
1500
+ }
1501
+ else {
1502
+ deps.errorLog(`[executor] job ${job.id}: Legacy rebase handling — no focused_rebase context; running its ` +
1503
+ "original prompt under the existing completion contract (no executor-supervised wait)");
1504
+ }
1505
+ }
1299
1506
  // Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
1300
- // synthesis (implement + recovery).
1507
+ // synthesis (implement + the remediate/rebase recovery jobs).
1301
1508
  //
1302
1509
  // BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
1303
1510
  // — the reconciler builds its prompt server-side and ships it in
@@ -1325,14 +1532,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1325
1532
  });
1326
1533
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1327
1534
  }
1328
- const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
1329
- // Recovery jobs (`remediate`/`ci_fix`/`rebase`) continue on the ticket's
1535
+ // Recovery jobs (`remediate`/`rebase`; `ci_fix` above) continue on the ticket's
1330
1536
  // existing bound branch/PR (it carries the implement commit), so they reuse it
1331
1537
  // from `origin/<branch>` with the F7 stale-branch guard OFF. `implement` and
1332
1538
  // `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
1333
1539
  // here — it has its own preservation protocol above.
1334
1540
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
1335
- const wt = await ensureWorktree(job, options, deps, {
1541
+ const wt = await ensureJobWorktree(job, options, deps, seams, {
1336
1542
  reuseExistingBranch,
1337
1543
  // BAPI-862: hand the guard the reconciler's own reading of why this branch
1338
1544
  // may already carry commits. Read from the SERVER-minted payload field and
@@ -1341,14 +1547,15 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1341
1547
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1342
1548
  });
1343
1549
  if (!wt.ok) {
1344
- await httpClient.fail(job, {
1345
- error_kind: "WorktreeError",
1346
- error_message: wt.error,
1347
- classification: "crashed",
1348
- });
1550
+ await httpClient.fail(job, jobWorktreeFailure(wt));
1349
1551
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1350
1552
  }
1553
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1554
+ if (drift)
1555
+ return drift;
1351
1556
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
1557
+ // BAPI-1311: for a focused rebase that explicit prompt IS the validated
1558
+ // server-rendered prompt (`validateFocusedRebaseContext` required it).
1352
1559
  if (explicit.ok) {
1353
1560
  return {
1354
1561
  ok: true,
@@ -1356,6 +1563,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1356
1563
  branch: wt.branch,
1357
1564
  prompt: explicit.prompt,
1358
1565
  expectedHeadSha: wt.expectedHeadSha,
1566
+ ...(focusedRebase ? { focusedRebase } : {}),
1359
1567
  };
1360
1568
  }
1361
1569
  // No explicit/synthesized prompt: render the structured `payload.prompt_spec`
@@ -1391,6 +1599,201 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1391
1599
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1392
1600
  }
1393
1601
  }
1602
+ /**
1603
+ * BAPI-1296 — verify a prepared `ci_fix` checkout against its dispatched CI
1604
+ * context and render the focused prompt, UNDER THE WORKTREE LOCK.
1605
+ *
1606
+ * The dispatched checks describe ONE observed head of ONE branch. Rendering them
1607
+ * into a worker prompt for any other checkout would send the worker to fix
1608
+ * failures that may not exist there, so the comparison runs after the lock is
1609
+ * held (no contender can move the worktree underneath it) and before artifact
1610
+ * cleanup, provisioning, and the spawn. The HEAD is read fresh from the
1611
+ * worktree; nothing is inferred from a remote URL or from worker output. Any
1612
+ * mismatch is a named `ContractError.CiFixContext` refusal with a fixed reason.
1613
+ *
1614
+ * Rendering happens only after the checks pass, through the single-pass
1615
+ * `renderPromptSpecPrompt`, so untrusted check or ticket text is never
1616
+ * re-expanded and never reaches a worker for an unverified checkout.
1617
+ */
1618
+ async function renderVerifiedCiFixPrompt(params) {
1619
+ const { job, ciFix, worktreePath, branch, baseBranch, deps, seams } = params;
1620
+ if (branch.trim() !== ciFix.expectedBranch) {
1621
+ return { ok: false, error: ciFixContextError(job, "branch_mismatch") };
1622
+ }
1623
+ let head;
1624
+ try {
1625
+ const revParse = await deps.runCommand("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
1626
+ head = revParse.exitCode === 0 ? revParse.stdout.trim().toLowerCase() : "";
1627
+ }
1628
+ catch {
1629
+ head = "";
1630
+ }
1631
+ if (!head) {
1632
+ return { ok: false, error: ciFixContextError(job, "head_unreadable") };
1633
+ }
1634
+ if (head !== ciFix.headSha) {
1635
+ return { ok: false, error: ciFixContextError(job, "stale_head") };
1636
+ }
1637
+ const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
1638
+ try {
1639
+ const prompt = await renderPrompt(job, {
1640
+ worktreePath,
1641
+ workBranch: branch,
1642
+ baseBranch,
1643
+ runCommand: deps.runCommand,
1644
+ });
1645
+ return { ok: true, prompt };
1646
+ }
1647
+ catch (err) {
1648
+ return {
1649
+ ok: false,
1650
+ error: isExecutorNamedError(err) ? err : ciFixContextError(job, "prompt_spec_invalid"),
1651
+ };
1652
+ }
1653
+ }
1654
+ /** BAPI-1311: cap on any one focused finalization read, before the deadline clamp. */
1655
+ const FOCUSED_FINALIZATION_READ_MAX_MS = 120_000;
1656
+ /**
1657
+ * BAPI-1311 — run the focused rebase's post-push wait and, unless it is `ready`,
1658
+ * report the job's ONE terminal outcome.
1659
+ *
1660
+ * Returns `null` when the wait is `ready`: the caller then submits the ordinary
1661
+ * `/complete` exactly once, and the server's completion verifier re-reads the
1662
+ * provider as the final authority. Every other ending is reported here, once:
1663
+ *
1664
+ * - a PROVEN failure (red required check, closed or conflicting pull request, a
1665
+ * moved number/head/head branch/base) → `ContractError.RebaseNotVerified`, the
1666
+ * kind the server records when its own verifier refuses a rebase;
1667
+ * - an UNVERIFIED stop (evidence still unavailable or pending at the deadline, or
1668
+ * checks unobservable from this executor) → the ordinary `Timeout`
1669
+ * classification, whose message says the repair is unverified — never that CI
1670
+ * failed;
1671
+ * - a lost claim returns `abandoned` with NO terminal mutation; a server stop and
1672
+ * a supervised timeout or crash use the existing driver-phase vocabulary.
1673
+ *
1674
+ * The pushed commit is never reset or discarded on any path: it is on origin, and
1675
+ * this function issues no git write at all.
1676
+ */
1677
+ async function superviseFocusedRebaseWait(params) {
1678
+ const { job, httpClient, options, deps, ownership, observation, seams, control } = params;
1679
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
1680
+ const fail = async (failure) => {
1681
+ await params.finalizeRegistry();
1682
+ const terminal = await sendTerminalMutationWithAuthReport({
1683
+ kind: "fail",
1684
+ send: () => httpClient.fail(job, { ...failure, ...params.commitResidue, telemetry: observation.snapshot() }),
1685
+ deps,
1686
+ options,
1687
+ ownership,
1688
+ log: deps.log,
1689
+ }, job, control);
1690
+ return terminalToRunResult(terminal, "failed");
1691
+ };
1692
+ const pushed = params.pushedHeadSha;
1693
+ if (!pushed) {
1694
+ deps.errorLog(`[executor] Stopped: evidence unavailable — ${label} has no verified pushed head to wait on; ` +
1695
+ "pushed work (if any) preserved");
1696
+ return await fail({
1697
+ error_kind: "Timeout",
1698
+ error_message: `${label}: focused rebase evidence unavailable (reason=pushed_head_unverified); the repair is unverified, not failed CI`,
1699
+ classification: "timeout",
1700
+ });
1701
+ }
1702
+ const wait = seams.waitForFocusedRebase ?? waitForFocusedRebase;
1703
+ const pollChecks = seams.pollProviderChecks ??
1704
+ lazyProviderCheckPoller({
1705
+ env: deps.env,
1706
+ cwd: deps.cwd,
1707
+ homedir: deps.homedir,
1708
+ platform: deps.platform,
1709
+ readFile: deps.readFile,
1710
+ stat: deps.stat,
1711
+ });
1712
+ const phase = await superviseDriverPhase({
1713
+ job,
1714
+ httpClient,
1715
+ options: params.jobOptions,
1716
+ deps,
1717
+ ownership,
1718
+ observation,
1719
+ control,
1720
+ timeoutSeconds: Math.max(1, Math.ceil(params.remainingMs() / 1000)),
1721
+ collectTelemetry: params.collectTelemetry,
1722
+ run: (signal) => wait({
1723
+ jobId: job.id,
1724
+ context: params.context,
1725
+ pushedHeadSha: pushed,
1726
+ expectedBaseBranch: params.expectedBaseBranch,
1727
+ readPullRequest: (timeoutMs) => readFocusedPullRequest(deps.runCommand, params.worktreePath, params.context.prNumber, { timeoutMs }),
1728
+ pollChecks,
1729
+ now: deps.now,
1730
+ sleep: deps.sleep,
1731
+ signal,
1732
+ deadlineMs: params.jobDeadlineMs,
1733
+ errorLog: deps.errorLog,
1734
+ }),
1735
+ });
1736
+ if (phase.kind === "abandoned") {
1737
+ // The claim is gone: another owner may already hold this job, so no terminal
1738
+ // mutation is sent. The pushed commit stays on origin.
1739
+ await params.finalizeRegistry();
1740
+ return { status: "abandoned", reason: ownership.abandonReason };
1741
+ }
1742
+ if (phase.kind === "server_stop") {
1743
+ await params.finalizeRegistry();
1744
+ await httpClient.fail(job, {
1745
+ error_kind: "ServerStopRequested",
1746
+ error_message: "the server requested this run stop; the focused rebase wait was stopped",
1747
+ classification: "killed",
1748
+ telemetry: observation.snapshot(),
1749
+ });
1750
+ ownership.abandoned = true;
1751
+ ownership.abandonReason = "server_stop";
1752
+ return { status: "abandoned", reason: "server_stop" };
1753
+ }
1754
+ if (phase.kind === "timeout") {
1755
+ return await fail({
1756
+ error_kind: "Timeout",
1757
+ error_message: `${label}: focused rebase wait exceeded the job deadline; pushed work preserved; the repair is unverified`,
1758
+ classification: "timeout",
1759
+ });
1760
+ }
1761
+ if (phase.kind === "error") {
1762
+ const message = secretFreeErrorMessage(phase.error);
1763
+ deps.errorLog(`[executor] Stopped: wait_error — ${label}: focused rebase wait failed unexpectedly; pushed work preserved`);
1764
+ return await fail({
1765
+ error_kind: "WorkerCrashed",
1766
+ error_message: `${label}: focused rebase wait failed: ${message}`,
1767
+ classification: "crashed",
1768
+ });
1769
+ }
1770
+ const outcome = phase.value;
1771
+ if (outcome.kind === "ready")
1772
+ return null;
1773
+ if (outcome.kind === "cancelled") {
1774
+ // Cancellation reaches here only when the abort fired without the supervisor
1775
+ // classifying it (a race at the very end of the wait). Nothing was proven.
1776
+ return await fail({
1777
+ error_kind: "Timeout",
1778
+ error_message: `${label}: focused rebase wait was cancelled before evidence was current; pushed work preserved; the repair is unverified`,
1779
+ classification: "timeout",
1780
+ });
1781
+ }
1782
+ if (outcome.proven) {
1783
+ return await fail({
1784
+ error_kind: RebaseNotVerified,
1785
+ error_message: `${label}: focused rebase pushed its repair but cannot succeed (reason=${outcome.reason}); ` +
1786
+ "pushed work preserved on origin; no further repair was launched by this job",
1787
+ classification: "crashed",
1788
+ });
1789
+ }
1790
+ return await fail({
1791
+ error_kind: "Timeout",
1792
+ error_message: `${label}: focused rebase evidence was not current by the job deadline (reason=${outcome.reason}); ` +
1793
+ "pushed work preserved; the repair is unverified, not failed CI",
1794
+ classification: "timeout",
1795
+ });
1796
+ }
1394
1797
  /**
1395
1798
  * Final pre-spawn preparation order (BAPI-724, BAPI-727): worktree + prompt
1396
1799
  * preparation (which itself performs reset-based untracked cleanup and command
@@ -1408,6 +1811,34 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1408
1811
  * adds no second worktree entry and no second worker launcher.
1409
1812
  */
1410
1813
  driver) {
1814
+ // A server-owned read precedes every worktree reset/cut and worker launch.
1815
+ // Only this boundary can authorize a no-charge pre-work wait; a later plan
1816
+ // retrieval failure is ordinary bounded worker failure.
1817
+ if (job.epic_run_id && (httpClient.scopeReadiness ||
1818
+ job.required_capabilities?.includes("scope_wait.v1"))) {
1819
+ if (!httpClient.scopeReadiness) {
1820
+ await httpClient.fail(job, {
1821
+ error_kind: "ContractError.ScopeReadiness",
1822
+ error_message: "Required pre-work scope protocol is unavailable.",
1823
+ classification: "crashed",
1824
+ });
1825
+ return { status: "failed", reason: "scope_readiness_unsupported" };
1826
+ }
1827
+ try {
1828
+ const readiness = await httpClient.scopeReadiness(job);
1829
+ if (readiness.decision !== "ready") {
1830
+ return { status: "failed", reason: `scope_${readiness.decision}` };
1831
+ }
1832
+ }
1833
+ catch {
1834
+ await httpClient.fail(job, {
1835
+ error_kind: "ScopeReadinessUnavailable",
1836
+ error_message: "Server scope readiness could not be confirmed.",
1837
+ classification: "crashed",
1838
+ });
1839
+ return { status: "failed", reason: "scope_readiness_unavailable" };
1840
+ }
1841
+ }
1411
1842
  // --- Per-job base branch (BAPI-586) ----------------------------------
1412
1843
  // Resolve the effective logical base BEFORE any side effect: the persisted
1413
1844
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -1508,11 +1939,12 @@ driver) {
1508
1939
  // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
1509
1940
  // The FIRST thing that happens once a canonical worktree path exists, and
1510
1941
  // ahead of every executor-owned write to it: the deny layer, MCP
1511
- // provisioning, the `worker.log` truncation, the viewer tab, and the spawn
1512
- // itself all live inside the lock. `worker.log` in particular is created with
1513
- // an empty write, so a refused contender that got as far as the tee would
1514
- // erase the running worker's forensic log without ever spawning — which is
1515
- // why refusal returns from HERE rather than anywhere downstream.
1942
+ // provisioning, the attempt log allocation, the viewer tab, and the spawn
1943
+ // itself all live inside the lock. Before BAPI-1330 the shared `worker.log`
1944
+ // was created with a truncating write, so a refused contender that got as far
1945
+ // as the tee could erase the running worker's forensic log; per-attempt logs
1946
+ // are now created exclusively, and refusal still returns from HERE rather
1947
+ // than anywhere downstream.
1516
1948
  //
1517
1949
  // Known residual window, stated rather than papered over: `prepareSpawn`
1518
1950
  // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
@@ -1548,6 +1980,29 @@ driver) {
1548
1980
  // which is exactly the path that has no return value to inspect.
1549
1981
  const spawnLifecycle = {};
1550
1982
  try {
1983
+ // --- Focused ci_fix checkout verification (BAPI-1296) ---------------
1984
+ // Inside the lock, ahead of artifact cleanup and every worker-owned write:
1985
+ // a refused ci_fix leaves the worktree exactly as preparation left it. The
1986
+ // verified, rendered prompt replaces preparation's empty placeholder.
1987
+ let readyPrep = prep;
1988
+ if (prep.ciFix) {
1989
+ const rendered = await renderVerifiedCiFixPrompt({
1990
+ job,
1991
+ ciFix: prep.ciFix,
1992
+ worktreePath,
1993
+ branch,
1994
+ baseBranch: effectiveBaseBranch,
1995
+ deps,
1996
+ seams,
1997
+ });
1998
+ if (!rendered.ok) {
1999
+ const failure = toExecutorFailure(rendered.error);
2000
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
2001
+ await httpClient.fail(job, failure);
2002
+ return { status: "failed", reason: "ci_fix_context" };
2003
+ }
2004
+ readyPrep = { ...prep, prompt: rendered.prompt };
2005
+ }
1551
2006
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
1552
2007
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
1553
2008
  // so every job type routed through `runSpawnJob` is covered — `implement`,
@@ -1591,7 +2046,7 @@ driver) {
1591
2046
  seams,
1592
2047
  worktreePath,
1593
2048
  branch,
1594
- prep,
2049
+ prep: readyPrep,
1595
2050
  effectiveBaseBranch,
1596
2051
  indexScope,
1597
2052
  permissionPosture,
@@ -1638,7 +2093,16 @@ async function runPreparedSpawn(params) {
1638
2093
  // are told to open the PR against the injected run base. Verdict-only
1639
2094
  // `spec_review` produces no PR, so it is excluded from the instruction while
1640
2095
  // still using the pinned base for its fresh worktree.
1641
- let prompt = isImplementationStyleJobType(job.job_type)
2096
+ //
2097
+ // BAPI-1296: a focused `ci_fix` prompt is used VERBATIM. It repairs a branch
2098
+ // whose PR already exists and must not be told how to open one; the prompt
2099
+ // itself pins it to the existing work branch.
2100
+ //
2101
+ // BAPI-1311: a focused rebase prompt is also used VERBATIM, for the same reason:
2102
+ // it repairs an existing branch whose pull request already exists, and the PR
2103
+ // base contract's "when you open the pull request" instruction would re-add the
2104
+ // new-PR step the focused prompt deliberately excludes.
2105
+ let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
1642
2106
  ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
1643
2107
  : prep.prompt;
1644
2108
  // --- Timeout contract ------------------------------------------------
@@ -1651,6 +2115,22 @@ async function runPreparedSpawn(params) {
1651
2115
  });
1652
2116
  return { status: "failed", reason: "timeout_contract" };
1653
2117
  }
2118
+ // --- Covered completion contract (BAPI-1301) --------------------------
2119
+ // Decided from the CLAIM (the server stamped the requirement), never from the
2120
+ // payload or the worker. A requirement this runner cannot honor — a job type
2121
+ // outside the covered set, or a driver-owned job — is refused explicitly with
2122
+ // an envelope, rather than run under the legacy contract that the server
2123
+ // would then refuse to accept.
2124
+ const covered = claimIsCovered(job);
2125
+ if (covered && (!isCoveredJobType(job.job_type) || driver)) {
2126
+ return await refuseCoveredClaimBeforeLaunch({
2127
+ job, httpClient, options, deps, ownership, observation, control,
2128
+ runtime: unrecognizedRuntime(adapter),
2129
+ dispatchHeadSha: prep.expectedHeadSha ?? null,
2130
+ reason: "uncoverable_job",
2131
+ message: "this executor cannot run this job type under the completion-evidence contract",
2132
+ });
2133
+ }
1654
2134
  // --- Spawn-job driver: the pre-worker phase (BAPI-1152) --------------
1655
2135
  // Runs INSIDE the lock and before every worker-owned side effect. A driver may
1656
2136
  // finish the whole job here (a clean, green reconciliation pushes with no
@@ -1750,6 +2230,12 @@ async function runPreparedSpawn(params) {
1750
2230
  // safety margin but must not kill a job. Refusing to CLAIM on unenforced deny
1751
2231
  // remains a separate, fatal preflight concern.
1752
2232
  const denyCapability = adapter.denyEnforcement;
2233
+ const correctionWork = isRemainingWork(job.job_type, job.payload);
2234
+ let correctionDenyInstalled = false;
2235
+ const correctionHookBinPath = denyCapability?.supported === true
2236
+ ? (seams.resolveWorkerGuardBinPath ??
2237
+ (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))()
2238
+ : "";
1753
2239
  if (denyCapability?.supported === true) {
1754
2240
  const deny = await denyCapability.value.provisionWorktreeDenyLayer({
1755
2241
  worktreePath,
@@ -1761,8 +2247,8 @@ async function runPreparedSpawn(params) {
1761
2247
  // "is this push destination mine?" by comparing against it, so a value the
1762
2248
  // payload could influence would let a job nominate its own safe branch.
1763
2249
  workerBranch: branch,
1764
- workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
1765
- (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
2250
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
2251
+ workerGuardHookBinPath: correctionHookBinPath,
1766
2252
  });
1767
2253
  // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
1768
2254
  // hygiene step can degrade without failing the deny layer — present-but-
@@ -1774,6 +2260,40 @@ async function runPreparedSpawn(params) {
1774
2260
  // fail-OPEN and the job continues to the spawn boundary.
1775
2261
  if (deny.warning)
1776
2262
  deps.errorLog(deny.warning);
2263
+ correctionDenyInstalled = deny.ok;
2264
+ }
2265
+ if (correctionWork && !correctionDenyInstalled) {
2266
+ await httpClient.fail(job, {
2267
+ error_kind: "ContractError.CorrectionLegacy",
2268
+ error_message: "correction_contract_invalid",
2269
+ classification: "crashed",
2270
+ });
2271
+ return { status: "failed", reason: "correction_hook_unavailable" };
2272
+ }
2273
+ if (correctionWork) {
2274
+ let hookQualified = false;
2275
+ try {
2276
+ if (seams.verifyInstalledCorrectionHook) {
2277
+ hookQualified = await seams.verifyInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2278
+ }
2279
+ else {
2280
+ const settings = await deps.readFile(`${worktreePath}/.claude/settings.local.json`);
2281
+ hookQualified = settings.includes(correctionHookBinPath) &&
2282
+ settings.includes("--correction-intent-version 1") &&
2283
+ probeInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2284
+ }
2285
+ }
2286
+ catch {
2287
+ hookQualified = false;
2288
+ }
2289
+ if (!hookQualified) {
2290
+ await httpClient.fail(job, {
2291
+ error_kind: "ContractError.CorrectionLegacy",
2292
+ error_message: "correction_contract_invalid",
2293
+ classification: "crashed",
2294
+ });
2295
+ return { status: "failed", reason: "correction_hook_unverified" };
2296
+ }
1777
2297
  }
1778
2298
  // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
1779
2299
  // Unconditional for every job type that reaches this shared spawn path
@@ -1823,28 +2343,45 @@ async function runPreparedSpawn(params) {
1823
2343
  }
1824
2344
  const mcpContract = registration.contract;
1825
2345
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
2346
+ // BAPI-1330: ONE attempt identity — the server's job id and attempt number
2347
+ // plus a claim id minted here — names this attempt's log file, its registry
2348
+ // entry, and its active-log key. It is allocated BEFORE the tee exists, the
2349
+ // file is created exclusively, and every terminal branch below finishes and
2350
+ // deactivates exactly this identity, so no later claim of the same job (a
2351
+ // retry, a reclaim, a reused worktree) can overwrite or be confused with it.
1826
2352
  const registryDeps = buildJobLogRegistryDeps(deps);
1827
2353
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
1828
2354
  const createTee = seams.createWorkerLogTee ?? createWorkerLogTee;
1829
- let tee = null;
2355
+ let logClaimId = null;
1830
2356
  try {
1831
- tee = await createTee(worktreePath, {
1832
- mkdir: deps.mkdir,
1833
- writeFile: deps.writeFile,
1834
- appendFile: deps.appendFile,
1835
- platform: deps.platform,
1836
- // BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
1837
- // already, so this is source-compatible with every existing deps
1838
- // builder — archival simply does not run when either is absent.
1839
- rename: deps.rename,
1840
- stat: deps.stat,
1841
- now: deps.now,
1842
- errorLog: deps.errorLog,
1843
- });
2357
+ logClaimId = (seams.createWorkerLogClaimId ?? createWorkerLogClaimId)();
1844
2358
  }
1845
2359
  catch {
1846
- tee = null;
2360
+ logClaimId = null;
2361
+ }
2362
+ let tee = null;
2363
+ if (logClaimId !== null) {
2364
+ try {
2365
+ tee = await createTee(worktreePath, {
2366
+ mkdir: deps.mkdir,
2367
+ writeFile: deps.writeFile,
2368
+ appendFile: deps.appendFile,
2369
+ writeFileExclusive: deps.writeFileExclusive,
2370
+ stat: deps.stat,
2371
+ platform: deps.platform,
2372
+ errorLog: deps.errorLog,
2373
+ }, { jobId: job.id, attempt: job.attempt_count, claimId: logClaimId });
2374
+ }
2375
+ catch {
2376
+ tee = null;
2377
+ }
2378
+ }
2379
+ if (tee === null) {
2380
+ // A logging failure never changes the worker's disposition; it only means
2381
+ // this attempt has no transcript, which is said once, without detail.
2382
+ deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
1847
2383
  }
2384
+ const teeClaimId = tee?.identity.claimId;
1848
2385
  // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
1849
2386
  // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
1850
2387
  // directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
@@ -1857,8 +2394,10 @@ async function runPreparedSpawn(params) {
1857
2394
  return;
1858
2395
  workerLogClosed = true;
1859
2396
  // Deactivate FIRST: once the tee is closing, an append from the suspend
1860
- // diagnostic would be writing to a log nobody is reading any more.
1861
- deactivateJobLog(job.id);
2397
+ // diagnostic would be writing to a log nobody is reading any more. Only
2398
+ // THIS attempt's entry is removed (BAPI-1330).
2399
+ if (teeClaimId !== undefined)
2400
+ deactivateJobLog(job.id, teeClaimId);
1862
2401
  await closeWorkerLogTee(tee);
1863
2402
  };
1864
2403
  lifecycle.closeWorkerLog = closeWorkerLog;
@@ -1869,7 +2408,9 @@ async function runPreparedSpawn(params) {
1869
2408
  // visibility nicety for a separate `executor watch` process), and chaining
1870
2409
  // the active marker to it would mean a failed registry write silently
1871
2410
  // disables suspend annotation for a worker whose tee is perfectly healthy.
1872
- activateJobLog(job.id, tee.logPath);
2411
+ // BAPI-1330: an attempt whose file could not be allocated is never active.
2412
+ if (tee.available)
2413
+ activateJobLog(job.id, tee.logPath, tee.identity.claimId);
1873
2414
  const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
1874
2415
  try {
1875
2416
  await registerLog({
@@ -1880,12 +2421,17 @@ async function runPreparedSpawn(params) {
1880
2421
  worktree_path: worktreePath,
1881
2422
  log_path: tee.logPath,
1882
2423
  started_at: nowIso(deps),
2424
+ attempt: tee.identity.attempt,
2425
+ claim_id: tee.identity.claimId,
2426
+ availability: tee.available ? "available" : "unavailable",
2427
+ ...(tee.unavailableReason ? { unavailable_reason: tee.unavailableReason } : {}),
1883
2428
  }, registryDeps);
1884
2429
  }
1885
2430
  catch {
1886
2431
  /* registry is a visibility nicety — never fail the job */
2432
+ deps.errorLog(`executor job-log registry: could not record job ${job.id}'s attempt log (non-fatal)`);
1887
2433
  }
1888
- if (executorViewerTabsEnabled(deps.platform, deps.env)) {
2434
+ if (tee.available && executorViewerTabsEnabled(deps.platform, deps.env)) {
1889
2435
  const openViewer = seams.openExecutorViewerTab ?? openExecutorViewerTab;
1890
2436
  try {
1891
2437
  const viewer = await openViewer({
@@ -1909,11 +2455,21 @@ async function runPreparedSpawn(params) {
1909
2455
  const finalizeRegistry = async () => {
1910
2456
  if (!tee)
1911
2457
  return;
2458
+ const finishing = tee;
1912
2459
  try {
1913
- await markFinished(job.id, nowIso(deps), registryDeps);
2460
+ // BAPI-1330: stamps only THIS attempt's entry, and records a transcript
2461
+ // that became incomplete mid-run as unavailable rather than leaving a
2462
+ // reader to trust it.
2463
+ await markFinished(job.id, finishing.identity.claimId, nowIso(deps), registryDeps, finishing.available
2464
+ ? {}
2465
+ : {
2466
+ availability: "unavailable",
2467
+ ...(finishing.unavailableReason ? { unavailable_reason: finishing.unavailableReason } : {}),
2468
+ });
1914
2469
  }
1915
2470
  catch {
1916
2471
  /* non-fatal */
2472
+ deps.errorLog(`executor job-log registry: could not finish job ${job.id}'s attempt log (non-fatal)`);
1917
2473
  }
1918
2474
  };
1919
2475
  // --- Spawn -----------------------------------------------------------
@@ -1957,6 +2513,9 @@ async function runPreparedSpawn(params) {
1957
2513
  const alias = invocation.value.resolveModelAlias(job.payload);
1958
2514
  spawnShape = invocation.value.buildSpawnShape({
1959
2515
  prompt,
2516
+ // BAPI-1301: a covered attempt sends the prompt on a held stdin instead
2517
+ // of argv; every other job keeps the byte-identical legacy argv.
2518
+ ...(covered ? { promptTransport: "stdin-stream-json" } : {}),
1960
2519
  modelAlias: alias,
1961
2520
  posture: permissionPosture,
1962
2521
  mcpConfigPath: mcpContract.configPath,
@@ -1970,6 +2529,7 @@ async function runPreparedSpawn(params) {
1970
2529
  // denies every `git push` the worker attempts — the worker has no remote
1971
2530
  // write authority at all.
1972
2531
  ...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
2532
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
1973
2533
  ...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
1974
2534
  parentEnv: deps.env,
1975
2535
  });
@@ -2023,7 +2583,10 @@ async function runPreparedSpawn(params) {
2023
2583
  // `.env` and truncated it — so "could not confirm the worktree is clean" must
2024
2584
  // refuse, never proceed and hope.
2025
2585
  const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
2026
- const stripped = await stripEnvFiles(worktreePath, {
2586
+ // BAPI-1302: a named function so the covered lane can re-run the SAME guard
2587
+ // before its independent checks — a check must never load an environment file
2588
+ // the worker left behind any more than the worker could load the operator's.
2589
+ const stripWorktreeEnvFiles = () => stripEnvFiles(worktreePath, {
2027
2590
  readdir: (dirPath) => readdir(dirPath),
2028
2591
  // `lstat`/`unlink` only — the guard must never resolve a symlink, because an
2029
2592
  // entry pointing at the main checkout would resolve to the operator's real
@@ -2076,6 +2639,7 @@ async function runPreparedSpawn(params) {
2076
2639
  },
2077
2640
  platform: deps.platform,
2078
2641
  });
2642
+ const stripped = await stripWorktreeEnvFiles();
2079
2643
  if (!stripped.ok) {
2080
2644
  await closeWorkerLog();
2081
2645
  await finalizeRegistry();
@@ -2090,12 +2654,84 @@ async function runPreparedSpawn(params) {
2090
2654
  });
2091
2655
  return { status: "failed", reason: "worker_env_file_present" };
2092
2656
  }
2657
+ // --- Covered attempt: runtime recheck, reservation, phase host (BAPI-1301)
2658
+ // Immediately before spawn, re-probe the EXACT runtime this claim requires.
2659
+ // The claim was admitted on the preflight's qualification, but the installed
2660
+ // CLI can change between the two; a mismatch refuses here, before any worker
2661
+ // exists. Then reserve cleanup/verification/finalization/report time out of
2662
+ // the REMAINING allowance, refusing an impossible reservation instead of
2663
+ // skipping a phase or extending the budget.
2664
+ // The ONE worker spawn boundary (BAPI-790/828). Both the legacy spawn below and
2665
+ // the covered attempt launch through it, so there is still exactly one place a
2666
+ // worker can be started, always from the verified spawn shape. Only a covered
2667
+ // attempt asks for a held stdin; the legacy options stay byte-identical.
2668
+ const spawnWorker = (spawnOptions) => deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
2669
+ cwd: worktreePath,
2670
+ env: spawnOptions.env,
2671
+ ...(spawnOptions.stdin ? { stdin: spawnOptions.stdin } : {}),
2672
+ });
2673
+ if (covered) {
2674
+ const capability = resolveCompletionEvidenceCapability(adapter);
2675
+ const dispatchHeadSha = verification.observedSha ?? prep.expectedHeadSha ?? null;
2676
+ const probed = capability.supported === true
2677
+ ? await capability.value.probeRuntime({
2678
+ runCommand: deps.runCommand,
2679
+ platform: deps.platform,
2680
+ ...(deps.arch !== undefined ? { arch: deps.arch } : {}),
2681
+ })
2682
+ : null;
2683
+ const runtime = probed ?? unrecognizedRuntime(adapter);
2684
+ const refuse = async (reason, message) => {
2685
+ await closeWorkerLog();
2686
+ await finalizeRegistry();
2687
+ return await refuseCoveredClaimBeforeLaunch({
2688
+ job, httpClient, options, deps, ownership, observation, control,
2689
+ runtime, dispatchHeadSha, reason, message,
2690
+ });
2691
+ };
2692
+ if (capability.supported !== true || !runtimeMatchesSupported(runtime)) {
2693
+ return await refuse("unsupported_runtime", "the installed agent runtime is not the qualified completion-evidence runtime; refusing to launch");
2694
+ }
2695
+ if (!spawnShape.stdinMessages?.initial) {
2696
+ return await refuse("unsupported_runtime", "the executor adapter produced no held-stdin prompt message for a covered job");
2697
+ }
2698
+ // BAPI-1302 (D4c) — the server-stamped declaration is validated WHOLE,
2699
+ // before any allowance is reserved or a worker exists. A truncated,
2700
+ // malformed, unsupported or server-refused declaration never launches work
2701
+ // against a partial set of checks.
2702
+ const verificationRead = readRequiredVerification(job.payload);
2703
+ if (verificationRead.kind === "refused") {
2704
+ deps.errorLog(`[executor] job ${job.id}: required-verification declaration refused (${verificationRead.reason})`);
2705
+ return await refuse("declaration_refused", "the job's required-verification declaration is unusable; refusing to launch");
2706
+ }
2707
+ const { spentAllowanceMs } = readCompletionBinding(job, dispatchHeadSha);
2708
+ const plan = planCompletionAllowance({
2709
+ nowMs: deps.now(),
2710
+ remainingMs: remainingMs(),
2711
+ spentAllowanceMs,
2712
+ declaredCheckTimeoutsMs: readDeclaredChecks(job.payload).map((c) => c.timeoutSeconds * 1000),
2713
+ });
2714
+ if (!plan.ok) {
2715
+ return await refuse("allowance_exhausted", "the remaining attempt allowance cannot reserve cleanup, verification and finalization; refusing to launch");
2716
+ }
2717
+ return await runCoveredAttempt({
2718
+ prepared: params,
2719
+ spawnShape,
2720
+ spawnWorker,
2721
+ mcpSurface,
2722
+ tee,
2723
+ closeWorkerLog,
2724
+ finalizeRegistry,
2725
+ plan,
2726
+ runtime,
2727
+ nudge: (ids) => capability.value.buildWatcherNudge(ids),
2728
+ dispatchHeadSha,
2729
+ stripEnvFiles: stripWorktreeEnvFiles,
2730
+ });
2731
+ }
2093
2732
  let proc;
2094
2733
  try {
2095
- proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
2096
- cwd: worktreePath,
2097
- env: spawnShape.env,
2098
- });
2734
+ proc = spawnWorker({ env: spawnShape.env });
2099
2735
  }
2100
2736
  catch (err) {
2101
2737
  await closeWorkerLog();
@@ -2397,12 +3033,40 @@ async function runPreparedSpawn(params) {
2397
3033
  // the verdict-artifact path above stays limited to verdict jobs like
2398
3034
  // `spec_review`. Recovery jobs complete through this same generic envelope.
2399
3035
  const artifacts = await readCompletionArtifacts(worktreePath, deps);
2400
- const genericResult = buildGenericSuccessResult({
3036
+ const executorResult = buildGenericSuccessResult({
2401
3037
  summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
2402
3038
  branch,
2403
3039
  headSha: git.last_commit_sha,
2404
3040
  localCommitCount: git.local_commit_count,
2405
3041
  });
3042
+ // BAPI-1296: a focused ci_fix carries its per-check mapping back to the
3043
+ // server at `result.check_fix_claims`, where the exact-job evidence reader
3044
+ // compares it with the dispatched set. Only the allowlisted, validated fields
3045
+ // are merged, and they are spread FIRST so every executor-observed field
3046
+ // (branch, head, commit count, and the verified PR below) wins over anything
3047
+ // the worker file could have said. A missing or invalid file forwards no
3048
+ // claims at all — the server then records an incomplete attempt — and its
3049
+ // status is the executor's own observation, never inferred from exit code.
3050
+ let genericResult = executorResult;
3051
+ if (prep.ciFix) {
3052
+ const readCiResult = seams.readCiFixResultArtifact ?? readCiFixResultArtifact;
3053
+ const ciResult = await readCiResult(worktreePath, prep.ciFix.requiredClaimChecks, {
3054
+ readFile: deps.readFile,
3055
+ lstatPath: deps.lstatPath,
3056
+ });
3057
+ if ("fields" in ciResult) {
3058
+ genericResult = {
3059
+ ...ciResult.fields,
3060
+ ...executorResult,
3061
+ [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status,
3062
+ };
3063
+ }
3064
+ else {
3065
+ deps.errorLog(`[executor] job ${job.id}: ci_fix result artifact ${ciResult.status} (${ciResult.code}); ` +
3066
+ "no check_fix_claims forwarded");
3067
+ genericResult = { ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status };
3068
+ }
3069
+ }
2406
3070
  // BAPI-551: a worker can exit `clean_exit` yet strand its implementation if
2407
3071
  // its push never landed on origin and no PR was opened (e.g. it backgrounded
2408
3072
  // a slow push and returned early). Convert that into a fail-loud executor
@@ -2415,6 +3079,15 @@ async function runPreparedSpawn(params) {
2415
3079
  runCommand: deps.runCommand,
2416
3080
  headSha: git.last_commit_sha,
2417
3081
  expectedBaseBranch: effectiveBaseBranch,
3082
+ // BAPI-1311: a focused rebase's finalization reads are bounded by the
3083
+ // claim's remaining absolute deadline; every other job keeps the legacy,
3084
+ // unbounded-options invocation exactly.
3085
+ ...(prep.focusedRebase
3086
+ ? {
3087
+ callTimeoutMs: () => Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs()),
3088
+ exposePushedHead: true,
3089
+ }
3090
+ : {}),
2418
3091
  });
2419
3092
  if (!finalization.ok) {
2420
3093
  await finalizeRegistry();
@@ -2422,6 +3095,9 @@ async function runPreparedSpawn(params) {
2422
3095
  kind: "fail",
2423
3096
  send: () => httpClient.fail(job, {
2424
3097
  ...finalization.failure,
3098
+ // BAPI-1330: the process's observed exit rides beside the
3099
+ // finalization failure; it is never rewritten to look like a crash.
3100
+ ...observedExitCode(procResult.exitCode),
2425
3101
  ...commitResidue(git),
2426
3102
  telemetry: observation.snapshot(),
2427
3103
  }),
@@ -2432,6 +3108,30 @@ async function runPreparedSpawn(params) {
2432
3108
  }, job, control);
2433
3109
  return terminalToRunResult(terminal, "failed");
2434
3110
  }
3111
+ // BAPI-1311 — the FOCUSED rebase's supervised post-push wait. The worker has
3112
+ // exited cleanly and finalization PROVED its push is the origin tip, so what
3113
+ // remains is evidence the server's completion verifier will read: the bound
3114
+ // pull request at that head, mergeable, with its effective required checks
3115
+ // green. The wait runs under the SAME claim, heartbeat, dead-man, and timeout
3116
+ // supervision a worker gets, bounded by the job's ORIGINAL absolute deadline.
3117
+ // It launches no worker, enqueues nothing, and resets nothing: a pending check
3118
+ // is waited on, never repaired again. Only `ready` reaches `/complete`, once.
3119
+ if (prep.focusedRebase) {
3120
+ const focusedTerminal = await superviseFocusedRebaseWait({
3121
+ job, httpClient, options, jobOptions, deps, ownership, observation, seams, control,
3122
+ context: prep.focusedRebase,
3123
+ pushedHeadSha: finalization.pushedHeadSha ?? null,
3124
+ expectedBaseBranch: effectiveBaseBranch,
3125
+ worktreePath,
3126
+ jobDeadlineMs,
3127
+ remainingMs,
3128
+ collectTelemetry: driverTelemetry,
3129
+ commitResidue: commitResidue(git),
3130
+ finalizeRegistry,
3131
+ });
3132
+ if (focusedTerminal !== null)
3133
+ return focusedTerminal;
3134
+ }
2435
3135
  // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
2436
3136
  // populated on this path before — `buildGenericSuccessResult` has no PR field
2437
3137
  // at all — which is why job 1397's stored `result.pr_url` was null even though
@@ -2512,6 +3212,7 @@ async function runPreparedSpawn(params) {
2512
3212
  ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
2513
3213
  : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
2514
3214
  classification: toWireClassification(procResult.classification),
3215
+ ...observedExitCode(procResult.exitCode),
2515
3216
  ...commitResidue(git),
2516
3217
  telemetry: observation.snapshot(),
2517
3218
  ...(capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}),
@@ -2523,3 +3224,797 @@ async function runPreparedSpawn(params) {
2523
3224
  }, job, control);
2524
3225
  return terminalToRunResult(terminal, "failed");
2525
3226
  }
3227
+ // =========================================================================
3228
+ // BAPI-1301 (D4a) — the covered-attempt phase host
3229
+ // =========================================================================
3230
+ /** How often the worker's descendant tree is sampled for containment. */
3231
+ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
3232
+ /** Bound on delivering the initial prompt message on the held stdin. */
3233
+ const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3234
+ /** Bound on closing the held stdin once the closure rule fires. */
3235
+ const STDIN_CLOSE_BOUND_MS = 5_000;
3236
+ const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3237
+ /**
3238
+ * BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
3239
+ * null, never invented) when the process ended without a usable code, e.g. on a
3240
+ * signal. Only ever read from the process result, never from worker output.
3241
+ */
3242
+ function observedExitCode(exitCode) {
3243
+ return typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0
3244
+ ? { exit_code: exitCode }
3245
+ : {};
3246
+ }
3247
+ /**
3248
+ * Whether this claim runs under the covered completion contract.
3249
+ *
3250
+ * Only the SERVER can put a job under it (by stamping the capability
3251
+ * requirement), and only for a covered job type. A job carrying the
3252
+ * requirement that this runner cannot honor is refused explicitly — see
3253
+ * {@link refuseCoveredClaimBeforeLaunch} — never silently run the legacy way.
3254
+ */
3255
+ function claimIsCovered(job) {
3256
+ return claimRequiresCompletionEvidence(job.required_capabilities);
3257
+ }
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) {
3263
+ const wrapped = Object.create(httpClient);
3264
+ wrapped.fail = (claimed, failure) => {
3265
+ if (failure.completion_evidence !== undefined)
3266
+ return httpClient.fail(claimed, failure);
3267
+ const { binding } = readCompletionBinding(job, typeof job.expected_head_sha === "string" ? job.expected_head_sha : null);
3268
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3269
+ const envelope = buildNotLaunchedEnvelope({
3270
+ binding,
3271
+ runtime: { ...SUPPORTED_COMPLETION_RUNTIME },
3272
+ verification: unlaunched.verification,
3273
+ residuals: CLEANUP_RESIDUALS_NOT_COVERED,
3274
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3275
+ });
3276
+ return httpClient.fail(claimed, {
3277
+ ...failure,
3278
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3279
+ completion_evidence: envelope,
3280
+ });
3281
+ };
3282
+ return wrapped;
3283
+ }
3284
+ /**
3285
+ * The verification a covered report carries when no declared check ran
3286
+ * (BAPI-1302): before launch, after a worker failure, or on a stop.
3287
+ *
3288
+ * A job with no declaration keeps BAPI-1301's version-1 `not_declared`. A job
3289
+ * with one reports version-2 facts naming the stamped declaration with no
3290
+ * command run, which reduces to `not_declared` for an absent or explicitly
3291
+ * empty declaration and `incomplete` otherwise — never `passed`.
3292
+ */
3293
+ export function unlaunchedVerification(read) {
3294
+ if (read.kind === "none")
3295
+ return { verification: "not_declared" };
3296
+ const digest = read.kind === "ok" ? read.declaration.digest : read.digest;
3297
+ if (digest === null) {
3298
+ // A declaration whose digest is not even well-formed cannot be named in a
3299
+ // version-2 envelope; version 1 with an incomplete verdict is the truthful
3300
+ // report, and the server refuses to bind it to the stamped row.
3301
+ return { verification: "incomplete" };
3302
+ }
3303
+ const provenance = read.kind === "ok" ? read.declaration.provenance : (read.provenance ?? "refused");
3304
+ const facts = {
3305
+ provenance,
3306
+ declaration_digest: digest,
3307
+ tested_head_sha: null,
3308
+ artifact: "not_observed",
3309
+ commands: [],
3310
+ };
3311
+ return { verification: verificationVerdictFromFacts(facts), facts };
3312
+ }
3313
+ /**
3314
+ * The runner's facts restricted to the declaration: in declared order, one per
3315
+ * declared command, stopping at the first command the runner reported nothing
3316
+ * for. A missing fact is never invented — the verdict reads it as not run.
3317
+ */
3318
+ function declaredOrderFacts(checks, runFacts) {
3319
+ const out = [];
3320
+ for (let i = 0; i < checks.length; i++) {
3321
+ const fact = runFacts[i];
3322
+ if (!fact || fact.identity !== checks[i].identity)
3323
+ break;
3324
+ out.push(fact);
3325
+ }
3326
+ return out;
3327
+ }
3328
+ /** An identity that cannot match the supported runtime, for truthful refusal envelopes. */
3329
+ function unrecognizedRuntime(adapter) {
3330
+ return {
3331
+ adapter: adapter.identity.agentId,
3332
+ cli_version: "unrecognized",
3333
+ platform: "unrecognized",
3334
+ event_schema: "unrecognized",
3335
+ };
3336
+ }
3337
+ /**
3338
+ * Refuse a covered claim before any worker is launched (BAPI-1301).
3339
+ *
3340
+ * The server requires an envelope on every terminal report for a covered job,
3341
+ * including a refusal. Nothing was spawned, so cleanup is confirmed with zero
3342
+ * survivors; the ledger saw no lifecycle at all, so evidence is insufficient
3343
+ * and the server parks the child with the cause the envelope supports
3344
+ * (`unsupported_completion_evidence` for a runtime refusal,
3345
+ * `required_work_incomplete` for an allowance that cannot fit).
3346
+ */
3347
+ async function refuseCoveredClaimBeforeLaunch(input) {
3348
+ const { job, deps } = input;
3349
+ const { binding } = readCompletionBinding(job, input.dispatchHeadSha);
3350
+ const ledger = createRequiredWorkLedger({ binding, lifecycleSupported: input.reason !== "uncoverable_job" });
3351
+ if (input.reason === "allowance_exhausted")
3352
+ ledger.recordAllowanceExhausted();
3353
+ if (input.reason === "declaration_refused")
3354
+ ledger.recordDeclarationRefused();
3355
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3356
+ const envelope = ledger.buildEnvelope({
3357
+ runtime: input.runtime,
3358
+ finalHeadSha: input.dispatchHeadSha,
3359
+ finalization: "unknown",
3360
+ verification: unlaunched.verification,
3361
+ cleanup: buildCleanupVerdict("confirmed", 0, []),
3362
+ missingCapabilities: [],
3363
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3364
+ });
3365
+ const cause = envelope.cause ?? "required_work_incomplete";
3366
+ deps.errorLog(`[executor] job ${job.id}: covered attempt refused before launch (${input.reason})`);
3367
+ const terminal = await sendTerminalMutationWithAuthReport({
3368
+ kind: "fail",
3369
+ send: () => input.httpClient.fail(job, {
3370
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
3371
+ error_message: input.message,
3372
+ classification: "crashed",
3373
+ telemetry: input.observation.snapshot(),
3374
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3375
+ completion_evidence: envelope,
3376
+ }),
3377
+ deps,
3378
+ options: input.options,
3379
+ ownership: input.ownership,
3380
+ log: deps.log,
3381
+ }, job, input.control);
3382
+ return terminalToRunResult(terminal, "failed");
3383
+ }
3384
+ /**
3385
+ * Run ONE covered attempt from spawn to terminal acknowledgement (BAPI-1301).
3386
+ *
3387
+ * The phases, all under one absolute deadline and one continuous heartbeat:
3388
+ *
3389
+ * 1. worker, prompt on held stdin, stdout read by the authoritative ledger;
3390
+ * stdin closed only by the closure rule or the hold deadline, then EOF
3391
+ * drained (bounded);
3392
+ * 2. owned-process cleanup sweep (tag + tracked descendants);
3393
+ * 3. D4c declared checks, when a runner is installed, plus their cleanup;
3394
+ * 4. finalization (`validateWorkerFinalization`, unchanged);
3395
+ * 5. terminal report carrying the versioned envelope, sent only while this
3396
+ * executor still owns the claim.
3397
+ *
3398
+ * The heartbeat is started HERE, not by `superviseProcess`, precisely so it
3399
+ * does not stop when the worker exits: cleanup, finalization and the report
3400
+ * all still run under a live claim, and a stop/stale/dead-man outcome during
3401
+ * any of them is honored. After the worker exits, no heartbeat outcome ever
3402
+ * signals its (possibly recycled) pid.
3403
+ */
3404
+ async function runCoveredAttempt(params) {
3405
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, adapter, control, lifecycle, } = params.prepared;
3406
+ const { spawnShape, mcpSurface, plan, closeWorkerLog, finalizeRegistry } = params;
3407
+ // --- Ownership identity -------------------------------------------------
3408
+ // Unpredictable, executor-generated, and injected AFTER the adapter built its
3409
+ // environment, so neither ambient input nor the job payload can choose it.
3410
+ const token = createOwnershipToken();
3411
+ const tokenDigest = ownershipTokenDigest(token);
3412
+ const hostDigest = hostIdentityDigest(os.hostname());
3413
+ const workerEnv = withOwnershipTag(spawnShape.env, token);
3414
+ const stateDir = resolveExecutorStateDir(buildJobLogRegistryDeps(deps));
3415
+ const recordFs = seams.ownershipRecordFs ?? createDefaultOwnershipRecordFs();
3416
+ try {
3417
+ // Written BEFORE the spawn: if this executor is SIGKILLed mid-attempt, a
3418
+ // restarted executor finds the record and sweeps with the real token.
3419
+ await writeOwnershipRecord(stateDir, {
3420
+ jobId: job.id,
3421
+ repoName: job.repo_name,
3422
+ token,
3423
+ hostDigest,
3424
+ createdAt: nowIso(deps),
3425
+ // Lets another executor starting on this host tell a live job from an
3426
+ // interrupted one, so it never sweeps this executor's running worker.
3427
+ executorPid: process.pid,
3428
+ }, recordFs);
3429
+ }
3430
+ catch (err) {
3431
+ deps.errorLog(`[executor] job ${job.id}: ownership record not written (non-fatal): ${boundedError(err)}`);
3432
+ }
3433
+ const inspector = seams.processInspector ??
3434
+ createMacProcessInspector({ runCommand: deps.runCommand, selfPid: process.pid, platform: deps.platform });
3435
+ // --- The ledger and its bindings ------------------------------------------
3436
+ const { binding } = readCompletionBinding(job, params.dispatchHeadSha);
3437
+ const obligationTargets = finalizationObligations(branch, effectiveBaseBranch);
3438
+ const obligations = [
3439
+ { operation: "push", target: obligationTargets.push },
3440
+ { operation: "pr_create", target: obligationTargets.prCreate },
3441
+ ];
3442
+ const ledger = createRequiredWorkLedger({
3443
+ binding,
3444
+ lifecycleSupported: true,
3445
+ optionalWatchers: readOptionalWatchers(job.payload),
3446
+ obligations,
3447
+ });
3448
+ // --- Spawn (held stdin) ---------------------------------------------------
3449
+ let proc;
3450
+ try {
3451
+ proc = params.spawnWorker({ env: workerEnv, stdin: "pipe" });
3452
+ }
3453
+ catch (err) {
3454
+ await closeWorkerLog();
3455
+ await finalizeRegistry();
3456
+ await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
3457
+ const raw = `failed to spawn worker: ${boundedError(err)}`;
3458
+ const redactionCapability = adapter.redaction;
3459
+ ledger.recordTransportFailure(true);
3460
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3461
+ const envelope = ledger.buildEnvelope({
3462
+ runtime: params.runtime,
3463
+ finalHeadSha: params.dispatchHeadSha,
3464
+ finalization: "unknown",
3465
+ verification: unlaunched.verification,
3466
+ cleanup: buildCleanupVerdict("confirmed", 0, []),
3467
+ missingCapabilities: [],
3468
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3469
+ });
3470
+ await httpClient.fail(job, {
3471
+ error_kind: "SpawnError",
3472
+ error_message: redactionCapability?.supported === true
3473
+ ? redactAdapterDiagnostic(raw, deps.env, redactionCapability.value)
3474
+ : raw,
3475
+ classification: "crashed",
3476
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3477
+ completion_evidence: envelope,
3478
+ });
3479
+ return { status: "failed", reason: "spawn_failed" };
3480
+ }
3481
+ const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
3482
+ lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
3483
+ const supervisedProc = params.tee
3484
+ ? {
3485
+ ...proc,
3486
+ stdout: proc.stdout ? teeAsyncIterable(proc.stdout, params.tee, "stdout") : null,
3487
+ stderr: proc.stderr ? teeAsyncIterable(proc.stderr, params.tee, "stderr") : null,
3488
+ }
3489
+ : proc;
3490
+ // --- Ownership lifetime: one heartbeat for the whole attempt ---------------
3491
+ let childLive = true;
3492
+ let workerStartedAt = null;
3493
+ const tracker = proc.pid !== undefined ? createDescendantTracker({ inspector, rootPid: proc.pid }) : null;
3494
+ if (proc.pid !== undefined) {
3495
+ void inspector
3496
+ .identity(proc.pid)
3497
+ .then((identity) => {
3498
+ if (identity)
3499
+ workerStartedAt = identity.startedAt.slice(0, 64);
3500
+ })
3501
+ .catch(() => undefined);
3502
+ }
3503
+ const ownershipContext = () => ({
3504
+ version: 1,
3505
+ token_digest: tokenDigest,
3506
+ host_digest: hostDigest,
3507
+ worker: childLive && proc.pid !== undefined && workerStartedAt !== null
3508
+ ? { pid: proc.pid, started_at: workerStartedAt }
3509
+ : null,
3510
+ });
3511
+ const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
3512
+ const collectRemoteMarker = () => collectRemoteTrackingSha({ runCommand: deps.runCommand }, worktreePath, branch);
3513
+ const attemptDone = { value: false };
3514
+ let heartbeatOutcome = null;
3515
+ const heartbeat = runHeartbeatLoop({
3516
+ job,
3517
+ httpClient,
3518
+ options: jobOptions,
3519
+ deps,
3520
+ ownership,
3521
+ proc: supervisedProc,
3522
+ observation,
3523
+ collectTelemetry,
3524
+ collectRemoteMarker,
3525
+ isDone: () => attemptDone.value,
3526
+ currentChild: () => (childLive ? proc : null),
3527
+ ownershipContext,
3528
+ }).then((outcome) => {
3529
+ heartbeatOutcome = outcome;
3530
+ return outcome;
3531
+ });
3532
+ const endHeartbeat = async () => {
3533
+ attemptDone.value = true;
3534
+ const outcome = await heartbeat;
3535
+ if (outcome.kind === "auth_fatal") {
3536
+ control?.reportAuthFatal?.({
3537
+ jobId: job.id,
3538
+ repoName: job.repo_name,
3539
+ category: "heartbeat",
3540
+ httpStatus: outcome.httpStatus,
3541
+ });
3542
+ }
3543
+ };
3544
+ // --- Descendant sampling (containment input) -------------------------------
3545
+ let sampleTimer;
3546
+ const scheduleSample = () => {
3547
+ if (!tracker || !childLive)
3548
+ return;
3549
+ sampleTimer = deps.setTimer(() => {
3550
+ sampleTimer = undefined;
3551
+ void tracker.sample().finally(scheduleSample);
3552
+ }, DESCENDANT_SAMPLE_INTERVAL_MS);
3553
+ };
3554
+ if (tracker)
3555
+ void tracker.sample().finally(scheduleSample);
3556
+ // --- Held-stdin closure supervision -----------------------------------------
3557
+ const stdinDeps = { now: deps.now, setTimer: deps.setTimer, clearTimer: deps.clearTimer };
3558
+ const supervisor = createClosureSupervisor({
3559
+ ledger,
3560
+ deps: stdinDeps,
3561
+ writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
3562
+ closeStdin: async () => {
3563
+ await closeStdinBounded(proc, {
3564
+ deadlineAtMs: Math.min(plan.workerDeadlineAtMs, deps.now() + STDIN_CLOSE_BOUND_MS),
3565
+ deps: stdinDeps,
3566
+ });
3567
+ },
3568
+ buildNudge: params.nudge,
3569
+ deadlineAtMs: plan.holdDeadlineAtMs,
3570
+ });
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
+ };
3598
+ const runPromise = runProcessWithTimeout(supervisedProc, Math.max(1, Math.ceil(plan.workerWindowMs / 1000)), deps, {
3599
+ termGraceMs: jobOptions.termGraceMs,
3600
+ terminationController,
3601
+ onStdout: (chunk) => observation.recordStdout(chunk),
3602
+ authoritativeStdout,
3603
+ drainDeadlineMs: EOF_DRAIN_BOUND_MS,
3604
+ onStdoutTerminationCheck: (chunk) => {
3605
+ mcpSurface.observeStdout(chunk);
3606
+ return mcpSurface.shouldTerminate();
3607
+ },
3608
+ });
3609
+ // The prompt goes in AFTER supervision started reading stdout, so a child
3610
+ // that writes before it reads can never deadlock against this write.
3611
+ const initialMessage = spawnShape.stdinMessages?.initial;
3612
+ const initialWrite = initialMessage
3613
+ ? await writeStdinBounded(proc, initialMessage, {
3614
+ deadlineAtMs: Math.min(plan.holdDeadlineAtMs, deps.now() + INITIAL_PROMPT_WRITE_BOUND_MS),
3615
+ deps: stdinDeps,
3616
+ })
3617
+ : { ok: false, reason: "no_stdin" };
3618
+ if (!initialWrite.ok) {
3619
+ // Transport failed before any work: nothing the worker did can be
3620
+ // evidence. Stop it through the one termination path.
3621
+ ledger.recordTransportFailure(true);
3622
+ terminationController.requestTermination();
3623
+ }
3624
+ const procResult = await runPromise;
3625
+ childLive = false;
3626
+ if (sampleTimer !== undefined)
3627
+ deps.clearTimer(sampleTimer);
3628
+ supervisor.dispose();
3629
+ if (!discardingOversize && lineBuffer.length > 0)
3630
+ ledger.ingestLine(lineBuffer);
3631
+ ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
3632
+ ? "stream_error"
3633
+ : (procResult.stdoutEnd ?? "eof"));
3634
+ ledger.recordProcessExit({ code: procResult.exitCode, signal: procResult.signal });
3635
+ observation.setExitCode(procResult.exitCode);
3636
+ // --- Phase 2: owned-process cleanup (success-blocking) ----------------------
3637
+ let cleanup;
3638
+ if (tracker) {
3639
+ tracker.markRootExited();
3640
+ await tracker.sample();
3641
+ }
3642
+ cleanup = await runOwnedProcessSweep({
3643
+ token,
3644
+ inspector,
3645
+ deps: { now: deps.now, sleep: deps.sleep },
3646
+ deadlineAtMs: deps.now() + plan.reservations.cleanupMs,
3647
+ trackedDescendants: tracker?.owned() ?? [],
3648
+ trackerOverflowed: tracker?.overflowed() ?? false,
3649
+ });
3650
+ // Adapter configuration hygiene stays separate and best-effort; it can never
3651
+ // stand in for the process-cleanup verdict above.
3652
+ const lifecycleCapability = adapter.lifecycle;
3653
+ if (lifecycleCapability?.supported === true && lifecycleCapability.value.declaration.kind === "managed") {
3654
+ try {
3655
+ await lifecycleCapability.value.declaration.cleanupAfterJob({ worktreePath });
3656
+ }
3657
+ catch (err) {
3658
+ deps.errorLog(`adapter lifecycle cleanup failed (non-fatal): ${boundedError(err)}`);
3659
+ }
3660
+ }
3661
+ const finalTelemetry = await collectTelemetry();
3662
+ observation.setGitTelemetry(finalTelemetry);
3663
+ if (typeof finalTelemetry.last_commit_sha === "string") {
3664
+ observation.setAttemptEndSha(finalTelemetry.last_commit_sha);
3665
+ }
3666
+ const finalHeadSha = typeof finalTelemetry.last_commit_sha === "string" ? finalTelemetry.last_commit_sha : null;
3667
+ await closeWorkerLog();
3668
+ await reportWorkerLogSessionInvariant(params.tee, deps);
3669
+ // BAPI-1302: the declaration was validated whole at admission; a refused one
3670
+ // never reaches this point.
3671
+ const verificationRead = readRequiredVerification(job.payload);
3672
+ const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
3673
+ 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 } : {}),
3686
+ });
3687
+ const unverifiedEnvelope = () => {
3688
+ const unlaunched = unlaunchedVerification(verificationRead);
3689
+ return envelopeWith({
3690
+ finalization: "unknown",
3691
+ verification: unlaunched.verification,
3692
+ missing: [],
3693
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3694
+ });
3695
+ };
3696
+ // The terminal report's retry window (BAPI-1301). The shared terminal sender
3697
+ // retries a retryable response until the dead-man window since the last
3698
+ // successful heartbeat passes. Here the heartbeat keeps succeeding through the
3699
+ // report, so that window alone would never close: the report would retry
3700
+ // forever. This view keeps the claim fence and abandonment shared with the
3701
+ // heartbeat, and additionally closes the window at the attempt's absolute
3702
+ // deadline (the end of the report reservation) — never later.
3703
+ const reportOwnership = {
3704
+ get abandoned() {
3705
+ return ownership.abandoned;
3706
+ },
3707
+ set abandoned(value) {
3708
+ ownership.abandoned = value;
3709
+ },
3710
+ get abandonReason() {
3711
+ return ownership.abandonReason;
3712
+ },
3713
+ set abandonReason(value) {
3714
+ ownership.abandonReason = value;
3715
+ },
3716
+ get lastSuccessfulHeartbeatAt() {
3717
+ return Math.min(ownership.lastSuccessfulHeartbeatAt, plan.deadlineAtMs - options.deadmanMs);
3718
+ },
3719
+ set lastSuccessfulHeartbeatAt(value) {
3720
+ ownership.lastSuccessfulHeartbeatAt = value;
3721
+ },
3722
+ };
3723
+ const git = observation.git();
3724
+ const failWith = async (failure, envelope, extra = {}) => {
3725
+ await finalizeRegistry();
3726
+ const terminal = await sendTerminalMutationWithAuthReport({
3727
+ kind: "fail",
3728
+ send: () => httpClient.fail(job, {
3729
+ ...failure,
3730
+ ...commitResidue(git),
3731
+ telemetry: observation.snapshot(),
3732
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3733
+ completion_evidence: envelope,
3734
+ ...extra,
3735
+ }),
3736
+ deps,
3737
+ options,
3738
+ ownership: reportOwnership,
3739
+ log: deps.log,
3740
+ }, job, control);
3741
+ return terminalToRunResult(terminal, "failed");
3742
+ };
3743
+ const finish = async (result) => {
3744
+ // The terminal report is acknowledged (or ownership is gone): only now does
3745
+ // the job-level heartbeat end, and only now is the restart record retired.
3746
+ await endHeartbeat();
3747
+ await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
3748
+ return result;
3749
+ };
3750
+ // --- Stronger outcomes keep their precedence ---------------------------------
3751
+ // Checked here AND again right before the terminal report: the heartbeat now
3752
+ // outlives the worker, so a lost claim or a server stop can arrive during
3753
+ // cleanup, verification or finalization, and must still win over a report
3754
+ // built from those phases.
3755
+ const strongerOutcome = async () => {
3756
+ if (ownership.abandoned) {
3757
+ await finalizeRegistry();
3758
+ return finish({ status: "abandoned", reason: ownership.abandonReason });
3759
+ }
3760
+ const hbNow = heartbeatOutcome;
3761
+ if (hbNow?.kind === "server_stop") {
3762
+ const result = await failWith({
3763
+ error_kind: "ServerStopRequested",
3764
+ error_message: "the server requested this run stop; the owned worker was terminated",
3765
+ classification: "killed",
3766
+ }, unverifiedEnvelope());
3767
+ ownership.abandoned = true;
3768
+ ownership.abandonReason = "server_stop";
3769
+ return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
3770
+ }
3771
+ return null;
3772
+ };
3773
+ const earlyStronger = await strongerOutcome();
3774
+ if (earlyStronger)
3775
+ return earlyStronger;
3776
+ const surfaceVerdict = mcpSurface.verdict();
3777
+ if (surfaceVerdict.kind === "mismatch") {
3778
+ return finish(await failWith({
3779
+ error_kind: McpSurfaceMismatch,
3780
+ error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
3781
+ classification: "crashed",
3782
+ }, unverifiedEnvelope()));
3783
+ }
3784
+ if (surfaceVerdict.kind === "absent" &&
3785
+ procResult.classification === "crashed" &&
3786
+ procResult.stdoutExcerpt.trim().length === 0) {
3787
+ return finish(await failWith({ error_kind: WorkerStartupFatal, error_message: WORKER_STARTUP_FATAL_MESSAGE, classification: "crashed" }, unverifiedEnvelope()));
3788
+ }
3789
+ if (procResult.classification !== "clean_exit") {
3790
+ if (procResult.classification === "timeout" || procResult.classification === "killed") {
3791
+ await cleanupInterruptedWorktree(worktreePath, {
3792
+ runCommand: deps.runCommand,
3793
+ removeFile: deps.removeFile ?? ((filePath) => rm(filePath)),
3794
+ errorLog: deps.errorLog,
3795
+ });
3796
+ }
3797
+ const authDetection = adapter.authFailureDetection;
3798
+ const authFailure = authDetection?.supported === true && authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
3799
+ const capacityEvidence = authFailure
3800
+ ? undefined
3801
+ : buildTerminalCapacityEvidence(job, observation, adapter.identity.agentId);
3802
+ const envelope = unverifiedEnvelope();
3803
+ const cause = envelope.cause;
3804
+ return finish(await failWith({
3805
+ error_kind: authFailure
3806
+ ? ClaudeNotAuthenticated
3807
+ : cause
3808
+ ? COMPLETION_CAUSE_ERROR_KINDS[cause]
3809
+ : classificationToErrorKind(procResult.classification),
3810
+ error_message: authFailure
3811
+ ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3812
+ : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3813
+ classification: toWireClassification(procResult.classification),
3814
+ ...observedExitCode(procResult.exitCode),
3815
+ }, envelope, capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}));
3816
+ }
3817
+ // --- Phase 3: independent declared checks (BAPI-1302, D4c) ---------------------
3818
+ // Every declared command runs HERE, by the executor, after the worker exited
3819
+ // and its processes were swept — including commands the worker never started
3820
+ // or claims already passed. Worker text is never read. The checks are bound
3821
+ // to the final local artifact: full HEAD plus the tracked tree/index is read
3822
+ // before them and again after them AND their cleanup; any movement makes the
3823
+ // evidence stale. Nothing is committed, reset or repaired.
3824
+ let verification = "not_declared";
3825
+ let verificationFacts;
3826
+ if (declaration !== null && declaration.provenance !== "declared") {
3827
+ // `absent` or `declared_empty`: no declared local verification. Reported
3828
+ // as such — never as a pass.
3829
+ verificationFacts = {
3830
+ provenance: declaration.provenance,
3831
+ declaration_digest: declaration.digest,
3832
+ tested_head_sha: null,
3833
+ artifact: "not_observed",
3834
+ commands: [],
3835
+ };
3836
+ verification = verificationVerdictFromFacts(verificationFacts);
3837
+ }
3838
+ else if (declaration !== null) {
3839
+ const runner = seams.declaredCheckRunner ??
3840
+ createRequiredVerificationRunner({
3841
+ launch: createSupervisedCommandLauncher({
3842
+ deps,
3843
+ registerLive: (controller) => control?.liveWorkers.register(controller),
3844
+ }),
3845
+ now: deps.now,
3846
+ log: deps.log,
3847
+ });
3848
+ const ownershipHeld = () => !ownership.abandoned && heartbeatOutcome?.kind !== "server_stop";
3849
+ const envGuard = params.stripEnvFiles ? await params.stripEnvFiles() : { ok: true };
3850
+ const before = envGuard.ok
3851
+ ? await observeFinalArtifact(deps.runCommand, worktreePath)
3852
+ : { head: null, trackedStatus: null };
3853
+ const preState = envGuard.ok ? preCheckArtifactState(before, finalHeadSha) : "not_observed";
3854
+ let runFacts = [];
3855
+ let artifact;
3856
+ if (preState === null) {
3857
+ const checkAbort = new AbortController();
3858
+ // Ownership is re-checked WHILE a command runs, not only between commands:
3859
+ // a lost claim or a server stop terminates the running check.
3860
+ let watchTimer;
3861
+ const watchOwnership = () => {
3862
+ watchTimer = deps.setTimer(() => {
3863
+ watchTimer = undefined;
3864
+ if (!ownershipHeld())
3865
+ checkAbort.abort();
3866
+ else
3867
+ watchOwnership();
3868
+ }, OWNERSHIP_POLL_MS);
3869
+ };
3870
+ watchOwnership();
3871
+ try {
3872
+ const outcome = await runner.run({
3873
+ checks,
3874
+ worktreePath,
3875
+ finalHeadSha,
3876
+ env: workerEnv,
3877
+ deadlineAtMs: deps.now() + plan.reservations.verificationMs,
3878
+ signal: checkAbort.signal,
3879
+ ownershipHeld,
3880
+ });
3881
+ runFacts = outcome.facts ?? [];
3882
+ }
3883
+ catch {
3884
+ // A runner that throws proved nothing ran to completion.
3885
+ runFacts = [];
3886
+ }
3887
+ finally {
3888
+ if (watchTimer !== undefined)
3889
+ deps.clearTimer(watchTimer);
3890
+ }
3891
+ // Check cleanup BEFORE the final observation: the checks ran with the same
3892
+ // ownership tag, so the same sweep proves they left nothing behind, and a
3893
+ // survivor could still be writing to the worktree. The worse verdict wins.
3894
+ const checkCleanup = await runOwnedProcessSweep({
3895
+ token,
3896
+ inspector,
3897
+ deps: { now: deps.now, sleep: deps.sleep },
3898
+ deadlineAtMs: deps.now() + plan.reservations.checkCleanupMs,
3899
+ });
3900
+ if (checkCleanup.verdict !== "confirmed") {
3901
+ cleanup = buildCleanupVerdict(cleanup.verdict === "failed" || checkCleanup.verdict === "failed" ? "failed" : "unconfirmed", cleanup.survivors + checkCleanup.survivors, [...cleanup.reasons, ...checkCleanup.reasons]);
3902
+ }
3903
+ artifact = compareFinalArtifact(before, await observeFinalArtifact(deps.runCommand, worktreePath));
3904
+ }
3905
+ else {
3906
+ artifact = preState;
3907
+ }
3908
+ verificationFacts = {
3909
+ provenance: "declared",
3910
+ declaration_digest: declaration.digest,
3911
+ tested_head_sha: before.head,
3912
+ artifact,
3913
+ commands: declaredOrderFacts(checks, runFacts).map(toCompletionCheckFact),
3914
+ };
3915
+ verification = verificationVerdictFromFacts(verificationFacts, checks.length);
3916
+ }
3917
+ // --- Phase 4: finalization (unchanged executor facts) ----------------------------
3918
+ const artifacts = await readCompletionArtifacts(worktreePath, deps);
3919
+ const executorResult = buildGenericSuccessResult({
3920
+ summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
3921
+ branch,
3922
+ headSha: git.last_commit_sha,
3923
+ localCommitCount: git.local_commit_count,
3924
+ });
3925
+ let genericResult = executorResult;
3926
+ if (prep.ciFix) {
3927
+ const readCiResult = seams.readCiFixResultArtifact ?? readCiFixResultArtifact;
3928
+ const ciResult = await readCiResult(worktreePath, prep.ciFix.requiredClaimChecks, {
3929
+ readFile: deps.readFile,
3930
+ lstatPath: deps.lstatPath,
3931
+ });
3932
+ genericResult =
3933
+ "fields" in ciResult
3934
+ ? { ...ciResult.fields, ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status }
3935
+ : { ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status };
3936
+ }
3937
+ const finalization = await validateWorkerFinalization({
3938
+ job,
3939
+ branch,
3940
+ worktreePath,
3941
+ result: genericResult,
3942
+ runCommand: deps.runCommand,
3943
+ headSha: git.last_commit_sha,
3944
+ expectedBaseBranch: effectiveBaseBranch,
3945
+ // BAPI-1330: this host only runs claims the SERVER stamped as covered, so
3946
+ // the existence-only legacy fallback is refused here.
3947
+ coveredAttempt: true,
3948
+ });
3949
+ const facts = finalizationFacts(finalization.ok ? { ok: true } : { ok: false, errorKind: finalization.failure.error_kind });
3950
+ const envelope = envelopeWith({
3951
+ finalization: facts.verdict,
3952
+ verification,
3953
+ missing: [],
3954
+ pushSatisfied: facts.pushSatisfied,
3955
+ prSatisfied: facts.prSatisfied,
3956
+ ...(verificationFacts ? { verificationFacts } : {}),
3957
+ });
3958
+ // --- Phase 5: terminal report -----------------------------------------------------
3959
+ const lateStronger = await strongerOutcome();
3960
+ if (lateStronger)
3961
+ return lateStronger;
3962
+ if (finalization.ok && successRefusalReason(envelope) === null) {
3963
+ const result = typeof finalization.prUrl === "string" ? { ...genericResult, pr_url: finalization.prUrl } : genericResult;
3964
+ const completion = {
3965
+ job_type: job.job_type,
3966
+ exit_code: procResult.exitCode ?? 0,
3967
+ classification: "clean_exit",
3968
+ result,
3969
+ artifacts,
3970
+ ...commitResidue(git),
3971
+ telemetry: observation.snapshot(),
3972
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3973
+ completion_evidence: envelope,
3974
+ };
3975
+ const terminal = await sendTerminalMutationWithAuthReport({
3976
+ kind: "complete",
3977
+ send: () => httpClient.complete(job, completion),
3978
+ deps,
3979
+ options,
3980
+ ownership: reportOwnership,
3981
+ log: deps.log,
3982
+ }, job, control);
3983
+ await finalizeRegistry();
3984
+ return finish(terminalToRunResult(terminal, "completed"));
3985
+ }
3986
+ // Not a success. A completion cause (required work, cleanup, verification,
3987
+ // permission) outranks the finalization kind; with none, finalization keeps
3988
+ // its existing error kind and recovery lane.
3989
+ //
3990
+ // BAPI-1330: every branch below is reached only after the worker exited
3991
+ // cleanly. A completion refusal reports that observed process fact —
3992
+ // `clean_exit` and the real exit code — beside the refusal (the server decides
3993
+ // it from the envelope's completion cause, never the classification). A
3994
+ // finalization failure adds the exit code to its own kind and classification.
3995
+ const observedExit = {
3996
+ classification: toWireClassification(procResult.classification),
3997
+ ...observedExitCode(procResult.exitCode),
3998
+ };
3999
+ const cause = envelope.cause;
4000
+ if (cause) {
4001
+ return finish(await failWith({
4002
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
4003
+ error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? cause})`,
4004
+ ...observedExit,
4005
+ }, envelope));
4006
+ }
4007
+ if (!finalization.ok) {
4008
+ // The finalization failure keeps its OWN classification, exactly as the
4009
+ // legacy path does: the kinds that mean "the process ended cleanly"
4010
+ // (`PrNotAttached`, `PublicationUnverified`) already say `clean_exit`, and
4011
+ // the rest keep the classification their existing recovery routing reads.
4012
+ // Only the observed exit code is added.
4013
+ return finish(await failWith({ ...finalization.failure, ...observedExitCode(procResult.exitCode) }, envelope));
4014
+ }
4015
+ return finish(await failWith({
4016
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS.required_work_incomplete,
4017
+ error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? "unknown"})`,
4018
+ ...observedExit,
4019
+ }, envelope));
4020
+ }