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