@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -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, 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";
20
+ import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, WorkerEndedWithPendingBackgroundWork, WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, CiFixContext, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
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";
@@ -26,13 +26,13 @@ import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter
26
26
  import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
27
27
  import { resolveExecutorAgentId } from "./agent-identity.js";
28
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";
29
+ import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, COMPLETION_CONTRACT_MARKER_VALUES, isCoveredJobType, MAX_EVENT_BYTES, runtimeMatchesSupported, successRefusalReason, verificationVerdictFromFacts, } from "./completion-contract.js";
30
30
  import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, } from "./completion-supervision.js";
31
31
  import { compareFinalArtifact, createRequiredVerificationRunner, createSupervisedCommandLauncher, observeFinalArtifact, OWNERSHIP_POLL_MS, preCheckArtifactState, toCompletionCheckFact, } from "./required-verification.js";
32
32
  import { createClosureSupervisor, createRequiredWorkLedger } from "./required-work-ledger.js";
33
33
  import { buildCleanupVerdict, CLEANUP_RESIDUALS_NOT_COVERED, createDefaultOwnershipRecordFs, createDescendantTracker, createMacProcessInspector, createOwnershipToken, hostIdentityDigest, ownershipTokenDigest, removeOwnershipRecord, runOwnedProcessSweep, withOwnershipTag, writeOwnershipRecord, } from "./process-containment.js";
34
34
  import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
35
- import { prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, } from "./resume-pre-spawn.js";
35
+ import { autoCheckpointDirtyState, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE, prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, verifyContinuationHeadLineage, } from "./resume-pre-spawn.js";
36
36
  import { ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
37
37
  import { defaultResolveBinPath } from "../start-tickets-conductor.js";
38
38
  /** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
@@ -42,7 +42,7 @@ import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
42
42
  import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
43
43
  import { stripWorkerEnvFiles } from "./env-file-guard.js";
44
44
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
45
- import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
45
+ import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
46
46
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
47
47
  import { resolveExecutorStateDir, activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
48
48
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
@@ -56,8 +56,10 @@ import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseCon
56
56
  import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
57
57
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
58
58
  import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
59
+ import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
59
60
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
60
61
  import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
62
+ import { probeInstalledCorrectionHook } from "./preflight.js";
61
63
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
62
64
  /**
63
65
  * Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
@@ -174,6 +176,9 @@ function buildJobLogRegistryDeps(deps) {
174
176
  env: deps.env,
175
177
  tmpdir: () => os.tmpdir(),
176
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,
177
182
  };
178
183
  }
179
184
  /**
@@ -535,6 +540,46 @@ function createNoopProcess(deps, durationMs, message) {
535
540
  },
536
541
  };
537
542
  }
543
+ /**
544
+ * Bounded newline assembly of worker stdout into whole lines for a
545
+ * {@link RequiredWorkLedger} (BAPI-1301 covered path, BAPI-1364 legacy
546
+ * observation). A line that outgrows the event bound is handed over once (the
547
+ * ledger marks it `stream_oversize`) and the rest of it is discarded up to its
548
+ * newline. `onLine` runs after each completed line is ingested.
549
+ */
550
+ function createLedgerLineFeeder(ledger, onLine) {
551
+ let lineBuffer = "";
552
+ let discardingOversize = false;
553
+ return {
554
+ feed: (chunk) => {
555
+ lineBuffer += chunk;
556
+ let newline;
557
+ while ((newline = lineBuffer.indexOf("\n")) >= 0) {
558
+ const line = lineBuffer.slice(0, newline);
559
+ lineBuffer = lineBuffer.slice(newline + 1);
560
+ if (discardingOversize) {
561
+ discardingOversize = false;
562
+ continue;
563
+ }
564
+ ledger.ingestLine(line);
565
+ onLine?.();
566
+ }
567
+ if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
568
+ ledger.ingestLine(lineBuffer);
569
+ lineBuffer = "";
570
+ discardingOversize = true;
571
+ }
572
+ else if (discardingOversize) {
573
+ lineBuffer = "";
574
+ }
575
+ },
576
+ flush: () => {
577
+ if (!discardingOversize && lineBuffer.length > 0)
578
+ ledger.ingestLine(lineBuffer);
579
+ lineBuffer = "";
580
+ },
581
+ };
582
+ }
538
583
  /**
539
584
  * Run the process and heartbeat loop concurrently; return the process outcome.
540
585
  *
@@ -564,7 +609,10 @@ async function superviseProcess(params) {
564
609
  ...(params.terminationController
565
610
  ? { terminationController: params.terminationController }
566
611
  : {}),
567
- onStdout: (chunk) => params.observation.recordStdout(chunk),
612
+ onStdout: (chunk) => {
613
+ params.observation.recordStdout(chunk);
614
+ params.onStdoutObserved?.(chunk);
615
+ },
568
616
  ...(mcpSurface
569
617
  ? {
570
618
  onStdoutTerminationCheck: (chunk) => {
@@ -651,6 +699,23 @@ async function sendTerminalMutationWithAuthReport(params, job, control) {
651
699
  * `/executor/jobs`. Integration actions are NOT ordinary executor jobs.
652
700
  */
653
701
  export async function runIntegrationCorrectionSpawn(job, httpClient, options, deps, driver, seams = {}, control) {
702
+ const validated = validateCorrectionIntent(job.payload?.correction_intent, {
703
+ repo_name: job.repo_name,
704
+ epic_run_id: job.epic_run_id ?? null,
705
+ ticket_key: job.ticket_key ?? null,
706
+ expected_branch: job.expected_branch,
707
+ expected_head_sha: job.expected_head_sha,
708
+ });
709
+ if (!validated.ok || validated.intent.kind !== "integration_correction" ||
710
+ (validated.ok && validated.intent.context_ref.action_id !==
711
+ job.payload?.integration_action_id)) {
712
+ const reason = validated.ok ? "correction_contract_invalid" : validated.code;
713
+ await httpClient.fail(job, {
714
+ error_kind: "ContractError.CorrectionLegacy",
715
+ error_message: reason, classification: "crashed",
716
+ });
717
+ return { status: "failed", reason };
718
+ }
654
719
  const { ownership, observation } = createJobRunState(options, deps);
655
720
  return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
656
721
  }
@@ -695,7 +760,11 @@ control) {
695
760
  // any /fail that has no envelope gets the truthful "not launched" envelope and
696
761
  // keeps its own error kind; reports that already carry one are untouched.
697
762
  if (claimIsCovered(job)) {
698
- httpClient = withNotLaunchedEnvelopeFallback(httpClient, job);
763
+ httpClient = withNotLaunchedEnvelopeFallback(httpClient, job, {
764
+ jobTimeRuntime: () => jobTimeCompletionRuntimes.get(ownership) ?? null,
765
+ preflightRuntime: _report?.completionEvidenceQualified === true ? (_report.completionRuntime ?? null) : null,
766
+ agentId: resolveExecutorAgentId(options.agentId),
767
+ });
699
768
  }
700
769
  // `merge` is a deterministic executor action: dispatched BEFORE any worktree
701
770
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
@@ -1175,7 +1244,20 @@ async function superviseDriverPhase(params) {
1175
1244
  collectTelemetry: params.collectTelemetry,
1176
1245
  control: params.control,
1177
1246
  });
1178
- await Promise.race([flow.settled, params.deps.sleep(DRIVER_PHASE_SETTLE_MAX_MS)]);
1247
+ let settleTimer;
1248
+ try {
1249
+ await Promise.race([
1250
+ flow.settled,
1251
+ new Promise((resolve) => {
1252
+ settleTimer = params.deps.setTimer(resolve, DRIVER_PHASE_SETTLE_MAX_MS);
1253
+ }),
1254
+ ]);
1255
+ }
1256
+ finally {
1257
+ // A normal phase must not leave its four-minute bound alive after it settles.
1258
+ if (settleTimer !== undefined)
1259
+ params.deps.clearTimer(settleTimer);
1260
+ }
1179
1261
  if (procResult.serverStopRequested)
1180
1262
  return { kind: "server_stop" };
1181
1263
  if (params.ownership.abandoned)
@@ -1323,10 +1405,56 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
1323
1405
  }
1324
1406
  }
1325
1407
  async function prepareSpawn(job, httpClient, options, deps, seams) {
1408
+ // A continuation passes no local head here (`head === undefined`): its retained
1409
+ // worktree was already proven by `prepareRetainedContinuationWorktree` →
1410
+ // `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
1411
+ // descends from `continuation.bound_head_sha` — the same head as
1412
+ // `correction_intent.head_sha`, including one the server derived for a first
1413
+ // attempt with no bound ticket head (BAPI-1344). The spawn source is always
1414
+ // that retained worktree, never an origin checkout.
1415
+ const refuseCorrectionDrift = async (branch, head) => {
1416
+ if (!isRemainingWork(job.job_type, job.payload))
1417
+ return null;
1418
+ const intent = job.payload?.correction_intent;
1419
+ if (branch === intent.branch && (head === undefined || head === intent.head_sha))
1420
+ return null;
1421
+ const reason = "correction_context_missing";
1422
+ await httpClient.fail(job, {
1423
+ error_kind: "ContractError.CorrectionLegacy",
1424
+ error_message: reason,
1425
+ classification: "crashed",
1426
+ });
1427
+ return { ok: false, result: { status: "failed", reason } };
1428
+ };
1429
+ if (isRemainingWork(job.job_type, job.payload)) {
1430
+ const intent = validateCorrectionIntent(job.payload?.correction_intent, {
1431
+ repo_name: job.repo_name,
1432
+ epic_run_id: job.epic_run_id ?? null,
1433
+ ticket_key: job.ticket_key ?? null,
1434
+ expected_branch: job.expected_branch,
1435
+ expected_head_sha: job.expected_head_sha,
1436
+ });
1437
+ const reason = !intent.ok
1438
+ ? intent.code
1439
+ : !job.required_capabilities?.includes(CORRECTION_INTENT_CAPABILITY)
1440
+ ? "correction_contract_invalid"
1441
+ : null;
1442
+ if (reason !== null) {
1443
+ await httpClient.fail(job, {
1444
+ error_kind: "ContractError.CorrectionLegacy",
1445
+ error_message: reason,
1446
+ classification: "crashed",
1447
+ });
1448
+ return { ok: false, result: { status: "failed", reason } };
1449
+ }
1450
+ }
1326
1451
  if (job.job_type === "resume") {
1327
1452
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
1328
1453
  try {
1329
1454
  const prepared = await prepareResume(job, options, buildResumeSeams(deps, options));
1455
+ const drift = await refuseCorrectionDrift(prepared.branch);
1456
+ if (drift)
1457
+ return drift;
1330
1458
  return {
1331
1459
  ok: true,
1332
1460
  worktreePath: prepared.worktreePath,
@@ -1386,6 +1514,9 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1386
1514
  await httpClient.fail(job, jobWorktreeFailure(wt));
1387
1515
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1388
1516
  }
1517
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1518
+ if (drift)
1519
+ return drift;
1389
1520
  return {
1390
1521
  ok: true,
1391
1522
  worktreePath: wt.worktreePath,
@@ -1461,11 +1592,18 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1461
1592
  // validated against a closed set — never inferred locally, and never taken
1462
1593
  // from worker output (R14 rules 3 and 4).
1463
1594
  staleBranchClassification: resolveStaleBranchClassification(job.payload),
1595
+ // BAPI-1367: the server-minted (and client-normalized) prior head. Only an
1596
+ // `implement` retry acts on it; `ensureExecutorWorktree` re-checks that.
1597
+ // Spread only when present so an ordinary job keeps its exact legacy policy.
1598
+ ...(job.prior_attempt_head_sha ? { priorAttemptHeadSha: job.prior_attempt_head_sha } : {}),
1464
1599
  });
1465
1600
  if (!wt.ok) {
1466
1601
  await httpClient.fail(job, jobWorktreeFailure(wt));
1467
1602
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1468
1603
  }
1604
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1605
+ if (drift)
1606
+ return drift;
1469
1607
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
1470
1608
  // BAPI-1311: for a focused rebase that explicit prompt IS the validated
1471
1609
  // server-rendered prompt (`validateFocusedRebaseContext` required it).
@@ -1476,6 +1614,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1476
1614
  branch: wt.branch,
1477
1615
  prompt: explicit.prompt,
1478
1616
  expectedHeadSha: wt.expectedHeadSha,
1617
+ ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1479
1618
  ...(focusedRebase ? { focusedRebase } : {}),
1480
1619
  };
1481
1620
  }
@@ -1498,6 +1637,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1498
1637
  branch: wt.branch,
1499
1638
  prompt: rendered,
1500
1639
  expectedHeadSha: wt.expectedHeadSha,
1640
+ ...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
1501
1641
  };
1502
1642
  }
1503
1643
  catch (err) {
@@ -1512,6 +1652,93 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1512
1652
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
1513
1653
  }
1514
1654
  }
1655
+ /** The first twelve characters of a SHA — safe, supporting evidence only. */
1656
+ function shortSha(sha) {
1657
+ return sha.slice(0, 12);
1658
+ }
1659
+ /**
1660
+ * BAPI-1367 — the continuation wording prepended to a retried `implement` prompt.
1661
+ *
1662
+ * States the work state first (this is prior-attempt work, not a fresh start),
1663
+ * says whether uncommitted files survived as a WIP checkpoint or were unavailable
1664
+ * because the worktree was recreated from the pushed branch, and leaves the
1665
+ * resolved prompt untouched after it. Short SHAs are the only identifiers used.
1666
+ */
1667
+ export function buildPriorAttemptContinuationNotice(params) {
1668
+ const { branch, priorWork, checkpointed, ticketKey } = params;
1669
+ const recreated = priorWork.kind === "recreated_from_pushed_branch";
1670
+ const command = ticketKey ? `/implement-ticket ${ticketKey} --auto` : "/implement-ticket";
1671
+ const files = recreated
1672
+ ? "Prior uncommitted files were unavailable on this host: the worktree was recreated from the pushed branch, so only committed work is present."
1673
+ : checkpointed
1674
+ ? "Prior uncommitted files were saved as a WIP checkpoint commit so nothing was lost."
1675
+ : "The retained worktree was clean, so no WIP checkpoint was needed.";
1676
+ return (`CONTINUATION: this is a retry. An earlier attempt of this job left work on branch '${branch}' ` +
1677
+ `(its recorded head was ${shortSha(priorWork.priorHeadSha)}). You are continuing that prior-attempt ` +
1678
+ `work, not starting ${command} from base: read the branch's existing commits first, keep what is ` +
1679
+ `correct, and finish only what is missing. ${files} The original instructions follow unchanged.\n\n`);
1680
+ }
1681
+ /**
1682
+ * BAPI-1367 — finish a prior-attempt resume UNDER THE WORKTREE LOCK.
1683
+ *
1684
+ * Order is the contract: prove the retained HEAD descends from the recorded prior
1685
+ * head, WIP-checkpoint dirty files (a checkpoint commits onto whatever HEAD is, so
1686
+ * lineage must be proven first), then finalize — which pins `expectedHeadSha` to
1687
+ * the POST-checkpoint head and provisions command assets. No destructive Git
1688
+ * command is ever issued. Diagnostics carry the job id, attempt number, and short
1689
+ * SHAs only — never the prompt or a full hash.
1690
+ */
1691
+ async function resumePriorAttemptUnderLock(params) {
1692
+ const { job, prep, options, deps, worktreePath, branch } = params;
1693
+ const priorWork = prep.priorWork;
1694
+ const resumeSeams = buildResumeSeams(deps, options);
1695
+ try {
1696
+ await verifyContinuationHeadLineage(worktreePath, priorWork.priorHeadSha, resumeSeams);
1697
+ const checkpoint = await autoCheckpointDirtyState(worktreePath, resumeSeams, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE);
1698
+ const finalized = await finalizeRetainedExecutorWorktree(worktreePath, branch, deps);
1699
+ if (!finalized.ok) {
1700
+ return {
1701
+ ok: false,
1702
+ failure: {
1703
+ error_kind: "WorktreeError",
1704
+ error_message: finalized.error,
1705
+ classification: "crashed",
1706
+ },
1707
+ };
1708
+ }
1709
+ const notice = buildPriorAttemptContinuationNotice({
1710
+ branch,
1711
+ priorWork,
1712
+ checkpointed: checkpoint.committed,
1713
+ ticketKey: job.ticket_key,
1714
+ });
1715
+ deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} resuming prior-attempt work ` +
1716
+ `(prior head ${shortSha(priorWork.priorHeadSha)}, resumed at ${shortSha(finalized.expectedHeadSha)}` +
1717
+ `${checkpoint.committed ? ", dirty files checkpointed" : ""})`);
1718
+ if (priorWork.kind === "recreated_from_pushed_branch") {
1719
+ deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} worktree recreated from the pushed ` +
1720
+ `branch; prior uncommitted files were unavailable (prior head ${shortSha(priorWork.priorHeadSha)})`);
1721
+ }
1722
+ return {
1723
+ ok: true,
1724
+ prep: {
1725
+ ...prep,
1726
+ prompt: `${notice}${prep.prompt}`,
1727
+ expectedHeadSha: finalized.expectedHeadSha,
1728
+ },
1729
+ };
1730
+ }
1731
+ catch (err) {
1732
+ const failure = isExecutorNamedError(err)
1733
+ ? toExecutorFailure(err)
1734
+ : {
1735
+ error_kind: "WorktreeError",
1736
+ error_message: secretFreeErrorMessage(err),
1737
+ classification: "crashed",
1738
+ };
1739
+ return { ok: false, failure };
1740
+ }
1741
+ }
1515
1742
  /**
1516
1743
  * BAPI-1296 — verify a prepared `ci_fix` checkout against its dispatched CI
1517
1744
  * context and render the focused prompt, UNDER THE WORKTREE LOCK.
@@ -1724,6 +1951,34 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1724
1951
  * adds no second worktree entry and no second worker launcher.
1725
1952
  */
1726
1953
  driver) {
1954
+ // A server-owned read precedes every worktree reset/cut and worker launch.
1955
+ // Only this boundary can authorize a no-charge pre-work wait; a later plan
1956
+ // retrieval failure is ordinary bounded worker failure.
1957
+ if (job.epic_run_id && (httpClient.scopeReadiness ||
1958
+ job.required_capabilities?.includes("scope_wait.v1"))) {
1959
+ if (!httpClient.scopeReadiness) {
1960
+ await httpClient.fail(job, {
1961
+ error_kind: "ContractError.ScopeReadiness",
1962
+ error_message: "Required pre-work scope protocol is unavailable.",
1963
+ classification: "crashed",
1964
+ });
1965
+ return { status: "failed", reason: "scope_readiness_unsupported" };
1966
+ }
1967
+ try {
1968
+ const readiness = await httpClient.scopeReadiness(job);
1969
+ if (readiness.decision !== "ready") {
1970
+ return { status: "failed", reason: `scope_${readiness.decision}` };
1971
+ }
1972
+ }
1973
+ catch {
1974
+ await httpClient.fail(job, {
1975
+ error_kind: "ScopeReadinessUnavailable",
1976
+ error_message: "Server scope readiness could not be confirmed.",
1977
+ classification: "crashed",
1978
+ });
1979
+ return { status: "failed", reason: "scope_readiness_unavailable" };
1980
+ }
1981
+ }
1727
1982
  // --- Per-job base branch (BAPI-586) ----------------------------------
1728
1983
  // Resolve the effective logical base BEFORE any side effect: the persisted
1729
1984
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -1824,11 +2079,12 @@ driver) {
1824
2079
  // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
1825
2080
  // The FIRST thing that happens once a canonical worktree path exists, and
1826
2081
  // ahead of every executor-owned write to it: the deny layer, MCP
1827
- // provisioning, the `worker.log` truncation, the viewer tab, and the spawn
1828
- // itself all live inside the lock. `worker.log` in particular is created with
1829
- // an empty write, so a refused contender that got as far as the tee would
1830
- // erase the running worker's forensic log without ever spawning — which is
1831
- // why refusal returns from HERE rather than anywhere downstream.
2082
+ // provisioning, the attempt log allocation, the viewer tab, and the spawn
2083
+ // itself all live inside the lock. Before BAPI-1330 the shared `worker.log`
2084
+ // was created with a truncating write, so a refused contender that got as far
2085
+ // as the tee could erase the running worker's forensic log; per-attempt logs
2086
+ // are now created exclusively, and refusal still returns from HERE rather
2087
+ // than anywhere downstream.
1832
2088
  //
1833
2089
  // Known residual window, stated rather than papered over: `prepareSpawn`
1834
2090
  // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
@@ -1869,6 +2125,20 @@ driver) {
1869
2125
  // a refused ci_fix leaves the worktree exactly as preparation left it. The
1870
2126
  // verified, rendered prompt replaces preparation's empty placeholder.
1871
2127
  let readyPrep = prep;
2128
+ // --- Prior-attempt resume (BAPI-1367) --------------------------------
2129
+ // Inside the lock, and BEFORE artifact cleanup and every executor-owned write:
2130
+ // prove the retained head's lineage, WIP-checkpoint any dirty files, and only
2131
+ // then pin the pre-spawn expectation to the POST-checkpoint head. Pinning
2132
+ // earlier (to base or to the lineage anchor) would make the BAPI-731
2133
+ // assertion refuse the very checkpoint this step just made.
2134
+ if (prep.priorWork) {
2135
+ const resumed = await resumePriorAttemptUnderLock({ job, prep, options, deps, worktreePath, branch });
2136
+ if (!resumed.ok) {
2137
+ await httpClient.fail(job, resumed.failure);
2138
+ return { status: "failed", reason: "prior_attempt_resume_failed" };
2139
+ }
2140
+ readyPrep = resumed.prep;
2141
+ }
1872
2142
  if (prep.ciFix) {
1873
2143
  const rendered = await renderVerifiedCiFixPrompt({
1874
2144
  job,
@@ -2114,6 +2384,12 @@ async function runPreparedSpawn(params) {
2114
2384
  // safety margin but must not kill a job. Refusing to CLAIM on unenforced deny
2115
2385
  // remains a separate, fatal preflight concern.
2116
2386
  const denyCapability = adapter.denyEnforcement;
2387
+ const correctionWork = isRemainingWork(job.job_type, job.payload);
2388
+ let correctionDenyInstalled = false;
2389
+ const correctionHookBinPath = denyCapability?.supported === true
2390
+ ? (seams.resolveWorkerGuardBinPath ??
2391
+ (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))()
2392
+ : "";
2117
2393
  if (denyCapability?.supported === true) {
2118
2394
  const deny = await denyCapability.value.provisionWorktreeDenyLayer({
2119
2395
  worktreePath,
@@ -2125,8 +2401,8 @@ async function runPreparedSpawn(params) {
2125
2401
  // "is this push destination mine?" by comparing against it, so a value the
2126
2402
  // payload could influence would let a job nominate its own safe branch.
2127
2403
  workerBranch: branch,
2128
- workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
2129
- (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
2404
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
2405
+ workerGuardHookBinPath: correctionHookBinPath,
2130
2406
  });
2131
2407
  // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
2132
2408
  // hygiene step can degrade without failing the deny layer — present-but-
@@ -2138,6 +2414,40 @@ async function runPreparedSpawn(params) {
2138
2414
  // fail-OPEN and the job continues to the spawn boundary.
2139
2415
  if (deny.warning)
2140
2416
  deps.errorLog(deny.warning);
2417
+ correctionDenyInstalled = deny.ok;
2418
+ }
2419
+ if (correctionWork && !correctionDenyInstalled) {
2420
+ await httpClient.fail(job, {
2421
+ error_kind: "ContractError.CorrectionLegacy",
2422
+ error_message: "correction_contract_invalid",
2423
+ classification: "crashed",
2424
+ });
2425
+ return { status: "failed", reason: "correction_hook_unavailable" };
2426
+ }
2427
+ if (correctionWork) {
2428
+ let hookQualified = false;
2429
+ try {
2430
+ if (seams.verifyInstalledCorrectionHook) {
2431
+ hookQualified = await seams.verifyInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2432
+ }
2433
+ else {
2434
+ const settings = await deps.readFile(`${worktreePath}/.claude/settings.local.json`);
2435
+ hookQualified = settings.includes(correctionHookBinPath) &&
2436
+ settings.includes("--correction-intent-version 1") &&
2437
+ probeInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2438
+ }
2439
+ }
2440
+ catch {
2441
+ hookQualified = false;
2442
+ }
2443
+ if (!hookQualified) {
2444
+ await httpClient.fail(job, {
2445
+ error_kind: "ContractError.CorrectionLegacy",
2446
+ error_message: "correction_contract_invalid",
2447
+ classification: "crashed",
2448
+ });
2449
+ return { status: "failed", reason: "correction_hook_unverified" };
2450
+ }
2141
2451
  }
2142
2452
  // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
2143
2453
  // Unconditional for every job type that reaches this shared spawn path
@@ -2187,28 +2497,45 @@ async function runPreparedSpawn(params) {
2187
2497
  }
2188
2498
  const mcpContract = registration.contract;
2189
2499
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
2500
+ // BAPI-1330: ONE attempt identity — the server's job id and attempt number
2501
+ // plus a claim id minted here — names this attempt's log file, its registry
2502
+ // entry, and its active-log key. It is allocated BEFORE the tee exists, the
2503
+ // file is created exclusively, and every terminal branch below finishes and
2504
+ // deactivates exactly this identity, so no later claim of the same job (a
2505
+ // retry, a reclaim, a reused worktree) can overwrite or be confused with it.
2190
2506
  const registryDeps = buildJobLogRegistryDeps(deps);
2191
2507
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
2192
2508
  const createTee = seams.createWorkerLogTee ?? createWorkerLogTee;
2193
- let tee = null;
2509
+ let logClaimId = null;
2194
2510
  try {
2195
- tee = await createTee(worktreePath, {
2196
- mkdir: deps.mkdir,
2197
- writeFile: deps.writeFile,
2198
- appendFile: deps.appendFile,
2199
- platform: deps.platform,
2200
- // BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
2201
- // already, so this is source-compatible with every existing deps
2202
- // builder — archival simply does not run when either is absent.
2203
- rename: deps.rename,
2204
- stat: deps.stat,
2205
- now: deps.now,
2206
- errorLog: deps.errorLog,
2207
- });
2511
+ logClaimId = (seams.createWorkerLogClaimId ?? createWorkerLogClaimId)();
2208
2512
  }
2209
2513
  catch {
2210
- tee = null;
2514
+ logClaimId = null;
2211
2515
  }
2516
+ let tee = null;
2517
+ if (logClaimId !== null) {
2518
+ try {
2519
+ tee = await createTee(worktreePath, {
2520
+ mkdir: deps.mkdir,
2521
+ writeFile: deps.writeFile,
2522
+ appendFile: deps.appendFile,
2523
+ writeFileExclusive: deps.writeFileExclusive,
2524
+ stat: deps.stat,
2525
+ platform: deps.platform,
2526
+ errorLog: deps.errorLog,
2527
+ }, { jobId: job.id, attempt: job.attempt_count, claimId: logClaimId });
2528
+ }
2529
+ catch {
2530
+ tee = null;
2531
+ }
2532
+ }
2533
+ if (tee === null) {
2534
+ // A logging failure never changes the worker's disposition; it only means
2535
+ // this attempt has no transcript, which is said once, without detail.
2536
+ deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
2537
+ }
2538
+ const teeClaimId = tee?.identity.claimId;
2212
2539
  // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
2213
2540
  // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
2214
2541
  // directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
@@ -2221,8 +2548,10 @@ async function runPreparedSpawn(params) {
2221
2548
  return;
2222
2549
  workerLogClosed = true;
2223
2550
  // Deactivate FIRST: once the tee is closing, an append from the suspend
2224
- // diagnostic would be writing to a log nobody is reading any more.
2225
- deactivateJobLog(job.id);
2551
+ // diagnostic would be writing to a log nobody is reading any more. Only
2552
+ // THIS attempt's entry is removed (BAPI-1330).
2553
+ if (teeClaimId !== undefined)
2554
+ deactivateJobLog(job.id, teeClaimId);
2226
2555
  await closeWorkerLogTee(tee);
2227
2556
  };
2228
2557
  lifecycle.closeWorkerLog = closeWorkerLog;
@@ -2233,7 +2562,9 @@ async function runPreparedSpawn(params) {
2233
2562
  // visibility nicety for a separate `executor watch` process), and chaining
2234
2563
  // the active marker to it would mean a failed registry write silently
2235
2564
  // disables suspend annotation for a worker whose tee is perfectly healthy.
2236
- activateJobLog(job.id, tee.logPath);
2565
+ // BAPI-1330: an attempt whose file could not be allocated is never active.
2566
+ if (tee.available)
2567
+ activateJobLog(job.id, tee.logPath, tee.identity.claimId);
2237
2568
  const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
2238
2569
  try {
2239
2570
  await registerLog({
@@ -2244,12 +2575,17 @@ async function runPreparedSpawn(params) {
2244
2575
  worktree_path: worktreePath,
2245
2576
  log_path: tee.logPath,
2246
2577
  started_at: nowIso(deps),
2578
+ attempt: tee.identity.attempt,
2579
+ claim_id: tee.identity.claimId,
2580
+ availability: tee.available ? "available" : "unavailable",
2581
+ ...(tee.unavailableReason ? { unavailable_reason: tee.unavailableReason } : {}),
2247
2582
  }, registryDeps);
2248
2583
  }
2249
2584
  catch {
2250
2585
  /* registry is a visibility nicety — never fail the job */
2586
+ deps.errorLog(`executor job-log registry: could not record job ${job.id}'s attempt log (non-fatal)`);
2251
2587
  }
2252
- if (executorViewerTabsEnabled(deps.platform, deps.env)) {
2588
+ if (tee.available && executorViewerTabsEnabled(deps.platform, deps.env)) {
2253
2589
  const openViewer = seams.openExecutorViewerTab ?? openExecutorViewerTab;
2254
2590
  try {
2255
2591
  const viewer = await openViewer({
@@ -2273,11 +2609,21 @@ async function runPreparedSpawn(params) {
2273
2609
  const finalizeRegistry = async () => {
2274
2610
  if (!tee)
2275
2611
  return;
2612
+ const finishing = tee;
2276
2613
  try {
2277
- await markFinished(job.id, nowIso(deps), registryDeps);
2614
+ // BAPI-1330: stamps only THIS attempt's entry, and records a transcript
2615
+ // that became incomplete mid-run as unavailable rather than leaving a
2616
+ // reader to trust it.
2617
+ await markFinished(job.id, finishing.identity.claimId, nowIso(deps), registryDeps, finishing.available
2618
+ ? {}
2619
+ : {
2620
+ availability: "unavailable",
2621
+ ...(finishing.unavailableReason ? { unavailable_reason: finishing.unavailableReason } : {}),
2622
+ });
2278
2623
  }
2279
2624
  catch {
2280
2625
  /* non-fatal */
2626
+ deps.errorLog(`executor job-log registry: could not finish job ${job.id}'s attempt log (non-fatal)`);
2281
2627
  }
2282
2628
  };
2283
2629
  // --- Spawn -----------------------------------------------------------
@@ -2337,6 +2683,7 @@ async function runPreparedSpawn(params) {
2337
2683
  // denies every `git push` the worker attempts — the worker has no remote
2338
2684
  // write authority at all.
2339
2685
  ...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
2686
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
2340
2687
  ...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
2341
2688
  parentEnv: deps.env,
2342
2689
  });
@@ -2488,6 +2835,9 @@ async function runPreparedSpawn(params) {
2488
2835
  })
2489
2836
  : null;
2490
2837
  const runtime = probed ?? unrecognizedRuntime(adapter);
2838
+ // Carry the probe identity into any later not-launched report for this job.
2839
+ if (probed !== null)
2840
+ jobTimeCompletionRuntimes.set(ownership, probed);
2491
2841
  const refuse = async (reason, message) => {
2492
2842
  await closeWorkerLog();
2493
2843
  await finalizeRegistry();
@@ -2536,6 +2886,17 @@ async function runPreparedSpawn(params) {
2536
2886
  stripEnvFiles: stripWorktreeEnvFiles,
2537
2887
  });
2538
2888
  }
2889
+ // BAPI-1364: an observe-only ledger for a legacy run of a covered job type. It
2890
+ // never enters `runCoveredAttempt()` and never enables the completion contract;
2891
+ // it only lets a clean exit be checked for required background work still live.
2892
+ const legacyLedger = isCoveredJobType(job.job_type)
2893
+ ? createRequiredWorkLedger({
2894
+ binding: readCompletionBinding(job, null).binding,
2895
+ lifecycleSupported: true,
2896
+ optionalWatchers: readOptionalWatchers(job.payload),
2897
+ })
2898
+ : null;
2899
+ const legacyLineFeeder = legacyLedger ? createLedgerLineFeeder(legacyLedger) : null;
2539
2900
  let proc;
2540
2901
  try {
2541
2902
  proc = spawnWorker({ env: spawnShape.env });
@@ -2600,12 +2961,15 @@ async function runPreparedSpawn(params) {
2600
2961
  collectTelemetry,
2601
2962
  collectRemoteMarker,
2602
2963
  mcpSurface,
2964
+ ...(legacyLineFeeder ? { onStdoutObserved: legacyLineFeeder.feed } : {}),
2603
2965
  // The SAME controller the registry holds, so an executor shutdown, a timeout,
2604
2966
  // and an MCP containment refusal all converge on one `SIGTERM` and one grace
2605
2967
  // timer for this child (BAPI-828).
2606
2968
  terminationController,
2607
2969
  control,
2608
2970
  });
2971
+ // Flush a trailing partial stdout line so the observe-only ledger sees it.
2972
+ legacyLineFeeder?.flush();
2609
2973
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
2610
2974
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
2611
2975
  // nothing runs here — which is correct, because BAPI-790 deleted the per-job
@@ -2835,6 +3199,32 @@ async function runPreparedSpawn(params) {
2835
3199
  await finalizeRegistry();
2836
3200
  return terminalToRunResult(terminal, "completed");
2837
3201
  }
3202
+ // BAPI-1364: a legacy worker that ended its (headless) session while the
3203
+ // structured stream still showed a required background command with no
3204
+ // terminal notification can never receive that command's result. Report it
3205
+ // before generic success, the ci_fix artifact merge, or finalization can hide
3206
+ // it. Only the fixed kind and message are sent: no event text, command, path,
3207
+ // or output. A matching terminal task_notification leaves nothing pending.
3208
+ if (legacyLedger?.observeLiveRequiredWork() === "live_required_work") {
3209
+ deps.errorLog(`[executor] job ${job.id}: worker exited cleanly with required background work still live`);
3210
+ await finalizeRegistry();
3211
+ const terminal = await sendTerminalMutationWithAuthReport({
3212
+ kind: "fail",
3213
+ send: () => httpClient.fail(job, {
3214
+ error_kind: WorkerEndedWithPendingBackgroundWork,
3215
+ error_message: WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE,
3216
+ classification: "crashed",
3217
+ ...observedExitCode(procResult.exitCode),
3218
+ ...commitResidue(git),
3219
+ telemetry: observation.snapshot(),
3220
+ }),
3221
+ deps,
3222
+ options,
3223
+ ownership,
3224
+ log: deps.log,
3225
+ }, job, control);
3226
+ return terminalToRunResult(terminal, "failed");
3227
+ }
2838
3228
  // Non-verdict spawn jobs (`implement`, `resume`, and the recovery jobs
2839
3229
  // `remediate`/`ci_fix`/`rebase`) use the generic success-result construction;
2840
3230
  // the verdict-artifact path above stays limited to verdict jobs like
@@ -2902,6 +3292,9 @@ async function runPreparedSpawn(params) {
2902
3292
  kind: "fail",
2903
3293
  send: () => httpClient.fail(job, {
2904
3294
  ...finalization.failure,
3295
+ // BAPI-1330: the process's observed exit rides beside the
3296
+ // finalization failure; it is never rewritten to look like a crash.
3297
+ ...observedExitCode(procResult.exitCode),
2905
3298
  ...commitResidue(git),
2906
3299
  telemetry: observation.snapshot(),
2907
3300
  }),
@@ -3016,6 +3409,7 @@ async function runPreparedSpawn(params) {
3016
3409
  ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3017
3410
  : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3018
3411
  classification: toWireClassification(procResult.classification),
3412
+ ...observedExitCode(procResult.exitCode),
3019
3413
  ...commitResidue(git),
3020
3414
  telemetry: observation.snapshot(),
3021
3415
  ...(capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}),
@@ -3037,6 +3431,16 @@ const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3037
3431
  /** Bound on closing the held stdin once the closure rule fires. */
3038
3432
  const STDIN_CLOSE_BOUND_MS = 5_000;
3039
3433
  const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3434
+ /**
3435
+ * BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
3436
+ * null, never invented) when the process ended without a usable code, e.g. on a
3437
+ * signal. Only ever read from the process result, never from worker output.
3438
+ */
3439
+ function observedExitCode(exitCode) {
3440
+ return typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0
3441
+ ? { exit_code: exitCode }
3442
+ : {};
3443
+ }
3040
3444
  /**
3041
3445
  * Whether this claim runs under the covered completion contract.
3042
3446
  *
@@ -3048,22 +3452,25 @@ const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3048
3452
  function claimIsCovered(job) {
3049
3453
  return claimRequiresCompletionEvidence(job.required_capabilities);
3050
3454
  }
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) {
3455
+ /** Exported for the covered-job runtime tests; not part of the executor's public surface. */
3456
+ export function withNotLaunchedEnvelopeFallback(httpClient, job, runtimeSources) {
3056
3457
  const wrapped = Object.create(httpClient);
3057
3458
  wrapped.fail = (claimed, failure) => {
3058
3459
  if (failure.completion_evidence !== undefined)
3059
3460
  return httpClient.fail(claimed, failure);
3060
3461
  const { binding } = readCompletionBinding(job, typeof job.expected_head_sha === "string" ? job.expected_head_sha : null);
3061
3462
  const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
3463
+ // The runtime actually probed, never the measured baseline: the job-time probe
3464
+ // when it ran, then the qualified preflight runtime, then an unrecognized one.
3465
+ // A runtime that does not satisfy the predicate makes the report a truthful
3466
+ // refusal, so the server accepts it and still never reads it as success.
3467
+ const runtime = runtimeSources.jobTimeRuntime() ?? runtimeSources.preflightRuntime ?? unrecognizedRuntimeForAgent(runtimeSources.agentId);
3062
3468
  const envelope = buildNotLaunchedEnvelope({
3063
3469
  binding,
3064
- runtime: { ...SUPPORTED_COMPLETION_RUNTIME },
3470
+ runtime: { ...runtime },
3065
3471
  verification: unlaunched.verification,
3066
3472
  residuals: CLEANUP_RESIDUALS_NOT_COVERED,
3473
+ unsupportedRuntime: !runtimeMatchesSupported(runtime),
3067
3474
  ...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
3068
3475
  });
3069
3476
  return httpClient.fail(claimed, {
@@ -3074,6 +3481,12 @@ function withNotLaunchedEnvelopeFallback(httpClient, job) {
3074
3481
  };
3075
3482
  return wrapped;
3076
3483
  }
3484
+ /**
3485
+ * The covered-job runtime probe's identity per job, recorded before launch so a
3486
+ * later not-launched report stamps what was actually probed (BAPI-1386). Keyed by
3487
+ * the job's ownership state, which both the wrapper and the spawn path share.
3488
+ */
3489
+ const jobTimeCompletionRuntimes = new WeakMap();
3077
3490
  /**
3078
3491
  * The verification a covered report carries when no declared check ran
3079
3492
  * (BAPI-1302): before launch, after a worker failure, or on a stop.
@@ -3120,8 +3533,11 @@ function declaredOrderFacts(checks, runFacts) {
3120
3533
  }
3121
3534
  /** An identity that cannot match the supported runtime, for truthful refusal envelopes. */
3122
3535
  function unrecognizedRuntime(adapter) {
3536
+ return unrecognizedRuntimeForAgent(adapter.identity.agentId);
3537
+ }
3538
+ function unrecognizedRuntimeForAgent(agentId) {
3123
3539
  return {
3124
- adapter: adapter.identity.agentId,
3540
+ adapter: agentId,
3125
3541
  cli_version: "unrecognized",
3126
3542
  platform: "unrecognized",
3127
3543
  event_schema: "unrecognized",
@@ -3361,33 +3777,9 @@ async function runCoveredAttempt(params) {
3361
3777
  buildNudge: params.nudge,
3362
3778
  deadlineAtMs: plan.holdDeadlineAtMs,
3363
3779
  });
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
- };
3780
+ // The authoritative consumer: whole lines into the ledger, bounded.
3781
+ const lineFeeder = createLedgerLineFeeder(ledger, () => supervisor.onActivity());
3782
+ const authoritativeStdout = lineFeeder.feed;
3391
3783
  const runPromise = runProcessWithTimeout(supervisedProc, Math.max(1, Math.ceil(plan.workerWindowMs / 1000)), deps, {
3392
3784
  termGraceMs: jobOptions.termGraceMs,
3393
3785
  terminationController,
@@ -3419,8 +3811,7 @@ async function runCoveredAttempt(params) {
3419
3811
  if (sampleTimer !== undefined)
3420
3812
  deps.clearTimer(sampleTimer);
3421
3813
  supervisor.dispose();
3422
- if (!discardingOversize && lineBuffer.length > 0)
3423
- ledger.ingestLine(lineBuffer);
3814
+ lineFeeder.flush();
3424
3815
  ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
3425
3816
  ? "stream_error"
3426
3817
  : (procResult.stdoutEnd ?? "eof"));
@@ -3604,6 +3995,7 @@ async function runCoveredAttempt(params) {
3604
3995
  ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3605
3996
  : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3606
3997
  classification: toWireClassification(procResult.classification),
3998
+ ...observedExitCode(procResult.exitCode),
3607
3999
  }, envelope, capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}));
3608
4000
  }
3609
4001
  // --- Phase 3: independent declared checks (BAPI-1302, D4c) ---------------------
@@ -3734,6 +4126,9 @@ async function runCoveredAttempt(params) {
3734
4126
  runCommand: deps.runCommand,
3735
4127
  headSha: git.last_commit_sha,
3736
4128
  expectedBaseBranch: effectiveBaseBranch,
4129
+ // BAPI-1330: this host only runs claims the SERVER stamped as covered, so
4130
+ // the existence-only legacy fallback is refused here.
4131
+ coveredAttempt: true,
3737
4132
  });
3738
4133
  const facts = finalizationFacts(finalization.ok ? { ok: true } : { ok: false, errorKind: finalization.failure.error_kind });
3739
4134
  const envelope = envelopeWith({
@@ -3775,19 +4170,35 @@ async function runCoveredAttempt(params) {
3775
4170
  // Not a success. A completion cause (required work, cleanup, verification,
3776
4171
  // permission) outranks the finalization kind; with none, finalization keeps
3777
4172
  // its existing error kind and recovery lane.
4173
+ //
4174
+ // BAPI-1330: every branch below is reached only after the worker exited
4175
+ // cleanly. A completion refusal reports that observed process fact —
4176
+ // `clean_exit` and the real exit code — beside the refusal (the server decides
4177
+ // it from the envelope's completion cause, never the classification). A
4178
+ // finalization failure adds the exit code to its own kind and classification.
4179
+ const observedExit = {
4180
+ classification: toWireClassification(procResult.classification),
4181
+ ...observedExitCode(procResult.exitCode),
4182
+ };
3778
4183
  const cause = envelope.cause;
3779
4184
  if (cause) {
3780
4185
  return finish(await failWith({
3781
4186
  error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
3782
4187
  error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? cause})`,
3783
- classification: "crashed",
4188
+ ...observedExit,
3784
4189
  }, envelope));
3785
4190
  }
3786
- if (!finalization.ok)
3787
- return finish(await failWith(finalization.failure, envelope));
4191
+ if (!finalization.ok) {
4192
+ // The finalization failure keeps its OWN classification, exactly as the
4193
+ // legacy path does: the kinds that mean "the process ended cleanly"
4194
+ // (`PrNotAttached`, `PublicationUnverified`) already say `clean_exit`, and
4195
+ // the rest keep the classification their existing recovery routing reads.
4196
+ // Only the observed exit code is added.
4197
+ return finish(await failWith({ ...finalization.failure, ...observedExitCode(procResult.exitCode) }, envelope));
4198
+ }
3788
4199
  return finish(await failWith({
3789
4200
  error_kind: COMPLETION_CAUSE_ERROR_KINDS.required_work_incomplete,
3790
4201
  error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? "unknown"})`,
3791
- classification: "crashed",
4202
+ ...observedExit,
3792
4203
  }, envelope));
3793
4204
  }