@bridge_gpt/mcp-server 0.2.67 → 0.2.68
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 +178 -15
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1374 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +18 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +964 -768
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +544 -37
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +29 -5
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { writeRecentlyFailedTestsFile } from "./recently-failed-tests-file.js";
|
|
2
|
+
import { guardedFastForwardPush } from "./guarded-fast-forward-push.js";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { findWorktreeByBranch, parseGitWorktreePorcelain } from "./worktree-inspection.js";
|
|
6
|
+
import { resolveExecutorBranch, configureExecutorBranch } from "./worktree.js";
|
|
7
|
+
import { renderWorkerVerificationGuidance, renderWorkerPrBaseGuidance } from "./worker-verification-guidance.js";
|
|
1
8
|
/**
|
|
2
9
|
* Claimed-job execution engine (BAPI-534 core, BAPI-535 job behaviors; TDD §7/§8/§9).
|
|
3
10
|
*
|
|
@@ -14,12 +21,13 @@
|
|
|
14
21
|
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
15
22
|
* read-only viewer tabs wired around every spawned worker.
|
|
16
23
|
*/
|
|
17
|
-
import { rm, readdir, lstat, unlink } from "node:fs/promises";
|
|
24
|
+
import { rm, readdir, lstat, unlink, realpath } from "node:fs/promises";
|
|
18
25
|
import os from "node:os";
|
|
19
26
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
-
import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, WorkerEndedWithPendingBackgroundWork, WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, CiFixContext, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
|
|
27
|
+
import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, MissingVerdictArtifact, PackagedRecipeUndeliverable, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, WorkerEndedWithPendingBackgroundWork, WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, CiFixContext, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
|
|
21
28
|
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
29
|
import { createProductionCiRerunDeps, runCiRerunJob, } from "./ci-rerun-job.js";
|
|
30
|
+
import { createProductionGateQualificationDeps, runGateQualificationJob, GATE_QUALIFICATION_FAILED, GATE_QUALIFICATION_INTERRUPTED, } from "./gate-qualification-job.js";
|
|
23
31
|
import { createObservationState } from "./observation.js";
|
|
24
32
|
import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
25
33
|
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
@@ -27,13 +35,15 @@ import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js"
|
|
|
27
35
|
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
28
36
|
import { closeStdinBounded, createProcessTerminationController, runProcessWithTimeout, writeStdinBounded, } from "./process.js";
|
|
29
37
|
import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, 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";
|
|
38
|
+
import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, STDIN_CLOSE_GRACE_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, } from "./completion-supervision.js";
|
|
31
39
|
import { compareFinalArtifact, createRequiredVerificationRunner, createSupervisedCommandLauncher, observeFinalArtifact, OWNERSHIP_POLL_MS, preCheckArtifactState, toCompletionCheckFact, } from "./required-verification.js";
|
|
32
40
|
import { createClosureSupervisor, createRequiredWorkLedger } from "./required-work-ledger.js";
|
|
33
41
|
import { buildCleanupVerdict, CLEANUP_RESIDUALS_NOT_COVERED, createDefaultOwnershipRecordFs, createDescendantTracker, createMacProcessInspector, createOwnershipToken, hostIdentityDigest, ownershipTokenDigest, removeOwnershipRecord, runOwnedProcessSweep, withOwnershipTag, writeOwnershipRecord, } from "./process-containment.js";
|
|
34
42
|
import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
|
|
35
43
|
import { autoCheckpointDirtyState, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE, prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, verifyContinuationHeadLineage, } from "./resume-pre-spawn.js";
|
|
36
|
-
import { ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
|
|
44
|
+
import { buildEpicWorkaroundWorkerInstruction, ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
|
|
45
|
+
/** BAPI-1399: the job type whose worker implements ticket requirements from scratch. */
|
|
46
|
+
const WORKAROUND_CONTRACT_JOB_TYPES = new Set(["implement"]);
|
|
37
47
|
import { defaultResolveBinPath } from "../start-tickets-conductor.js";
|
|
38
48
|
/** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
|
|
39
49
|
const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
|
|
@@ -45,7 +55,7 @@ import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
|
45
55
|
import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
46
56
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
47
57
|
import { resolveExecutorStateDir, activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
48
|
-
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
58
|
+
import { EXECUTOR_JOB_END_LOG_CHUNK, executorViewerTabsEnabled, openExecutorViewerTab, } from "./viewer-tabs.js";
|
|
49
59
|
import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
50
60
|
import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
|
|
51
61
|
import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
|
|
@@ -54,11 +64,11 @@ import { resolveExecutorJobIndexScope } from "./index-scope.js";
|
|
|
54
64
|
import { readFocusedPullRequest, validateWorkerFinalization } from "./worker-finalization.js";
|
|
55
65
|
import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseContext, validateFocusedRebaseContext, waitForFocusedRebase, } from "./rebase-finalization.js";
|
|
56
66
|
import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
|
|
57
|
-
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
58
67
|
import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
59
68
|
import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
|
|
60
69
|
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
|
|
61
|
-
import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
|
|
70
|
+
import { readCustomizedCommands, TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
|
|
71
|
+
import { resolvePackagedRecipes } from "./packaged-recipe.js";
|
|
62
72
|
import { probeInstalledCorrectionHook } from "./preflight.js";
|
|
63
73
|
import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
|
|
64
74
|
/**
|
|
@@ -89,7 +99,10 @@ function classificationToErrorKind(c) {
|
|
|
89
99
|
case "timeout":
|
|
90
100
|
return "Timeout";
|
|
91
101
|
case "killed":
|
|
92
|
-
|
|
102
|
+
// Legacy terminal disposition retries WorkerCrashed but has no Killed
|
|
103
|
+
// branch. Keep the established retry signal while the classification
|
|
104
|
+
// records that the executor deliberately ended the process.
|
|
105
|
+
return "WorkerCrashed";
|
|
93
106
|
default:
|
|
94
107
|
return "WorkerCrashed";
|
|
95
108
|
}
|
|
@@ -591,11 +604,22 @@ function createLedgerLineFeeder(ledger, onLine) {
|
|
|
591
604
|
*/
|
|
592
605
|
async function superviseProcess(params) {
|
|
593
606
|
const isDoneRef = { value: false };
|
|
607
|
+
const heartbeatStop = new AbortController();
|
|
608
|
+
const heartbeatDeps = params.cancelHeartbeatSleepOnExit ? { ...params.deps, sleep: (ms) => new Promise((resolve) => {
|
|
609
|
+
if (heartbeatStop.signal.aborted) {
|
|
610
|
+
resolve();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
let timer;
|
|
614
|
+
const done = () => { params.deps.clearTimer(timer); heartbeatStop.signal.removeEventListener("abort", done); resolve(); };
|
|
615
|
+
timer = params.deps.setTimer(done, ms);
|
|
616
|
+
heartbeatStop.signal.addEventListener("abort", done, { once: true });
|
|
617
|
+
}) } : params.deps;
|
|
594
618
|
const hb = runHeartbeatLoop({
|
|
595
619
|
job: params.job,
|
|
596
620
|
httpClient: params.httpClient,
|
|
597
621
|
options: params.options,
|
|
598
|
-
deps:
|
|
622
|
+
deps: heartbeatDeps,
|
|
599
623
|
ownership: params.ownership,
|
|
600
624
|
proc: params.proc,
|
|
601
625
|
observation: params.observation,
|
|
@@ -623,8 +647,10 @@ async function superviseProcess(params) {
|
|
|
623
647
|
: {}),
|
|
624
648
|
});
|
|
625
649
|
isDoneRef.value = true;
|
|
650
|
+
heartbeatStop.abort();
|
|
626
651
|
const loopOutcome = await hb;
|
|
627
|
-
params.
|
|
652
|
+
if (params.recordProcessExit !== false)
|
|
653
|
+
params.observation.setExitCode(result.exitCode);
|
|
628
654
|
if (loopOutcome.kind === "auth_fatal") {
|
|
629
655
|
// BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
|
|
630
656
|
// identifiers — never carried onto `result` itself, so the process's own
|
|
@@ -785,6 +811,13 @@ control) {
|
|
|
785
811
|
if (job.job_type === "ci_rerun") {
|
|
786
812
|
return runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
787
813
|
}
|
|
814
|
+
// BAPI-1391: `gate_qualification` is the run-less, deterministic gate
|
|
815
|
+
// qualification, dispatched here beside `ci_rerun` for the same reasons: no
|
|
816
|
+
// worktrunk worktree, no worker, and no fallback to one. `job-types.ts` keeps
|
|
817
|
+
// it out of `SPAWN_JOB_TYPES` and `RECOVERY_JOB_TYPES`.
|
|
818
|
+
if (job.job_type === "gate_qualification") {
|
|
819
|
+
return runGateQualificationExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
820
|
+
}
|
|
788
821
|
// BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
|
|
789
822
|
// and before the generic spawn path. It needs a prepared, locked worktree like
|
|
790
823
|
// a spawn job, but its body is the reconcile module's state machine: the
|
|
@@ -822,6 +855,124 @@ control) {
|
|
|
822
855
|
});
|
|
823
856
|
return { status: "failed", reason: "unsupported_job_type" };
|
|
824
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Bound on waiting for a killed `gate_qualification` body to finish its own
|
|
860
|
+
* teardown (terminating a running command, removing the detached checkout)
|
|
861
|
+
* before the terminal report is sent.
|
|
862
|
+
*/
|
|
863
|
+
const GATE_QUALIFICATION_SETTLE_MAX_MS = 180_000;
|
|
864
|
+
/**
|
|
865
|
+
* Run a deterministic `gate_qualification` job (BAPI-1391).
|
|
866
|
+
*
|
|
867
|
+
* The same lifecycle as {@link runCiRerunExecutorJob}: the payload timeout is
|
|
868
|
+
* resolved first, the body runs as a flow under {@link superviseProcess} (the
|
|
869
|
+
* one ownership, heartbeat, dead-man, and timeout protocol), and every terminal
|
|
870
|
+
* report goes through {@link sendTerminalMutationWithAuthReport}.
|
|
871
|
+
*
|
|
872
|
+
* The completion carries only the bounded result envelope from
|
|
873
|
+
* `gate-qualification-job.ts`. An inability goes to `/fail` with a closed
|
|
874
|
+
* error kind and fixed text; command output never reaches either.
|
|
875
|
+
*/
|
|
876
|
+
async function runGateQualificationExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control) {
|
|
877
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
878
|
+
if (!timeout.ok) {
|
|
879
|
+
await httpClient.fail(job, {
|
|
880
|
+
error_kind: "ContractError.Timeout",
|
|
881
|
+
error_message: timeout.error,
|
|
882
|
+
classification: "crashed",
|
|
883
|
+
});
|
|
884
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
885
|
+
}
|
|
886
|
+
const timeoutSeconds = timeout.timeoutSeconds;
|
|
887
|
+
const runGateQualification = seams.runGateQualification ??
|
|
888
|
+
((claimed, input) => runGateQualificationJob(claimed, input, createProductionGateQualificationDeps({
|
|
889
|
+
deps,
|
|
890
|
+
options,
|
|
891
|
+
registerLive: (controller) => control?.liveWorkers.register(controller),
|
|
892
|
+
ownershipHeld: () => !ownership.abandoned,
|
|
893
|
+
})));
|
|
894
|
+
const flow = createFlowProcess((signal) => runGateQualification(job, { timeoutSeconds, signal }), (result) => result.ok);
|
|
895
|
+
const procResult = await superviseProcess({
|
|
896
|
+
job,
|
|
897
|
+
httpClient,
|
|
898
|
+
options,
|
|
899
|
+
deps,
|
|
900
|
+
ownership,
|
|
901
|
+
observation,
|
|
902
|
+
proc: flow.proc,
|
|
903
|
+
timeoutSeconds,
|
|
904
|
+
collectTelemetry: async () => ({}),
|
|
905
|
+
control,
|
|
906
|
+
});
|
|
907
|
+
// A killed flow keeps running until its own `finally` has terminated any
|
|
908
|
+
// command and removed the detached checkout. Wait for that, bounded, so the
|
|
909
|
+
// terminal report never races the teardown of a checkout it describes.
|
|
910
|
+
let settleTimer;
|
|
911
|
+
try {
|
|
912
|
+
await Promise.race([
|
|
913
|
+
flow.settled,
|
|
914
|
+
new Promise((resolve) => {
|
|
915
|
+
settleTimer = deps.setTimer(resolve, GATE_QUALIFICATION_SETTLE_MAX_MS);
|
|
916
|
+
}),
|
|
917
|
+
]);
|
|
918
|
+
}
|
|
919
|
+
finally {
|
|
920
|
+
if (settleTimer !== undefined)
|
|
921
|
+
deps.clearTimer(settleTimer);
|
|
922
|
+
}
|
|
923
|
+
if (ownership.abandoned) {
|
|
924
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
925
|
+
}
|
|
926
|
+
const sendFail = async (errorKind, message, classification) => {
|
|
927
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
928
|
+
kind: "fail",
|
|
929
|
+
send: () => httpClient.fail(job, {
|
|
930
|
+
error_kind: errorKind,
|
|
931
|
+
error_message: message,
|
|
932
|
+
classification,
|
|
933
|
+
telemetry: observation.snapshot(),
|
|
934
|
+
}),
|
|
935
|
+
deps,
|
|
936
|
+
options,
|
|
937
|
+
ownership,
|
|
938
|
+
log: deps.log,
|
|
939
|
+
}, job, control);
|
|
940
|
+
return terminalToRunResult(terminal, "failed");
|
|
941
|
+
};
|
|
942
|
+
if (procResult.classification === "timeout") {
|
|
943
|
+
return sendFail(GATE_QUALIFICATION_FAILED, "gate_qualification exceeded its job timeout", "timeout");
|
|
944
|
+
}
|
|
945
|
+
const outcome = flow.getOutcome();
|
|
946
|
+
if (outcome === undefined) {
|
|
947
|
+
// Killed without a timeout (a server stop) — the commands were cut short —
|
|
948
|
+
// or the body threw, which it is built never to do.
|
|
949
|
+
return procResult.serverStopRequested
|
|
950
|
+
? sendFail(GATE_QUALIFICATION_INTERRUPTED, "gate qualification was stopped before it finished", "killed")
|
|
951
|
+
: sendFail(GATE_QUALIFICATION_FAILED, "gate_qualification completed without an outcome", "crashed");
|
|
952
|
+
}
|
|
953
|
+
if (outcome.ok) {
|
|
954
|
+
const completion = {
|
|
955
|
+
job_type: "gate_qualification",
|
|
956
|
+
exit_code: 0,
|
|
957
|
+
classification: "clean_exit",
|
|
958
|
+
// The bounded result envelope, verbatim. The backend model is
|
|
959
|
+
// `extra="forbid"` and recomputes the outcome against the STORED payload.
|
|
960
|
+
result: outcome.result,
|
|
961
|
+
telemetry: observation.snapshot(),
|
|
962
|
+
};
|
|
963
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
964
|
+
kind: "complete",
|
|
965
|
+
send: () => httpClient.complete(job, completion),
|
|
966
|
+
deps,
|
|
967
|
+
options,
|
|
968
|
+
ownership,
|
|
969
|
+
log: deps.log,
|
|
970
|
+
}, job, control);
|
|
971
|
+
return terminalToRunResult(terminal, "completed");
|
|
972
|
+
}
|
|
973
|
+
// An inability: a closed error kind and fixed text, never command output.
|
|
974
|
+
return sendFail(outcome.failure.errorKind, outcome.failure.message, "crashed");
|
|
975
|
+
}
|
|
825
976
|
/**
|
|
826
977
|
* Run a deterministic `ci_rerun` job (BAPI-1160).
|
|
827
978
|
*
|
|
@@ -1240,6 +1391,8 @@ async function superviseDriverPhase(params) {
|
|
|
1240
1391
|
ownership: params.ownership,
|
|
1241
1392
|
observation: params.observation,
|
|
1242
1393
|
proc: flow.proc,
|
|
1394
|
+
cancelHeartbeatSleepOnExit: true,
|
|
1395
|
+
recordProcessExit: false,
|
|
1243
1396
|
timeoutSeconds: Math.max(1, params.timeoutSeconds),
|
|
1244
1397
|
collectTelemetry: params.collectTelemetry,
|
|
1245
1398
|
control: params.control,
|
|
@@ -1404,7 +1557,19 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
|
|
|
1404
1557
|
return { ok: false, error: failure.error_message, failure };
|
|
1405
1558
|
}
|
|
1406
1559
|
}
|
|
1407
|
-
|
|
1560
|
+
/** Advisory comparison capture; never reads a payload-provided replacement. */
|
|
1561
|
+
async function captureComparisonSha(worktreePath, deps) {
|
|
1562
|
+
try {
|
|
1563
|
+
const result = await deps.runCommand("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
|
|
1564
|
+
const sha = result.stdout.trim();
|
|
1565
|
+
if (result.exitCode === 0 && /^[a-fA-F0-9]{40}$/.test(sha))
|
|
1566
|
+
return sha.toLowerCase();
|
|
1567
|
+
}
|
|
1568
|
+
catch { /* advisory only */ }
|
|
1569
|
+
deps.errorLog("[executor] immutable comparison SHA unavailable; disclose the discovery gap in the attempt transcript.");
|
|
1570
|
+
return undefined;
|
|
1571
|
+
}
|
|
1572
|
+
async function prepareSpawn(job, httpClient, options, deps, seams, preparation) {
|
|
1408
1573
|
// A continuation passes no local head here (`head === undefined`): its retained
|
|
1409
1574
|
// worktree was already proven by `prepareRetainedContinuationWorktree` →
|
|
1410
1575
|
// `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
|
|
@@ -1412,12 +1577,23 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1412
1577
|
// `correction_intent.head_sha`, including one the server derived for a first
|
|
1413
1578
|
// attempt with no bound ticket head (BAPI-1344). The spawn source is always
|
|
1414
1579
|
// that retained worktree, never an origin checkout.
|
|
1415
|
-
const refuseCorrectionDrift = async (branch, head) => {
|
|
1580
|
+
const refuseCorrectionDrift = async (branch, head, publication, worktreePath) => {
|
|
1416
1581
|
if (!isRemainingWork(job.job_type, job.payload))
|
|
1417
1582
|
return null;
|
|
1418
1583
|
const intent = job.payload?.correction_intent;
|
|
1419
1584
|
if (branch === intent.branch && (head === undefined || head === intent.head_sha))
|
|
1420
1585
|
return null;
|
|
1586
|
+
if (preparation && publication && worktreePath && publication.jobId === job.id &&
|
|
1587
|
+
publication.preparationId === preparation.preparationId && publication.branch === branch &&
|
|
1588
|
+
branch === intent.branch && head === publication.to_sha && head !== intent.head_sha &&
|
|
1589
|
+
typeof intent.head_sha === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(intent.head_sha)) {
|
|
1590
|
+
try {
|
|
1591
|
+
const proof = await deps.runCommand("git", ["merge-base", "--is-ancestor", intent.head_sha, head], { cwd: worktreePath });
|
|
1592
|
+
if (proof.exitCode === 0)
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
catch { /* fail closed */ }
|
|
1596
|
+
}
|
|
1421
1597
|
const reason = "correction_context_missing";
|
|
1422
1598
|
await httpClient.fail(job, {
|
|
1423
1599
|
error_kind: "ContractError.CorrectionLegacy",
|
|
@@ -1508,13 +1684,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1508
1684
|
}
|
|
1509
1685
|
const wt = await ensureJobWorktree(job, options, deps, seams, {
|
|
1510
1686
|
reuseExistingBranch: isRecoveryJobType(job.job_type),
|
|
1687
|
+
preparation,
|
|
1511
1688
|
staleBranchClassification: resolveStaleBranchClassification(job.payload),
|
|
1512
1689
|
});
|
|
1513
1690
|
if (!wt.ok) {
|
|
1514
1691
|
await httpClient.fail(job, jobWorktreeFailure(wt));
|
|
1515
1692
|
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
1516
1693
|
}
|
|
1517
|
-
const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
|
|
1694
|
+
const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha, wt.publication, wt.worktreePath);
|
|
1518
1695
|
if (drift)
|
|
1519
1696
|
return drift;
|
|
1520
1697
|
return {
|
|
@@ -1523,6 +1700,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1523
1700
|
branch: wt.branch,
|
|
1524
1701
|
prompt: "",
|
|
1525
1702
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1703
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1526
1704
|
ciFix,
|
|
1527
1705
|
};
|
|
1528
1706
|
}
|
|
@@ -1587,6 +1765,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1587
1765
|
const reuseExistingBranch = isRecoveryJobType(job.job_type);
|
|
1588
1766
|
const wt = await ensureJobWorktree(job, options, deps, seams, {
|
|
1589
1767
|
reuseExistingBranch,
|
|
1768
|
+
preparation,
|
|
1590
1769
|
// BAPI-862: hand the guard the reconciler's own reading of why this branch
|
|
1591
1770
|
// may already carry commits. Read from the SERVER-minted payload field and
|
|
1592
1771
|
// validated against a closed set — never inferred locally, and never taken
|
|
@@ -1601,7 +1780,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1601
1780
|
await httpClient.fail(job, jobWorktreeFailure(wt));
|
|
1602
1781
|
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
1603
1782
|
}
|
|
1604
|
-
const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
|
|
1783
|
+
const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha, wt.publication, wt.worktreePath);
|
|
1605
1784
|
if (drift)
|
|
1606
1785
|
return drift;
|
|
1607
1786
|
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
@@ -1614,6 +1793,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1614
1793
|
branch: wt.branch,
|
|
1615
1794
|
prompt: explicit.prompt,
|
|
1616
1795
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1796
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1617
1797
|
...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
|
|
1618
1798
|
...(focusedRebase ? { focusedRebase } : {}),
|
|
1619
1799
|
};
|
|
@@ -1630,6 +1810,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1630
1810
|
workBranch: wt.branch,
|
|
1631
1811
|
baseBranch: options.baseBranch,
|
|
1632
1812
|
runCommand: deps.runCommand,
|
|
1813
|
+
deferComparisonGuidance: true,
|
|
1633
1814
|
});
|
|
1634
1815
|
return {
|
|
1635
1816
|
ok: true,
|
|
@@ -1637,6 +1818,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1637
1818
|
branch: wt.branch,
|
|
1638
1819
|
prompt: rendered,
|
|
1639
1820
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1821
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1640
1822
|
...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
|
|
1641
1823
|
};
|
|
1642
1824
|
}
|
|
@@ -1772,7 +1954,15 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1772
1954
|
return { ok: false, error: ciFixContextError(job, "head_unreadable") };
|
|
1773
1955
|
}
|
|
1774
1956
|
if (head !== ciFix.headSha) {
|
|
1775
|
-
|
|
1957
|
+
const fact = params.publication;
|
|
1958
|
+
const context = params.preparation;
|
|
1959
|
+
if (!context || !fact || fact.jobId !== job.id || fact.preparationId !== context.preparationId ||
|
|
1960
|
+
fact.branch !== branch || fact.to_sha !== head || fact.from_sha !== ciFix.headSha) {
|
|
1961
|
+
return { ok: false, error: ciFixContextError(job, "stale_head") };
|
|
1962
|
+
}
|
|
1963
|
+
const proof = await deps.runCommand("git", ["merge-base", "--is-ancestor", ciFix.headSha, head], { cwd: worktreePath });
|
|
1964
|
+
if (proof.exitCode !== 0)
|
|
1965
|
+
return { ok: false, error: ciFixContextError(job, "stale_head") };
|
|
1776
1966
|
}
|
|
1777
1967
|
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
1778
1968
|
try {
|
|
@@ -1781,6 +1971,7 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1781
1971
|
workBranch: branch,
|
|
1782
1972
|
baseBranch,
|
|
1783
1973
|
runCommand: deps.runCommand,
|
|
1974
|
+
comparisonSha: ciFix.headSha,
|
|
1784
1975
|
});
|
|
1785
1976
|
return { ok: true, prompt };
|
|
1786
1977
|
}
|
|
@@ -1944,13 +2135,106 @@ async function superviseFocusedRebaseWait(params) {
|
|
|
1944
2135
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
1945
2136
|
* touches a worktree that will not become a runnable worker.
|
|
1946
2137
|
*/
|
|
1947
|
-
|
|
2138
|
+
/** Reserve the branch before Worktrunk can mutate it; lock an existing tree before preparation. */
|
|
2139
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver) {
|
|
2140
|
+
const originalHttp = httpClient;
|
|
2141
|
+
let diagnosticWarningSent = false;
|
|
2142
|
+
const failWithTelemetry = async (failedJob, body) => {
|
|
2143
|
+
let telemetry = { ...body.telemetry, ...observation.snapshot() };
|
|
2144
|
+
try {
|
|
2145
|
+
const diagnostics = observation.workerDiagnostics();
|
|
2146
|
+
if (diagnostics)
|
|
2147
|
+
telemetry = { ...telemetry, worker_diagnostics: diagnostics };
|
|
2148
|
+
}
|
|
2149
|
+
catch {
|
|
2150
|
+
if (!diagnosticWarningSent) {
|
|
2151
|
+
diagnosticWarningSent = true;
|
|
2152
|
+
try {
|
|
2153
|
+
deps.errorLog(`[executor] job ${job.id} ${new Date(deps.now()).toISOString()}: diagnostics unavailable (collection_failed)`);
|
|
2154
|
+
}
|
|
2155
|
+
catch { /* supplemental sink */ }
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return originalHttp.fail(failedJob, { ...body, telemetry });
|
|
2159
|
+
};
|
|
2160
|
+
httpClient = new Proxy(originalHttp, { get(target, key) {
|
|
2161
|
+
if (key === "fail")
|
|
2162
|
+
return failWithTelemetry;
|
|
2163
|
+
const value = Reflect.get(target, key);
|
|
2164
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
2165
|
+
} });
|
|
2166
|
+
const originalDeps = deps;
|
|
2167
|
+
deps = { ...deps, errorLog: (message) => {
|
|
2168
|
+
try {
|
|
2169
|
+
originalDeps.errorLog(`[executor] job ${job.id} ${new Date(originalDeps.now()).toISOString()}: ${message}`);
|
|
2170
|
+
}
|
|
2171
|
+
catch { /* supplemental logging cannot fail the job */ }
|
|
2172
|
+
} };
|
|
2173
|
+
const resolved = resolveExecutorBranch(job);
|
|
2174
|
+
if (!resolved.ok)
|
|
2175
|
+
return runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
|
|
2176
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
2177
|
+
if (!timeout.ok)
|
|
2178
|
+
return runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
|
|
2179
|
+
const deadline = deps.now() + timeout.timeoutSeconds * 1000;
|
|
2180
|
+
const boundedDeps = { ...deps, runCommand: async (file, args, opts) => {
|
|
2181
|
+
const remaining = deadline - deps.now();
|
|
2182
|
+
if (remaining <= 0 || ownership.abandoned)
|
|
2183
|
+
throw new Error("preparation unavailable");
|
|
2184
|
+
return deps.runCommand(file, args, { ...opts, timeoutMs: Math.min(opts?.timeoutMs ?? 120_000, remaining) });
|
|
2185
|
+
} };
|
|
2186
|
+
const acquire = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
|
|
2187
|
+
let reservation;
|
|
2188
|
+
let retainedLock;
|
|
2189
|
+
const reserve = async () => {
|
|
2190
|
+
try {
|
|
2191
|
+
const common = await boundedDeps.runCommand("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: deps.cwd });
|
|
2192
|
+
if (common.exitCode !== 0 || !path.isAbsolute(common.stdout.trim()))
|
|
2193
|
+
throw new Error("reservation unavailable");
|
|
2194
|
+
const reservationPath = path.join(common.stdout.trim(), "executor-preparation", createHash("sha256").update(resolved.branch).digest("hex"));
|
|
2195
|
+
const request = (worktreePath) => ({ worktreePath, jobId: job.id, claimToken: job.claim_token,
|
|
2196
|
+
executorId: options.executorId, ownerPid: process.pid, acquiredAt: nowIso(deps) });
|
|
2197
|
+
const reserved = await acquire(request(reservationPath), buildWorktreeLockSeams(deps));
|
|
2198
|
+
if (!reserved.acquired) {
|
|
2199
|
+
deps.errorLog(`[executor] worktree already held job_id=${job.id} held_by=job_id=${reserved.owner?.jobId ?? "unknown"}`);
|
|
2200
|
+
return null;
|
|
2201
|
+
}
|
|
2202
|
+
reservation = reserved;
|
|
2203
|
+
const entries = await boundedDeps.runCommand("git", ["worktree", "list", "--porcelain"], { cwd: deps.cwd });
|
|
2204
|
+
if (entries.exitCode !== 0)
|
|
2205
|
+
throw new Error("worktree discovery unavailable");
|
|
2206
|
+
const retained = findWorktreeByBranch(parseGitWorktreePorcelain(entries.stdout), resolved.branch);
|
|
2207
|
+
if (retained) {
|
|
2208
|
+
const held = await acquire(request(retained), buildWorktreeLockSeams(deps));
|
|
2209
|
+
if (!held.acquired)
|
|
2210
|
+
throw new Error("worktree busy");
|
|
2211
|
+
retainedLock = held;
|
|
2212
|
+
}
|
|
2213
|
+
const preparationId = randomUUID();
|
|
2214
|
+
const preparation = { jobId: job.id, preparationId, branch: resolved.branch,
|
|
2215
|
+
remainingMs: () => Math.max(0, deadline - deps.now()), mayMutate: () => !ownership.abandoned,
|
|
2216
|
+
recordPublication: (fact) => observation.recordPublication(fact) };
|
|
2217
|
+
return { retainedLock, preparation, deps: boundedDeps };
|
|
2218
|
+
}
|
|
2219
|
+
catch {
|
|
2220
|
+
return null;
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
try {
|
|
2224
|
+
return await runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver, undefined, undefined, reserve);
|
|
2225
|
+
}
|
|
2226
|
+
finally {
|
|
2227
|
+
await retainedLock?.release();
|
|
2228
|
+
await reservation?.release();
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
async function runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
|
|
1948
2232
|
/**
|
|
1949
2233
|
* BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
|
|
1950
2234
|
* the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
|
|
1951
2235
|
* adds no second worktree entry and no second worker launcher.
|
|
1952
2236
|
*/
|
|
1953
|
-
driver) {
|
|
2237
|
+
driver, retainedLock, preparation, reserve) {
|
|
1954
2238
|
// A server-owned read precedes every worktree reset/cut and worker launch.
|
|
1955
2239
|
// Only this boundary can authorize a no-charge pre-work wait; a later plan
|
|
1956
2240
|
// retrieval failure is ordinary bounded worker failure.
|
|
@@ -2071,11 +2355,94 @@ driver) {
|
|
|
2071
2355
|
});
|
|
2072
2356
|
return { status: "failed", reason: "permission_posture_contract" };
|
|
2073
2357
|
}
|
|
2358
|
+
const executionDeps = deps;
|
|
2359
|
+
// Focused payload admission also precedes every lock/filesystem mutation.
|
|
2360
|
+
if (job.job_type === CI_FIX_JOB_TYPE) {
|
|
2361
|
+
try {
|
|
2362
|
+
(seams.readCiFixDispatchContext ?? readCiFixDispatchContext)(job);
|
|
2363
|
+
}
|
|
2364
|
+
catch (err) {
|
|
2365
|
+
const failure = isExecutorNamedError(err) ? toExecutorFailure(err) : { error_kind: CiFixContext, error_message: secretFreeErrorMessage(err), classification: "crashed" };
|
|
2366
|
+
deps.errorLog(failure.error_message);
|
|
2367
|
+
await httpClient.fail(job, failure);
|
|
2368
|
+
return { status: "failed", reason: "ci_fix_context" };
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
if (job.job_type === FOCUSED_REBASE_JOB_TYPE && hasFocusedRebaseContext(job)) {
|
|
2372
|
+
const validation = validateFocusedRebaseContext(job);
|
|
2373
|
+
if (!validation.ok) {
|
|
2374
|
+
const failure = toExecutorFailure(focusedRebaseContextError(job, validation.reason));
|
|
2375
|
+
deps.errorLog(failure.error_message);
|
|
2376
|
+
await httpClient.fail(job, failure);
|
|
2377
|
+
return { status: "failed", reason: "focused_rebase_context" };
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
// Acquire ownership only after read-only admission and contract validation.
|
|
2381
|
+
if (reserve) {
|
|
2382
|
+
const held = await reserve();
|
|
2383
|
+
if (!held) {
|
|
2384
|
+
if (ownership.abandoned)
|
|
2385
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
2386
|
+
await httpClient.fail(job, { error_kind: WorktreeBusy, error_message: WORKTREE_BUSY_MESSAGE,
|
|
2387
|
+
classification: "crashed", telemetry: observation.snapshot() });
|
|
2388
|
+
return { status: "failed", reason: "worktree_busy" };
|
|
2389
|
+
}
|
|
2390
|
+
retainedLock = held.retainedLock;
|
|
2391
|
+
preparation = held.preparation;
|
|
2392
|
+
deps = held.deps;
|
|
2393
|
+
}
|
|
2074
2394
|
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
2075
|
-
|
|
2395
|
+
let prep;
|
|
2396
|
+
if (preparation) {
|
|
2397
|
+
const phase = await superviseDriverPhase({ job, httpClient, options: jobOptions, deps, ownership, observation, control,
|
|
2398
|
+
timeoutSeconds: Math.max(1, Math.ceil(preparation.remainingMs() / 1000)), collectTelemetry: async () => ({}),
|
|
2399
|
+
run: async (signal) => {
|
|
2400
|
+
const allowed = preparation.mayMutate;
|
|
2401
|
+
const context = { ...preparation, mayMutate: () => allowed() && !signal.aborted };
|
|
2402
|
+
const phaseDeps = { ...deps, runCommand: (file, args, opts) => deps.runCommand(file, args, { ...opts, signal }) };
|
|
2403
|
+
return prepareSpawn(job, httpClient, jobOptions, phaseDeps, seams, context);
|
|
2404
|
+
} });
|
|
2405
|
+
if (phase.kind !== "result") {
|
|
2406
|
+
if (phase.kind === "abandoned")
|
|
2407
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
2408
|
+
await httpClient.fail(job, { error_kind: phase.kind === "server_stop" ? "ServerStopRequested" : phase.kind === "timeout" ? "Timeout" : "WorktreeError",
|
|
2409
|
+
error_message: "executor preparation stopped; retained work preserved", classification: phase.kind === "timeout" ? "timeout" : phase.kind === "server_stop" ? "killed" : "crashed" });
|
|
2410
|
+
if (phase.kind === "server_stop") {
|
|
2411
|
+
ownership.abandoned = true;
|
|
2412
|
+
ownership.abandonReason = "server_stop";
|
|
2413
|
+
return { status: "abandoned", reason: "server_stop" };
|
|
2414
|
+
}
|
|
2415
|
+
return { status: "failed", reason: "preparation_stopped" };
|
|
2416
|
+
}
|
|
2417
|
+
prep = phase.value;
|
|
2418
|
+
}
|
|
2419
|
+
else
|
|
2420
|
+
prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
|
|
2076
2421
|
if (!prep.ok)
|
|
2077
2422
|
return prep.result;
|
|
2423
|
+
deps = executionDeps;
|
|
2424
|
+
if (preparation)
|
|
2425
|
+
prep = { ...prep, deadlineMs: deps.now() + preparation.remainingMs() };
|
|
2078
2426
|
const { worktreePath, branch } = prep;
|
|
2427
|
+
let retainedPathMatches = !retainedLock || path.resolve(retainedLock.owner.worktreePath) === path.resolve(worktreePath);
|
|
2428
|
+
if (!retainedPathMatches && retainedLock) {
|
|
2429
|
+
try {
|
|
2430
|
+
retainedPathMatches = await realpath(retainedLock.owner.worktreePath) === await realpath(worktreePath);
|
|
2431
|
+
}
|
|
2432
|
+
catch { /* unreadable identity fails closed */ }
|
|
2433
|
+
}
|
|
2434
|
+
if (!retainedPathMatches) {
|
|
2435
|
+
await httpClient.fail(job, { error_kind: WorktreeBusy, error_message: WORKTREE_BUSY_MESSAGE, classification: "crashed" });
|
|
2436
|
+
return { status: "failed", reason: "worktree_lock_path_changed" };
|
|
2437
|
+
}
|
|
2438
|
+
if (preparation) {
|
|
2439
|
+
const routing = await configureExecutorBranch(worktreePath, branch, deps);
|
|
2440
|
+
if (routing) {
|
|
2441
|
+
await httpClient.fail(job, { error_kind: "WorktreeError", error_message: routing,
|
|
2442
|
+
classification: "crashed", telemetry: observation.snapshot() });
|
|
2443
|
+
return { status: "failed", reason: "push_routing" };
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2079
2446
|
// --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
|
|
2080
2447
|
// The FIRST thing that happens once a canonical worktree path exists, and
|
|
2081
2448
|
// ahead of every executor-owned write to it: the deny layer, MCP
|
|
@@ -2086,15 +2453,10 @@ driver) {
|
|
|
2086
2453
|
// are now created exclusively, and refusal still returns from HERE rather
|
|
2087
2454
|
// than anywhere downstream.
|
|
2088
2455
|
//
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
2091
|
-
// Its path comes back FROM Worktrunk, so there is no path to lock before it
|
|
2092
|
-
// without duplicating Worktrunk's layout rules here — a copy that would go
|
|
2093
|
-
// wrong silently. Dispatch exclusion and same-ticket claim serialization
|
|
2094
|
-
// (the Python half of this ticket) are what shrink that window; this lock is
|
|
2095
|
-
// what makes everything after it safe.
|
|
2456
|
+
// The branch reservation covers not-yet-created worktrees; retained trees
|
|
2457
|
+
// already hold this same worktree lock before preparation.
|
|
2096
2458
|
const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
|
|
2097
|
-
const worktreeLock = await acquireLock({
|
|
2459
|
+
const worktreeLock = retainedLock ?? await acquireLock({
|
|
2098
2460
|
worktreePath,
|
|
2099
2461
|
jobId: job.id,
|
|
2100
2462
|
claimToken: job.claim_token,
|
|
@@ -2143,6 +2505,7 @@ driver) {
|
|
|
2143
2505
|
const rendered = await renderVerifiedCiFixPrompt({
|
|
2144
2506
|
job,
|
|
2145
2507
|
ciFix: prep.ciFix,
|
|
2508
|
+
publication: prep.publication, preparation,
|
|
2146
2509
|
worktreePath,
|
|
2147
2510
|
branch,
|
|
2148
2511
|
baseBranch: effectiveBaseBranch,
|
|
@@ -2157,6 +2520,22 @@ driver) {
|
|
|
2157
2520
|
}
|
|
2158
2521
|
readyPrep = { ...prep, prompt: rendered.prompt };
|
|
2159
2522
|
}
|
|
2523
|
+
if (["implement", "resume", "remediate", "rebase", "ci_fix"].includes(job.job_type)) {
|
|
2524
|
+
readyPrep = { ...readyPrep, comparisonSha: prep.ciFix?.headSha ??
|
|
2525
|
+
await captureComparisonSha(worktreePath, deps) };
|
|
2526
|
+
if (!prep.ciFix && isRemainingWork(job.job_type, job.payload)) {
|
|
2527
|
+
if (job.job_type === "resume" || prep.priorWork) {
|
|
2528
|
+
// Resume preparation owns retained continuation text, including seam-
|
|
2529
|
+
// supplied text. Do not reconstruct it from the earlier payload.
|
|
2530
|
+
readyPrep = { ...readyPrep, prompt: readyPrep.prompt + "\n\n" + renderWorkerVerificationGuidance(readyPrep.comparisonSha) };
|
|
2531
|
+
}
|
|
2532
|
+
else {
|
|
2533
|
+
const final = resolveExecutorPrompt(job, { comparisonSha: readyPrep.comparisonSha, diagnostic: deps.errorLog });
|
|
2534
|
+
readyPrep = { ...readyPrep, prompt: final.ok ? final.prompt :
|
|
2535
|
+
readyPrep.prompt + "\n\n" + renderWorkerVerificationGuidance(readyPrep.comparisonSha) };
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2160
2539
|
// --- Stale verdict artifacts (BAPI-814/D1) --------------------------
|
|
2161
2540
|
// Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
|
|
2162
2541
|
// so every job type routed through `runSpawnJob` is covered — `implement`,
|
|
@@ -2189,6 +2568,15 @@ driver) {
|
|
|
2189
2568
|
});
|
|
2190
2569
|
return { status: "failed", reason: "stale_artifact_cleanup" };
|
|
2191
2570
|
}
|
|
2571
|
+
try {
|
|
2572
|
+
await (seams.writeRecentlyFailedTestsFile ?? writeRecentlyFailedTestsFile)(worktreePath, job.payload?.recently_failed_tests);
|
|
2573
|
+
}
|
|
2574
|
+
catch {
|
|
2575
|
+
await httpClient.fail(job, { error_kind: "ContractError.RecentlyFailedTests",
|
|
2576
|
+
error_message: "Recent failure resolver file could not be safely confirmed",
|
|
2577
|
+
classification: "crashed" });
|
|
2578
|
+
return { status: "failed", reason: "recently_failed_tests_file" };
|
|
2579
|
+
}
|
|
2192
2580
|
return await runPreparedSpawn({
|
|
2193
2581
|
job,
|
|
2194
2582
|
httpClient,
|
|
@@ -2231,6 +2619,32 @@ driver) {
|
|
|
2231
2619
|
await worktreeLock.release();
|
|
2232
2620
|
}
|
|
2233
2621
|
}
|
|
2622
|
+
function retainedHeadPublisher(params) {
|
|
2623
|
+
return async () => {
|
|
2624
|
+
const { job, branch, worktreePath, prep, deps, ownership, observation, remainingMs } = params;
|
|
2625
|
+
if (!prep.publicationBaseline)
|
|
2626
|
+
return { status: "refused", reason: "invalid_input" };
|
|
2627
|
+
const phase = await superviseDriverPhase({ job, httpClient: params.httpClient, options: params.options, deps, ownership, observation, control: params.control,
|
|
2628
|
+
timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)), collectTelemetry: async () => ({}),
|
|
2629
|
+
run: async (signal) => {
|
|
2630
|
+
const result = await guardedFastForwardPush({ branch, expectedOriginSha: prep.publicationBaseline,
|
|
2631
|
+
worktreePath, runCommand: deps.runCommand, env: deps.env, remainingMs, signal,
|
|
2632
|
+
mayMutate: () => !ownership.abandoned && !signal.aborted });
|
|
2633
|
+
if (result.status === "published")
|
|
2634
|
+
observation.recordPublication({ ...result,
|
|
2635
|
+
jobId: job.id, preparationId: prep.publication?.preparationId ?? "finalization", branch });
|
|
2636
|
+
return result;
|
|
2637
|
+
} });
|
|
2638
|
+
if (phase.kind !== "result") {
|
|
2639
|
+
if (phase.kind === "server_stop") {
|
|
2640
|
+
ownership.abandoned = true;
|
|
2641
|
+
ownership.abandonReason = "server_stop";
|
|
2642
|
+
}
|
|
2643
|
+
return { status: "refused", reason: phase.kind === "error" ? "remote_rejected" : "timeout" };
|
|
2644
|
+
}
|
|
2645
|
+
return phase.value;
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2234
2648
|
/**
|
|
2235
2649
|
* Run a spawn job whose worktree is already prepared AND exclusively locked.
|
|
2236
2650
|
*
|
|
@@ -2257,8 +2671,18 @@ async function runPreparedSpawn(params) {
|
|
|
2257
2671
|
// base contract's "when you open the pull request" instruction would re-add the
|
|
2258
2672
|
// new-PR step the focused prompt deliberately excludes.
|
|
2259
2673
|
let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
|
|
2260
|
-
? `${prep.prompt} ${
|
|
2674
|
+
? `${prep.prompt} ${renderWorkerPrBaseGuidance()}`
|
|
2261
2675
|
: prep.prompt;
|
|
2676
|
+
// BAPI-1399: a worker that implements ticket requirements is told the
|
|
2677
|
+
// minor-versus-core workaround contract in the same launch prompt. Repair jobs
|
|
2678
|
+
// (`ci_fix`, a focused rebase, `reconcile_overlap`) and a focused remaining-work
|
|
2679
|
+
// correction implement no new requirement, so their prompts stay as they are.
|
|
2680
|
+
if (WORKAROUND_CONTRACT_JOB_TYPES.has(job.job_type) &&
|
|
2681
|
+
!prep.ciFix &&
|
|
2682
|
+
!prep.focusedRebase &&
|
|
2683
|
+
!isRemainingWork(job.job_type, job.payload)) {
|
|
2684
|
+
prompt = `${prompt} ${buildEpicWorkaroundWorkerInstruction(job.ticket_key)}`;
|
|
2685
|
+
}
|
|
2262
2686
|
// --- Timeout contract ------------------------------------------------
|
|
2263
2687
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
2264
2688
|
if (!timeout.ok) {
|
|
@@ -2290,12 +2714,26 @@ async function runPreparedSpawn(params) {
|
|
|
2290
2714
|
// finish the whole job here (a clean, green reconciliation pushes with no
|
|
2291
2715
|
// worker at all), or hand back the ONE worker it needs and the pre-spawn state
|
|
2292
2716
|
// it has already proven. The job's overall timeout budget spans every phase.
|
|
2293
|
-
const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
|
|
2717
|
+
const jobDeadlineMs = prep.deadlineMs ?? deps.now() + timeout.timeoutSeconds * 1000;
|
|
2294
2718
|
const remainingMs = () => Math.max(0, jobDeadlineMs - deps.now());
|
|
2719
|
+
if (!driver) {
|
|
2720
|
+
try {
|
|
2721
|
+
const remote = await deps.runCommand("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branch}`], { cwd: worktreePath, timeoutMs: Math.min(120_000, remainingMs()) });
|
|
2722
|
+
const fields = remote.stdout.trim().split(/\s+/);
|
|
2723
|
+
if (remote.exitCode === 0 && fields.length === 2 && fields[1] === `refs/heads/${branch}` && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(fields[0])) {
|
|
2724
|
+
prep.publicationBaseline = isRecoveryJobType(job.job_type) ? prep.expectedHeadSha : fields[0];
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
catch { /* no eligible lease baseline */ }
|
|
2728
|
+
}
|
|
2295
2729
|
const driverBaseContext = {
|
|
2296
2730
|
worktreePath,
|
|
2297
2731
|
branch,
|
|
2298
2732
|
preparedHeadSha: prep.expectedHeadSha,
|
|
2733
|
+
preparationPublication: prep.publication,
|
|
2734
|
+
recordPublication: (fact) => observation.recordPublication({
|
|
2735
|
+
...fact, jobId: job.id, preparationId: prep.publication?.preparationId ?? "finalization", branch,
|
|
2736
|
+
}),
|
|
2299
2737
|
effectiveBaseBranch,
|
|
2300
2738
|
};
|
|
2301
2739
|
const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
@@ -2328,6 +2766,37 @@ async function runPreparedSpawn(params) {
|
|
|
2328
2766
|
// launch instruction would tell a worker with no push authority to open a PR.
|
|
2329
2767
|
prompt = driverStep.prompt;
|
|
2330
2768
|
}
|
|
2769
|
+
// --- Packaged recipe delivery (BAPI-1392) ------------------------------
|
|
2770
|
+
// Applied ONCE, to the final prompt, so a command-bearing prompt makes the worker
|
|
2771
|
+
// follow the CURRENT packaged recipe instead of a stale committed copy (command
|
|
2772
|
+
// provisioning is fill-only, BAPI-664). Prompt expansion reaches both launch
|
|
2773
|
+
// transports because each carries exactly this string. Nothing is written under
|
|
2774
|
+
// the worktree. Only the two command-bearing prompts are eligible — the initial
|
|
2775
|
+
// `implement` synthesis and the server-built `spec_review` prompt. Every focused,
|
|
2776
|
+
// driver-rendered and remaining-work prompt is left byte-identical, as is any
|
|
2777
|
+
// prompt that names no recognized, non-customized Bridge command.
|
|
2778
|
+
if (!driverStep &&
|
|
2779
|
+
!prep.ciFix &&
|
|
2780
|
+
!prep.focusedRebase &&
|
|
2781
|
+
(job.job_type === "implement" || job.job_type === "spec_review") &&
|
|
2782
|
+
!isRemainingWork(job.job_type, job.payload)) {
|
|
2783
|
+
const recipes = resolvePackagedRecipes(prompt, readCustomizedCommands(job.payload));
|
|
2784
|
+
if (!recipes.ok) {
|
|
2785
|
+
deps.errorLog(`[executor] job ${job.id}: ${recipes.error}`);
|
|
2786
|
+
await httpClient.fail(job, {
|
|
2787
|
+
error_kind: PackagedRecipeUndeliverable,
|
|
2788
|
+
error_message: recipes.error,
|
|
2789
|
+
classification: "crashed",
|
|
2790
|
+
});
|
|
2791
|
+
return { status: "failed", reason: "packaged_recipe_undeliverable" };
|
|
2792
|
+
}
|
|
2793
|
+
prompt = recipes.prompt;
|
|
2794
|
+
}
|
|
2795
|
+
// Trusted starter context is appended AFTER packaged-command expansion, never
|
|
2796
|
+
// inserted into command arguments or scanned through untrusted server evidence.
|
|
2797
|
+
if (job.job_type === "implement" && !isRemainingWork(job.job_type, job.payload)) {
|
|
2798
|
+
prompt += "\n\n" + renderWorkerVerificationGuidance(prep.comparisonSha);
|
|
2799
|
+
}
|
|
2331
2800
|
// --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
|
|
2332
2801
|
// FIRST of the pre-spawn contracts, and deliberately BEFORE every
|
|
2333
2802
|
// executor-owned write to the worktree (deny layer, MCP provisioning, worker
|
|
@@ -2517,6 +2986,7 @@ async function runPreparedSpawn(params) {
|
|
|
2517
2986
|
if (logClaimId !== null) {
|
|
2518
2987
|
try {
|
|
2519
2988
|
tee = await createTee(worktreePath, {
|
|
2989
|
+
now: deps.now,
|
|
2520
2990
|
mkdir: deps.mkdir,
|
|
2521
2991
|
writeFile: deps.writeFile,
|
|
2522
2992
|
appendFile: deps.appendFile,
|
|
@@ -2535,6 +3005,8 @@ async function runPreparedSpawn(params) {
|
|
|
2535
3005
|
// this attempt has no transcript, which is said once, without detail.
|
|
2536
3006
|
deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
|
|
2537
3007
|
}
|
|
3008
|
+
if (tee)
|
|
3009
|
+
observation.allocateAttemptLog(tee.identity, tee.available);
|
|
2538
3010
|
const teeClaimId = tee?.identity.claimId;
|
|
2539
3011
|
// BAPI-828 — idempotent worker-log closure, defined the moment a tee could
|
|
2540
3012
|
// exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
|
|
@@ -2552,6 +3024,22 @@ async function runPreparedSpawn(params) {
|
|
|
2552
3024
|
// THIS attempt's entry is removed (BAPI-1330).
|
|
2553
3025
|
if (teeClaimId !== undefined)
|
|
2554
3026
|
deactivateJobLog(job.id, teeClaimId);
|
|
3027
|
+
// BAPI-1397 (F2): one fixed end-of-job line, AFTER deactivation so nothing
|
|
3028
|
+
// else can append behind it, on every terminal path (this closure is the
|
|
3029
|
+
// single idempotent close every branch and the `finally` backstop share).
|
|
3030
|
+
// The read-only viewer tab stops following the log when it reads it. Written
|
|
3031
|
+
// straight to the attempt's own file rather than through the tee, so a failed
|
|
3032
|
+
// write cannot mark the transcript unavailable; it is never fatal.
|
|
3033
|
+
if (tee &&
|
|
3034
|
+
deps.appendFile &&
|
|
3035
|
+
(tee.unavailableReason === undefined || tee.unavailableReason === "append_failed")) {
|
|
3036
|
+
try {
|
|
3037
|
+
await deps.appendFile(tee.logPath, EXECUTOR_JOB_END_LOG_CHUNK);
|
|
3038
|
+
}
|
|
3039
|
+
catch {
|
|
3040
|
+
deps.errorLog(`executor viewer: could not mark job ${job.id}'s worker log as ended (non-fatal)`);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
2555
3043
|
await closeWorkerLogTee(tee);
|
|
2556
3044
|
};
|
|
2557
3045
|
lifecycle.closeWorkerLog = closeWorkerLog;
|
|
@@ -2594,6 +3082,8 @@ async function runPreparedSpawn(params) {
|
|
|
2594
3082
|
workerLogPath: tee.logPath,
|
|
2595
3083
|
worktreePath,
|
|
2596
3084
|
ticketKey: job.ticket_key ?? undefined,
|
|
3085
|
+
// BAPI-1397 (F2): the tab is titled `<TICKET> · <job type>`.
|
|
3086
|
+
jobType: job.job_type,
|
|
2597
3087
|
runCommand: deps.runCommand,
|
|
2598
3088
|
cwd: deps.cwd,
|
|
2599
3089
|
});
|
|
@@ -2882,6 +3372,9 @@ async function runPreparedSpawn(params) {
|
|
|
2882
3372
|
plan,
|
|
2883
3373
|
runtime,
|
|
2884
3374
|
nudge: (ids) => capability.value.buildWatcherNudge(ids),
|
|
3375
|
+
...(capability.value.buildCompletionWindowNudge
|
|
3376
|
+
? { windowNudge: (ids) => capability.value.buildCompletionWindowNudge(ids) }
|
|
3377
|
+
: {}),
|
|
2885
3378
|
dispatchHeadSha,
|
|
2886
3379
|
stripEnvFiles: stripWorktreeEnvFiles,
|
|
2887
3380
|
});
|
|
@@ -2957,7 +3450,7 @@ async function runPreparedSpawn(params) {
|
|
|
2957
3450
|
proc: supervisedProc,
|
|
2958
3451
|
// BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
|
|
2959
3452
|
// pre-worker phase has already spent some of it.
|
|
2960
|
-
timeoutSeconds:
|
|
3453
|
+
timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
|
|
2961
3454
|
collectTelemetry,
|
|
2962
3455
|
collectRemoteMarker,
|
|
2963
3456
|
mcpSurface,
|
|
@@ -3066,7 +3559,7 @@ async function runPreparedSpawn(params) {
|
|
|
3066
3559
|
await httpClient.fail(job, {
|
|
3067
3560
|
error_kind: McpSurfaceMismatch,
|
|
3068
3561
|
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
3069
|
-
classification:
|
|
3562
|
+
classification: toWireClassification(procResult.classification),
|
|
3070
3563
|
...commitResidue(finalTelemetry),
|
|
3071
3564
|
telemetry: observation.snapshot(),
|
|
3072
3565
|
});
|
|
@@ -3273,8 +3766,10 @@ async function runPreparedSpawn(params) {
|
|
|
3273
3766
|
branch,
|
|
3274
3767
|
worktreePath,
|
|
3275
3768
|
result: genericResult,
|
|
3769
|
+
publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, remainingMs, httpClient, options: jobOptions, control }),
|
|
3276
3770
|
runCommand: deps.runCommand,
|
|
3277
3771
|
headSha: git.last_commit_sha,
|
|
3772
|
+
callTimeoutMs: () => Math.max(1, Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs())),
|
|
3278
3773
|
expectedBaseBranch: effectiveBaseBranch,
|
|
3279
3774
|
// BAPI-1311: a focused rebase's finalization reads are bounded by the
|
|
3280
3775
|
// claim's remaining absolute deadline; every other job keeps the legacy,
|
|
@@ -3378,7 +3873,8 @@ async function runPreparedSpawn(params) {
|
|
|
3378
3873
|
// BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
|
|
3379
3874
|
// `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
|
|
3380
3875
|
// the next job's reset fail. Removed for EVERY spawn job, never with a hard
|
|
3381
|
-
// reset;
|
|
3876
|
+
// reset; reconcile_overlap additionally discards unpublished wrapper merge
|
|
3877
|
+
// state, while preserving successful or ambiguous publication.
|
|
3382
3878
|
if (procResult.classification === "timeout" || procResult.classification === "killed") {
|
|
3383
3879
|
await cleanupInterruptedWorktree(worktreePath, {
|
|
3384
3880
|
runCommand: deps.runCommand,
|
|
@@ -3430,6 +3926,15 @@ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
|
|
|
3430
3926
|
const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
|
|
3431
3927
|
/** Bound on closing the held stdin once the closure rule fires. */
|
|
3432
3928
|
const STDIN_CLOSE_BOUND_MS = 5_000;
|
|
3929
|
+
/**
|
|
3930
|
+
* BAPI-1395 — how long a worker may keep running after the completion window
|
|
3931
|
+
* closed its stdin before the one termination path is requested. Equal to the
|
|
3932
|
+
* grace the allowance plan already leaves between the hold deadline and the
|
|
3933
|
+
* process timer (the measured CLI stops its own tasks within ~0.4 s of EOF).
|
|
3934
|
+
* The request is also capped at the worker deadline, so the existing 20 s
|
|
3935
|
+
* cleanup reservation that follows the worker's exit is never eaten into.
|
|
3936
|
+
*/
|
|
3937
|
+
const POST_WINDOW_EXIT_GRACE_MS = STDIN_CLOSE_GRACE_MS;
|
|
3433
3938
|
const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
|
|
3434
3939
|
/**
|
|
3435
3940
|
* BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
|
|
@@ -3699,7 +4204,14 @@ async function runCoveredAttempt(params) {
|
|
|
3699
4204
|
// --- Ownership lifetime: one heartbeat for the whole attempt ---------------
|
|
3700
4205
|
let childLive = true;
|
|
3701
4206
|
let workerStartedAt = null;
|
|
3702
|
-
const tracker = proc.pid !== undefined
|
|
4207
|
+
const tracker = proc.pid !== undefined
|
|
4208
|
+
? createDescendantTracker({
|
|
4209
|
+
inspector,
|
|
4210
|
+
rootPid: proc.pid,
|
|
4211
|
+
// Only the job id and the live count: never the token, a command, or stdout.
|
|
4212
|
+
onOverflow: (liveDescendants) => deps.errorLog(`descendant tracker overflow job=${job.id} live_descendants=${liveDescendants}; cleanup cannot be confirmed`),
|
|
4213
|
+
})
|
|
4214
|
+
: null;
|
|
3703
4215
|
if (proc.pid !== undefined) {
|
|
3704
4216
|
void inspector
|
|
3705
4217
|
.identity(proc.pid)
|
|
@@ -3764,9 +4276,20 @@ async function runCoveredAttempt(params) {
|
|
|
3764
4276
|
void tracker.sample().finally(scheduleSample);
|
|
3765
4277
|
// --- Held-stdin closure supervision -----------------------------------------
|
|
3766
4278
|
const stdinDeps = { now: deps.now, setTimer: deps.setTimer, clearTimer: deps.clearTimer };
|
|
4279
|
+
// BAPI-1395: a worker that ignores the stdin EOF the completion window sent is
|
|
4280
|
+
// ended through the existing termination controller, not a second kill path.
|
|
4281
|
+
let postWindowExitTimer;
|
|
3767
4282
|
const supervisor = createClosureSupervisor({
|
|
3768
4283
|
ledger,
|
|
3769
4284
|
deps: stdinDeps,
|
|
4285
|
+
...(params.windowNudge ? { buildWindowNudge: params.windowNudge } : {}),
|
|
4286
|
+
onWindowExpiry: () => {
|
|
4287
|
+
const graceMs = Math.max(0, Math.min(POST_WINDOW_EXIT_GRACE_MS, plan.workerDeadlineAtMs - deps.now()));
|
|
4288
|
+
postWindowExitTimer = deps.setTimer(() => {
|
|
4289
|
+
postWindowExitTimer = undefined;
|
|
4290
|
+
terminationController.requestTermination("completion_window");
|
|
4291
|
+
}, graceMs);
|
|
4292
|
+
},
|
|
3770
4293
|
writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
|
|
3771
4294
|
closeStdin: async () => {
|
|
3772
4295
|
await closeStdinBounded(proc, {
|
|
@@ -3804,12 +4327,25 @@ async function runCoveredAttempt(params) {
|
|
|
3804
4327
|
// Transport failed before any work: nothing the worker did can be
|
|
3805
4328
|
// evidence. Stop it through the one termination path.
|
|
3806
4329
|
ledger.recordTransportFailure(true);
|
|
3807
|
-
terminationController.requestTermination();
|
|
4330
|
+
terminationController.requestTermination("executor_abort");
|
|
3808
4331
|
}
|
|
3809
4332
|
const procResult = await runPromise;
|
|
4333
|
+
const closedReason = supervisor.state().closedReason;
|
|
4334
|
+
// A termination request has already latched the effective cause. Closing
|
|
4335
|
+
// stdin later must not overwrite an earlier shutdown or containment stop.
|
|
4336
|
+
const processEndCause = procResult.endCause && procResult.endCause !== "worker_exit"
|
|
4337
|
+
? procResult.endCause
|
|
4338
|
+
: closedReason === "window" ? "completion_window"
|
|
4339
|
+
: closedReason === "deadline" ? "job_timeout"
|
|
4340
|
+
: closedReason === "abort" ? "executor_abort"
|
|
4341
|
+
: "worker_exit";
|
|
4342
|
+
console.error(`[executor] End cause ${processEndCause}`);
|
|
3810
4343
|
childLive = false;
|
|
3811
4344
|
if (sampleTimer !== undefined)
|
|
3812
4345
|
deps.clearTimer(sampleTimer);
|
|
4346
|
+
if (postWindowExitTimer !== undefined)
|
|
4347
|
+
deps.clearTimer(postWindowExitTimer);
|
|
4348
|
+
postWindowExitTimer = undefined;
|
|
3813
4349
|
supervisor.dispose();
|
|
3814
4350
|
lineFeeder.flush();
|
|
3815
4351
|
ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
|
|
@@ -3855,18 +4391,21 @@ async function runCoveredAttempt(params) {
|
|
|
3855
4391
|
const verificationRead = readRequiredVerification(job.payload);
|
|
3856
4392
|
const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
|
|
3857
4393
|
const checks = declaration?.checks ?? [];
|
|
3858
|
-
const envelopeWith = (facts) =>
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
4394
|
+
const envelopeWith = (facts) => ({
|
|
4395
|
+
...ledger.buildEnvelope({
|
|
4396
|
+
runtime: params.runtime,
|
|
4397
|
+
finalHeadSha,
|
|
4398
|
+
finalization: facts.finalization,
|
|
4399
|
+
verification: facts.verification,
|
|
4400
|
+
cleanup,
|
|
4401
|
+
missingCapabilities: facts.missing,
|
|
4402
|
+
satisfiedObligations: [
|
|
4403
|
+
...(facts.pushSatisfied ? [obligations[0]] : []),
|
|
4404
|
+
...(facts.prSatisfied ? [obligations[1]] : []),
|
|
4405
|
+
],
|
|
4406
|
+
...(facts.verificationFacts ? { verificationFacts: facts.verificationFacts } : {}),
|
|
4407
|
+
}),
|
|
4408
|
+
end_cause: processEndCause,
|
|
3870
4409
|
});
|
|
3871
4410
|
const unverifiedEnvelope = () => {
|
|
3872
4411
|
const unlaunched = unlaunchedVerification(verificationRead);
|
|
@@ -3947,7 +4486,7 @@ async function runCoveredAttempt(params) {
|
|
|
3947
4486
|
error_kind: "ServerStopRequested",
|
|
3948
4487
|
error_message: "the server requested this run stop; the owned worker was terminated",
|
|
3949
4488
|
classification: "killed",
|
|
3950
|
-
}, unverifiedEnvelope());
|
|
4489
|
+
}, { ...unverifiedEnvelope(), end_cause: "server_stop" });
|
|
3951
4490
|
ownership.abandoned = true;
|
|
3952
4491
|
ownership.abandonReason = "server_stop";
|
|
3953
4492
|
return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
|
|
@@ -3962,7 +4501,7 @@ async function runCoveredAttempt(params) {
|
|
|
3962
4501
|
return finish(await failWith({
|
|
3963
4502
|
error_kind: McpSurfaceMismatch,
|
|
3964
4503
|
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
3965
|
-
classification:
|
|
4504
|
+
classification: toWireClassification(procResult.classification),
|
|
3966
4505
|
}, unverifiedEnvelope()));
|
|
3967
4506
|
}
|
|
3968
4507
|
if (surfaceVerdict.kind === "absent" &&
|
|
@@ -4123,6 +4662,9 @@ async function runCoveredAttempt(params) {
|
|
|
4123
4662
|
branch,
|
|
4124
4663
|
worktreePath,
|
|
4125
4664
|
result: genericResult,
|
|
4665
|
+
publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, httpClient, options: jobOptions, control,
|
|
4666
|
+
remainingMs: () => Math.max(0, plan.deadlineAtMs - deps.now()) }),
|
|
4667
|
+
callTimeoutMs: () => Math.min(120_000, Math.max(0, plan.deadlineAtMs - deps.now())),
|
|
4126
4668
|
runCommand: deps.runCommand,
|
|
4127
4669
|
headSha: git.last_commit_sha,
|
|
4128
4670
|
expectedBaseBranch: effectiveBaseBranch,
|