@bridge_gpt/mcp-server 0.2.62 → 0.2.65

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 (80) hide show
  1. package/README.md +47 -8
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. package/package.json +2 -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,30 +25,37 @@ 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
45
  import { 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";
52
59
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
53
60
  import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
54
61
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
@@ -679,6 +686,17 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
679
686
  */
680
687
  control) {
681
688
  const { ownership, observation } = createJobRunState(options, deps);
689
+ // BAPI-1301: a covered claim must carry an envelope on EVERY terminal report,
690
+ // including the many refusals that happen before a worker exists (worktree
691
+ // preparation, pre-spawn verification, MCP registration, …). Without one the
692
+ // server refuses the report, the row dead-letters and the child parks as
693
+ // "cleanup unconfirmed" — a transient pre-spawn problem would need a human.
694
+ // Wrapping the client ONCE makes that structural rather than a per-site duty:
695
+ // any /fail that has no envelope gets the truthful "not launched" envelope and
696
+ // keeps its own error kind; reports that already carry one are untouched.
697
+ if (claimIsCovered(job)) {
698
+ httpClient = withNotLaunchedEnvelopeFallback(httpClient, job);
699
+ }
682
700
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
683
701
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
684
702
  // worker (TDD §5/§9).
@@ -1266,6 +1284,44 @@ function resolveStaleBranchClassification(payload) {
1266
1284
  return "unknown";
1267
1285
  return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
1268
1286
  }
1287
+ /** The `/fail` payload for a failed worktree preparation. */
1288
+ function jobWorktreeFailure(wt) {
1289
+ return (wt.failure ?? {
1290
+ error_kind: "WorktreeError",
1291
+ error_message: wt.error,
1292
+ classification: "crashed",
1293
+ });
1294
+ }
1295
+ /**
1296
+ * Prepare a job's worktree. A job the server dispatched as a parked-child
1297
+ * CONTINUATION (BAPI-1300; `payload.continuation.retained_worktree === true`)
1298
+ * follows the retained-work protocol: it reuses the existing local worktree,
1299
+ * proves its branch and head lineage, and WIP-checkpoints it — it is never
1300
+ * freshened onto origin (which hard-resets and would drop unpushed work) and
1301
+ * never recreated. Every other job keeps the ordinary path unchanged.
1302
+ */
1303
+ async function ensureJobWorktree(job, options, deps, seams, policy) {
1304
+ const continuation = readContinuationBinding(job);
1305
+ if (continuation === null) {
1306
+ const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
1307
+ return ensureWorktree(job, options, deps, policy);
1308
+ }
1309
+ const prepareRetained = seams.prepareRetainedContinuationWorktree ?? prepareRetainedContinuationWorktree;
1310
+ try {
1311
+ const retained = await prepareRetained(job, continuation, buildResumeSeams(deps, options));
1312
+ return await finalizeRetainedExecutorWorktree(retained.worktreePath, retained.branch, deps);
1313
+ }
1314
+ catch (err) {
1315
+ const failure = isExecutorNamedError(err)
1316
+ ? toExecutorFailure(err)
1317
+ : {
1318
+ error_kind: "WorktreeError",
1319
+ error_message: secretFreeErrorMessage(err),
1320
+ classification: "crashed",
1321
+ };
1322
+ return { ok: false, error: failure.error_message, failure };
1323
+ }
1324
+ }
1269
1325
  async function prepareSpawn(job, httpClient, options, deps, seams) {
1270
1326
  if (job.job_type === "resume") {
1271
1327
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
@@ -1296,8 +1352,75 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1296
1352
  };
1297
1353
  }
1298
1354
  }
1355
+ // BAPI-1296 — the FOCUSED ci_fix route, decided before any worktree effect.
1356
+ // The payload's focused contract (no explicit prompt, a valid ci_fix prompt
1357
+ // spec, a ticket spec, and a head-bound dispatched check set) is validated
1358
+ // here; a failure is a named `ContractError.CiFixContext` refusal and the job
1359
+ // never reaches `ensureWorktree`, let alone the full `/implement-ticket`
1360
+ // recipe it used to re-enter. The recovery classification is unchanged, so the
1361
+ // job still reuses the ticket's bound branch under the same lock and
1362
+ // finalization checks.
1363
+ if (job.job_type === CI_FIX_JOB_TYPE) {
1364
+ const readCiFix = seams.readCiFixDispatchContext ?? readCiFixDispatchContext;
1365
+ let ciFix;
1366
+ try {
1367
+ ciFix = readCiFix(job);
1368
+ }
1369
+ catch (err) {
1370
+ const failure = isExecutorNamedError(err)
1371
+ ? toExecutorFailure(err)
1372
+ : {
1373
+ error_kind: CiFixContext,
1374
+ error_message: secretFreeErrorMessage(err),
1375
+ classification: "crashed",
1376
+ };
1377
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
1378
+ await httpClient.fail(job, failure);
1379
+ return { ok: false, result: { status: "failed", reason: "ci_fix_context" } };
1380
+ }
1381
+ const wt = await ensureJobWorktree(job, options, deps, seams, {
1382
+ reuseExistingBranch: isRecoveryJobType(job.job_type),
1383
+ staleBranchClassification: resolveStaleBranchClassification(job.payload),
1384
+ });
1385
+ if (!wt.ok) {
1386
+ await httpClient.fail(job, jobWorktreeFailure(wt));
1387
+ return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1388
+ }
1389
+ return {
1390
+ ok: true,
1391
+ worktreePath: wt.worktreePath,
1392
+ branch: wt.branch,
1393
+ prompt: "",
1394
+ expectedHeadSha: wt.expectedHeadSha,
1395
+ ciFix,
1396
+ };
1397
+ }
1398
+ // BAPI-1311 — the FOCUSED merge-stage rebase, decided before any worktree
1399
+ // effect. A `rebase` job that carries `payload.focused_rebase` must validate
1400
+ // against its own claim; a failure is a named `ContractError.FocusedRebaseContext`
1401
+ // refusal and the job never reaches `ensureWorktree`, never spawns, and is never
1402
+ // reinterpreted as a legacy rebase. A `rebase` WITHOUT the context is an older
1403
+ // queued job: it keeps its original prompt, deadline, finalization, and server
1404
+ // completion contract, and says so once on stderr.
1405
+ let focusedRebase;
1406
+ if (job.job_type === FOCUSED_REBASE_JOB_TYPE) {
1407
+ if (hasFocusedRebaseContext(job)) {
1408
+ const validation = validateFocusedRebaseContext(job);
1409
+ if (!validation.ok) {
1410
+ const failure = toExecutorFailure(focusedRebaseContextError(job, validation.reason));
1411
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
1412
+ await httpClient.fail(job, failure);
1413
+ return { ok: false, result: { status: "failed", reason: "focused_rebase_context" } };
1414
+ }
1415
+ focusedRebase = validation.context;
1416
+ }
1417
+ else {
1418
+ deps.errorLog(`[executor] job ${job.id}: Legacy rebase handling — no focused_rebase context; running its ` +
1419
+ "original prompt under the existing completion contract (no executor-supervised wait)");
1420
+ }
1421
+ }
1299
1422
  // Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
1300
- // synthesis (implement + recovery).
1423
+ // synthesis (implement + the remediate/rebase recovery jobs).
1301
1424
  //
1302
1425
  // BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
1303
1426
  // — the reconciler builds its prompt server-side and ships it in
@@ -1325,14 +1448,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1325
1448
  });
1326
1449
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1327
1450
  }
1328
- const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
1329
- // Recovery jobs (`remediate`/`ci_fix`/`rebase`) continue on the ticket's
1451
+ // Recovery jobs (`remediate`/`rebase`; `ci_fix` above) continue on the ticket's
1330
1452
  // existing bound branch/PR (it carries the implement commit), so they reuse it
1331
1453
  // from `origin/<branch>` with the F7 stale-branch guard OFF. `implement` and
1332
1454
  // `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
1333
1455
  // here — it has its own preservation protocol above.
1334
1456
  const reuseExistingBranch = isRecoveryJobType(job.job_type);
1335
- const wt = await ensureWorktree(job, options, deps, {
1457
+ const wt = await ensureJobWorktree(job, options, deps, seams, {
1336
1458
  reuseExistingBranch,
1337
1459
  // BAPI-862: hand the guard the reconciler's own reading of why this branch
1338
1460
  // may already carry commits. Read from the SERVER-minted payload field and
@@ -1341,14 +1463,12 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1341
1463
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1342
1464
  });
1343
1465
  if (!wt.ok) {
1344
- await httpClient.fail(job, {
1345
- error_kind: "WorktreeError",
1346
- error_message: wt.error,
1347
- classification: "crashed",
1348
- });
1466
+ await httpClient.fail(job, jobWorktreeFailure(wt));
1349
1467
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1350
1468
  }
1351
1469
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
1470
+ // BAPI-1311: for a focused rebase that explicit prompt IS the validated
1471
+ // server-rendered prompt (`validateFocusedRebaseContext` required it).
1352
1472
  if (explicit.ok) {
1353
1473
  return {
1354
1474
  ok: true,
@@ -1356,6 +1476,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1356
1476
  branch: wt.branch,
1357
1477
  prompt: explicit.prompt,
1358
1478
  expectedHeadSha: wt.expectedHeadSha,
1479
+ ...(focusedRebase ? { focusedRebase } : {}),
1359
1480
  };
1360
1481
  }
1361
1482
  // No explicit/synthesized prompt: render the structured `payload.prompt_spec`
@@ -1391,6 +1512,201 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1391
1512
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1392
1513
  }
1393
1514
  }
1515
+ /**
1516
+ * BAPI-1296 — verify a prepared `ci_fix` checkout against its dispatched CI
1517
+ * context and render the focused prompt, UNDER THE WORKTREE LOCK.
1518
+ *
1519
+ * The dispatched checks describe ONE observed head of ONE branch. Rendering them
1520
+ * into a worker prompt for any other checkout would send the worker to fix
1521
+ * failures that may not exist there, so the comparison runs after the lock is
1522
+ * held (no contender can move the worktree underneath it) and before artifact
1523
+ * cleanup, provisioning, and the spawn. The HEAD is read fresh from the
1524
+ * worktree; nothing is inferred from a remote URL or from worker output. Any
1525
+ * mismatch is a named `ContractError.CiFixContext` refusal with a fixed reason.
1526
+ *
1527
+ * Rendering happens only after the checks pass, through the single-pass
1528
+ * `renderPromptSpecPrompt`, so untrusted check or ticket text is never
1529
+ * re-expanded and never reaches a worker for an unverified checkout.
1530
+ */
1531
+ async function renderVerifiedCiFixPrompt(params) {
1532
+ const { job, ciFix, worktreePath, branch, baseBranch, deps, seams } = params;
1533
+ if (branch.trim() !== ciFix.expectedBranch) {
1534
+ return { ok: false, error: ciFixContextError(job, "branch_mismatch") };
1535
+ }
1536
+ let head;
1537
+ try {
1538
+ const revParse = await deps.runCommand("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
1539
+ head = revParse.exitCode === 0 ? revParse.stdout.trim().toLowerCase() : "";
1540
+ }
1541
+ catch {
1542
+ head = "";
1543
+ }
1544
+ if (!head) {
1545
+ return { ok: false, error: ciFixContextError(job, "head_unreadable") };
1546
+ }
1547
+ if (head !== ciFix.headSha) {
1548
+ return { ok: false, error: ciFixContextError(job, "stale_head") };
1549
+ }
1550
+ const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
1551
+ try {
1552
+ const prompt = await renderPrompt(job, {
1553
+ worktreePath,
1554
+ workBranch: branch,
1555
+ baseBranch,
1556
+ runCommand: deps.runCommand,
1557
+ });
1558
+ return { ok: true, prompt };
1559
+ }
1560
+ catch (err) {
1561
+ return {
1562
+ ok: false,
1563
+ error: isExecutorNamedError(err) ? err : ciFixContextError(job, "prompt_spec_invalid"),
1564
+ };
1565
+ }
1566
+ }
1567
+ /** BAPI-1311: cap on any one focused finalization read, before the deadline clamp. */
1568
+ const FOCUSED_FINALIZATION_READ_MAX_MS = 120_000;
1569
+ /**
1570
+ * BAPI-1311 — run the focused rebase's post-push wait and, unless it is `ready`,
1571
+ * report the job's ONE terminal outcome.
1572
+ *
1573
+ * Returns `null` when the wait is `ready`: the caller then submits the ordinary
1574
+ * `/complete` exactly once, and the server's completion verifier re-reads the
1575
+ * provider as the final authority. Every other ending is reported here, once:
1576
+ *
1577
+ * - a PROVEN failure (red required check, closed or conflicting pull request, a
1578
+ * moved number/head/head branch/base) → `ContractError.RebaseNotVerified`, the
1579
+ * kind the server records when its own verifier refuses a rebase;
1580
+ * - an UNVERIFIED stop (evidence still unavailable or pending at the deadline, or
1581
+ * checks unobservable from this executor) → the ordinary `Timeout`
1582
+ * classification, whose message says the repair is unverified — never that CI
1583
+ * failed;
1584
+ * - a lost claim returns `abandoned` with NO terminal mutation; a server stop and
1585
+ * a supervised timeout or crash use the existing driver-phase vocabulary.
1586
+ *
1587
+ * The pushed commit is never reset or discarded on any path: it is on origin, and
1588
+ * this function issues no git write at all.
1589
+ */
1590
+ async function superviseFocusedRebaseWait(params) {
1591
+ const { job, httpClient, options, deps, ownership, observation, seams, control } = params;
1592
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
1593
+ const fail = async (failure) => {
1594
+ await params.finalizeRegistry();
1595
+ const terminal = await sendTerminalMutationWithAuthReport({
1596
+ kind: "fail",
1597
+ send: () => httpClient.fail(job, { ...failure, ...params.commitResidue, telemetry: observation.snapshot() }),
1598
+ deps,
1599
+ options,
1600
+ ownership,
1601
+ log: deps.log,
1602
+ }, job, control);
1603
+ return terminalToRunResult(terminal, "failed");
1604
+ };
1605
+ const pushed = params.pushedHeadSha;
1606
+ if (!pushed) {
1607
+ deps.errorLog(`[executor] Stopped: evidence unavailable — ${label} has no verified pushed head to wait on; ` +
1608
+ "pushed work (if any) preserved");
1609
+ return await fail({
1610
+ error_kind: "Timeout",
1611
+ error_message: `${label}: focused rebase evidence unavailable (reason=pushed_head_unverified); the repair is unverified, not failed CI`,
1612
+ classification: "timeout",
1613
+ });
1614
+ }
1615
+ const wait = seams.waitForFocusedRebase ?? waitForFocusedRebase;
1616
+ const pollChecks = seams.pollProviderChecks ??
1617
+ lazyProviderCheckPoller({
1618
+ env: deps.env,
1619
+ cwd: deps.cwd,
1620
+ homedir: deps.homedir,
1621
+ platform: deps.platform,
1622
+ readFile: deps.readFile,
1623
+ stat: deps.stat,
1624
+ });
1625
+ const phase = await superviseDriverPhase({
1626
+ job,
1627
+ httpClient,
1628
+ options: params.jobOptions,
1629
+ deps,
1630
+ ownership,
1631
+ observation,
1632
+ control,
1633
+ timeoutSeconds: Math.max(1, Math.ceil(params.remainingMs() / 1000)),
1634
+ collectTelemetry: params.collectTelemetry,
1635
+ run: (signal) => wait({
1636
+ jobId: job.id,
1637
+ context: params.context,
1638
+ pushedHeadSha: pushed,
1639
+ expectedBaseBranch: params.expectedBaseBranch,
1640
+ readPullRequest: (timeoutMs) => readFocusedPullRequest(deps.runCommand, params.worktreePath, params.context.prNumber, { timeoutMs }),
1641
+ pollChecks,
1642
+ now: deps.now,
1643
+ sleep: deps.sleep,
1644
+ signal,
1645
+ deadlineMs: params.jobDeadlineMs,
1646
+ errorLog: deps.errorLog,
1647
+ }),
1648
+ });
1649
+ if (phase.kind === "abandoned") {
1650
+ // The claim is gone: another owner may already hold this job, so no terminal
1651
+ // mutation is sent. The pushed commit stays on origin.
1652
+ await params.finalizeRegistry();
1653
+ return { status: "abandoned", reason: ownership.abandonReason };
1654
+ }
1655
+ if (phase.kind === "server_stop") {
1656
+ await params.finalizeRegistry();
1657
+ await httpClient.fail(job, {
1658
+ error_kind: "ServerStopRequested",
1659
+ error_message: "the server requested this run stop; the focused rebase wait was stopped",
1660
+ classification: "killed",
1661
+ telemetry: observation.snapshot(),
1662
+ });
1663
+ ownership.abandoned = true;
1664
+ ownership.abandonReason = "server_stop";
1665
+ return { status: "abandoned", reason: "server_stop" };
1666
+ }
1667
+ if (phase.kind === "timeout") {
1668
+ return await fail({
1669
+ error_kind: "Timeout",
1670
+ error_message: `${label}: focused rebase wait exceeded the job deadline; pushed work preserved; the repair is unverified`,
1671
+ classification: "timeout",
1672
+ });
1673
+ }
1674
+ if (phase.kind === "error") {
1675
+ const message = secretFreeErrorMessage(phase.error);
1676
+ deps.errorLog(`[executor] Stopped: wait_error — ${label}: focused rebase wait failed unexpectedly; pushed work preserved`);
1677
+ return await fail({
1678
+ error_kind: "WorkerCrashed",
1679
+ error_message: `${label}: focused rebase wait failed: ${message}`,
1680
+ classification: "crashed",
1681
+ });
1682
+ }
1683
+ const outcome = phase.value;
1684
+ if (outcome.kind === "ready")
1685
+ return null;
1686
+ if (outcome.kind === "cancelled") {
1687
+ // Cancellation reaches here only when the abort fired without the supervisor
1688
+ // classifying it (a race at the very end of the wait). Nothing was proven.
1689
+ return await fail({
1690
+ error_kind: "Timeout",
1691
+ error_message: `${label}: focused rebase wait was cancelled before evidence was current; pushed work preserved; the repair is unverified`,
1692
+ classification: "timeout",
1693
+ });
1694
+ }
1695
+ if (outcome.proven) {
1696
+ return await fail({
1697
+ error_kind: RebaseNotVerified,
1698
+ error_message: `${label}: focused rebase pushed its repair but cannot succeed (reason=${outcome.reason}); ` +
1699
+ "pushed work preserved on origin; no further repair was launched by this job",
1700
+ classification: "crashed",
1701
+ });
1702
+ }
1703
+ return await fail({
1704
+ error_kind: "Timeout",
1705
+ error_message: `${label}: focused rebase evidence was not current by the job deadline (reason=${outcome.reason}); ` +
1706
+ "pushed work preserved; the repair is unverified, not failed CI",
1707
+ classification: "timeout",
1708
+ });
1709
+ }
1394
1710
  /**
1395
1711
  * Final pre-spawn preparation order (BAPI-724, BAPI-727): worktree + prompt
1396
1712
  * preparation (which itself performs reset-based untracked cleanup and command
@@ -1548,6 +1864,29 @@ driver) {
1548
1864
  // which is exactly the path that has no return value to inspect.
1549
1865
  const spawnLifecycle = {};
1550
1866
  try {
1867
+ // --- Focused ci_fix checkout verification (BAPI-1296) ---------------
1868
+ // Inside the lock, ahead of artifact cleanup and every worker-owned write:
1869
+ // a refused ci_fix leaves the worktree exactly as preparation left it. The
1870
+ // verified, rendered prompt replaces preparation's empty placeholder.
1871
+ let readyPrep = prep;
1872
+ if (prep.ciFix) {
1873
+ const rendered = await renderVerifiedCiFixPrompt({
1874
+ job,
1875
+ ciFix: prep.ciFix,
1876
+ worktreePath,
1877
+ branch,
1878
+ baseBranch: effectiveBaseBranch,
1879
+ deps,
1880
+ seams,
1881
+ });
1882
+ if (!rendered.ok) {
1883
+ const failure = toExecutorFailure(rendered.error);
1884
+ deps.errorLog(`[executor] job ${job.id}: ${failure.error_message}`);
1885
+ await httpClient.fail(job, failure);
1886
+ return { status: "failed", reason: "ci_fix_context" };
1887
+ }
1888
+ readyPrep = { ...prep, prompt: rendered.prompt };
1889
+ }
1551
1890
  // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
1552
1891
  // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
1553
1892
  // so every job type routed through `runSpawnJob` is covered — `implement`,
@@ -1591,7 +1930,7 @@ driver) {
1591
1930
  seams,
1592
1931
  worktreePath,
1593
1932
  branch,
1594
- prep,
1933
+ prep: readyPrep,
1595
1934
  effectiveBaseBranch,
1596
1935
  indexScope,
1597
1936
  permissionPosture,
@@ -1638,7 +1977,16 @@ async function runPreparedSpawn(params) {
1638
1977
  // are told to open the PR against the injected run base. Verdict-only
1639
1978
  // `spec_review` produces no PR, so it is excluded from the instruction while
1640
1979
  // still using the pinned base for its fresh worktree.
1641
- let prompt = isImplementationStyleJobType(job.job_type)
1980
+ //
1981
+ // BAPI-1296: a focused `ci_fix` prompt is used VERBATIM. It repairs a branch
1982
+ // whose PR already exists and must not be told how to open one; the prompt
1983
+ // itself pins it to the existing work branch.
1984
+ //
1985
+ // BAPI-1311: a focused rebase prompt is also used VERBATIM, for the same reason:
1986
+ // it repairs an existing branch whose pull request already exists, and the PR
1987
+ // base contract's "when you open the pull request" instruction would re-add the
1988
+ // new-PR step the focused prompt deliberately excludes.
1989
+ let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
1642
1990
  ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
1643
1991
  : prep.prompt;
1644
1992
  // --- Timeout contract ------------------------------------------------
@@ -1651,6 +1999,22 @@ async function runPreparedSpawn(params) {
1651
1999
  });
1652
2000
  return { status: "failed", reason: "timeout_contract" };
1653
2001
  }
2002
+ // --- Covered completion contract (BAPI-1301) --------------------------
2003
+ // Decided from the CLAIM (the server stamped the requirement), never from the
2004
+ // payload or the worker. A requirement this runner cannot honor — a job type
2005
+ // outside the covered set, or a driver-owned job — is refused explicitly with
2006
+ // an envelope, rather than run under the legacy contract that the server
2007
+ // would then refuse to accept.
2008
+ const covered = claimIsCovered(job);
2009
+ if (covered && (!isCoveredJobType(job.job_type) || driver)) {
2010
+ return await refuseCoveredClaimBeforeLaunch({
2011
+ job, httpClient, options, deps, ownership, observation, control,
2012
+ runtime: unrecognizedRuntime(adapter),
2013
+ dispatchHeadSha: prep.expectedHeadSha ?? null,
2014
+ reason: "uncoverable_job",
2015
+ message: "this executor cannot run this job type under the completion-evidence contract",
2016
+ });
2017
+ }
1654
2018
  // --- Spawn-job driver: the pre-worker phase (BAPI-1152) --------------
1655
2019
  // Runs INSIDE the lock and before every worker-owned side effect. A driver may
1656
2020
  // finish the whole job here (a clean, green reconciliation pushes with no
@@ -1957,6 +2321,9 @@ async function runPreparedSpawn(params) {
1957
2321
  const alias = invocation.value.resolveModelAlias(job.payload);
1958
2322
  spawnShape = invocation.value.buildSpawnShape({
1959
2323
  prompt,
2324
+ // BAPI-1301: a covered attempt sends the prompt on a held stdin instead
2325
+ // of argv; every other job keeps the byte-identical legacy argv.
2326
+ ...(covered ? { promptTransport: "stdin-stream-json" } : {}),
1960
2327
  modelAlias: alias,
1961
2328
  posture: permissionPosture,
1962
2329
  mcpConfigPath: mcpContract.configPath,
@@ -2023,7 +2390,10 @@ async function runPreparedSpawn(params) {
2023
2390
  // `.env` and truncated it — so "could not confirm the worktree is clean" must
2024
2391
  // refuse, never proceed and hope.
2025
2392
  const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
2026
- const stripped = await stripEnvFiles(worktreePath, {
2393
+ // BAPI-1302: a named function so the covered lane can re-run the SAME guard
2394
+ // before its independent checks — a check must never load an environment file
2395
+ // the worker left behind any more than the worker could load the operator's.
2396
+ const stripWorktreeEnvFiles = () => stripEnvFiles(worktreePath, {
2027
2397
  readdir: (dirPath) => readdir(dirPath),
2028
2398
  // `lstat`/`unlink` only — the guard must never resolve a symlink, because an
2029
2399
  // entry pointing at the main checkout would resolve to the operator's real
@@ -2076,6 +2446,7 @@ async function runPreparedSpawn(params) {
2076
2446
  },
2077
2447
  platform: deps.platform,
2078
2448
  });
2449
+ const stripped = await stripWorktreeEnvFiles();
2079
2450
  if (!stripped.ok) {
2080
2451
  await closeWorkerLog();
2081
2452
  await finalizeRegistry();
@@ -2090,12 +2461,84 @@ async function runPreparedSpawn(params) {
2090
2461
  });
2091
2462
  return { status: "failed", reason: "worker_env_file_present" };
2092
2463
  }
2464
+ // --- Covered attempt: runtime recheck, reservation, phase host (BAPI-1301)
2465
+ // Immediately before spawn, re-probe the EXACT runtime this claim requires.
2466
+ // The claim was admitted on the preflight's qualification, but the installed
2467
+ // CLI can change between the two; a mismatch refuses here, before any worker
2468
+ // exists. Then reserve cleanup/verification/finalization/report time out of
2469
+ // the REMAINING allowance, refusing an impossible reservation instead of
2470
+ // skipping a phase or extending the budget.
2471
+ // The ONE worker spawn boundary (BAPI-790/828). Both the legacy spawn below and
2472
+ // the covered attempt launch through it, so there is still exactly one place a
2473
+ // worker can be started, always from the verified spawn shape. Only a covered
2474
+ // attempt asks for a held stdin; the legacy options stay byte-identical.
2475
+ const spawnWorker = (spawnOptions) => deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
2476
+ cwd: worktreePath,
2477
+ env: spawnOptions.env,
2478
+ ...(spawnOptions.stdin ? { stdin: spawnOptions.stdin } : {}),
2479
+ });
2480
+ if (covered) {
2481
+ const capability = resolveCompletionEvidenceCapability(adapter);
2482
+ const dispatchHeadSha = verification.observedSha ?? prep.expectedHeadSha ?? null;
2483
+ const probed = capability.supported === true
2484
+ ? await capability.value.probeRuntime({
2485
+ runCommand: deps.runCommand,
2486
+ platform: deps.platform,
2487
+ ...(deps.arch !== undefined ? { arch: deps.arch } : {}),
2488
+ })
2489
+ : null;
2490
+ const runtime = probed ?? unrecognizedRuntime(adapter);
2491
+ const refuse = async (reason, message) => {
2492
+ await closeWorkerLog();
2493
+ await finalizeRegistry();
2494
+ return await refuseCoveredClaimBeforeLaunch({
2495
+ job, httpClient, options, deps, ownership, observation, control,
2496
+ runtime, dispatchHeadSha, reason, message,
2497
+ });
2498
+ };
2499
+ if (capability.supported !== true || !runtimeMatchesSupported(runtime)) {
2500
+ return await refuse("unsupported_runtime", "the installed agent runtime is not the qualified completion-evidence runtime; refusing to launch");
2501
+ }
2502
+ if (!spawnShape.stdinMessages?.initial) {
2503
+ return await refuse("unsupported_runtime", "the executor adapter produced no held-stdin prompt message for a covered job");
2504
+ }
2505
+ // BAPI-1302 (D4c) — the server-stamped declaration is validated WHOLE,
2506
+ // before any allowance is reserved or a worker exists. A truncated,
2507
+ // malformed, unsupported or server-refused declaration never launches work
2508
+ // against a partial set of checks.
2509
+ const verificationRead = readRequiredVerification(job.payload);
2510
+ if (verificationRead.kind === "refused") {
2511
+ deps.errorLog(`[executor] job ${job.id}: required-verification declaration refused (${verificationRead.reason})`);
2512
+ return await refuse("declaration_refused", "the job's required-verification declaration is unusable; refusing to launch");
2513
+ }
2514
+ const { spentAllowanceMs } = readCompletionBinding(job, dispatchHeadSha);
2515
+ const plan = planCompletionAllowance({
2516
+ nowMs: deps.now(),
2517
+ remainingMs: remainingMs(),
2518
+ spentAllowanceMs,
2519
+ declaredCheckTimeoutsMs: readDeclaredChecks(job.payload).map((c) => c.timeoutSeconds * 1000),
2520
+ });
2521
+ if (!plan.ok) {
2522
+ return await refuse("allowance_exhausted", "the remaining attempt allowance cannot reserve cleanup, verification and finalization; refusing to launch");
2523
+ }
2524
+ return await runCoveredAttempt({
2525
+ prepared: params,
2526
+ spawnShape,
2527
+ spawnWorker,
2528
+ mcpSurface,
2529
+ tee,
2530
+ closeWorkerLog,
2531
+ finalizeRegistry,
2532
+ plan,
2533
+ runtime,
2534
+ nudge: (ids) => capability.value.buildWatcherNudge(ids),
2535
+ dispatchHeadSha,
2536
+ stripEnvFiles: stripWorktreeEnvFiles,
2537
+ });
2538
+ }
2093
2539
  let proc;
2094
2540
  try {
2095
- proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
2096
- cwd: worktreePath,
2097
- env: spawnShape.env,
2098
- });
2541
+ proc = spawnWorker({ env: spawnShape.env });
2099
2542
  }
2100
2543
  catch (err) {
2101
2544
  await closeWorkerLog();
@@ -2397,12 +2840,40 @@ async function runPreparedSpawn(params) {
2397
2840
  // the verdict-artifact path above stays limited to verdict jobs like
2398
2841
  // `spec_review`. Recovery jobs complete through this same generic envelope.
2399
2842
  const artifacts = await readCompletionArtifacts(worktreePath, deps);
2400
- const genericResult = buildGenericSuccessResult({
2843
+ const executorResult = buildGenericSuccessResult({
2401
2844
  summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
2402
2845
  branch,
2403
2846
  headSha: git.last_commit_sha,
2404
2847
  localCommitCount: git.local_commit_count,
2405
2848
  });
2849
+ // BAPI-1296: a focused ci_fix carries its per-check mapping back to the
2850
+ // server at `result.check_fix_claims`, where the exact-job evidence reader
2851
+ // compares it with the dispatched set. Only the allowlisted, validated fields
2852
+ // are merged, and they are spread FIRST so every executor-observed field
2853
+ // (branch, head, commit count, and the verified PR below) wins over anything
2854
+ // the worker file could have said. A missing or invalid file forwards no
2855
+ // claims at all — the server then records an incomplete attempt — and its
2856
+ // status is the executor's own observation, never inferred from exit code.
2857
+ let genericResult = executorResult;
2858
+ if (prep.ciFix) {
2859
+ const readCiResult = seams.readCiFixResultArtifact ?? readCiFixResultArtifact;
2860
+ const ciResult = await readCiResult(worktreePath, prep.ciFix.requiredClaimChecks, {
2861
+ readFile: deps.readFile,
2862
+ lstatPath: deps.lstatPath,
2863
+ });
2864
+ if ("fields" in ciResult) {
2865
+ genericResult = {
2866
+ ...ciResult.fields,
2867
+ ...executorResult,
2868
+ [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status,
2869
+ };
2870
+ }
2871
+ else {
2872
+ deps.errorLog(`[executor] job ${job.id}: ci_fix result artifact ${ciResult.status} (${ciResult.code}); ` +
2873
+ "no check_fix_claims forwarded");
2874
+ genericResult = { ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status };
2875
+ }
2876
+ }
2406
2877
  // BAPI-551: a worker can exit `clean_exit` yet strand its implementation if
2407
2878
  // its push never landed on origin and no PR was opened (e.g. it backgrounded
2408
2879
  // a slow push and returned early). Convert that into a fail-loud executor
@@ -2415,6 +2886,15 @@ async function runPreparedSpawn(params) {
2415
2886
  runCommand: deps.runCommand,
2416
2887
  headSha: git.last_commit_sha,
2417
2888
  expectedBaseBranch: effectiveBaseBranch,
2889
+ // BAPI-1311: a focused rebase's finalization reads are bounded by the
2890
+ // claim's remaining absolute deadline; every other job keeps the legacy,
2891
+ // unbounded-options invocation exactly.
2892
+ ...(prep.focusedRebase
2893
+ ? {
2894
+ callTimeoutMs: () => Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs()),
2895
+ exposePushedHead: true,
2896
+ }
2897
+ : {}),
2418
2898
  });
2419
2899
  if (!finalization.ok) {
2420
2900
  await finalizeRegistry();
@@ -2432,6 +2912,30 @@ async function runPreparedSpawn(params) {
2432
2912
  }, job, control);
2433
2913
  return terminalToRunResult(terminal, "failed");
2434
2914
  }
2915
+ // BAPI-1311 — the FOCUSED rebase's supervised post-push wait. The worker has
2916
+ // exited cleanly and finalization PROVED its push is the origin tip, so what
2917
+ // remains is evidence the server's completion verifier will read: the bound
2918
+ // pull request at that head, mergeable, with its effective required checks
2919
+ // green. The wait runs under the SAME claim, heartbeat, dead-man, and timeout
2920
+ // supervision a worker gets, bounded by the job's ORIGINAL absolute deadline.
2921
+ // It launches no worker, enqueues nothing, and resets nothing: a pending check
2922
+ // is waited on, never repaired again. Only `ready` reaches `/complete`, once.
2923
+ if (prep.focusedRebase) {
2924
+ const focusedTerminal = await superviseFocusedRebaseWait({
2925
+ job, httpClient, options, jobOptions, deps, ownership, observation, seams, control,
2926
+ context: prep.focusedRebase,
2927
+ pushedHeadSha: finalization.pushedHeadSha ?? null,
2928
+ expectedBaseBranch: effectiveBaseBranch,
2929
+ worktreePath,
2930
+ jobDeadlineMs,
2931
+ remainingMs,
2932
+ collectTelemetry: driverTelemetry,
2933
+ commitResidue: commitResidue(git),
2934
+ finalizeRegistry,
2935
+ });
2936
+ if (focusedTerminal !== null)
2937
+ return focusedTerminal;
2938
+ }
2435
2939
  // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
2436
2940
  // populated on this path before — `buildGenericSuccessResult` has no PR field
2437
2941
  // at all — which is why job 1397's stored `result.pr_url` was null even though
@@ -2523,3 +3027,767 @@ async function runPreparedSpawn(params) {
2523
3027
  }, job, control);
2524
3028
  return terminalToRunResult(terminal, "failed");
2525
3029
  }
3030
+ // =========================================================================
3031
+ // BAPI-1301 (D4a) — the covered-attempt phase host
3032
+ // =========================================================================
3033
+ /** How often the worker's descendant tree is sampled for containment. */
3034
+ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
3035
+ /** Bound on delivering the initial prompt message on the held stdin. */
3036
+ const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3037
+ /** Bound on closing the held stdin once the closure rule fires. */
3038
+ const STDIN_CLOSE_BOUND_MS = 5_000;
3039
+ const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3040
+ /**
3041
+ * Whether this claim runs under the covered completion contract.
3042
+ *
3043
+ * Only the SERVER can put a job under it (by stamping the capability
3044
+ * requirement), and only for a covered job type. A job carrying the
3045
+ * requirement that this runner cannot honor is refused explicitly — see
3046
+ * {@link refuseCoveredClaimBeforeLaunch} — never silently run the legacy way.
3047
+ */
3048
+ function claimIsCovered(job) {
3049
+ return claimRequiresCompletionEvidence(job.required_capabilities);
3050
+ }
3051
+ /**
3052
+ * Wrap the client so a covered claim's /fail without an envelope carries the
3053
+ * "not launched" envelope (BAPI-1301). Every other call passes straight through.
3054
+ */
3055
+ function withNotLaunchedEnvelopeFallback(httpClient, job) {
3056
+ const wrapped = Object.create(httpClient);
3057
+ wrapped.fail = (claimed, failure) => {
3058
+ if (failure.completion_evidence !== undefined)
3059
+ return httpClient.fail(claimed, failure);
3060
+ const { binding } = readCompletionBinding(job, typeof job.expected_head_sha === "string" ? job.expected_head_sha : null);
3061
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3062
+ const envelope = buildNotLaunchedEnvelope({
3063
+ binding,
3064
+ runtime: { ...SUPPORTED_COMPLETION_RUNTIME },
3065
+ verification: unlaunched.verification,
3066
+ residuals: CLEANUP_RESIDUALS_NOT_COVERED,
3067
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3068
+ });
3069
+ return httpClient.fail(claimed, {
3070
+ ...failure,
3071
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3072
+ completion_evidence: envelope,
3073
+ });
3074
+ };
3075
+ return wrapped;
3076
+ }
3077
+ /**
3078
+ * The verification a covered report carries when no declared check ran
3079
+ * (BAPI-1302): before launch, after a worker failure, or on a stop.
3080
+ *
3081
+ * A job with no declaration keeps BAPI-1301's version-1 `not_declared`. A job
3082
+ * with one reports version-2 facts naming the stamped declaration with no
3083
+ * command run, which reduces to `not_declared` for an absent or explicitly
3084
+ * empty declaration and `incomplete` otherwise — never `passed`.
3085
+ */
3086
+ export function unlaunchedVerification(read) {
3087
+ if (read.kind === "none")
3088
+ return { verification: "not_declared" };
3089
+ const digest = read.kind === "ok" ? read.declaration.digest : read.digest;
3090
+ if (digest === null) {
3091
+ // A declaration whose digest is not even well-formed cannot be named in a
3092
+ // version-2 envelope; version 1 with an incomplete verdict is the truthful
3093
+ // report, and the server refuses to bind it to the stamped row.
3094
+ return { verification: "incomplete" };
3095
+ }
3096
+ const provenance = read.kind === "ok" ? read.declaration.provenance : (read.provenance ?? "refused");
3097
+ const facts = {
3098
+ provenance,
3099
+ declaration_digest: digest,
3100
+ tested_head_sha: null,
3101
+ artifact: "not_observed",
3102
+ commands: [],
3103
+ };
3104
+ return { verification: verificationVerdictFromFacts(facts), facts };
3105
+ }
3106
+ /**
3107
+ * The runner's facts restricted to the declaration: in declared order, one per
3108
+ * declared command, stopping at the first command the runner reported nothing
3109
+ * for. A missing fact is never invented — the verdict reads it as not run.
3110
+ */
3111
+ function declaredOrderFacts(checks, runFacts) {
3112
+ const out = [];
3113
+ for (let i = 0; i < checks.length; i++) {
3114
+ const fact = runFacts[i];
3115
+ if (!fact || fact.identity !== checks[i].identity)
3116
+ break;
3117
+ out.push(fact);
3118
+ }
3119
+ return out;
3120
+ }
3121
+ /** An identity that cannot match the supported runtime, for truthful refusal envelopes. */
3122
+ function unrecognizedRuntime(adapter) {
3123
+ return {
3124
+ adapter: adapter.identity.agentId,
3125
+ cli_version: "unrecognized",
3126
+ platform: "unrecognized",
3127
+ event_schema: "unrecognized",
3128
+ };
3129
+ }
3130
+ /**
3131
+ * Refuse a covered claim before any worker is launched (BAPI-1301).
3132
+ *
3133
+ * The server requires an envelope on every terminal report for a covered job,
3134
+ * including a refusal. Nothing was spawned, so cleanup is confirmed with zero
3135
+ * survivors; the ledger saw no lifecycle at all, so evidence is insufficient
3136
+ * and the server parks the child with the cause the envelope supports
3137
+ * (`unsupported_completion_evidence` for a runtime refusal,
3138
+ * `required_work_incomplete` for an allowance that cannot fit).
3139
+ */
3140
+ async function refuseCoveredClaimBeforeLaunch(input) {
3141
+ const { job, deps } = input;
3142
+ const { binding } = readCompletionBinding(job, input.dispatchHeadSha);
3143
+ const ledger = createRequiredWorkLedger({ binding, lifecycleSupported: input.reason !== "uncoverable_job" });
3144
+ if (input.reason === "allowance_exhausted")
3145
+ ledger.recordAllowanceExhausted();
3146
+ if (input.reason === "declaration_refused")
3147
+ ledger.recordDeclarationRefused();
3148
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3149
+ const envelope = ledger.buildEnvelope({
3150
+ runtime: input.runtime,
3151
+ finalHeadSha: input.dispatchHeadSha,
3152
+ finalization: "unknown",
3153
+ verification: unlaunched.verification,
3154
+ cleanup: buildCleanupVerdict("confirmed", 0, []),
3155
+ missingCapabilities: [],
3156
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3157
+ });
3158
+ const cause = envelope.cause ?? "required_work_incomplete";
3159
+ deps.errorLog(`[executor] job ${job.id}: covered attempt refused before launch (${input.reason})`);
3160
+ const terminal = await sendTerminalMutationWithAuthReport({
3161
+ kind: "fail",
3162
+ send: () => input.httpClient.fail(job, {
3163
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
3164
+ error_message: input.message,
3165
+ classification: "crashed",
3166
+ telemetry: input.observation.snapshot(),
3167
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3168
+ completion_evidence: envelope,
3169
+ }),
3170
+ deps,
3171
+ options: input.options,
3172
+ ownership: input.ownership,
3173
+ log: deps.log,
3174
+ }, job, input.control);
3175
+ return terminalToRunResult(terminal, "failed");
3176
+ }
3177
+ /**
3178
+ * Run ONE covered attempt from spawn to terminal acknowledgement (BAPI-1301).
3179
+ *
3180
+ * The phases, all under one absolute deadline and one continuous heartbeat:
3181
+ *
3182
+ * 1. worker, prompt on held stdin, stdout read by the authoritative ledger;
3183
+ * stdin closed only by the closure rule or the hold deadline, then EOF
3184
+ * drained (bounded);
3185
+ * 2. owned-process cleanup sweep (tag + tracked descendants);
3186
+ * 3. D4c declared checks, when a runner is installed, plus their cleanup;
3187
+ * 4. finalization (`validateWorkerFinalization`, unchanged);
3188
+ * 5. terminal report carrying the versioned envelope, sent only while this
3189
+ * executor still owns the claim.
3190
+ *
3191
+ * The heartbeat is started HERE, not by `superviseProcess`, precisely so it
3192
+ * does not stop when the worker exits: cleanup, finalization and the report
3193
+ * all still run under a live claim, and a stop/stale/dead-man outcome during
3194
+ * any of them is honored. After the worker exits, no heartbeat outcome ever
3195
+ * signals its (possibly recycled) pid.
3196
+ */
3197
+ async function runCoveredAttempt(params) {
3198
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, adapter, control, lifecycle, } = params.prepared;
3199
+ const { spawnShape, mcpSurface, plan, closeWorkerLog, finalizeRegistry } = params;
3200
+ // --- Ownership identity -------------------------------------------------
3201
+ // Unpredictable, executor-generated, and injected AFTER the adapter built its
3202
+ // environment, so neither ambient input nor the job payload can choose it.
3203
+ const token = createOwnershipToken();
3204
+ const tokenDigest = ownershipTokenDigest(token);
3205
+ const hostDigest = hostIdentityDigest(os.hostname());
3206
+ const workerEnv = withOwnershipTag(spawnShape.env, token);
3207
+ const stateDir = resolveExecutorStateDir(buildJobLogRegistryDeps(deps));
3208
+ const recordFs = seams.ownershipRecordFs ?? createDefaultOwnershipRecordFs();
3209
+ try {
3210
+ // Written BEFORE the spawn: if this executor is SIGKILLed mid-attempt, a
3211
+ // restarted executor finds the record and sweeps with the real token.
3212
+ await writeOwnershipRecord(stateDir, {
3213
+ jobId: job.id,
3214
+ repoName: job.repo_name,
3215
+ token,
3216
+ hostDigest,
3217
+ createdAt: nowIso(deps),
3218
+ // Lets another executor starting on this host tell a live job from an
3219
+ // interrupted one, so it never sweeps this executor's running worker.
3220
+ executorPid: process.pid,
3221
+ }, recordFs);
3222
+ }
3223
+ catch (err) {
3224
+ deps.errorLog(`[executor] job ${job.id}: ownership record not written (non-fatal): ${boundedError(err)}`);
3225
+ }
3226
+ const inspector = seams.processInspector ??
3227
+ createMacProcessInspector({ runCommand: deps.runCommand, selfPid: process.pid, platform: deps.platform });
3228
+ // --- The ledger and its bindings ------------------------------------------
3229
+ const { binding } = readCompletionBinding(job, params.dispatchHeadSha);
3230
+ const obligationTargets = finalizationObligations(branch, effectiveBaseBranch);
3231
+ const obligations = [
3232
+ { operation: "push", target: obligationTargets.push },
3233
+ { operation: "pr_create", target: obligationTargets.prCreate },
3234
+ ];
3235
+ const ledger = createRequiredWorkLedger({
3236
+ binding,
3237
+ lifecycleSupported: true,
3238
+ optionalWatchers: readOptionalWatchers(job.payload),
3239
+ obligations,
3240
+ });
3241
+ // --- Spawn (held stdin) ---------------------------------------------------
3242
+ let proc;
3243
+ try {
3244
+ proc = params.spawnWorker({ env: workerEnv, stdin: "pipe" });
3245
+ }
3246
+ catch (err) {
3247
+ await closeWorkerLog();
3248
+ await finalizeRegistry();
3249
+ await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
3250
+ const raw = `failed to spawn worker: ${boundedError(err)}`;
3251
+ const redactionCapability = adapter.redaction;
3252
+ ledger.recordTransportFailure(true);
3253
+ const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3254
+ const envelope = ledger.buildEnvelope({
3255
+ runtime: params.runtime,
3256
+ finalHeadSha: params.dispatchHeadSha,
3257
+ finalization: "unknown",
3258
+ verification: unlaunched.verification,
3259
+ cleanup: buildCleanupVerdict("confirmed", 0, []),
3260
+ missingCapabilities: [],
3261
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3262
+ });
3263
+ await httpClient.fail(job, {
3264
+ error_kind: "SpawnError",
3265
+ error_message: redactionCapability?.supported === true
3266
+ ? redactAdapterDiagnostic(raw, deps.env, redactionCapability.value)
3267
+ : raw,
3268
+ classification: "crashed",
3269
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3270
+ completion_evidence: envelope,
3271
+ });
3272
+ return { status: "failed", reason: "spawn_failed" };
3273
+ }
3274
+ const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
3275
+ lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
3276
+ const supervisedProc = params.tee
3277
+ ? {
3278
+ ...proc,
3279
+ stdout: proc.stdout ? teeAsyncIterable(proc.stdout, params.tee, "stdout") : null,
3280
+ stderr: proc.stderr ? teeAsyncIterable(proc.stderr, params.tee, "stderr") : null,
3281
+ }
3282
+ : proc;
3283
+ // --- Ownership lifetime: one heartbeat for the whole attempt ---------------
3284
+ let childLive = true;
3285
+ let workerStartedAt = null;
3286
+ const tracker = proc.pid !== undefined ? createDescendantTracker({ inspector, rootPid: proc.pid }) : null;
3287
+ if (proc.pid !== undefined) {
3288
+ void inspector
3289
+ .identity(proc.pid)
3290
+ .then((identity) => {
3291
+ if (identity)
3292
+ workerStartedAt = identity.startedAt.slice(0, 64);
3293
+ })
3294
+ .catch(() => undefined);
3295
+ }
3296
+ const ownershipContext = () => ({
3297
+ version: 1,
3298
+ token_digest: tokenDigest,
3299
+ host_digest: hostDigest,
3300
+ worker: childLive && proc.pid !== undefined && workerStartedAt !== null
3301
+ ? { pid: proc.pid, started_at: workerStartedAt }
3302
+ : null,
3303
+ });
3304
+ const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
3305
+ const collectRemoteMarker = () => collectRemoteTrackingSha({ runCommand: deps.runCommand }, worktreePath, branch);
3306
+ const attemptDone = { value: false };
3307
+ let heartbeatOutcome = null;
3308
+ const heartbeat = runHeartbeatLoop({
3309
+ job,
3310
+ httpClient,
3311
+ options: jobOptions,
3312
+ deps,
3313
+ ownership,
3314
+ proc: supervisedProc,
3315
+ observation,
3316
+ collectTelemetry,
3317
+ collectRemoteMarker,
3318
+ isDone: () => attemptDone.value,
3319
+ currentChild: () => (childLive ? proc : null),
3320
+ ownershipContext,
3321
+ }).then((outcome) => {
3322
+ heartbeatOutcome = outcome;
3323
+ return outcome;
3324
+ });
3325
+ const endHeartbeat = async () => {
3326
+ attemptDone.value = true;
3327
+ const outcome = await heartbeat;
3328
+ if (outcome.kind === "auth_fatal") {
3329
+ control?.reportAuthFatal?.({
3330
+ jobId: job.id,
3331
+ repoName: job.repo_name,
3332
+ category: "heartbeat",
3333
+ httpStatus: outcome.httpStatus,
3334
+ });
3335
+ }
3336
+ };
3337
+ // --- Descendant sampling (containment input) -------------------------------
3338
+ let sampleTimer;
3339
+ const scheduleSample = () => {
3340
+ if (!tracker || !childLive)
3341
+ return;
3342
+ sampleTimer = deps.setTimer(() => {
3343
+ sampleTimer = undefined;
3344
+ void tracker.sample().finally(scheduleSample);
3345
+ }, DESCENDANT_SAMPLE_INTERVAL_MS);
3346
+ };
3347
+ if (tracker)
3348
+ void tracker.sample().finally(scheduleSample);
3349
+ // --- Held-stdin closure supervision -----------------------------------------
3350
+ const stdinDeps = { now: deps.now, setTimer: deps.setTimer, clearTimer: deps.clearTimer };
3351
+ const supervisor = createClosureSupervisor({
3352
+ ledger,
3353
+ deps: stdinDeps,
3354
+ writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
3355
+ closeStdin: async () => {
3356
+ await closeStdinBounded(proc, {
3357
+ deadlineAtMs: Math.min(plan.workerDeadlineAtMs, deps.now() + STDIN_CLOSE_BOUND_MS),
3358
+ deps: stdinDeps,
3359
+ });
3360
+ },
3361
+ buildNudge: params.nudge,
3362
+ deadlineAtMs: plan.holdDeadlineAtMs,
3363
+ });
3364
+ // The authoritative consumer: whole lines into the ledger, bounded. A line
3365
+ // that outgrows the event bound is handed over once (the ledger marks it
3366
+ // `stream_oversize`) and the rest of it is discarded up to its newline.
3367
+ let lineBuffer = "";
3368
+ let discardingOversize = false;
3369
+ const authoritativeStdout = (chunk) => {
3370
+ lineBuffer += chunk;
3371
+ let newline;
3372
+ while ((newline = lineBuffer.indexOf("\n")) >= 0) {
3373
+ const line = lineBuffer.slice(0, newline);
3374
+ lineBuffer = lineBuffer.slice(newline + 1);
3375
+ if (discardingOversize) {
3376
+ discardingOversize = false;
3377
+ continue;
3378
+ }
3379
+ ledger.ingestLine(line);
3380
+ supervisor.onActivity();
3381
+ }
3382
+ if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
3383
+ ledger.ingestLine(lineBuffer);
3384
+ lineBuffer = "";
3385
+ discardingOversize = true;
3386
+ }
3387
+ else if (discardingOversize) {
3388
+ lineBuffer = "";
3389
+ }
3390
+ };
3391
+ const runPromise = runProcessWithTimeout(supervisedProc, Math.max(1, Math.ceil(plan.workerWindowMs / 1000)), deps, {
3392
+ termGraceMs: jobOptions.termGraceMs,
3393
+ terminationController,
3394
+ onStdout: (chunk) => observation.recordStdout(chunk),
3395
+ authoritativeStdout,
3396
+ drainDeadlineMs: EOF_DRAIN_BOUND_MS,
3397
+ onStdoutTerminationCheck: (chunk) => {
3398
+ mcpSurface.observeStdout(chunk);
3399
+ return mcpSurface.shouldTerminate();
3400
+ },
3401
+ });
3402
+ // The prompt goes in AFTER supervision started reading stdout, so a child
3403
+ // that writes before it reads can never deadlock against this write.
3404
+ const initialMessage = spawnShape.stdinMessages?.initial;
3405
+ const initialWrite = initialMessage
3406
+ ? await writeStdinBounded(proc, initialMessage, {
3407
+ deadlineAtMs: Math.min(plan.holdDeadlineAtMs, deps.now() + INITIAL_PROMPT_WRITE_BOUND_MS),
3408
+ deps: stdinDeps,
3409
+ })
3410
+ : { ok: false, reason: "no_stdin" };
3411
+ if (!initialWrite.ok) {
3412
+ // Transport failed before any work: nothing the worker did can be
3413
+ // evidence. Stop it through the one termination path.
3414
+ ledger.recordTransportFailure(true);
3415
+ terminationController.requestTermination();
3416
+ }
3417
+ const procResult = await runPromise;
3418
+ childLive = false;
3419
+ if (sampleTimer !== undefined)
3420
+ deps.clearTimer(sampleTimer);
3421
+ supervisor.dispose();
3422
+ if (!discardingOversize && lineBuffer.length > 0)
3423
+ ledger.ingestLine(lineBuffer);
3424
+ ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
3425
+ ? "stream_error"
3426
+ : (procResult.stdoutEnd ?? "eof"));
3427
+ ledger.recordProcessExit({ code: procResult.exitCode, signal: procResult.signal });
3428
+ observation.setExitCode(procResult.exitCode);
3429
+ // --- Phase 2: owned-process cleanup (success-blocking) ----------------------
3430
+ let cleanup;
3431
+ if (tracker) {
3432
+ tracker.markRootExited();
3433
+ await tracker.sample();
3434
+ }
3435
+ cleanup = await runOwnedProcessSweep({
3436
+ token,
3437
+ inspector,
3438
+ deps: { now: deps.now, sleep: deps.sleep },
3439
+ deadlineAtMs: deps.now() + plan.reservations.cleanupMs,
3440
+ trackedDescendants: tracker?.owned() ?? [],
3441
+ trackerOverflowed: tracker?.overflowed() ?? false,
3442
+ });
3443
+ // Adapter configuration hygiene stays separate and best-effort; it can never
3444
+ // stand in for the process-cleanup verdict above.
3445
+ const lifecycleCapability = adapter.lifecycle;
3446
+ if (lifecycleCapability?.supported === true && lifecycleCapability.value.declaration.kind === "managed") {
3447
+ try {
3448
+ await lifecycleCapability.value.declaration.cleanupAfterJob({ worktreePath });
3449
+ }
3450
+ catch (err) {
3451
+ deps.errorLog(`adapter lifecycle cleanup failed (non-fatal): ${boundedError(err)}`);
3452
+ }
3453
+ }
3454
+ const finalTelemetry = await collectTelemetry();
3455
+ observation.setGitTelemetry(finalTelemetry);
3456
+ if (typeof finalTelemetry.last_commit_sha === "string") {
3457
+ observation.setAttemptEndSha(finalTelemetry.last_commit_sha);
3458
+ }
3459
+ const finalHeadSha = typeof finalTelemetry.last_commit_sha === "string" ? finalTelemetry.last_commit_sha : null;
3460
+ await closeWorkerLog();
3461
+ await reportWorkerLogSessionInvariant(params.tee, deps);
3462
+ // BAPI-1302: the declaration was validated whole at admission; a refused one
3463
+ // never reaches this point.
3464
+ const verificationRead = readRequiredVerification(job.payload);
3465
+ const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
3466
+ const checks = declaration?.checks ?? [];
3467
+ const envelopeWith = (facts) => ledger.buildEnvelope({
3468
+ runtime: params.runtime,
3469
+ finalHeadSha,
3470
+ finalization: facts.finalization,
3471
+ verification: facts.verification,
3472
+ cleanup,
3473
+ missingCapabilities: facts.missing,
3474
+ satisfiedObligations: [
3475
+ ...(facts.pushSatisfied ? [obligations[0]] : []),
3476
+ ...(facts.prSatisfied ? [obligations[1]] : []),
3477
+ ],
3478
+ ...(facts.verificationFacts ? { verificationFacts: facts.verificationFacts } : {}),
3479
+ });
3480
+ const unverifiedEnvelope = () => {
3481
+ const unlaunched = unlaunchedVerification(verificationRead);
3482
+ return envelopeWith({
3483
+ finalization: "unknown",
3484
+ verification: unlaunched.verification,
3485
+ missing: [],
3486
+ ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3487
+ });
3488
+ };
3489
+ // The terminal report's retry window (BAPI-1301). The shared terminal sender
3490
+ // retries a retryable response until the dead-man window since the last
3491
+ // successful heartbeat passes. Here the heartbeat keeps succeeding through the
3492
+ // report, so that window alone would never close: the report would retry
3493
+ // forever. This view keeps the claim fence and abandonment shared with the
3494
+ // heartbeat, and additionally closes the window at the attempt's absolute
3495
+ // deadline (the end of the report reservation) — never later.
3496
+ const reportOwnership = {
3497
+ get abandoned() {
3498
+ return ownership.abandoned;
3499
+ },
3500
+ set abandoned(value) {
3501
+ ownership.abandoned = value;
3502
+ },
3503
+ get abandonReason() {
3504
+ return ownership.abandonReason;
3505
+ },
3506
+ set abandonReason(value) {
3507
+ ownership.abandonReason = value;
3508
+ },
3509
+ get lastSuccessfulHeartbeatAt() {
3510
+ return Math.min(ownership.lastSuccessfulHeartbeatAt, plan.deadlineAtMs - options.deadmanMs);
3511
+ },
3512
+ set lastSuccessfulHeartbeatAt(value) {
3513
+ ownership.lastSuccessfulHeartbeatAt = value;
3514
+ },
3515
+ };
3516
+ const git = observation.git();
3517
+ const failWith = async (failure, envelope, extra = {}) => {
3518
+ await finalizeRegistry();
3519
+ const terminal = await sendTerminalMutationWithAuthReport({
3520
+ kind: "fail",
3521
+ send: () => httpClient.fail(job, {
3522
+ ...failure,
3523
+ ...commitResidue(git),
3524
+ telemetry: observation.snapshot(),
3525
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3526
+ completion_evidence: envelope,
3527
+ ...extra,
3528
+ }),
3529
+ deps,
3530
+ options,
3531
+ ownership: reportOwnership,
3532
+ log: deps.log,
3533
+ }, job, control);
3534
+ return terminalToRunResult(terminal, "failed");
3535
+ };
3536
+ const finish = async (result) => {
3537
+ // The terminal report is acknowledged (or ownership is gone): only now does
3538
+ // the job-level heartbeat end, and only now is the restart record retired.
3539
+ await endHeartbeat();
3540
+ await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
3541
+ return result;
3542
+ };
3543
+ // --- Stronger outcomes keep their precedence ---------------------------------
3544
+ // Checked here AND again right before the terminal report: the heartbeat now
3545
+ // outlives the worker, so a lost claim or a server stop can arrive during
3546
+ // cleanup, verification or finalization, and must still win over a report
3547
+ // built from those phases.
3548
+ const strongerOutcome = async () => {
3549
+ if (ownership.abandoned) {
3550
+ await finalizeRegistry();
3551
+ return finish({ status: "abandoned", reason: ownership.abandonReason });
3552
+ }
3553
+ const hbNow = heartbeatOutcome;
3554
+ if (hbNow?.kind === "server_stop") {
3555
+ const result = await failWith({
3556
+ error_kind: "ServerStopRequested",
3557
+ error_message: "the server requested this run stop; the owned worker was terminated",
3558
+ classification: "killed",
3559
+ }, unverifiedEnvelope());
3560
+ ownership.abandoned = true;
3561
+ ownership.abandonReason = "server_stop";
3562
+ return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
3563
+ }
3564
+ return null;
3565
+ };
3566
+ const earlyStronger = await strongerOutcome();
3567
+ if (earlyStronger)
3568
+ return earlyStronger;
3569
+ const surfaceVerdict = mcpSurface.verdict();
3570
+ if (surfaceVerdict.kind === "mismatch") {
3571
+ return finish(await failWith({
3572
+ error_kind: McpSurfaceMismatch,
3573
+ error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
3574
+ classification: "crashed",
3575
+ }, unverifiedEnvelope()));
3576
+ }
3577
+ if (surfaceVerdict.kind === "absent" &&
3578
+ procResult.classification === "crashed" &&
3579
+ procResult.stdoutExcerpt.trim().length === 0) {
3580
+ return finish(await failWith({ error_kind: WorkerStartupFatal, error_message: WORKER_STARTUP_FATAL_MESSAGE, classification: "crashed" }, unverifiedEnvelope()));
3581
+ }
3582
+ if (procResult.classification !== "clean_exit") {
3583
+ if (procResult.classification === "timeout" || procResult.classification === "killed") {
3584
+ await cleanupInterruptedWorktree(worktreePath, {
3585
+ runCommand: deps.runCommand,
3586
+ removeFile: deps.removeFile ?? ((filePath) => rm(filePath)),
3587
+ errorLog: deps.errorLog,
3588
+ });
3589
+ }
3590
+ const authDetection = adapter.authFailureDetection;
3591
+ const authFailure = authDetection?.supported === true && authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
3592
+ const capacityEvidence = authFailure
3593
+ ? undefined
3594
+ : buildTerminalCapacityEvidence(job, observation, adapter.identity.agentId);
3595
+ const envelope = unverifiedEnvelope();
3596
+ const cause = envelope.cause;
3597
+ return finish(await failWith({
3598
+ error_kind: authFailure
3599
+ ? ClaudeNotAuthenticated
3600
+ : cause
3601
+ ? COMPLETION_CAUSE_ERROR_KINDS[cause]
3602
+ : classificationToErrorKind(procResult.classification),
3603
+ error_message: authFailure
3604
+ ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3605
+ : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3606
+ classification: toWireClassification(procResult.classification),
3607
+ }, envelope, capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}));
3608
+ }
3609
+ // --- Phase 3: independent declared checks (BAPI-1302, D4c) ---------------------
3610
+ // Every declared command runs HERE, by the executor, after the worker exited
3611
+ // and its processes were swept — including commands the worker never started
3612
+ // or claims already passed. Worker text is never read. The checks are bound
3613
+ // to the final local artifact: full HEAD plus the tracked tree/index is read
3614
+ // before them and again after them AND their cleanup; any movement makes the
3615
+ // evidence stale. Nothing is committed, reset or repaired.
3616
+ let verification = "not_declared";
3617
+ let verificationFacts;
3618
+ if (declaration !== null && declaration.provenance !== "declared") {
3619
+ // `absent` or `declared_empty`: no declared local verification. Reported
3620
+ // as such — never as a pass.
3621
+ verificationFacts = {
3622
+ provenance: declaration.provenance,
3623
+ declaration_digest: declaration.digest,
3624
+ tested_head_sha: null,
3625
+ artifact: "not_observed",
3626
+ commands: [],
3627
+ };
3628
+ verification = verificationVerdictFromFacts(verificationFacts);
3629
+ }
3630
+ else if (declaration !== null) {
3631
+ const runner = seams.declaredCheckRunner ??
3632
+ createRequiredVerificationRunner({
3633
+ launch: createSupervisedCommandLauncher({
3634
+ deps,
3635
+ registerLive: (controller) => control?.liveWorkers.register(controller),
3636
+ }),
3637
+ now: deps.now,
3638
+ log: deps.log,
3639
+ });
3640
+ const ownershipHeld = () => !ownership.abandoned && heartbeatOutcome?.kind !== "server_stop";
3641
+ const envGuard = params.stripEnvFiles ? await params.stripEnvFiles() : { ok: true };
3642
+ const before = envGuard.ok
3643
+ ? await observeFinalArtifact(deps.runCommand, worktreePath)
3644
+ : { head: null, trackedStatus: null };
3645
+ const preState = envGuard.ok ? preCheckArtifactState(before, finalHeadSha) : "not_observed";
3646
+ let runFacts = [];
3647
+ let artifact;
3648
+ if (preState === null) {
3649
+ const checkAbort = new AbortController();
3650
+ // Ownership is re-checked WHILE a command runs, not only between commands:
3651
+ // a lost claim or a server stop terminates the running check.
3652
+ let watchTimer;
3653
+ const watchOwnership = () => {
3654
+ watchTimer = deps.setTimer(() => {
3655
+ watchTimer = undefined;
3656
+ if (!ownershipHeld())
3657
+ checkAbort.abort();
3658
+ else
3659
+ watchOwnership();
3660
+ }, OWNERSHIP_POLL_MS);
3661
+ };
3662
+ watchOwnership();
3663
+ try {
3664
+ const outcome = await runner.run({
3665
+ checks,
3666
+ worktreePath,
3667
+ finalHeadSha,
3668
+ env: workerEnv,
3669
+ deadlineAtMs: deps.now() + plan.reservations.verificationMs,
3670
+ signal: checkAbort.signal,
3671
+ ownershipHeld,
3672
+ });
3673
+ runFacts = outcome.facts ?? [];
3674
+ }
3675
+ catch {
3676
+ // A runner that throws proved nothing ran to completion.
3677
+ runFacts = [];
3678
+ }
3679
+ finally {
3680
+ if (watchTimer !== undefined)
3681
+ deps.clearTimer(watchTimer);
3682
+ }
3683
+ // Check cleanup BEFORE the final observation: the checks ran with the same
3684
+ // ownership tag, so the same sweep proves they left nothing behind, and a
3685
+ // survivor could still be writing to the worktree. The worse verdict wins.
3686
+ const checkCleanup = await runOwnedProcessSweep({
3687
+ token,
3688
+ inspector,
3689
+ deps: { now: deps.now, sleep: deps.sleep },
3690
+ deadlineAtMs: deps.now() + plan.reservations.checkCleanupMs,
3691
+ });
3692
+ if (checkCleanup.verdict !== "confirmed") {
3693
+ cleanup = buildCleanupVerdict(cleanup.verdict === "failed" || checkCleanup.verdict === "failed" ? "failed" : "unconfirmed", cleanup.survivors + checkCleanup.survivors, [...cleanup.reasons, ...checkCleanup.reasons]);
3694
+ }
3695
+ artifact = compareFinalArtifact(before, await observeFinalArtifact(deps.runCommand, worktreePath));
3696
+ }
3697
+ else {
3698
+ artifact = preState;
3699
+ }
3700
+ verificationFacts = {
3701
+ provenance: "declared",
3702
+ declaration_digest: declaration.digest,
3703
+ tested_head_sha: before.head,
3704
+ artifact,
3705
+ commands: declaredOrderFacts(checks, runFacts).map(toCompletionCheckFact),
3706
+ };
3707
+ verification = verificationVerdictFromFacts(verificationFacts, checks.length);
3708
+ }
3709
+ // --- Phase 4: finalization (unchanged executor facts) ----------------------------
3710
+ const artifacts = await readCompletionArtifacts(worktreePath, deps);
3711
+ const executorResult = buildGenericSuccessResult({
3712
+ summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
3713
+ branch,
3714
+ headSha: git.last_commit_sha,
3715
+ localCommitCount: git.local_commit_count,
3716
+ });
3717
+ let genericResult = executorResult;
3718
+ if (prep.ciFix) {
3719
+ const readCiResult = seams.readCiFixResultArtifact ?? readCiFixResultArtifact;
3720
+ const ciResult = await readCiResult(worktreePath, prep.ciFix.requiredClaimChecks, {
3721
+ readFile: deps.readFile,
3722
+ lstatPath: deps.lstatPath,
3723
+ });
3724
+ genericResult =
3725
+ "fields" in ciResult
3726
+ ? { ...ciResult.fields, ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status }
3727
+ : { ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status };
3728
+ }
3729
+ const finalization = await validateWorkerFinalization({
3730
+ job,
3731
+ branch,
3732
+ worktreePath,
3733
+ result: genericResult,
3734
+ runCommand: deps.runCommand,
3735
+ headSha: git.last_commit_sha,
3736
+ expectedBaseBranch: effectiveBaseBranch,
3737
+ });
3738
+ const facts = finalizationFacts(finalization.ok ? { ok: true } : { ok: false, errorKind: finalization.failure.error_kind });
3739
+ const envelope = envelopeWith({
3740
+ finalization: facts.verdict,
3741
+ verification,
3742
+ missing: [],
3743
+ pushSatisfied: facts.pushSatisfied,
3744
+ prSatisfied: facts.prSatisfied,
3745
+ ...(verificationFacts ? { verificationFacts } : {}),
3746
+ });
3747
+ // --- Phase 5: terminal report -----------------------------------------------------
3748
+ const lateStronger = await strongerOutcome();
3749
+ if (lateStronger)
3750
+ return lateStronger;
3751
+ if (finalization.ok && successRefusalReason(envelope) === null) {
3752
+ const result = typeof finalization.prUrl === "string" ? { ...genericResult, pr_url: finalization.prUrl } : genericResult;
3753
+ const completion = {
3754
+ job_type: job.job_type,
3755
+ exit_code: procResult.exitCode ?? 0,
3756
+ classification: "clean_exit",
3757
+ result,
3758
+ artifacts,
3759
+ ...commitResidue(git),
3760
+ telemetry: observation.snapshot(),
3761
+ completion_contract: SUPPORTED_COMPLETION_MARKER,
3762
+ completion_evidence: envelope,
3763
+ };
3764
+ const terminal = await sendTerminalMutationWithAuthReport({
3765
+ kind: "complete",
3766
+ send: () => httpClient.complete(job, completion),
3767
+ deps,
3768
+ options,
3769
+ ownership: reportOwnership,
3770
+ log: deps.log,
3771
+ }, job, control);
3772
+ await finalizeRegistry();
3773
+ return finish(terminalToRunResult(terminal, "completed"));
3774
+ }
3775
+ // Not a success. A completion cause (required work, cleanup, verification,
3776
+ // permission) outranks the finalization kind; with none, finalization keeps
3777
+ // its existing error kind and recovery lane.
3778
+ const cause = envelope.cause;
3779
+ if (cause) {
3780
+ return finish(await failWith({
3781
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
3782
+ error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? cause})`,
3783
+ classification: "crashed",
3784
+ }, envelope));
3785
+ }
3786
+ if (!finalization.ok)
3787
+ return finish(await failWith(finalization.failure, envelope));
3788
+ return finish(await failWith({
3789
+ error_kind: COMPLETION_CAUSE_ERROR_KINDS.required_work_incomplete,
3790
+ error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? "unknown"})`,
3791
+ classification: "crashed",
3792
+ }, envelope));
3793
+ }