@bridge_gpt/mcp-server 0.2.66 → 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 +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- 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/bounded-wait.js +67 -10
- 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 +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- 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 +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- 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 +112 -26
- 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 +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- 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 +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- 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 +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -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,26 +21,29 @@
|
|
|
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, 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";
|
|
26
34
|
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
|
-
import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence,
|
|
30
|
-
import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, } from "./completion-supervision.js";
|
|
37
|
+
import { buildNotLaunchedEnvelope, claimRequiresCompletionEvidence, COMPLETION_CONTRACT_MARKER_VALUES, isCoveredJobType, MAX_EVENT_BYTES, runtimeMatchesSupported, successRefusalReason, verificationVerdictFromFacts, } from "./completion-contract.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
|
-
import { prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, } from "./resume-pre-spawn.js";
|
|
36
|
-
import { ciFixContextError, readCiFixDispatchContext, renderPromptSpecPrompt, } from "./prompt-spec.js";
|
|
43
|
+
import { autoCheckpointDirtyState, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE, prepareResumeSpawn, prepareRetainedContinuationWorktree, readContinuationBinding, verifyContinuationHeadLineage, } from "./resume-pre-spawn.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
|
}
|
|
@@ -540,6 +553,46 @@ function createNoopProcess(deps, durationMs, message) {
|
|
|
540
553
|
},
|
|
541
554
|
};
|
|
542
555
|
}
|
|
556
|
+
/**
|
|
557
|
+
* Bounded newline assembly of worker stdout into whole lines for a
|
|
558
|
+
* {@link RequiredWorkLedger} (BAPI-1301 covered path, BAPI-1364 legacy
|
|
559
|
+
* observation). A line that outgrows the event bound is handed over once (the
|
|
560
|
+
* ledger marks it `stream_oversize`) and the rest of it is discarded up to its
|
|
561
|
+
* newline. `onLine` runs after each completed line is ingested.
|
|
562
|
+
*/
|
|
563
|
+
function createLedgerLineFeeder(ledger, onLine) {
|
|
564
|
+
let lineBuffer = "";
|
|
565
|
+
let discardingOversize = false;
|
|
566
|
+
return {
|
|
567
|
+
feed: (chunk) => {
|
|
568
|
+
lineBuffer += chunk;
|
|
569
|
+
let newline;
|
|
570
|
+
while ((newline = lineBuffer.indexOf("\n")) >= 0) {
|
|
571
|
+
const line = lineBuffer.slice(0, newline);
|
|
572
|
+
lineBuffer = lineBuffer.slice(newline + 1);
|
|
573
|
+
if (discardingOversize) {
|
|
574
|
+
discardingOversize = false;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
ledger.ingestLine(line);
|
|
578
|
+
onLine?.();
|
|
579
|
+
}
|
|
580
|
+
if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
|
|
581
|
+
ledger.ingestLine(lineBuffer);
|
|
582
|
+
lineBuffer = "";
|
|
583
|
+
discardingOversize = true;
|
|
584
|
+
}
|
|
585
|
+
else if (discardingOversize) {
|
|
586
|
+
lineBuffer = "";
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
flush: () => {
|
|
590
|
+
if (!discardingOversize && lineBuffer.length > 0)
|
|
591
|
+
ledger.ingestLine(lineBuffer);
|
|
592
|
+
lineBuffer = "";
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
543
596
|
/**
|
|
544
597
|
* Run the process and heartbeat loop concurrently; return the process outcome.
|
|
545
598
|
*
|
|
@@ -551,11 +604,22 @@ function createNoopProcess(deps, durationMs, message) {
|
|
|
551
604
|
*/
|
|
552
605
|
async function superviseProcess(params) {
|
|
553
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;
|
|
554
618
|
const hb = runHeartbeatLoop({
|
|
555
619
|
job: params.job,
|
|
556
620
|
httpClient: params.httpClient,
|
|
557
621
|
options: params.options,
|
|
558
|
-
deps:
|
|
622
|
+
deps: heartbeatDeps,
|
|
559
623
|
ownership: params.ownership,
|
|
560
624
|
proc: params.proc,
|
|
561
625
|
observation: params.observation,
|
|
@@ -569,7 +633,10 @@ async function superviseProcess(params) {
|
|
|
569
633
|
...(params.terminationController
|
|
570
634
|
? { terminationController: params.terminationController }
|
|
571
635
|
: {}),
|
|
572
|
-
onStdout: (chunk) =>
|
|
636
|
+
onStdout: (chunk) => {
|
|
637
|
+
params.observation.recordStdout(chunk);
|
|
638
|
+
params.onStdoutObserved?.(chunk);
|
|
639
|
+
},
|
|
573
640
|
...(mcpSurface
|
|
574
641
|
? {
|
|
575
642
|
onStdoutTerminationCheck: (chunk) => {
|
|
@@ -580,8 +647,10 @@ async function superviseProcess(params) {
|
|
|
580
647
|
: {}),
|
|
581
648
|
});
|
|
582
649
|
isDoneRef.value = true;
|
|
650
|
+
heartbeatStop.abort();
|
|
583
651
|
const loopOutcome = await hb;
|
|
584
|
-
params.
|
|
652
|
+
if (params.recordProcessExit !== false)
|
|
653
|
+
params.observation.setExitCode(result.exitCode);
|
|
585
654
|
if (loopOutcome.kind === "auth_fatal") {
|
|
586
655
|
// BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
|
|
587
656
|
// identifiers — never carried onto `result` itself, so the process's own
|
|
@@ -717,7 +786,11 @@ control) {
|
|
|
717
786
|
// any /fail that has no envelope gets the truthful "not launched" envelope and
|
|
718
787
|
// keeps its own error kind; reports that already carry one are untouched.
|
|
719
788
|
if (claimIsCovered(job)) {
|
|
720
|
-
httpClient = withNotLaunchedEnvelopeFallback(httpClient, job
|
|
789
|
+
httpClient = withNotLaunchedEnvelopeFallback(httpClient, job, {
|
|
790
|
+
jobTimeRuntime: () => jobTimeCompletionRuntimes.get(ownership) ?? null,
|
|
791
|
+
preflightRuntime: _report?.completionEvidenceQualified === true ? (_report.completionRuntime ?? null) : null,
|
|
792
|
+
agentId: resolveExecutorAgentId(options.agentId),
|
|
793
|
+
});
|
|
721
794
|
}
|
|
722
795
|
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
723
796
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
@@ -738,6 +811,13 @@ control) {
|
|
|
738
811
|
if (job.job_type === "ci_rerun") {
|
|
739
812
|
return runCiRerunExecutorJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
740
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
|
+
}
|
|
741
821
|
// BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
|
|
742
822
|
// and before the generic spawn path. It needs a prepared, locked worktree like
|
|
743
823
|
// a spawn job, but its body is the reconcile module's state machine: the
|
|
@@ -775,6 +855,124 @@ control) {
|
|
|
775
855
|
});
|
|
776
856
|
return { status: "failed", reason: "unsupported_job_type" };
|
|
777
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
|
+
}
|
|
778
976
|
/**
|
|
779
977
|
* Run a deterministic `ci_rerun` job (BAPI-1160).
|
|
780
978
|
*
|
|
@@ -1193,6 +1391,8 @@ async function superviseDriverPhase(params) {
|
|
|
1193
1391
|
ownership: params.ownership,
|
|
1194
1392
|
observation: params.observation,
|
|
1195
1393
|
proc: flow.proc,
|
|
1394
|
+
cancelHeartbeatSleepOnExit: true,
|
|
1395
|
+
recordProcessExit: false,
|
|
1196
1396
|
timeoutSeconds: Math.max(1, params.timeoutSeconds),
|
|
1197
1397
|
collectTelemetry: params.collectTelemetry,
|
|
1198
1398
|
control: params.control,
|
|
@@ -1357,7 +1557,19 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
|
|
|
1357
1557
|
return { ok: false, error: failure.error_message, failure };
|
|
1358
1558
|
}
|
|
1359
1559
|
}
|
|
1360
|
-
|
|
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) {
|
|
1361
1573
|
// A continuation passes no local head here (`head === undefined`): its retained
|
|
1362
1574
|
// worktree was already proven by `prepareRetainedContinuationWorktree` →
|
|
1363
1575
|
// `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
|
|
@@ -1365,12 +1577,23 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1365
1577
|
// `correction_intent.head_sha`, including one the server derived for a first
|
|
1366
1578
|
// attempt with no bound ticket head (BAPI-1344). The spawn source is always
|
|
1367
1579
|
// that retained worktree, never an origin checkout.
|
|
1368
|
-
const refuseCorrectionDrift = async (branch, head) => {
|
|
1580
|
+
const refuseCorrectionDrift = async (branch, head, publication, worktreePath) => {
|
|
1369
1581
|
if (!isRemainingWork(job.job_type, job.payload))
|
|
1370
1582
|
return null;
|
|
1371
1583
|
const intent = job.payload?.correction_intent;
|
|
1372
1584
|
if (branch === intent.branch && (head === undefined || head === intent.head_sha))
|
|
1373
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
|
+
}
|
|
1374
1597
|
const reason = "correction_context_missing";
|
|
1375
1598
|
await httpClient.fail(job, {
|
|
1376
1599
|
error_kind: "ContractError.CorrectionLegacy",
|
|
@@ -1461,13 +1684,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1461
1684
|
}
|
|
1462
1685
|
const wt = await ensureJobWorktree(job, options, deps, seams, {
|
|
1463
1686
|
reuseExistingBranch: isRecoveryJobType(job.job_type),
|
|
1687
|
+
preparation,
|
|
1464
1688
|
staleBranchClassification: resolveStaleBranchClassification(job.payload),
|
|
1465
1689
|
});
|
|
1466
1690
|
if (!wt.ok) {
|
|
1467
1691
|
await httpClient.fail(job, jobWorktreeFailure(wt));
|
|
1468
1692
|
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
1469
1693
|
}
|
|
1470
|
-
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);
|
|
1471
1695
|
if (drift)
|
|
1472
1696
|
return drift;
|
|
1473
1697
|
return {
|
|
@@ -1476,6 +1700,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1476
1700
|
branch: wt.branch,
|
|
1477
1701
|
prompt: "",
|
|
1478
1702
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1703
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1479
1704
|
ciFix,
|
|
1480
1705
|
};
|
|
1481
1706
|
}
|
|
@@ -1540,17 +1765,22 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1540
1765
|
const reuseExistingBranch = isRecoveryJobType(job.job_type);
|
|
1541
1766
|
const wt = await ensureJobWorktree(job, options, deps, seams, {
|
|
1542
1767
|
reuseExistingBranch,
|
|
1768
|
+
preparation,
|
|
1543
1769
|
// BAPI-862: hand the guard the reconciler's own reading of why this branch
|
|
1544
1770
|
// may already carry commits. Read from the SERVER-minted payload field and
|
|
1545
1771
|
// validated against a closed set — never inferred locally, and never taken
|
|
1546
1772
|
// from worker output (R14 rules 3 and 4).
|
|
1547
1773
|
staleBranchClassification: resolveStaleBranchClassification(job.payload),
|
|
1774
|
+
// BAPI-1367: the server-minted (and client-normalized) prior head. Only an
|
|
1775
|
+
// `implement` retry acts on it; `ensureExecutorWorktree` re-checks that.
|
|
1776
|
+
// Spread only when present so an ordinary job keeps its exact legacy policy.
|
|
1777
|
+
...(job.prior_attempt_head_sha ? { priorAttemptHeadSha: job.prior_attempt_head_sha } : {}),
|
|
1548
1778
|
});
|
|
1549
1779
|
if (!wt.ok) {
|
|
1550
1780
|
await httpClient.fail(job, jobWorktreeFailure(wt));
|
|
1551
1781
|
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
1552
1782
|
}
|
|
1553
|
-
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);
|
|
1554
1784
|
if (drift)
|
|
1555
1785
|
return drift;
|
|
1556
1786
|
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
@@ -1563,6 +1793,8 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1563
1793
|
branch: wt.branch,
|
|
1564
1794
|
prompt: explicit.prompt,
|
|
1565
1795
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1796
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1797
|
+
...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
|
|
1566
1798
|
...(focusedRebase ? { focusedRebase } : {}),
|
|
1567
1799
|
};
|
|
1568
1800
|
}
|
|
@@ -1578,6 +1810,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1578
1810
|
workBranch: wt.branch,
|
|
1579
1811
|
baseBranch: options.baseBranch,
|
|
1580
1812
|
runCommand: deps.runCommand,
|
|
1813
|
+
deferComparisonGuidance: true,
|
|
1581
1814
|
});
|
|
1582
1815
|
return {
|
|
1583
1816
|
ok: true,
|
|
@@ -1585,6 +1818,8 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1585
1818
|
branch: wt.branch,
|
|
1586
1819
|
prompt: rendered,
|
|
1587
1820
|
expectedHeadSha: wt.expectedHeadSha,
|
|
1821
|
+
...(wt.publication ? { publication: wt.publication } : {}),
|
|
1822
|
+
...(wt.priorWork ? { priorWork: wt.priorWork } : {}),
|
|
1588
1823
|
};
|
|
1589
1824
|
}
|
|
1590
1825
|
catch (err) {
|
|
@@ -1599,6 +1834,93 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
1599
1834
|
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
1600
1835
|
}
|
|
1601
1836
|
}
|
|
1837
|
+
/** The first twelve characters of a SHA — safe, supporting evidence only. */
|
|
1838
|
+
function shortSha(sha) {
|
|
1839
|
+
return sha.slice(0, 12);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* BAPI-1367 — the continuation wording prepended to a retried `implement` prompt.
|
|
1843
|
+
*
|
|
1844
|
+
* States the work state first (this is prior-attempt work, not a fresh start),
|
|
1845
|
+
* says whether uncommitted files survived as a WIP checkpoint or were unavailable
|
|
1846
|
+
* because the worktree was recreated from the pushed branch, and leaves the
|
|
1847
|
+
* resolved prompt untouched after it. Short SHAs are the only identifiers used.
|
|
1848
|
+
*/
|
|
1849
|
+
export function buildPriorAttemptContinuationNotice(params) {
|
|
1850
|
+
const { branch, priorWork, checkpointed, ticketKey } = params;
|
|
1851
|
+
const recreated = priorWork.kind === "recreated_from_pushed_branch";
|
|
1852
|
+
const command = ticketKey ? `/implement-ticket ${ticketKey} --auto` : "/implement-ticket";
|
|
1853
|
+
const files = recreated
|
|
1854
|
+
? "Prior uncommitted files were unavailable on this host: the worktree was recreated from the pushed branch, so only committed work is present."
|
|
1855
|
+
: checkpointed
|
|
1856
|
+
? "Prior uncommitted files were saved as a WIP checkpoint commit so nothing was lost."
|
|
1857
|
+
: "The retained worktree was clean, so no WIP checkpoint was needed.";
|
|
1858
|
+
return (`CONTINUATION: this is a retry. An earlier attempt of this job left work on branch '${branch}' ` +
|
|
1859
|
+
`(its recorded head was ${shortSha(priorWork.priorHeadSha)}). You are continuing that prior-attempt ` +
|
|
1860
|
+
`work, not starting ${command} from base: read the branch's existing commits first, keep what is ` +
|
|
1861
|
+
`correct, and finish only what is missing. ${files} The original instructions follow unchanged.\n\n`);
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* BAPI-1367 — finish a prior-attempt resume UNDER THE WORKTREE LOCK.
|
|
1865
|
+
*
|
|
1866
|
+
* Order is the contract: prove the retained HEAD descends from the recorded prior
|
|
1867
|
+
* head, WIP-checkpoint dirty files (a checkpoint commits onto whatever HEAD is, so
|
|
1868
|
+
* lineage must be proven first), then finalize — which pins `expectedHeadSha` to
|
|
1869
|
+
* the POST-checkpoint head and provisions command assets. No destructive Git
|
|
1870
|
+
* command is ever issued. Diagnostics carry the job id, attempt number, and short
|
|
1871
|
+
* SHAs only — never the prompt or a full hash.
|
|
1872
|
+
*/
|
|
1873
|
+
async function resumePriorAttemptUnderLock(params) {
|
|
1874
|
+
const { job, prep, options, deps, worktreePath, branch } = params;
|
|
1875
|
+
const priorWork = prep.priorWork;
|
|
1876
|
+
const resumeSeams = buildResumeSeams(deps, options);
|
|
1877
|
+
try {
|
|
1878
|
+
await verifyContinuationHeadLineage(worktreePath, priorWork.priorHeadSha, resumeSeams);
|
|
1879
|
+
const checkpoint = await autoCheckpointDirtyState(worktreePath, resumeSeams, PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE);
|
|
1880
|
+
const finalized = await finalizeRetainedExecutorWorktree(worktreePath, branch, deps);
|
|
1881
|
+
if (!finalized.ok) {
|
|
1882
|
+
return {
|
|
1883
|
+
ok: false,
|
|
1884
|
+
failure: {
|
|
1885
|
+
error_kind: "WorktreeError",
|
|
1886
|
+
error_message: finalized.error,
|
|
1887
|
+
classification: "crashed",
|
|
1888
|
+
},
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
const notice = buildPriorAttemptContinuationNotice({
|
|
1892
|
+
branch,
|
|
1893
|
+
priorWork,
|
|
1894
|
+
checkpointed: checkpoint.committed,
|
|
1895
|
+
ticketKey: job.ticket_key,
|
|
1896
|
+
});
|
|
1897
|
+
deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} resuming prior-attempt work ` +
|
|
1898
|
+
`(prior head ${shortSha(priorWork.priorHeadSha)}, resumed at ${shortSha(finalized.expectedHeadSha)}` +
|
|
1899
|
+
`${checkpoint.committed ? ", dirty files checkpointed" : ""})`);
|
|
1900
|
+
if (priorWork.kind === "recreated_from_pushed_branch") {
|
|
1901
|
+
deps.errorLog(`[executor] job ${job.id}: attempt ${job.attempt_count} worktree recreated from the pushed ` +
|
|
1902
|
+
`branch; prior uncommitted files were unavailable (prior head ${shortSha(priorWork.priorHeadSha)})`);
|
|
1903
|
+
}
|
|
1904
|
+
return {
|
|
1905
|
+
ok: true,
|
|
1906
|
+
prep: {
|
|
1907
|
+
...prep,
|
|
1908
|
+
prompt: `${notice}${prep.prompt}`,
|
|
1909
|
+
expectedHeadSha: finalized.expectedHeadSha,
|
|
1910
|
+
},
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
catch (err) {
|
|
1914
|
+
const failure = isExecutorNamedError(err)
|
|
1915
|
+
? toExecutorFailure(err)
|
|
1916
|
+
: {
|
|
1917
|
+
error_kind: "WorktreeError",
|
|
1918
|
+
error_message: secretFreeErrorMessage(err),
|
|
1919
|
+
classification: "crashed",
|
|
1920
|
+
};
|
|
1921
|
+
return { ok: false, failure };
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1602
1924
|
/**
|
|
1603
1925
|
* BAPI-1296 — verify a prepared `ci_fix` checkout against its dispatched CI
|
|
1604
1926
|
* context and render the focused prompt, UNDER THE WORKTREE LOCK.
|
|
@@ -1632,7 +1954,15 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1632
1954
|
return { ok: false, error: ciFixContextError(job, "head_unreadable") };
|
|
1633
1955
|
}
|
|
1634
1956
|
if (head !== ciFix.headSha) {
|
|
1635
|
-
|
|
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") };
|
|
1636
1966
|
}
|
|
1637
1967
|
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
1638
1968
|
try {
|
|
@@ -1641,6 +1971,7 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1641
1971
|
workBranch: branch,
|
|
1642
1972
|
baseBranch,
|
|
1643
1973
|
runCommand: deps.runCommand,
|
|
1974
|
+
comparisonSha: ciFix.headSha,
|
|
1644
1975
|
});
|
|
1645
1976
|
return { ok: true, prompt };
|
|
1646
1977
|
}
|
|
@@ -1804,13 +2135,106 @@ async function superviseFocusedRebaseWait(params) {
|
|
|
1804
2135
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
1805
2136
|
* touches a worktree that will not become a runnable worker.
|
|
1806
2137
|
*/
|
|
1807
|
-
|
|
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,
|
|
1808
2232
|
/**
|
|
1809
2233
|
* BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
|
|
1810
2234
|
* the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
|
|
1811
2235
|
* adds no second worktree entry and no second worker launcher.
|
|
1812
2236
|
*/
|
|
1813
|
-
driver) {
|
|
2237
|
+
driver, retainedLock, preparation, reserve) {
|
|
1814
2238
|
// A server-owned read precedes every worktree reset/cut and worker launch.
|
|
1815
2239
|
// Only this boundary can authorize a no-charge pre-work wait; a later plan
|
|
1816
2240
|
// retrieval failure is ordinary bounded worker failure.
|
|
@@ -1931,11 +2355,94 @@ driver) {
|
|
|
1931
2355
|
});
|
|
1932
2356
|
return { status: "failed", reason: "permission_posture_contract" };
|
|
1933
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
|
+
}
|
|
1934
2394
|
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
1935
|
-
|
|
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);
|
|
1936
2421
|
if (!prep.ok)
|
|
1937
2422
|
return prep.result;
|
|
2423
|
+
deps = executionDeps;
|
|
2424
|
+
if (preparation)
|
|
2425
|
+
prep = { ...prep, deadlineMs: deps.now() + preparation.remainingMs() };
|
|
1938
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
|
+
}
|
|
1939
2446
|
// --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
|
|
1940
2447
|
// The FIRST thing that happens once a canonical worktree path exists, and
|
|
1941
2448
|
// ahead of every executor-owned write to it: the deny layer, MCP
|
|
@@ -1946,15 +2453,10 @@ driver) {
|
|
|
1946
2453
|
// are now created exclusively, and refusal still returns from HERE rather
|
|
1947
2454
|
// than anywhere downstream.
|
|
1948
2455
|
//
|
|
1949
|
-
//
|
|
1950
|
-
//
|
|
1951
|
-
// Its path comes back FROM Worktrunk, so there is no path to lock before it
|
|
1952
|
-
// without duplicating Worktrunk's layout rules here — a copy that would go
|
|
1953
|
-
// wrong silently. Dispatch exclusion and same-ticket claim serialization
|
|
1954
|
-
// (the Python half of this ticket) are what shrink that window; this lock is
|
|
1955
|
-
// 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.
|
|
1956
2458
|
const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
|
|
1957
|
-
const worktreeLock = await acquireLock({
|
|
2459
|
+
const worktreeLock = retainedLock ?? await acquireLock({
|
|
1958
2460
|
worktreePath,
|
|
1959
2461
|
jobId: job.id,
|
|
1960
2462
|
claimToken: job.claim_token,
|
|
@@ -1985,10 +2487,25 @@ driver) {
|
|
|
1985
2487
|
// a refused ci_fix leaves the worktree exactly as preparation left it. The
|
|
1986
2488
|
// verified, rendered prompt replaces preparation's empty placeholder.
|
|
1987
2489
|
let readyPrep = prep;
|
|
2490
|
+
// --- Prior-attempt resume (BAPI-1367) --------------------------------
|
|
2491
|
+
// Inside the lock, and BEFORE artifact cleanup and every executor-owned write:
|
|
2492
|
+
// prove the retained head's lineage, WIP-checkpoint any dirty files, and only
|
|
2493
|
+
// then pin the pre-spawn expectation to the POST-checkpoint head. Pinning
|
|
2494
|
+
// earlier (to base or to the lineage anchor) would make the BAPI-731
|
|
2495
|
+
// assertion refuse the very checkpoint this step just made.
|
|
2496
|
+
if (prep.priorWork) {
|
|
2497
|
+
const resumed = await resumePriorAttemptUnderLock({ job, prep, options, deps, worktreePath, branch });
|
|
2498
|
+
if (!resumed.ok) {
|
|
2499
|
+
await httpClient.fail(job, resumed.failure);
|
|
2500
|
+
return { status: "failed", reason: "prior_attempt_resume_failed" };
|
|
2501
|
+
}
|
|
2502
|
+
readyPrep = resumed.prep;
|
|
2503
|
+
}
|
|
1988
2504
|
if (prep.ciFix) {
|
|
1989
2505
|
const rendered = await renderVerifiedCiFixPrompt({
|
|
1990
2506
|
job,
|
|
1991
2507
|
ciFix: prep.ciFix,
|
|
2508
|
+
publication: prep.publication, preparation,
|
|
1992
2509
|
worktreePath,
|
|
1993
2510
|
branch,
|
|
1994
2511
|
baseBranch: effectiveBaseBranch,
|
|
@@ -2003,6 +2520,22 @@ driver) {
|
|
|
2003
2520
|
}
|
|
2004
2521
|
readyPrep = { ...prep, prompt: rendered.prompt };
|
|
2005
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
|
+
}
|
|
2006
2539
|
// --- Stale verdict artifacts (BAPI-814/D1) --------------------------
|
|
2007
2540
|
// Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
|
|
2008
2541
|
// so every job type routed through `runSpawnJob` is covered — `implement`,
|
|
@@ -2035,6 +2568,15 @@ driver) {
|
|
|
2035
2568
|
});
|
|
2036
2569
|
return { status: "failed", reason: "stale_artifact_cleanup" };
|
|
2037
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
|
+
}
|
|
2038
2580
|
return await runPreparedSpawn({
|
|
2039
2581
|
job,
|
|
2040
2582
|
httpClient,
|
|
@@ -2077,6 +2619,32 @@ driver) {
|
|
|
2077
2619
|
await worktreeLock.release();
|
|
2078
2620
|
}
|
|
2079
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
|
+
}
|
|
2080
2648
|
/**
|
|
2081
2649
|
* Run a spawn job whose worktree is already prepared AND exclusively locked.
|
|
2082
2650
|
*
|
|
@@ -2103,8 +2671,18 @@ async function runPreparedSpawn(params) {
|
|
|
2103
2671
|
// base contract's "when you open the pull request" instruction would re-add the
|
|
2104
2672
|
// new-PR step the focused prompt deliberately excludes.
|
|
2105
2673
|
let prompt = isImplementationStyleJobType(job.job_type) && !prep.ciFix && !prep.focusedRebase
|
|
2106
|
-
? `${prep.prompt} ${
|
|
2674
|
+
? `${prep.prompt} ${renderWorkerPrBaseGuidance()}`
|
|
2107
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
|
+
}
|
|
2108
2686
|
// --- Timeout contract ------------------------------------------------
|
|
2109
2687
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
2110
2688
|
if (!timeout.ok) {
|
|
@@ -2136,12 +2714,26 @@ async function runPreparedSpawn(params) {
|
|
|
2136
2714
|
// finish the whole job here (a clean, green reconciliation pushes with no
|
|
2137
2715
|
// worker at all), or hand back the ONE worker it needs and the pre-spawn state
|
|
2138
2716
|
// it has already proven. The job's overall timeout budget spans every phase.
|
|
2139
|
-
const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
|
|
2717
|
+
const jobDeadlineMs = prep.deadlineMs ?? deps.now() + timeout.timeoutSeconds * 1000;
|
|
2140
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
|
+
}
|
|
2141
2729
|
const driverBaseContext = {
|
|
2142
2730
|
worktreePath,
|
|
2143
2731
|
branch,
|
|
2144
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
|
+
}),
|
|
2145
2737
|
effectiveBaseBranch,
|
|
2146
2738
|
};
|
|
2147
2739
|
const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
@@ -2174,6 +2766,37 @@ async function runPreparedSpawn(params) {
|
|
|
2174
2766
|
// launch instruction would tell a worker with no push authority to open a PR.
|
|
2175
2767
|
prompt = driverStep.prompt;
|
|
2176
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
|
+
}
|
|
2177
2800
|
// --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
|
|
2178
2801
|
// FIRST of the pre-spawn contracts, and deliberately BEFORE every
|
|
2179
2802
|
// executor-owned write to the worktree (deny layer, MCP provisioning, worker
|
|
@@ -2363,6 +2986,7 @@ async function runPreparedSpawn(params) {
|
|
|
2363
2986
|
if (logClaimId !== null) {
|
|
2364
2987
|
try {
|
|
2365
2988
|
tee = await createTee(worktreePath, {
|
|
2989
|
+
now: deps.now,
|
|
2366
2990
|
mkdir: deps.mkdir,
|
|
2367
2991
|
writeFile: deps.writeFile,
|
|
2368
2992
|
appendFile: deps.appendFile,
|
|
@@ -2381,6 +3005,8 @@ async function runPreparedSpawn(params) {
|
|
|
2381
3005
|
// this attempt has no transcript, which is said once, without detail.
|
|
2382
3006
|
deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
|
|
2383
3007
|
}
|
|
3008
|
+
if (tee)
|
|
3009
|
+
observation.allocateAttemptLog(tee.identity, tee.available);
|
|
2384
3010
|
const teeClaimId = tee?.identity.claimId;
|
|
2385
3011
|
// BAPI-828 — idempotent worker-log closure, defined the moment a tee could
|
|
2386
3012
|
// exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
|
|
@@ -2398,6 +3024,22 @@ async function runPreparedSpawn(params) {
|
|
|
2398
3024
|
// THIS attempt's entry is removed (BAPI-1330).
|
|
2399
3025
|
if (teeClaimId !== undefined)
|
|
2400
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
|
+
}
|
|
2401
3043
|
await closeWorkerLogTee(tee);
|
|
2402
3044
|
};
|
|
2403
3045
|
lifecycle.closeWorkerLog = closeWorkerLog;
|
|
@@ -2440,6 +3082,8 @@ async function runPreparedSpawn(params) {
|
|
|
2440
3082
|
workerLogPath: tee.logPath,
|
|
2441
3083
|
worktreePath,
|
|
2442
3084
|
ticketKey: job.ticket_key ?? undefined,
|
|
3085
|
+
// BAPI-1397 (F2): the tab is titled `<TICKET> · <job type>`.
|
|
3086
|
+
jobType: job.job_type,
|
|
2443
3087
|
runCommand: deps.runCommand,
|
|
2444
3088
|
cwd: deps.cwd,
|
|
2445
3089
|
});
|
|
@@ -2681,6 +3325,9 @@ async function runPreparedSpawn(params) {
|
|
|
2681
3325
|
})
|
|
2682
3326
|
: null;
|
|
2683
3327
|
const runtime = probed ?? unrecognizedRuntime(adapter);
|
|
3328
|
+
// Carry the probe identity into any later not-launched report for this job.
|
|
3329
|
+
if (probed !== null)
|
|
3330
|
+
jobTimeCompletionRuntimes.set(ownership, probed);
|
|
2684
3331
|
const refuse = async (reason, message) => {
|
|
2685
3332
|
await closeWorkerLog();
|
|
2686
3333
|
await finalizeRegistry();
|
|
@@ -2725,10 +3372,24 @@ async function runPreparedSpawn(params) {
|
|
|
2725
3372
|
plan,
|
|
2726
3373
|
runtime,
|
|
2727
3374
|
nudge: (ids) => capability.value.buildWatcherNudge(ids),
|
|
3375
|
+
...(capability.value.buildCompletionWindowNudge
|
|
3376
|
+
? { windowNudge: (ids) => capability.value.buildCompletionWindowNudge(ids) }
|
|
3377
|
+
: {}),
|
|
2728
3378
|
dispatchHeadSha,
|
|
2729
3379
|
stripEnvFiles: stripWorktreeEnvFiles,
|
|
2730
3380
|
});
|
|
2731
3381
|
}
|
|
3382
|
+
// BAPI-1364: an observe-only ledger for a legacy run of a covered job type. It
|
|
3383
|
+
// never enters `runCoveredAttempt()` and never enables the completion contract;
|
|
3384
|
+
// it only lets a clean exit be checked for required background work still live.
|
|
3385
|
+
const legacyLedger = isCoveredJobType(job.job_type)
|
|
3386
|
+
? createRequiredWorkLedger({
|
|
3387
|
+
binding: readCompletionBinding(job, null).binding,
|
|
3388
|
+
lifecycleSupported: true,
|
|
3389
|
+
optionalWatchers: readOptionalWatchers(job.payload),
|
|
3390
|
+
})
|
|
3391
|
+
: null;
|
|
3392
|
+
const legacyLineFeeder = legacyLedger ? createLedgerLineFeeder(legacyLedger) : null;
|
|
2732
3393
|
let proc;
|
|
2733
3394
|
try {
|
|
2734
3395
|
proc = spawnWorker({ env: spawnShape.env });
|
|
@@ -2789,16 +3450,19 @@ async function runPreparedSpawn(params) {
|
|
|
2789
3450
|
proc: supervisedProc,
|
|
2790
3451
|
// BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
|
|
2791
3452
|
// pre-worker phase has already spent some of it.
|
|
2792
|
-
timeoutSeconds:
|
|
3453
|
+
timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
|
|
2793
3454
|
collectTelemetry,
|
|
2794
3455
|
collectRemoteMarker,
|
|
2795
3456
|
mcpSurface,
|
|
3457
|
+
...(legacyLineFeeder ? { onStdoutObserved: legacyLineFeeder.feed } : {}),
|
|
2796
3458
|
// The SAME controller the registry holds, so an executor shutdown, a timeout,
|
|
2797
3459
|
// and an MCP containment refusal all converge on one `SIGTERM` and one grace
|
|
2798
3460
|
// timer for this child (BAPI-828).
|
|
2799
3461
|
terminationController,
|
|
2800
3462
|
control,
|
|
2801
3463
|
});
|
|
3464
|
+
// Flush a trailing partial stdout line so the observe-only ledger sees it.
|
|
3465
|
+
legacyLineFeeder?.flush();
|
|
2802
3466
|
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
2803
3467
|
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
2804
3468
|
// nothing runs here — which is correct, because BAPI-790 deleted the per-job
|
|
@@ -2895,7 +3559,7 @@ async function runPreparedSpawn(params) {
|
|
|
2895
3559
|
await httpClient.fail(job, {
|
|
2896
3560
|
error_kind: McpSurfaceMismatch,
|
|
2897
3561
|
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
2898
|
-
classification:
|
|
3562
|
+
classification: toWireClassification(procResult.classification),
|
|
2899
3563
|
...commitResidue(finalTelemetry),
|
|
2900
3564
|
telemetry: observation.snapshot(),
|
|
2901
3565
|
});
|
|
@@ -3028,6 +3692,32 @@ async function runPreparedSpawn(params) {
|
|
|
3028
3692
|
await finalizeRegistry();
|
|
3029
3693
|
return terminalToRunResult(terminal, "completed");
|
|
3030
3694
|
}
|
|
3695
|
+
// BAPI-1364: a legacy worker that ended its (headless) session while the
|
|
3696
|
+
// structured stream still showed a required background command with no
|
|
3697
|
+
// terminal notification can never receive that command's result. Report it
|
|
3698
|
+
// before generic success, the ci_fix artifact merge, or finalization can hide
|
|
3699
|
+
// it. Only the fixed kind and message are sent: no event text, command, path,
|
|
3700
|
+
// or output. A matching terminal task_notification leaves nothing pending.
|
|
3701
|
+
if (legacyLedger?.observeLiveRequiredWork() === "live_required_work") {
|
|
3702
|
+
deps.errorLog(`[executor] job ${job.id}: worker exited cleanly with required background work still live`);
|
|
3703
|
+
await finalizeRegistry();
|
|
3704
|
+
const terminal = await sendTerminalMutationWithAuthReport({
|
|
3705
|
+
kind: "fail",
|
|
3706
|
+
send: () => httpClient.fail(job, {
|
|
3707
|
+
error_kind: WorkerEndedWithPendingBackgroundWork,
|
|
3708
|
+
error_message: WORKER_ENDED_WITH_PENDING_BACKGROUND_WORK_MESSAGE,
|
|
3709
|
+
classification: "crashed",
|
|
3710
|
+
...observedExitCode(procResult.exitCode),
|
|
3711
|
+
...commitResidue(git),
|
|
3712
|
+
telemetry: observation.snapshot(),
|
|
3713
|
+
}),
|
|
3714
|
+
deps,
|
|
3715
|
+
options,
|
|
3716
|
+
ownership,
|
|
3717
|
+
log: deps.log,
|
|
3718
|
+
}, job, control);
|
|
3719
|
+
return terminalToRunResult(terminal, "failed");
|
|
3720
|
+
}
|
|
3031
3721
|
// Non-verdict spawn jobs (`implement`, `resume`, and the recovery jobs
|
|
3032
3722
|
// `remediate`/`ci_fix`/`rebase`) use the generic success-result construction;
|
|
3033
3723
|
// the verdict-artifact path above stays limited to verdict jobs like
|
|
@@ -3076,8 +3766,10 @@ async function runPreparedSpawn(params) {
|
|
|
3076
3766
|
branch,
|
|
3077
3767
|
worktreePath,
|
|
3078
3768
|
result: genericResult,
|
|
3769
|
+
publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, remainingMs, httpClient, options: jobOptions, control }),
|
|
3079
3770
|
runCommand: deps.runCommand,
|
|
3080
3771
|
headSha: git.last_commit_sha,
|
|
3772
|
+
callTimeoutMs: () => Math.max(1, Math.min(FOCUSED_FINALIZATION_READ_MAX_MS, remainingMs())),
|
|
3081
3773
|
expectedBaseBranch: effectiveBaseBranch,
|
|
3082
3774
|
// BAPI-1311: a focused rebase's finalization reads are bounded by the
|
|
3083
3775
|
// claim's remaining absolute deadline; every other job keeps the legacy,
|
|
@@ -3181,7 +3873,8 @@ async function runPreparedSpawn(params) {
|
|
|
3181
3873
|
// BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
|
|
3182
3874
|
// `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
|
|
3183
3875
|
// the next job's reset fail. Removed for EVERY spawn job, never with a hard
|
|
3184
|
-
// reset;
|
|
3876
|
+
// reset; reconcile_overlap additionally discards unpublished wrapper merge
|
|
3877
|
+
// state, while preserving successful or ambiguous publication.
|
|
3185
3878
|
if (procResult.classification === "timeout" || procResult.classification === "killed") {
|
|
3186
3879
|
await cleanupInterruptedWorktree(worktreePath, {
|
|
3187
3880
|
runCommand: deps.runCommand,
|
|
@@ -3233,6 +3926,15 @@ const DESCENDANT_SAMPLE_INTERVAL_MS = 2_000;
|
|
|
3233
3926
|
const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
|
|
3234
3927
|
/** Bound on closing the held stdin once the closure rule fires. */
|
|
3235
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;
|
|
3236
3938
|
const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
|
|
3237
3939
|
/**
|
|
3238
3940
|
* BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
|
|
@@ -3255,22 +3957,25 @@ function observedExitCode(exitCode) {
|
|
|
3255
3957
|
function claimIsCovered(job) {
|
|
3256
3958
|
return claimRequiresCompletionEvidence(job.required_capabilities);
|
|
3257
3959
|
}
|
|
3258
|
-
/**
|
|
3259
|
-
|
|
3260
|
-
* "not launched" envelope (BAPI-1301). Every other call passes straight through.
|
|
3261
|
-
*/
|
|
3262
|
-
function withNotLaunchedEnvelopeFallback(httpClient, job) {
|
|
3960
|
+
/** Exported for the covered-job runtime tests; not part of the executor's public surface. */
|
|
3961
|
+
export function withNotLaunchedEnvelopeFallback(httpClient, job, runtimeSources) {
|
|
3263
3962
|
const wrapped = Object.create(httpClient);
|
|
3264
3963
|
wrapped.fail = (claimed, failure) => {
|
|
3265
3964
|
if (failure.completion_evidence !== undefined)
|
|
3266
3965
|
return httpClient.fail(claimed, failure);
|
|
3267
3966
|
const { binding } = readCompletionBinding(job, typeof job.expected_head_sha === "string" ? job.expected_head_sha : null);
|
|
3268
3967
|
const unlaunched = unlaunchedVerification(readRequiredVerification(job.payload));
|
|
3968
|
+
// The runtime actually probed, never the measured baseline: the job-time probe
|
|
3969
|
+
// when it ran, then the qualified preflight runtime, then an unrecognized one.
|
|
3970
|
+
// A runtime that does not satisfy the predicate makes the report a truthful
|
|
3971
|
+
// refusal, so the server accepts it and still never reads it as success.
|
|
3972
|
+
const runtime = runtimeSources.jobTimeRuntime() ?? runtimeSources.preflightRuntime ?? unrecognizedRuntimeForAgent(runtimeSources.agentId);
|
|
3269
3973
|
const envelope = buildNotLaunchedEnvelope({
|
|
3270
3974
|
binding,
|
|
3271
|
-
runtime: { ...
|
|
3975
|
+
runtime: { ...runtime },
|
|
3272
3976
|
verification: unlaunched.verification,
|
|
3273
3977
|
residuals: CLEANUP_RESIDUALS_NOT_COVERED,
|
|
3978
|
+
unsupportedRuntime: !runtimeMatchesSupported(runtime),
|
|
3274
3979
|
...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
|
|
3275
3980
|
});
|
|
3276
3981
|
return httpClient.fail(claimed, {
|
|
@@ -3281,6 +3986,12 @@ function withNotLaunchedEnvelopeFallback(httpClient, job) {
|
|
|
3281
3986
|
};
|
|
3282
3987
|
return wrapped;
|
|
3283
3988
|
}
|
|
3989
|
+
/**
|
|
3990
|
+
* The covered-job runtime probe's identity per job, recorded before launch so a
|
|
3991
|
+
* later not-launched report stamps what was actually probed (BAPI-1386). Keyed by
|
|
3992
|
+
* the job's ownership state, which both the wrapper and the spawn path share.
|
|
3993
|
+
*/
|
|
3994
|
+
const jobTimeCompletionRuntimes = new WeakMap();
|
|
3284
3995
|
/**
|
|
3285
3996
|
* The verification a covered report carries when no declared check ran
|
|
3286
3997
|
* (BAPI-1302): before launch, after a worker failure, or on a stop.
|
|
@@ -3327,8 +4038,11 @@ function declaredOrderFacts(checks, runFacts) {
|
|
|
3327
4038
|
}
|
|
3328
4039
|
/** An identity that cannot match the supported runtime, for truthful refusal envelopes. */
|
|
3329
4040
|
function unrecognizedRuntime(adapter) {
|
|
4041
|
+
return unrecognizedRuntimeForAgent(adapter.identity.agentId);
|
|
4042
|
+
}
|
|
4043
|
+
function unrecognizedRuntimeForAgent(agentId) {
|
|
3330
4044
|
return {
|
|
3331
|
-
adapter:
|
|
4045
|
+
adapter: agentId,
|
|
3332
4046
|
cli_version: "unrecognized",
|
|
3333
4047
|
platform: "unrecognized",
|
|
3334
4048
|
event_schema: "unrecognized",
|
|
@@ -3490,7 +4204,14 @@ async function runCoveredAttempt(params) {
|
|
|
3490
4204
|
// --- Ownership lifetime: one heartbeat for the whole attempt ---------------
|
|
3491
4205
|
let childLive = true;
|
|
3492
4206
|
let workerStartedAt = null;
|
|
3493
|
-
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;
|
|
3494
4215
|
if (proc.pid !== undefined) {
|
|
3495
4216
|
void inspector
|
|
3496
4217
|
.identity(proc.pid)
|
|
@@ -3555,9 +4276,20 @@ async function runCoveredAttempt(params) {
|
|
|
3555
4276
|
void tracker.sample().finally(scheduleSample);
|
|
3556
4277
|
// --- Held-stdin closure supervision -----------------------------------------
|
|
3557
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;
|
|
3558
4282
|
const supervisor = createClosureSupervisor({
|
|
3559
4283
|
ledger,
|
|
3560
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
|
+
},
|
|
3561
4293
|
writeMessage: (line) => writeStdinBounded(proc, line, { deadlineAtMs: plan.holdDeadlineAtMs, deps: stdinDeps }),
|
|
3562
4294
|
closeStdin: async () => {
|
|
3563
4295
|
await closeStdinBounded(proc, {
|
|
@@ -3568,33 +4300,9 @@ async function runCoveredAttempt(params) {
|
|
|
3568
4300
|
buildNudge: params.nudge,
|
|
3569
4301
|
deadlineAtMs: plan.holdDeadlineAtMs,
|
|
3570
4302
|
});
|
|
3571
|
-
// The authoritative consumer: whole lines into the ledger, bounded.
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
let lineBuffer = "";
|
|
3575
|
-
let discardingOversize = false;
|
|
3576
|
-
const authoritativeStdout = (chunk) => {
|
|
3577
|
-
lineBuffer += chunk;
|
|
3578
|
-
let newline;
|
|
3579
|
-
while ((newline = lineBuffer.indexOf("\n")) >= 0) {
|
|
3580
|
-
const line = lineBuffer.slice(0, newline);
|
|
3581
|
-
lineBuffer = lineBuffer.slice(newline + 1);
|
|
3582
|
-
if (discardingOversize) {
|
|
3583
|
-
discardingOversize = false;
|
|
3584
|
-
continue;
|
|
3585
|
-
}
|
|
3586
|
-
ledger.ingestLine(line);
|
|
3587
|
-
supervisor.onActivity();
|
|
3588
|
-
}
|
|
3589
|
-
if (!discardingOversize && lineBuffer.length > MAX_EVENT_BYTES) {
|
|
3590
|
-
ledger.ingestLine(lineBuffer);
|
|
3591
|
-
lineBuffer = "";
|
|
3592
|
-
discardingOversize = true;
|
|
3593
|
-
}
|
|
3594
|
-
else if (discardingOversize) {
|
|
3595
|
-
lineBuffer = "";
|
|
3596
|
-
}
|
|
3597
|
-
};
|
|
4303
|
+
// The authoritative consumer: whole lines into the ledger, bounded.
|
|
4304
|
+
const lineFeeder = createLedgerLineFeeder(ledger, () => supervisor.onActivity());
|
|
4305
|
+
const authoritativeStdout = lineFeeder.feed;
|
|
3598
4306
|
const runPromise = runProcessWithTimeout(supervisedProc, Math.max(1, Math.ceil(plan.workerWindowMs / 1000)), deps, {
|
|
3599
4307
|
termGraceMs: jobOptions.termGraceMs,
|
|
3600
4308
|
terminationController,
|
|
@@ -3619,15 +4327,27 @@ async function runCoveredAttempt(params) {
|
|
|
3619
4327
|
// Transport failed before any work: nothing the worker did can be
|
|
3620
4328
|
// evidence. Stop it through the one termination path.
|
|
3621
4329
|
ledger.recordTransportFailure(true);
|
|
3622
|
-
terminationController.requestTermination();
|
|
4330
|
+
terminationController.requestTermination("executor_abort");
|
|
3623
4331
|
}
|
|
3624
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}`);
|
|
3625
4343
|
childLive = false;
|
|
3626
4344
|
if (sampleTimer !== undefined)
|
|
3627
4345
|
deps.clearTimer(sampleTimer);
|
|
4346
|
+
if (postWindowExitTimer !== undefined)
|
|
4347
|
+
deps.clearTimer(postWindowExitTimer);
|
|
4348
|
+
postWindowExitTimer = undefined;
|
|
3628
4349
|
supervisor.dispose();
|
|
3629
|
-
|
|
3630
|
-
ledger.ingestLine(lineBuffer);
|
|
4350
|
+
lineFeeder.flush();
|
|
3631
4351
|
ledger.recordStreamEnd(procResult.authoritativeStreamError || procResult.exitUnconfirmed
|
|
3632
4352
|
? "stream_error"
|
|
3633
4353
|
: (procResult.stdoutEnd ?? "eof"));
|
|
@@ -3671,18 +4391,21 @@ async function runCoveredAttempt(params) {
|
|
|
3671
4391
|
const verificationRead = readRequiredVerification(job.payload);
|
|
3672
4392
|
const declaration = verificationRead.kind === "ok" ? verificationRead.declaration : null;
|
|
3673
4393
|
const checks = declaration?.checks ?? [];
|
|
3674
|
-
const envelopeWith = (facts) =>
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
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,
|
|
3686
4409
|
});
|
|
3687
4410
|
const unverifiedEnvelope = () => {
|
|
3688
4411
|
const unlaunched = unlaunchedVerification(verificationRead);
|
|
@@ -3763,7 +4486,7 @@ async function runCoveredAttempt(params) {
|
|
|
3763
4486
|
error_kind: "ServerStopRequested",
|
|
3764
4487
|
error_message: "the server requested this run stop; the owned worker was terminated",
|
|
3765
4488
|
classification: "killed",
|
|
3766
|
-
}, unverifiedEnvelope());
|
|
4489
|
+
}, { ...unverifiedEnvelope(), end_cause: "server_stop" });
|
|
3767
4490
|
ownership.abandoned = true;
|
|
3768
4491
|
ownership.abandonReason = "server_stop";
|
|
3769
4492
|
return finish(result.status === "failed" ? { status: "abandoned", reason: "server_stop" } : result);
|
|
@@ -3778,7 +4501,7 @@ async function runCoveredAttempt(params) {
|
|
|
3778
4501
|
return finish(await failWith({
|
|
3779
4502
|
error_kind: McpSurfaceMismatch,
|
|
3780
4503
|
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
3781
|
-
classification:
|
|
4504
|
+
classification: toWireClassification(procResult.classification),
|
|
3782
4505
|
}, unverifiedEnvelope()));
|
|
3783
4506
|
}
|
|
3784
4507
|
if (surfaceVerdict.kind === "absent" &&
|
|
@@ -3939,6 +4662,9 @@ async function runCoveredAttempt(params) {
|
|
|
3939
4662
|
branch,
|
|
3940
4663
|
worktreePath,
|
|
3941
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())),
|
|
3942
4668
|
runCommand: deps.runCommand,
|
|
3943
4669
|
headSha: git.last_commit_sha,
|
|
3944
4670
|
expectedBaseBranch: effectiveBaseBranch,
|