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