@bridge_gpt/mcp-server 0.2.69 → 0.2.70
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 +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { findWorktreeByBranch, parseGitWorktreePorcelain } from "./worktree-inspection.js";
|
|
6
6
|
import { resolveExecutorBranch, configureExecutorBranch } from "./worktree.js";
|
|
7
|
-
import { renderWorkerVerificationGuidance, renderWorkerPrBaseGuidance } from "./worker-verification-guidance.js";
|
|
7
|
+
import { renderWorkerVerificationGuidance, renderWorkerPrBaseGuidance, renderWorkerTimeBudgetGuidance } from "./worker-verification-guidance.js";
|
|
8
8
|
/**
|
|
9
9
|
* Claimed-job execution engine (BAPI-534 core, BAPI-535 job behaviors; TDD §7/§8/§9).
|
|
10
10
|
*
|
|
@@ -21,11 +21,13 @@ import { renderWorkerVerificationGuidance, renderWorkerPrBaseGuidance } from "./
|
|
|
21
21
|
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
22
22
|
* read-only viewer tabs wired around every spawned worker.
|
|
23
23
|
*/
|
|
24
|
-
import { rm, readdir, lstat, unlink, realpath } from "node:fs/promises";
|
|
24
|
+
import { rm, readdir, lstat, unlink, realpath, rename as fsRename, stat as fsStat } from "node:fs/promises";
|
|
25
|
+
import { provisionWorktreeSettingsExclude } from "./permissions.js";
|
|
25
26
|
import os from "node:os";
|
|
26
|
-
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
27
|
+
import { runHeartbeatLoop, sendImmediateHeartbeat } from "./heartbeat.js";
|
|
28
|
+
import { cleanupReportPermitsRecordRemoval, retryWakeCleanupReport, submitCleanupReport, } from "./cleanup-report.js";
|
|
29
|
+
import { isExecutorNamedError, toExecutorFailure, RebaseNotVerified, secretFreeErrorMessage, secretFreeWorktreeMessage, WorktreeSetupRefused, 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";
|
|
30
|
+
import { buildConductorMergeAccessForExecutorJob, buildMergeLocalDepsForJob, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, MERGE_ACCESS_UNAVAILABLE, MERGE_FLOW_ERROR, MERGE_FLOW_TIMEOUT, MERGE_NO_OUTCOME, } from "./merge-job.js";
|
|
29
31
|
import { createProductionCiRerunDeps, runCiRerunJob, } from "./ci-rerun-job.js";
|
|
30
32
|
import { createProductionGateQualificationDeps, runGateQualificationJob, GATE_QUALIFICATION_FAILED, GATE_QUALIFICATION_INTERRUPTED, } from "./gate-qualification-job.js";
|
|
31
33
|
import { createObservationState } from "./observation.js";
|
|
@@ -34,8 +36,8 @@ import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter
|
|
|
34
36
|
import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
|
|
35
37
|
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
36
38
|
import { closeStdinBounded, createProcessTerminationController, runProcessWithTimeout, writeStdinBounded, } from "./process.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";
|
|
39
|
+
import { buildNotLaunchedEnvelope, claimRequiresCleanupReport, claimRequiresCompletionEvidence, COMPLETION_CONTRACT_MARKER_VALUES, isCoveredJobType, MAX_EVENT_BYTES, runtimeMatchesSupported, successRefusalReason, verificationVerdictFromFacts, } from "./completion-contract.js";
|
|
40
|
+
import { COMPLETION_CAUSE_ERROR_KINDS, EOF_DRAIN_BOUND_MS, STDIN_CLOSE_GRACE_MS, finalizationFacts, finalizationObligations, planCompletionAllowance, readCompletionBinding, readDeclaredChecks, readOptionalWatchers, readRequiredVerification, renderApprovedVerificationGuidance, } from "./completion-supervision.js";
|
|
39
41
|
import { compareFinalArtifact, createRequiredVerificationRunner, createSupervisedCommandLauncher, observeFinalArtifact, OWNERSHIP_POLL_MS, preCheckArtifactState, toCompletionCheckFact, } from "./required-verification.js";
|
|
40
42
|
import { createClosureSupervisor, createRequiredWorkLedger } from "./required-work-ledger.js";
|
|
41
43
|
import { buildCleanupVerdict, CLEANUP_RESIDUALS_NOT_COVERED, createDefaultOwnershipRecordFs, createDescendantTracker, createMacProcessInspector, createOwnershipToken, hostIdentityDigest, ownershipTokenDigest, removeOwnershipRecord, runOwnedProcessSweep, withOwnershipTag, writeOwnershipRecord, } from "./process-containment.js";
|
|
@@ -51,6 +53,8 @@ import { buildGenericSuccessResult, CI_FIX_CLAIMS_STATUS_KEY, readCiFixResultArt
|
|
|
51
53
|
import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
|
|
52
54
|
import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
53
55
|
import { stripWorkerEnvFiles } from "./env-file-guard.js";
|
|
56
|
+
import { formatWorkerDatabaseDropFailedLine, formatWorkerDatabaseUnavailableLine, } from "./worker-database.js";
|
|
57
|
+
import { formatWorkerCliLauncherUnavailableLine } from "./worker-cli-launcher.js";
|
|
54
58
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
55
59
|
import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
56
60
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
@@ -61,12 +65,14 @@ import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
|
|
|
61
65
|
import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
|
|
62
66
|
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
63
67
|
import { resolveExecutorJobIndexScope } from "./index-scope.js";
|
|
64
|
-
import { readFocusedPullRequest, validateWorkerFinalization } from "./worker-finalization.js";
|
|
68
|
+
import { deliveryHoldOf, readFocusedPullRequest, resolveOriginBranchSha, validateWorkerFinalization, } from "./worker-finalization.js";
|
|
69
|
+
import { DELIVERY_OUTCOME_READ_TIMEOUT_MS, fetchServerDeliveryOutcome, } from "./delivery-outcome.js";
|
|
65
70
|
import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseContext, validateFocusedRebaseContext, waitForFocusedRebase, } from "./rebase-finalization.js";
|
|
66
71
|
import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
|
|
67
|
-
import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
72
|
+
import { CI_FIX_JOB_TYPE, renderCiFixOperatorNote, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
68
73
|
import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
|
|
69
74
|
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
|
|
75
|
+
import { failureReasonCode, isFailureReasonCode } from "./failure-reason-codes.js";
|
|
70
76
|
import { readCustomizedCommands, TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
|
|
71
77
|
import { resolvePackagedRecipes } from "./packaged-recipe.js";
|
|
72
78
|
import { probeInstalledCorrectionHook } from "./preflight.js";
|
|
@@ -90,6 +96,71 @@ const ENV_FILE_TRACKED_LOOKUP_TIMEOUT_MS = 5_000;
|
|
|
90
96
|
const ENV_FILE_TRACKED_LOOKUP_FAILED_MESSAGE = "[executor] tracked env-file lookup unavailable; exempting nothing";
|
|
91
97
|
/** Default runtime for the no-op smoke process (ms). */
|
|
92
98
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
99
|
+
/**
|
|
100
|
+
* BAPI-1426: implementation-style job types that never read a delivery outcome.
|
|
101
|
+
* `reconcile_overlap`'s push is the wrapper's own, not a ticket delivery attempt.
|
|
102
|
+
* BAPI-1516: `ci_fix` is excluded too. A repair is judged by its own completion
|
|
103
|
+
* contract (a provider-observed push plus dispatched-check claims), not by ticket
|
|
104
|
+
* delivery readiness. An older server still guards `ci_fix`; the executor then
|
|
105
|
+
* reports success and that server's refusal parks the ticket as before, which the
|
|
106
|
+
* reconciler's green-head lane recovers once the PR head is green.
|
|
107
|
+
*/
|
|
108
|
+
const DELIVERY_OUTCOME_EXCLUDED_JOB_TYPES = new Set([
|
|
109
|
+
"reconcile_overlap",
|
|
110
|
+
"ci_fix",
|
|
111
|
+
]);
|
|
112
|
+
/** BAPI-1426: the outcome used when the read could not run or failed. */
|
|
113
|
+
const DELIVERY_OUTCOME_UNAVAILABLE = { bound: "unknown", unavailable: true };
|
|
114
|
+
/**
|
|
115
|
+
* BAPI-1426: read the server-confirmed delivery outcome for a finalizing
|
|
116
|
+
* implementation-style job. Returns undefined (no read at all) for a job with no
|
|
117
|
+
* ticket or branch, and for the excluded job types above. Bounded by
|
|
118
|
+
* `remainingMs`; never throws.
|
|
119
|
+
*/
|
|
120
|
+
async function readDeliveryOutcomeForFinalization(params) {
|
|
121
|
+
const { job, branch, deps, seams } = params;
|
|
122
|
+
const env = params.resolvedBaseUrl !== undefined && params.resolvedBaseUrl.trim() !== ""
|
|
123
|
+
? { ...deps.env, BAPI_BASE_URL: params.resolvedBaseUrl }
|
|
124
|
+
: deps.env;
|
|
125
|
+
if (!isImplementationStyleJobType(job.job_type) || DELIVERY_OUTCOME_EXCLUDED_JOB_TYPES.has(job.job_type)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
|
|
129
|
+
if (!trimmedBranch || !job.ticket_key)
|
|
130
|
+
return undefined;
|
|
131
|
+
const budgetMs = Math.min(DELIVERY_OUTCOME_READ_TIMEOUT_MS, params.remainingMs());
|
|
132
|
+
if (budgetMs <= 0)
|
|
133
|
+
return DELIVERY_OUTCOME_UNAVAILABLE;
|
|
134
|
+
try {
|
|
135
|
+
if (seams.fetchDeliveryOutcome)
|
|
136
|
+
return await seams.fetchDeliveryOutcome(job, trimmedBranch);
|
|
137
|
+
return await fetchServerDeliveryOutcome(job, trimmedBranch, {
|
|
138
|
+
fetch: deps.fetch,
|
|
139
|
+
accessDeps: {
|
|
140
|
+
env,
|
|
141
|
+
cwd: deps.cwd,
|
|
142
|
+
homedir: deps.homedir,
|
|
143
|
+
platform: deps.platform,
|
|
144
|
+
readFile: deps.readFile,
|
|
145
|
+
stat: deps.stat,
|
|
146
|
+
},
|
|
147
|
+
timeoutMs: budgetMs,
|
|
148
|
+
setTimer: deps.setTimer,
|
|
149
|
+
clearTimer: deps.clearTimer,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return DELIVERY_OUTCOME_UNAVAILABLE;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* BAPI-1426: a finalization failure may carry its own telemetry (the delivery
|
|
158
|
+
* hold marker). It is merged over the observation snapshot instead of being
|
|
159
|
+
* replaced by it; a failure without telemetry keeps the snapshot unchanged.
|
|
160
|
+
*/
|
|
161
|
+
function withFailureTelemetry(snapshot, failureTelemetry) {
|
|
162
|
+
return failureTelemetry ? { ...snapshot, ...failureTelemetry } : snapshot;
|
|
163
|
+
}
|
|
93
164
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
94
165
|
function toWireClassification(c) {
|
|
95
166
|
return c;
|
|
@@ -507,6 +578,7 @@ export async function defaultRunMergeForClaimed(job, deps, options, controls) {
|
|
|
507
578
|
error_kind: MERGE_RETRYABLE,
|
|
508
579
|
error_message: accessResult.error,
|
|
509
580
|
classification: "crashed",
|
|
581
|
+
telemetry: { failure_reason_code: MERGE_ACCESS_UNAVAILABLE },
|
|
510
582
|
},
|
|
511
583
|
};
|
|
512
584
|
}
|
|
@@ -514,12 +586,19 @@ export async function defaultRunMergeForClaimed(job, deps, options, controls) {
|
|
|
514
586
|
access: accessResult.access,
|
|
515
587
|
// Thread the overall-timeout abort signal into local merge deps WITHOUT
|
|
516
588
|
// changing the local `gh` credential model (the signal is not a credential).
|
|
517
|
-
|
|
589
|
+
// BAPI-1453: the same signal bounds the post-`gh_pr_view_failed` permission
|
|
590
|
+
// probe, so it can never outlive the merge flow's own timeout.
|
|
591
|
+
// BAPI-1461: resolved when the job runs, with the stored-entry fallback for
|
|
592
|
+
// an executor started in the person's own session. A durable-host service
|
|
593
|
+
// executor declared itself with --scrub-inherited-github-credentials and
|
|
594
|
+
// never reads a stored GitHub credential.
|
|
595
|
+
localMergeDeps: await buildMergeLocalDepsForJob(job, deps, { allowStoredEntry: options.scrubInheritedGithubCredentials !== true }, controls?.signal),
|
|
518
596
|
// BAPI-951: the executor's own configured base — resolveExecutorJobBaseBranch
|
|
519
597
|
// (called inside runExecutorMergeJob) resolves the CHILD PR's base from the
|
|
520
598
|
// job's persisted run base, falling back to this value for a legacy job.
|
|
521
599
|
repositoryBaseBranch: options.baseBranch,
|
|
522
600
|
epicIntegrationAdvisoryLog: deps.errorLog,
|
|
601
|
+
diagnosticLog: deps.errorLog,
|
|
523
602
|
cwd: deps.cwd,
|
|
524
603
|
});
|
|
525
604
|
}
|
|
@@ -1205,7 +1284,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1205
1284
|
error_kind: MERGE_RETRYABLE,
|
|
1206
1285
|
error_message: `merge flow timed out after ${timeoutSeconds} seconds`,
|
|
1207
1286
|
classification: "timeout",
|
|
1208
|
-
telemetry: observation.snapshot(),
|
|
1287
|
+
telemetry: { failure_reason_code: MERGE_FLOW_TIMEOUT, ...observation.snapshot() },
|
|
1209
1288
|
}),
|
|
1210
1289
|
deps,
|
|
1211
1290
|
options,
|
|
@@ -1223,7 +1302,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1223
1302
|
error_kind: MERGE_FAILED,
|
|
1224
1303
|
error_message: secretFreeErrorMessage(flowError),
|
|
1225
1304
|
classification: "crashed",
|
|
1226
|
-
telemetry: observation.snapshot(),
|
|
1305
|
+
telemetry: { failure_reason_code: MERGE_FLOW_ERROR, ...observation.snapshot() },
|
|
1227
1306
|
}),
|
|
1228
1307
|
deps,
|
|
1229
1308
|
options,
|
|
@@ -1242,7 +1321,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1242
1321
|
error_kind: MERGE_FAILED,
|
|
1243
1322
|
error_message: "merge flow completed without an outcome",
|
|
1244
1323
|
classification: "crashed",
|
|
1245
|
-
telemetry: observation.snapshot(),
|
|
1324
|
+
telemetry: { failure_reason_code: MERGE_NO_OUTCOME, ...observation.snapshot() },
|
|
1246
1325
|
}),
|
|
1247
1326
|
deps,
|
|
1248
1327
|
options,
|
|
@@ -1271,7 +1350,13 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1271
1350
|
}
|
|
1272
1351
|
const terminal = await sendTerminalMutationWithAuthReport({
|
|
1273
1352
|
kind: "fail",
|
|
1274
|
-
|
|
1353
|
+
// BAPI-1453: MERGE the builder's telemetry with the snapshot rather than
|
|
1354
|
+
// replacing it, so `failure_reason_code` survives. Observation state never
|
|
1355
|
+
// carries a code, so the snapshot cannot overwrite it.
|
|
1356
|
+
send: () => httpClient.fail(job, {
|
|
1357
|
+
...outcome.failure,
|
|
1358
|
+
telemetry: { ...outcome.failure.telemetry, ...observation.snapshot() },
|
|
1359
|
+
}),
|
|
1275
1360
|
deps,
|
|
1276
1361
|
options,
|
|
1277
1362
|
ownership,
|
|
@@ -1519,13 +1604,68 @@ function resolveStaleBranchClassification(payload) {
|
|
|
1519
1604
|
return "unknown";
|
|
1520
1605
|
return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
|
|
1521
1606
|
}
|
|
1522
|
-
/**
|
|
1607
|
+
/**
|
|
1608
|
+
* BAPI-1530: structured worktree outcomes the executor reports as the named,
|
|
1609
|
+
* deterministic `WorktreeSetupRefused`. A fetch, ref-resolution, or unclassified
|
|
1610
|
+
* failure (`unavailable` or no kind at all) keeps `WorktreeError` so the server's
|
|
1611
|
+
* legacy crash and transient handling is unchanged.
|
|
1612
|
+
*/
|
|
1613
|
+
const SETUP_REFUSAL_KINDS = new Set([
|
|
1614
|
+
"stale_branch",
|
|
1615
|
+
"lineage_refused",
|
|
1616
|
+
"setup_refused",
|
|
1617
|
+
]);
|
|
1618
|
+
/**
|
|
1619
|
+
* Display-only reason codes. They are set from structured outcomes only and read
|
|
1620
|
+
* by no disposition decision; a deliberate refusal with no accurate display
|
|
1621
|
+
* category (lock, authority, occupied destination) carries none.
|
|
1622
|
+
*/
|
|
1623
|
+
function worktreeDisplayReasonCode(kind) {
|
|
1624
|
+
switch (kind) {
|
|
1625
|
+
case "stale_branch":
|
|
1626
|
+
return failureReasonCode("worktree_stale_branch");
|
|
1627
|
+
case "lineage_refused":
|
|
1628
|
+
return failureReasonCode("worktree_lineage_refused");
|
|
1629
|
+
case "unavailable":
|
|
1630
|
+
return failureReasonCode("worktree_fetch_failed");
|
|
1631
|
+
default:
|
|
1632
|
+
return undefined;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The `/fail` payload for a failed worktree preparation. The message is redacted
|
|
1637
|
+
* and bounded here, before it reaches the server or a log line, so raw Worktrunk
|
|
1638
|
+
* stderr never crosses the boundary. A named failure that already carries its own
|
|
1639
|
+
* secret-free message is returned as it is.
|
|
1640
|
+
*/
|
|
1523
1641
|
function jobWorktreeFailure(wt) {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1642
|
+
if (wt.failure)
|
|
1643
|
+
return wt.failure;
|
|
1644
|
+
const refused = wt.failureKind !== undefined && SETUP_REFUSAL_KINDS.has(wt.failureKind);
|
|
1645
|
+
const displayCode = worktreeDisplayReasonCode(wt.failureKind);
|
|
1646
|
+
return {
|
|
1647
|
+
error_kind: refused ? WorktreeSetupRefused : "WorktreeError",
|
|
1648
|
+
error_message: secretFreeWorktreeMessage(wt.error),
|
|
1527
1649
|
classification: "crashed",
|
|
1528
|
-
|
|
1650
|
+
...(displayCode ? { telemetry: { failure_reason_code: displayCode } } : {}),
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
/** The payload key the reconciler stamps with a fenced child's resume head (BAPI-1530). */
|
|
1654
|
+
const FENCED_RESUME_HEAD_KEY = "fenced_implement_resume_head_sha";
|
|
1655
|
+
/**
|
|
1656
|
+
* BAPI-1530: the SERVER-AUTHORED resume head from the job payload, or `null`.
|
|
1657
|
+
* Absent, malformed, or non-SHA values degrade to the ordinary guarded
|
|
1658
|
+
* fresh-dispatch path: absence proves nothing about the server's version and
|
|
1659
|
+
* triggers no renaming.
|
|
1660
|
+
*/
|
|
1661
|
+
function readFencedResumeHead(payload) {
|
|
1662
|
+
if (!payload || typeof payload !== "object")
|
|
1663
|
+
return null;
|
|
1664
|
+
const raw = payload[FENCED_RESUME_HEAD_KEY];
|
|
1665
|
+
if (typeof raw !== "string")
|
|
1666
|
+
return null;
|
|
1667
|
+
const head = raw.trim().toLowerCase();
|
|
1668
|
+
return /^[0-9a-f]{40}$/.test(head) ? head : null;
|
|
1529
1669
|
}
|
|
1530
1670
|
/**
|
|
1531
1671
|
* Prepare a job's worktree. A job the server dispatched as a parked-child
|
|
@@ -1775,6 +1915,9 @@ async function prepareSpawn(job, httpClient, options, deps, seams, preparation)
|
|
|
1775
1915
|
// `implement` retry acts on it; `ensureExecutorWorktree` re-checks that.
|
|
1776
1916
|
// Spread only when present so an ordinary job keeps its exact legacy policy.
|
|
1777
1917
|
...(job.prior_attempt_head_sha ? { priorAttemptHeadSha: job.prior_attempt_head_sha } : {}),
|
|
1918
|
+
// BAPI-1530: forwarded separately from the claim's prior-attempt head so
|
|
1919
|
+
// same-row retry preservation keeps its own semantics.
|
|
1920
|
+
...(readFencedResumeHead(job.payload) ? { fencedImplementResumeHeadSha: readFencedResumeHead(job.payload) } : {}),
|
|
1778
1921
|
});
|
|
1779
1922
|
if (!wt.ok) {
|
|
1780
1923
|
await httpClient.fail(job, jobWorktreeFailure(wt));
|
|
@@ -1971,9 +2114,11 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1971
2114
|
workBranch: branch,
|
|
1972
2115
|
baseBranch,
|
|
1973
2116
|
runCommand: deps.runCommand,
|
|
1974
|
-
comparisonSha: ciFix.headSha,
|
|
2117
|
+
comparisonSha: ciFix.failingHeadSha ?? ciFix.headSha,
|
|
2118
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
2119
|
+
deferComparisonGuidance: true,
|
|
1975
2120
|
});
|
|
1976
|
-
return { ok: true, prompt };
|
|
2121
|
+
return { ok: true, prompt: prompt + ciFixProvenanceGuidance(ciFix) + renderCiFixOperatorNote(job.payload) };
|
|
1977
2122
|
}
|
|
1978
2123
|
catch (err) {
|
|
1979
2124
|
return {
|
|
@@ -1982,8 +2127,57 @@ async function renderVerifiedCiFixPrompt(params) {
|
|
|
1982
2127
|
};
|
|
1983
2128
|
}
|
|
1984
2129
|
}
|
|
2130
|
+
/**
|
|
2131
|
+
* BAPI-1514: the provenance line for a focused ci_fix prompt. It separates the
|
|
2132
|
+
* ORIGINAL failing head (`failingHeadSha`) from the head the supplied evidence
|
|
2133
|
+
* describes (`evidenceHeadSha` on a refreshed payload, else the legacy anchor),
|
|
2134
|
+
* and never calls the evidence stale when it describes the start head itself.
|
|
2135
|
+
*/
|
|
2136
|
+
function ciFixProvenanceGuidance(ciFix) {
|
|
2137
|
+
const original = ciFix.failingHeadSha;
|
|
2138
|
+
if (ciFix.evidenceHeadSha) {
|
|
2139
|
+
const evidence = ciFix.evidenceHeadSha;
|
|
2140
|
+
if (!original || original === evidence) {
|
|
2141
|
+
return "\n\nSupplied CI logs and snapshots describe the evidence head " + evidence +
|
|
2142
|
+
", the head this repair was dispatched against. Re-verify the affected behavior at the execution head.";
|
|
2143
|
+
}
|
|
2144
|
+
return "\n\nOriginal failing head " + original + "; evidence head " + evidence +
|
|
2145
|
+
"; the supplied CI logs and snapshots describe the evidence head, the head this repair was dispatched against." +
|
|
2146
|
+
" Inspect the diff from the original failing head for context and re-verify the affected behavior at the execution head.";
|
|
2147
|
+
}
|
|
2148
|
+
if (!original)
|
|
2149
|
+
return "";
|
|
2150
|
+
if (original === ciFix.headSha) {
|
|
2151
|
+
return "\n\nSupplied CI logs and snapshots describe the failing head " + original +
|
|
2152
|
+
", the head this repair was dispatched against. Re-verify the affected behavior at the execution head.";
|
|
2153
|
+
}
|
|
2154
|
+
return "\n\nSupplied CI logs and snapshots describe the failing head " + original +
|
|
2155
|
+
" and may predate this pushed checkout. Inspect the diff from that anchor and re-verify the affected behavior at the execution head.";
|
|
2156
|
+
}
|
|
1985
2157
|
/** BAPI-1311: cap on any one focused finalization read, before the deadline clamp. */
|
|
1986
2158
|
const FOCUSED_FINALIZATION_READ_MAX_MS = 120_000;
|
|
2159
|
+
/** BAPI-1473: bound on the pre-launch origin-tip read for a focused rebase. */
|
|
2160
|
+
const FOCUSED_REBASE_START_TIP_READ_MAX_MS = 30_000;
|
|
2161
|
+
/**
|
|
2162
|
+
* BAPI-1473: the work branch's authoritative origin tip just before a focused
|
|
2163
|
+
* rebase worker launches, as a full lower-case SHA, or null when it cannot be
|
|
2164
|
+
* established. Never throws; null keeps the existing post-push wait.
|
|
2165
|
+
*/
|
|
2166
|
+
async function readFocusedRebaseStartTip(deps, worktreePath, branch, remainingMs) {
|
|
2167
|
+
const timeoutMs = Math.min(FOCUSED_REBASE_START_TIP_READ_MAX_MS, remainingMs());
|
|
2168
|
+
if (timeoutMs <= 0)
|
|
2169
|
+
return null;
|
|
2170
|
+
try {
|
|
2171
|
+
const lookup = await resolveOriginBranchSha(deps.runCommand, worktreePath, branch, { timeoutMs });
|
|
2172
|
+
if (lookup.state !== "found")
|
|
2173
|
+
return null;
|
|
2174
|
+
const sha = lookup.sha.trim().toLowerCase();
|
|
2175
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
2176
|
+
}
|
|
2177
|
+
catch {
|
|
2178
|
+
return null;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
1987
2181
|
/**
|
|
1988
2182
|
* BAPI-1311 — run the focused rebase's post-push wait and, unless it is `ready`,
|
|
1989
2183
|
* report the job's ONE terminal outcome.
|
|
@@ -2057,6 +2251,7 @@ async function superviseFocusedRebaseWait(params) {
|
|
|
2057
2251
|
expectedBaseBranch: params.expectedBaseBranch,
|
|
2058
2252
|
readPullRequest: (timeoutMs) => readFocusedPullRequest(deps.runCommand, params.worktreePath, params.context.prNumber, { timeoutMs }),
|
|
2059
2253
|
pollChecks,
|
|
2254
|
+
...(params.readDelivery ? { readDelivery: params.readDelivery } : {}),
|
|
2060
2255
|
now: deps.now,
|
|
2061
2256
|
sleep: deps.sleep,
|
|
2062
2257
|
signal,
|
|
@@ -2135,6 +2330,20 @@ async function superviseFocusedRebaseWait(params) {
|
|
|
2135
2330
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
2136
2331
|
* touches a worktree that will not become a runnable worker.
|
|
2137
2332
|
*/
|
|
2333
|
+
/**
|
|
2334
|
+
* BAPI-1453 — the single executor log line for a pre-spawn refusal: the job id,
|
|
2335
|
+
* the closed reason, and the bounded dirty-path evidence from the observation
|
|
2336
|
+
* snapshot. Paths are already repository-relative, length-bounded,
|
|
2337
|
+
* control-escaped, and redaction-checked by `parseDirtyPathEvidence`, and are
|
|
2338
|
+
* JSON-quoted here so the line stays one line.
|
|
2339
|
+
*/
|
|
2340
|
+
export function formatPreSpawnRefusalLine(job, reason, telemetry) {
|
|
2341
|
+
const code = isFailureReasonCode(reason) ? reason : "unknown";
|
|
2342
|
+
const paths = Array.isArray(telemetry.dirty_paths) ? telemetry.dirty_paths : [];
|
|
2343
|
+
const overflow = typeof telemetry.dirty_paths_overflow_count === "number" ? telemetry.dirty_paths_overflow_count : 0;
|
|
2344
|
+
return (`pre-spawn verification refused job_id=${job.id} reason=${code} ` +
|
|
2345
|
+
`dirty_paths=${JSON.stringify(paths)} dirty_paths_overflow_count=${overflow}`);
|
|
2346
|
+
}
|
|
2138
2347
|
/** Reserve the branch before Worktrunk can mutate it; lock an existing tree before preparation. */
|
|
2139
2348
|
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver) {
|
|
2140
2349
|
const originalHttp = httpClient;
|
|
@@ -2221,20 +2430,37 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
2221
2430
|
}
|
|
2222
2431
|
};
|
|
2223
2432
|
try {
|
|
2224
|
-
return await runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver, undefined, undefined, reserve);
|
|
2433
|
+
return await runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver, undefined, undefined, reserve, timeout.timeoutSeconds);
|
|
2225
2434
|
}
|
|
2226
2435
|
finally {
|
|
2227
2436
|
await retainedLock?.release();
|
|
2228
2437
|
await reservation?.release();
|
|
2229
2438
|
}
|
|
2230
2439
|
}
|
|
2440
|
+
/**
|
|
2441
|
+
* The filesystem boundary for exclude provisioning, built from the runner's
|
|
2442
|
+
* injected deps. A deps builder that omits the optional `rename`/`removeFile`
|
|
2443
|
+
* seams gets the real operation, matching the worktree-lock fallback direction.
|
|
2444
|
+
*/
|
|
2445
|
+
function excludeProvisioningDeps(deps) {
|
|
2446
|
+
return {
|
|
2447
|
+
readFile: deps.readFile,
|
|
2448
|
+
writeFile: deps.writeFile,
|
|
2449
|
+
mkdir: deps.mkdir,
|
|
2450
|
+
stat: deps.stat ?? ((filePath) => fsStat(filePath)),
|
|
2451
|
+
rename: deps.rename ?? ((from, to) => fsRename(from, to)),
|
|
2452
|
+
removeFile: deps.removeFile ?? ((filePath) => rm(filePath, { force: true })),
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2231
2455
|
async function runReservedSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
|
|
2232
2456
|
/**
|
|
2233
2457
|
* BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
|
|
2234
2458
|
* the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
|
|
2235
2459
|
* adds no second worktree entry and no second worker launcher.
|
|
2236
2460
|
*/
|
|
2237
|
-
driver, retainedLock, preparation, reserve
|
|
2461
|
+
driver, retainedLock, preparation, reserve,
|
|
2462
|
+
/** Successful total budget resolved before preparation, never remaining time. */
|
|
2463
|
+
resolvedTimeoutSeconds) {
|
|
2238
2464
|
// A server-owned read precedes every worktree reset/cut and worker launch.
|
|
2239
2465
|
// Only this boundary can authorize a no-charge pre-work wait; a later plan
|
|
2240
2466
|
// retrieval failure is ordinary bounded worker failure.
|
|
@@ -2290,9 +2516,7 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2290
2516
|
claude: {
|
|
2291
2517
|
readFile: deps.readFile,
|
|
2292
2518
|
denyProvisioningDeps: {
|
|
2293
|
-
|
|
2294
|
-
writeFile: deps.writeFile,
|
|
2295
|
-
mkdir: deps.mkdir,
|
|
2519
|
+
...excludeProvisioningDeps(deps),
|
|
2296
2520
|
homedir: deps.homedir,
|
|
2297
2521
|
},
|
|
2298
2522
|
},
|
|
@@ -2377,6 +2601,18 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2377
2601
|
return { status: "failed", reason: "focused_rebase_context" };
|
|
2378
2602
|
}
|
|
2379
2603
|
}
|
|
2604
|
+
// BAPI-1451: provision the shared git exclude BEFORE any lock acquisition. The
|
|
2605
|
+
// retained-worktree lock (inside `reserve()`) and the fresh-worktree lock both
|
|
2606
|
+
// write `.conductor/` artifacts, and so do the recently-failed file and
|
|
2607
|
+
// pre-spawn verification. The executor clone's common `info/exclude` covers
|
|
2608
|
+
// both retained and newly prepared linked worktrees. A warning is logged and
|
|
2609
|
+
// the job continues unchanged: no failure reason, alert, cleanup, or early
|
|
2610
|
+
// return — dirty-worktree refusal stays with pre-spawn verification.
|
|
2611
|
+
{
|
|
2612
|
+
const provisioned = await (seams.provisionWorktreeSettingsExclude ?? provisionWorktreeSettingsExclude)(deps.cwd, excludeProvisioningDeps(deps));
|
|
2613
|
+
if (provisioned.warning)
|
|
2614
|
+
deps.errorLog(provisioned.warning);
|
|
2615
|
+
}
|
|
2380
2616
|
// Acquire ownership only after read-only admission and contract validation.
|
|
2381
2617
|
if (reserve) {
|
|
2382
2618
|
const held = await reserve();
|
|
@@ -2505,6 +2741,7 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2505
2741
|
const rendered = await renderVerifiedCiFixPrompt({
|
|
2506
2742
|
job,
|
|
2507
2743
|
ciFix: prep.ciFix,
|
|
2744
|
+
timeoutSeconds: resolvedTimeoutSeconds,
|
|
2508
2745
|
publication: prep.publication, preparation,
|
|
2509
2746
|
worktreePath,
|
|
2510
2747
|
branch,
|
|
@@ -2521,7 +2758,7 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2521
2758
|
readyPrep = { ...prep, prompt: rendered.prompt };
|
|
2522
2759
|
}
|
|
2523
2760
|
if (["implement", "resume", "remediate", "rebase", "ci_fix"].includes(job.job_type)) {
|
|
2524
|
-
readyPrep = { ...readyPrep, comparisonSha: prep.ciFix?.headSha ??
|
|
2761
|
+
readyPrep = { ...readyPrep, comparisonSha: prep.ciFix?.failingHeadSha ?? prep.ciFix?.headSha ??
|
|
2525
2762
|
await captureComparisonSha(worktreePath, deps) };
|
|
2526
2763
|
if (!prep.ciFix && isRemainingWork(job.job_type, job.payload)) {
|
|
2527
2764
|
if (job.job_type === "resume" || prep.priorWork) {
|
|
@@ -2568,6 +2805,9 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2568
2805
|
});
|
|
2569
2806
|
return { status: "failed", reason: "stale_artifact_cleanup" };
|
|
2570
2807
|
}
|
|
2808
|
+
// `.conductor/` is excluded through the executor-provisioned common
|
|
2809
|
+
// `info/exclude` (see the spawn-entry provisioning above), independently of
|
|
2810
|
+
// any repository `.gitignore`.
|
|
2571
2811
|
try {
|
|
2572
2812
|
await (seams.writeRecentlyFailedTestsFile ?? writeRecentlyFailedTestsFile)(worktreePath, job.payload?.recently_failed_tests);
|
|
2573
2813
|
}
|
|
@@ -2615,10 +2855,167 @@ driver, retainedLock, preparation, reserve) {
|
|
|
2615
2855
|
// touching it, and the two steps above are what establish that it cannot.
|
|
2616
2856
|
spawnLifecycle.deregisterLiveWorker?.();
|
|
2617
2857
|
spawnLifecycle.deregisterLiveWorker = undefined;
|
|
2858
|
+
// BAPI-1479 — the job database and launcher go AFTER the worker is
|
|
2859
|
+
// contained (supervision has settled by now) and BEFORE the worktree lock is
|
|
2860
|
+
// released. A drop failure is one bounded, non-fatal line on the executor's
|
|
2861
|
+
// stderr; it never changes the job's result.
|
|
2862
|
+
await spawnLifecycle.releaseWorkerResources?.().catch(() => undefined);
|
|
2618
2863
|
await spawnLifecycle.closeWorkerLog?.();
|
|
2619
2864
|
await worktreeLock.release();
|
|
2620
2865
|
}
|
|
2621
2866
|
}
|
|
2867
|
+
/**
|
|
2868
|
+
* BAPI-1479: the job types whose worker gets a disposable database and the
|
|
2869
|
+
* own-build launcher — the PR-producing worker jobs, which are the ones that run
|
|
2870
|
+
* the repository's tests. Verdict-only and driver-owned jobs keep the sentinel.
|
|
2871
|
+
*/
|
|
2872
|
+
const WORKER_RESOURCE_JOB_TYPES = new Set([
|
|
2873
|
+
"implement", "resume", "remediate", "ci_fix", "rebase",
|
|
2874
|
+
]);
|
|
2875
|
+
/**
|
|
2876
|
+
* BAPI-1479 — provision the job's disposable database and own-build launcher
|
|
2877
|
+
* before the worker's spawn shape is built.
|
|
2878
|
+
*
|
|
2879
|
+
* Runs under the SAME heartbeat, server-stop, and timeout supervision as a
|
|
2880
|
+
* driver phase, and an executor shutdown aborts it through the live-worker
|
|
2881
|
+
* registry (which is sticky, so a shutdown that already started still reaches
|
|
2882
|
+
* it). It never stops a job for a missing prerequisite: an unavailable database
|
|
2883
|
+
* or launcher logs ONE bounded non-fatal line and the worker spawns with the
|
|
2884
|
+
* sentinel database and the inherited `PATH`, exactly as before. Only the
|
|
2885
|
+
* existing interruptions end the job here — lease loss, a server stop, or the job
|
|
2886
|
+
* timeout — and they report through the existing outcomes, never a new code.
|
|
2887
|
+
*
|
|
2888
|
+
* Whatever was created is registered on `lifecycle.releaseWorkerResources` the
|
|
2889
|
+
* moment it exists, so `runSpawnJob`'s `finally` drops it on every exit path.
|
|
2890
|
+
*/
|
|
2891
|
+
async function prepareWorkerResources(params) {
|
|
2892
|
+
const { job, deps, lifecycle } = params;
|
|
2893
|
+
const jobId = String(job.id);
|
|
2894
|
+
const resources = deps.workerResources;
|
|
2895
|
+
if (!resources)
|
|
2896
|
+
return { kind: "ready" };
|
|
2897
|
+
let target;
|
|
2898
|
+
let disposeLauncher;
|
|
2899
|
+
let launcherDirectory;
|
|
2900
|
+
let released = false;
|
|
2901
|
+
lifecycle.releaseWorkerResources = async () => {
|
|
2902
|
+
if (released)
|
|
2903
|
+
return;
|
|
2904
|
+
released = true;
|
|
2905
|
+
if (target) {
|
|
2906
|
+
const dropped = await resources.dropDatabase(target).catch(() => ({ kind: "failed" }));
|
|
2907
|
+
if (dropped.kind !== "dropped") {
|
|
2908
|
+
try {
|
|
2909
|
+
deps.errorLog(formatWorkerDatabaseDropFailedLine(jobId, target.name));
|
|
2910
|
+
}
|
|
2911
|
+
catch { /* sink */ }
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
if (disposeLauncher)
|
|
2915
|
+
await disposeLauncher().catch(() => undefined);
|
|
2916
|
+
};
|
|
2917
|
+
// An executor shutdown aborts provisioning the same way it terminates a worker.
|
|
2918
|
+
const shutdown = new AbortController();
|
|
2919
|
+
const deregister = params.control?.liveWorkers.register({
|
|
2920
|
+
requestTermination: () => shutdown.abort(),
|
|
2921
|
+
});
|
|
2922
|
+
let phase;
|
|
2923
|
+
try {
|
|
2924
|
+
phase = await superviseDriverPhase({
|
|
2925
|
+
job,
|
|
2926
|
+
httpClient: params.httpClient,
|
|
2927
|
+
options: params.options,
|
|
2928
|
+
deps,
|
|
2929
|
+
ownership: params.ownership,
|
|
2930
|
+
observation: params.observation,
|
|
2931
|
+
control: params.control,
|
|
2932
|
+
timeoutSeconds: Math.max(1, Math.ceil(params.remainingMs() / 1000)),
|
|
2933
|
+
collectTelemetry: params.collectTelemetry,
|
|
2934
|
+
run: async (signal) => {
|
|
2935
|
+
// Combined by hand: `AbortSignal.any` is not available on Node 18.
|
|
2936
|
+
const combined = new AbortController();
|
|
2937
|
+
const abortCombined = () => combined.abort();
|
|
2938
|
+
if (signal.aborted || shutdown.signal.aborted)
|
|
2939
|
+
combined.abort();
|
|
2940
|
+
signal.addEventListener("abort", abortCombined, { once: true });
|
|
2941
|
+
shutdown.signal.addEventListener("abort", abortCombined, { once: true });
|
|
2942
|
+
const cancel = combined.signal;
|
|
2943
|
+
const provisioned = await resources
|
|
2944
|
+
.provisionDatabase({ jobId, worktreePath: params.worktreePath, signal: cancel })
|
|
2945
|
+
.catch(() => ({ kind: "unavailable", category: "create_failed" }));
|
|
2946
|
+
if (provisioned.kind === "provisioned") {
|
|
2947
|
+
target = provisioned.target;
|
|
2948
|
+
}
|
|
2949
|
+
else {
|
|
2950
|
+
try {
|
|
2951
|
+
deps.errorLog(formatWorkerDatabaseUnavailableLine(jobId, provisioned.category));
|
|
2952
|
+
// A created database whose rollback failed stays for the host operator.
|
|
2953
|
+
if ("retainedName" in provisioned && provisioned.retainedName) {
|
|
2954
|
+
deps.errorLog(formatWorkerDatabaseDropFailedLine(jobId, provisioned.retainedName));
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
catch { /* sink */ }
|
|
2958
|
+
}
|
|
2959
|
+
if (cancel.aborted)
|
|
2960
|
+
return;
|
|
2961
|
+
const launcher = await resources
|
|
2962
|
+
.createLauncher({ jobId })
|
|
2963
|
+
.catch(() => ({ kind: "unavailable", reason: "wrapper_unverified" }));
|
|
2964
|
+
if (launcher.kind === "available") {
|
|
2965
|
+
disposeLauncher = launcher.dispose;
|
|
2966
|
+
if (!released)
|
|
2967
|
+
launcherDirectory = launcher.directory;
|
|
2968
|
+
else
|
|
2969
|
+
await launcher.dispose().catch(() => undefined);
|
|
2970
|
+
}
|
|
2971
|
+
else {
|
|
2972
|
+
try {
|
|
2973
|
+
deps.errorLog(formatWorkerCliLauncherUnavailableLine(jobId, launcher.reason));
|
|
2974
|
+
}
|
|
2975
|
+
catch { /* sink */ }
|
|
2976
|
+
}
|
|
2977
|
+
},
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
finally {
|
|
2981
|
+
deregister?.();
|
|
2982
|
+
}
|
|
2983
|
+
if (phase.kind === "abandoned") {
|
|
2984
|
+
return { kind: "interrupted", result: { status: "abandoned", reason: params.ownership.abandonReason } };
|
|
2985
|
+
}
|
|
2986
|
+
if (phase.kind === "server_stop") {
|
|
2987
|
+
await params.httpClient.fail(job, {
|
|
2988
|
+
error_kind: "ServerStopRequested",
|
|
2989
|
+
error_message: "the server requested this run stop before the worker started",
|
|
2990
|
+
classification: "killed",
|
|
2991
|
+
telemetry: params.observation.snapshot(),
|
|
2992
|
+
});
|
|
2993
|
+
params.ownership.abandoned = true;
|
|
2994
|
+
params.ownership.abandonReason = "server_stop";
|
|
2995
|
+
return { kind: "interrupted", result: { status: "abandoned", reason: "server_stop" } };
|
|
2996
|
+
}
|
|
2997
|
+
if (phase.kind === "timeout") {
|
|
2998
|
+
await params.httpClient.fail(job, {
|
|
2999
|
+
error_kind: "Timeout",
|
|
3000
|
+
error_message: `${job.job_type} exceeded the job timeout before the worker started`,
|
|
3001
|
+
classification: "timeout",
|
|
3002
|
+
telemetry: params.observation.snapshot(),
|
|
3003
|
+
});
|
|
3004
|
+
return { kind: "interrupted", result: { status: "failed", reason: "timeout" } };
|
|
3005
|
+
}
|
|
3006
|
+
// An executor shutdown during provisioning: spawn nothing new with a
|
|
3007
|
+
// half-prepared target. The sticky registry terminates the worker on
|
|
3008
|
+
// registration anyway, so continuing with the sentinel keeps the existing
|
|
3009
|
+
// shutdown semantics while the database (if any) is dropped by the `finally`.
|
|
3010
|
+
if (shutdown.signal.aborted)
|
|
3011
|
+
return { kind: "ready" };
|
|
3012
|
+
// `kind: "error"` is an unexpected throw from the seam itself: degrade.
|
|
3013
|
+
return {
|
|
3014
|
+
kind: "ready",
|
|
3015
|
+
...(target ? { workerDatabase: target } : {}),
|
|
3016
|
+
...(launcherDirectory ? { workerLauncherDirectory: launcherDirectory } : {}),
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
2622
3019
|
function retainedHeadPublisher(params) {
|
|
2623
3020
|
return async () => {
|
|
2624
3021
|
const { job, branch, worktreePath, prep, deps, ownership, observation, remainingMs } = params;
|
|
@@ -2700,7 +3097,8 @@ async function runPreparedSpawn(params) {
|
|
|
2700
3097
|
// an envelope, rather than run under the legacy contract that the server
|
|
2701
3098
|
// would then refuse to accept.
|
|
2702
3099
|
const covered = claimIsCovered(job);
|
|
2703
|
-
|
|
3100
|
+
const policyRebase = job.job_type === "rebase" && job.required_capabilities?.includes("required_verification.ci_deferral.v2");
|
|
3101
|
+
if (covered && ((!isCoveredJobType(job.job_type) && !policyRebase) || driver)) {
|
|
2704
3102
|
return await refuseCoveredClaimBeforeLaunch({
|
|
2705
3103
|
job, httpClient, options, deps, ownership, observation, control,
|
|
2706
3104
|
runtime: unrecognizedRuntime(adapter),
|
|
@@ -2797,6 +3195,15 @@ async function runPreparedSpawn(params) {
|
|
|
2797
3195
|
if (job.job_type === "implement" && !isRemainingWork(job.job_type, job.payload)) {
|
|
2798
3196
|
prompt += "\n\n" + renderWorkerVerificationGuidance(prep.comparisonSha);
|
|
2799
3197
|
}
|
|
3198
|
+
// The locked ci_fix renderer deferred trusted guidance. All existing prompt
|
|
3199
|
+
// additions precede comparison guidance and the final total-budget block.
|
|
3200
|
+
if (prep.ciFix) {
|
|
3201
|
+
prompt += "\n\n" + renderWorkerVerificationGuidance(prep.comparisonSha) +
|
|
3202
|
+
"\n\n" + renderWorkerTimeBudgetGuidance(timeout.timeoutSeconds);
|
|
3203
|
+
}
|
|
3204
|
+
const approvedVerificationGuidance = renderApprovedVerificationGuidance(job.payload);
|
|
3205
|
+
if (approvedVerificationGuidance)
|
|
3206
|
+
prompt += "\n\n" + approvedVerificationGuidance;
|
|
2800
3207
|
// --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
|
|
2801
3208
|
// FIRST of the pre-spawn contracts, and deliberately BEFORE every
|
|
2802
3209
|
// executor-owned write to the worktree (deny layer, MCP provisioning, worker
|
|
@@ -2809,7 +3216,9 @@ async function runPreparedSpawn(params) {
|
|
|
2809
3216
|
// failing check. It does NOT replace or weaken the existing hard-reset /
|
|
2810
3217
|
// untracked-clean / head-verify behavior in `worktree-core.ts`; it is an
|
|
2811
3218
|
// additional independent check on top of them.
|
|
2812
|
-
const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch
|
|
3219
|
+
const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch,
|
|
3220
|
+
// BAPI-1453: a refusal must name the files it found.
|
|
3221
|
+
{ includeDirtyPaths: true });
|
|
2813
3222
|
// BAPI-1152: a driver proves its own pre-spawn state (a reconciliation worker
|
|
2814
3223
|
// may start inside an in-progress merge, which the generic "clean at the
|
|
2815
3224
|
// prepared commit" check would refuse by design).
|
|
@@ -2827,12 +3236,28 @@ async function runPreparedSpawn(params) {
|
|
|
2827
3236
|
observation.setAttemptEndSha(verification.observedSha);
|
|
2828
3237
|
}
|
|
2829
3238
|
observation.setGitTelemetry(preSpawnTelemetry);
|
|
3239
|
+
// BAPI-1453: one executor log line naming the job, the reason, and the
|
|
3240
|
+
// bounded dirty-path evidence. The paths come from the SAME observation
|
|
3241
|
+
// snapshot the `/fail` body carries, never from a second copy, and never
|
|
3242
|
+
// reach `error_message` (fixed strings and validated SHAs only).
|
|
3243
|
+
const refusalTelemetry = observation.snapshot();
|
|
3244
|
+
try {
|
|
3245
|
+
deps.errorLog(formatPreSpawnRefusalLine(job, verification.reason, refusalTelemetry));
|
|
3246
|
+
}
|
|
3247
|
+
catch {
|
|
3248
|
+
/* supplemental logging cannot change the refusal */
|
|
3249
|
+
}
|
|
2830
3250
|
await httpClient.fail(job, {
|
|
2831
3251
|
error_kind: PreSpawnVerification,
|
|
2832
3252
|
error_message: verification.message,
|
|
2833
3253
|
classification: "crashed",
|
|
2834
3254
|
...commitResidue(preSpawnTelemetry),
|
|
2835
|
-
|
|
3255
|
+
// Builder-owned code first; the spawn-path wrapper then layers the
|
|
3256
|
+
// snapshot over it, so both survive.
|
|
3257
|
+
telemetry: {
|
|
3258
|
+
...(isFailureReasonCode(verification.reason) ? { failure_reason_code: verification.reason } : {}),
|
|
3259
|
+
...refusalTelemetry,
|
|
3260
|
+
},
|
|
2836
3261
|
});
|
|
2837
3262
|
return { status: "failed", reason: "pre_spawn_verification" };
|
|
2838
3263
|
}
|
|
@@ -3141,6 +3566,29 @@ async function runPreparedSpawn(params) {
|
|
|
3141
3566
|
// is denied at the env builder and cannot reach `spawnShape.env`. It stays out
|
|
3142
3567
|
// of `argv` entirely: it is a routing declaration, not a command-line input,
|
|
3143
3568
|
// and argv is world-readable via `ps`.
|
|
3569
|
+
// --- Worker resources (BAPI-1479) --------------------------------------
|
|
3570
|
+
// The job's disposable database and own-build launcher, prepared ONCE for this
|
|
3571
|
+
// prepared job, before the one spawn shape both the covered and legacy launches
|
|
3572
|
+
// use. A retry of the job is a fresh claim with a fresh execution, so it gets
|
|
3573
|
+
// fresh resources; nothing is recovered from an earlier execution's state.
|
|
3574
|
+
// Driver-owned jobs (their worker runs a server-rendered reconciliation) and
|
|
3575
|
+
// verdict-only jobs keep the sentinel database.
|
|
3576
|
+
let workerResources = {};
|
|
3577
|
+
if (!driver && WORKER_RESOURCE_JOB_TYPES.has(job.job_type)) {
|
|
3578
|
+
const prepared = await prepareWorkerResources({
|
|
3579
|
+
job, httpClient, options: jobOptions, deps, ownership, observation, control,
|
|
3580
|
+
worktreePath, remainingMs, lifecycle, collectTelemetry: driverTelemetry,
|
|
3581
|
+
});
|
|
3582
|
+
if (prepared.kind === "interrupted") {
|
|
3583
|
+
await closeWorkerLog();
|
|
3584
|
+
await finalizeRegistry();
|
|
3585
|
+
return prepared.result;
|
|
3586
|
+
}
|
|
3587
|
+
workerResources = {
|
|
3588
|
+
...(prepared.workerDatabase ? { workerDatabase: prepared.workerDatabase } : {}),
|
|
3589
|
+
...(prepared.workerLauncherDirectory ? { workerLauncherDirectory: prepared.workerLauncherDirectory } : {}),
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3144
3592
|
const invocation = adapter.headlessInvocation;
|
|
3145
3593
|
if (invocation?.supported !== true) {
|
|
3146
3594
|
await closeWorkerLog();
|
|
@@ -3175,6 +3623,8 @@ async function runPreparedSpawn(params) {
|
|
|
3175
3623
|
...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
|
|
3176
3624
|
...(correctionWork ? { correctionIntentVersion: 1 } : {}),
|
|
3177
3625
|
...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
|
|
3626
|
+
// BAPI-1479 — explicit, executor-provisioned values; never read from `parentEnv`.
|
|
3627
|
+
...workerResources,
|
|
3178
3628
|
parentEnv: deps.env,
|
|
3179
3629
|
});
|
|
3180
3630
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -3309,6 +3759,15 @@ async function runPreparedSpawn(params) {
|
|
|
3309
3759
|
// the covered attempt launch through it, so there is still exactly one place a
|
|
3310
3760
|
// worker can be started, always from the verified spawn shape. Only a covered
|
|
3311
3761
|
// attempt asks for a held stdin; the legacy options stay byte-identical.
|
|
3762
|
+
// BAPI-1473: a focused rebase records the work branch's ORIGIN tip immediately
|
|
3763
|
+
// before its worker launches. When finalization later proves the origin tip is
|
|
3764
|
+
// still exactly this SHA, the worker pushed nothing (it only renewed evidence),
|
|
3765
|
+
// so there is no post-push evidence to wait for. Read with the same
|
|
3766
|
+
// authoritative `git ls-remote` finalization uses, never a local tracking ref
|
|
3767
|
+
// and never `job.expected_head_sha`. Unknown (null) keeps the existing wait.
|
|
3768
|
+
const focusedRebaseStartTip = prep.focusedRebase
|
|
3769
|
+
? await readFocusedRebaseStartTip(deps, worktreePath, branch, remainingMs)
|
|
3770
|
+
: null;
|
|
3312
3771
|
const spawnWorker = (spawnOptions) => deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
|
|
3313
3772
|
cwd: worktreePath,
|
|
3314
3773
|
env: spawnOptions.env,
|
|
@@ -3342,11 +3801,29 @@ async function runPreparedSpawn(params) {
|
|
|
3342
3801
|
if (!spawnShape.stdinMessages?.initial) {
|
|
3343
3802
|
return await refuse("unsupported_runtime", "the executor adapter produced no held-stdin prompt message for a covered job");
|
|
3344
3803
|
}
|
|
3804
|
+
// BAPI-1504 — a job stamped with `cleanup_report.v1` is only safe to run on a
|
|
3805
|
+
// client that can deliver the report; refusing here, before any worker exists,
|
|
3806
|
+
// names the missing capability in the envelope instead of launching a job whose
|
|
3807
|
+
// interruption could never be proven clean.
|
|
3808
|
+
if (claimRequiresCleanupReport(job.required_capabilities) && typeof httpClient.reportCleanup !== "function") {
|
|
3809
|
+
await closeWorkerLog();
|
|
3810
|
+
await finalizeRegistry();
|
|
3811
|
+
return await refuseCoveredClaimBeforeLaunch({
|
|
3812
|
+
job, httpClient, options, deps, ownership, observation, control,
|
|
3813
|
+
runtime, dispatchHeadSha, reason: "unsupported_runtime",
|
|
3814
|
+
message: "this executor cannot deliver cleanup reports (cleanup_report.v1); update the executor",
|
|
3815
|
+
missingCapabilities: ["cleanup_report"],
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3345
3818
|
// BAPI-1302 (D4c) — the server-stamped declaration is validated WHOLE,
|
|
3346
3819
|
// before any allowance is reserved or a worker exists. A truncated,
|
|
3347
3820
|
// malformed, unsupported or server-refused declaration never launches work
|
|
3348
3821
|
// against a partial set of checks.
|
|
3349
3822
|
const verificationRead = readRequiredVerification(job.payload);
|
|
3823
|
+
if (verificationRead.kind === "ok" && verificationRead.declaration.policyVersion === 2 &&
|
|
3824
|
+
!job.required_capabilities?.includes("required_verification.ci_deferral.v2")) {
|
|
3825
|
+
return await refuse("declaration_refused", "CI-deferral capability was not negotiated; refusing to launch");
|
|
3826
|
+
}
|
|
3350
3827
|
if (verificationRead.kind === "refused") {
|
|
3351
3828
|
deps.errorLog(`[executor] job ${job.id}: required-verification declaration refused (${verificationRead.reason})`);
|
|
3352
3829
|
return await refuse("declaration_refused", "the job's required-verification declaration is unusable; refusing to launch");
|
|
@@ -3761,11 +4238,24 @@ async function runPreparedSpawn(params) {
|
|
|
3761
4238
|
// its push never landed on origin and no PR was opened (e.g. it backgrounded
|
|
3762
4239
|
// a slow push and returned early). Convert that into a fail-loud executor
|
|
3763
4240
|
// failure rather than silently reporting success.
|
|
4241
|
+
//
|
|
4242
|
+
// BAPI-1426: a managed ticket's server-confirmed delivery outcome is read
|
|
4243
|
+
// first, so an intentional held checkpoint finalizes as a `/fail` with its
|
|
4244
|
+
// completion cause instead of a missing-PR failure or a success.
|
|
4245
|
+
const deliveryOutcome = await readDeliveryOutcomeForFinalization({
|
|
4246
|
+
job,
|
|
4247
|
+
branch,
|
|
4248
|
+
deps,
|
|
4249
|
+
seams,
|
|
4250
|
+
remainingMs,
|
|
4251
|
+
resolvedBaseUrl: options.resolvedBaseUrl,
|
|
4252
|
+
});
|
|
3764
4253
|
const finalization = await validateWorkerFinalization({
|
|
3765
4254
|
job,
|
|
3766
4255
|
branch,
|
|
3767
4256
|
worktreePath,
|
|
3768
4257
|
result: genericResult,
|
|
4258
|
+
...(deliveryOutcome !== undefined ? { deliveryOutcome } : {}),
|
|
3769
4259
|
publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, remainingMs, httpClient, options: jobOptions, control }),
|
|
3770
4260
|
runCommand: deps.runCommand,
|
|
3771
4261
|
headSha: git.last_commit_sha,
|
|
@@ -3791,7 +4281,7 @@ async function runPreparedSpawn(params) {
|
|
|
3791
4281
|
// finalization failure; it is never rewritten to look like a crash.
|
|
3792
4282
|
...observedExitCode(procResult.exitCode),
|
|
3793
4283
|
...commitResidue(git),
|
|
3794
|
-
telemetry: observation.snapshot(),
|
|
4284
|
+
telemetry: withFailureTelemetry(observation.snapshot(), finalization.failure.telemetry),
|
|
3795
4285
|
}),
|
|
3796
4286
|
deps,
|
|
3797
4287
|
options,
|
|
@@ -3808,11 +4298,38 @@ async function runPreparedSpawn(params) {
|
|
|
3808
4298
|
// supervision a worker gets, bounded by the job's ORIGINAL absolute deadline.
|
|
3809
4299
|
// It launches no worker, enqueues nothing, and resets nothing: a pending check
|
|
3810
4300
|
// is waited on, never repaired again. Only `ready` reaches `/complete`, once.
|
|
3811
|
-
|
|
4301
|
+
//
|
|
4302
|
+
// BAPI-1473: two narrower successful ends, both still submitting `/complete`
|
|
4303
|
+
// exactly once below and leaving the server's completion contract and
|
|
4304
|
+
// delivery guard as the authority:
|
|
4305
|
+
// - NO PUSH: the verified origin tip equals the tip recorded before launch,
|
|
4306
|
+
// so the worker only renewed evidence. Skip the post-push wait.
|
|
4307
|
+
// - DEFERRED CI: the wait itself ends `ready` while required checks are still
|
|
4308
|
+
// pending when the server's readiness at the pushed head shows renewed
|
|
4309
|
+
// evidence with only `remote_ci` deferrals (`readDelivery` below).
|
|
4310
|
+
// Neither is merge behavior: the merge guard keeps the child in `merging`
|
|
4311
|
+
// until readiness is `complete`, which needs green checks at the head.
|
|
4312
|
+
const focusedRebasePushedNothing = prep.focusedRebase !== undefined &&
|
|
4313
|
+
focusedRebaseStartTip !== null &&
|
|
4314
|
+
typeof finalization.pushedHeadSha === "string" &&
|
|
4315
|
+
finalization.pushedHeadSha.trim().toLowerCase() === focusedRebaseStartTip;
|
|
4316
|
+
if (focusedRebasePushedNothing) {
|
|
4317
|
+
deps.errorLog(`[executor] job ${job.id}: focused rebase pushed nothing (origin tip unchanged); ` +
|
|
4318
|
+
"skipping the post-push wait and submitting completion once for the server to decide");
|
|
4319
|
+
}
|
|
4320
|
+
if (prep.focusedRebase && !focusedRebasePushedNothing) {
|
|
3812
4321
|
const focusedTerminal = await superviseFocusedRebaseWait({
|
|
3813
4322
|
job, httpClient, options, jobOptions, deps, ownership, observation, seams, control,
|
|
3814
4323
|
context: prep.focusedRebase,
|
|
3815
4324
|
pushedHeadSha: finalization.pushedHeadSha ?? null,
|
|
4325
|
+
readDelivery: () => readDeliveryOutcomeForFinalization({
|
|
4326
|
+
job,
|
|
4327
|
+
branch,
|
|
4328
|
+
deps,
|
|
4329
|
+
seams,
|
|
4330
|
+
remainingMs,
|
|
4331
|
+
resolvedBaseUrl: options.resolvedBaseUrl,
|
|
4332
|
+
}),
|
|
3816
4333
|
expectedBaseBranch: effectiveBaseBranch,
|
|
3817
4334
|
worktreePath,
|
|
3818
4335
|
jobDeadlineMs,
|
|
@@ -4015,6 +4532,7 @@ export function unlaunchedVerification(read) {
|
|
|
4015
4532
|
const facts = {
|
|
4016
4533
|
provenance,
|
|
4017
4534
|
declaration_digest: digest,
|
|
4535
|
+
...(read.kind === "ok" && read.declaration.policyVersion === 2 ? { deferred_obligation_identities: read.declaration.deferredIdentities ?? [] } : {}),
|
|
4018
4536
|
tested_head_sha: null,
|
|
4019
4537
|
artifact: "not_observed",
|
|
4020
4538
|
commands: [],
|
|
@@ -4073,7 +4591,7 @@ async function refuseCoveredClaimBeforeLaunch(input) {
|
|
|
4073
4591
|
finalization: "unknown",
|
|
4074
4592
|
verification: unlaunched.verification,
|
|
4075
4593
|
cleanup: buildCleanupVerdict("confirmed", 0, []),
|
|
4076
|
-
missingCapabilities: [],
|
|
4594
|
+
missingCapabilities: input.missingCapabilities ?? [],
|
|
4077
4595
|
...(unlaunched.facts ? { verificationFacts: unlaunched.facts } : {}),
|
|
4078
4596
|
});
|
|
4079
4597
|
const cause = envelope.cause ?? "required_work_incomplete";
|
|
@@ -4159,6 +4677,90 @@ async function runCoveredAttempt(params) {
|
|
|
4159
4677
|
optionalWatchers: readOptionalWatchers(job.payload),
|
|
4160
4678
|
obligations,
|
|
4161
4679
|
});
|
|
4680
|
+
// --- Ownership persisted BEFORE the spawn (BAPI-1504) ---------------------------
|
|
4681
|
+
// A job stamped with `cleanup_report.v1` stores `NULL` ownership only if no worker
|
|
4682
|
+
// was ever spawned, which lets the server read a null as proof of that. So the
|
|
4683
|
+
// server must hold this attempt's ownership (token digest, `worker: null`) before
|
|
4684
|
+
// the worker exists. The local record above was written first.
|
|
4685
|
+
const requiresCleanupReport = claimRequiresCleanupReport(job.required_capabilities);
|
|
4686
|
+
if (requiresCleanupReport) {
|
|
4687
|
+
const abandonBeforeLaunch = async (result) => {
|
|
4688
|
+
await closeWorkerLog();
|
|
4689
|
+
await finalizeRegistry();
|
|
4690
|
+
await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
|
|
4691
|
+
return result;
|
|
4692
|
+
};
|
|
4693
|
+
const notLaunched = async (message) => {
|
|
4694
|
+
await closeWorkerLog();
|
|
4695
|
+
await finalizeRegistry();
|
|
4696
|
+
await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
|
|
4697
|
+
return await refuseCoveredClaimBeforeLaunch({
|
|
4698
|
+
job, httpClient, options, deps, ownership, observation, control,
|
|
4699
|
+
runtime: params.runtime, dispatchHeadSha: params.dispatchHeadSha,
|
|
4700
|
+
reason: "not_launched", message,
|
|
4701
|
+
});
|
|
4702
|
+
};
|
|
4703
|
+
const preSpawnContext = {
|
|
4704
|
+
version: 1,
|
|
4705
|
+
token_digest: tokenDigest,
|
|
4706
|
+
host_digest: hostDigest,
|
|
4707
|
+
worker: null,
|
|
4708
|
+
};
|
|
4709
|
+
// Transient failures retry only inside the existing dead-man window; the window
|
|
4710
|
+
// is measured from the last successful beat and is never extended by a retry.
|
|
4711
|
+
for (;;) {
|
|
4712
|
+
let beat;
|
|
4713
|
+
try {
|
|
4714
|
+
beat = await sendImmediateHeartbeat({
|
|
4715
|
+
job,
|
|
4716
|
+
httpClient,
|
|
4717
|
+
deps,
|
|
4718
|
+
ownership,
|
|
4719
|
+
observation,
|
|
4720
|
+
collectTelemetry: () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch),
|
|
4721
|
+
ownershipContext: preSpawnContext,
|
|
4722
|
+
});
|
|
4723
|
+
}
|
|
4724
|
+
catch {
|
|
4725
|
+
beat = { kind: "transient" };
|
|
4726
|
+
}
|
|
4727
|
+
if (beat.kind === "accepted")
|
|
4728
|
+
break;
|
|
4729
|
+
if (beat.kind === "stale_claim") {
|
|
4730
|
+
return await abandonBeforeLaunch({ status: "abandoned", reason: "stale_claim" });
|
|
4731
|
+
}
|
|
4732
|
+
if (beat.kind === "server_stop") {
|
|
4733
|
+
// Report first, then mark the claim abandoned, exactly as the post-spawn
|
|
4734
|
+
// server-stop path does: an abandoned claim skips the terminal report.
|
|
4735
|
+
const stopped = await notLaunched("the server requested this run stop before the worker launched");
|
|
4736
|
+
ownership.abandoned = true;
|
|
4737
|
+
ownership.abandonReason = "server_stop";
|
|
4738
|
+
return stopped.status === "failed" ? { status: "abandoned", reason: "server_stop" } : stopped;
|
|
4739
|
+
}
|
|
4740
|
+
if (beat.kind === "auth_fatal") {
|
|
4741
|
+
control?.reportAuthFatal?.({
|
|
4742
|
+
jobId: job.id,
|
|
4743
|
+
repoName: job.repo_name,
|
|
4744
|
+
category: "heartbeat",
|
|
4745
|
+
httpStatus: beat.httpStatus,
|
|
4746
|
+
});
|
|
4747
|
+
return await abandonBeforeLaunch({ status: "abandoned", reason: "auth_fatal" });
|
|
4748
|
+
}
|
|
4749
|
+
// transient
|
|
4750
|
+
if (deps.now() - ownership.lastSuccessfulHeartbeatAt >= options.deadmanMs) {
|
|
4751
|
+
return await notLaunched("ownership could not be recorded with the server before launch; not launching the worker");
|
|
4752
|
+
}
|
|
4753
|
+
await deps.sleep(Math.min(jobOptions.heartbeatIntervalMs, 5_000));
|
|
4754
|
+
}
|
|
4755
|
+
// The claim, the run, and the executor can all change while the beat was in
|
|
4756
|
+
// flight; check once more immediately before the spawn.
|
|
4757
|
+
if (ownership.abandoned) {
|
|
4758
|
+
return await abandonBeforeLaunch({ status: "abandoned", reason: ownership.abandonReason });
|
|
4759
|
+
}
|
|
4760
|
+
if (control?.liveWorkers.isShutdownRequested() === true) {
|
|
4761
|
+
return await notLaunched("the executor is shutting down; not launching the worker");
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4162
4764
|
// --- Spawn (held stdin) ---------------------------------------------------
|
|
4163
4765
|
let proc;
|
|
4164
4766
|
try {
|
|
@@ -4451,7 +5053,7 @@ async function runCoveredAttempt(params) {
|
|
|
4451
5053
|
send: () => httpClient.fail(job, {
|
|
4452
5054
|
...failure,
|
|
4453
5055
|
...commitResidue(git),
|
|
4454
|
-
telemetry: observation.snapshot(),
|
|
5056
|
+
telemetry: withFailureTelemetry(observation.snapshot(), failure.telemetry),
|
|
4455
5057
|
completion_contract: SUPPORTED_COMPLETION_MARKER,
|
|
4456
5058
|
completion_evidence: envelope,
|
|
4457
5059
|
...extra,
|
|
@@ -4467,6 +5069,53 @@ async function runCoveredAttempt(params) {
|
|
|
4467
5069
|
// The terminal report is acknowledged (or ownership is gone): only now does
|
|
4468
5070
|
// the job-level heartbeat end, and only now is the restart record retired.
|
|
4469
5071
|
await endHeartbeat();
|
|
5072
|
+
// BAPI-1504 — a lost claim after a CONFIRMED sweep: deliver the cleanup fact
|
|
5073
|
+
// before the record can be removed. The first attempt is awaited (bounded by
|
|
5074
|
+
// the HTTP client); a retained outcome continues in the background and the
|
|
5075
|
+
// record stays until a terminal server answer.
|
|
5076
|
+
if (requiresCleanupReport &&
|
|
5077
|
+
claimIsCovered(job) &&
|
|
5078
|
+
result.status === "abandoned" &&
|
|
5079
|
+
(result.reason === "stale_claim" || result.reason === "deadman") &&
|
|
5080
|
+
cleanup.verdict === "confirmed" &&
|
|
5081
|
+
cleanup.survivors === 0) {
|
|
5082
|
+
const first = await submitCleanupReport({
|
|
5083
|
+
httpClient, jobId: job.id, repoName: job.repo_name, ownershipToken: token, path: "wake",
|
|
5084
|
+
});
|
|
5085
|
+
if (cleanupReportPermitsRecordRemoval(first)) {
|
|
5086
|
+
await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
|
|
5087
|
+
}
|
|
5088
|
+
else if (first.kind === "retained" && first.retryable) {
|
|
5089
|
+
const retry = retryWakeCleanupReport({
|
|
5090
|
+
httpClient,
|
|
5091
|
+
jobId: job.id,
|
|
5092
|
+
repoName: job.repo_name,
|
|
5093
|
+
ownershipToken: token,
|
|
5094
|
+
firstResult: first,
|
|
5095
|
+
intervalMs: jobOptions.heartbeatIntervalMs,
|
|
5096
|
+
shouldStop: () => control?.liveWorkers.isShutdownRequested() === true,
|
|
5097
|
+
deps: {
|
|
5098
|
+
now: deps.now,
|
|
5099
|
+
sleep: deps.sleep,
|
|
5100
|
+
diagnostic: (d) => deps.errorLog(`[executor] ${d.event} job=${d.jobId}`),
|
|
5101
|
+
},
|
|
5102
|
+
onTerminal: () => removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined),
|
|
5103
|
+
});
|
|
5104
|
+
if (control?.trackBackground)
|
|
5105
|
+
control.trackBackground(retry);
|
|
5106
|
+
else
|
|
5107
|
+
void retry;
|
|
5108
|
+
}
|
|
5109
|
+
// Any other retained outcome keeps the record for the next start.
|
|
5110
|
+
return result;
|
|
5111
|
+
}
|
|
5112
|
+
// BAPI-1504 — an abandoned covered attempt whose sweep did NOT confirm keeps
|
|
5113
|
+
// its record, so the next start can sweep again and report only then.
|
|
5114
|
+
if (requiresCleanupReport &&
|
|
5115
|
+
result.status === "abandoned" &&
|
|
5116
|
+
(result.reason === "stale_claim" || result.reason === "deadman")) {
|
|
5117
|
+
return result;
|
|
5118
|
+
}
|
|
4470
5119
|
await removeOwnershipRecord(stateDir, job.id, recordFs).catch(() => undefined);
|
|
4471
5120
|
return result;
|
|
4472
5121
|
};
|
|
@@ -4552,6 +5201,7 @@ async function runCoveredAttempt(params) {
|
|
|
4552
5201
|
verificationFacts = {
|
|
4553
5202
|
provenance: declaration.provenance,
|
|
4554
5203
|
declaration_digest: declaration.digest,
|
|
5204
|
+
...(declaration.policyVersion === 2 ? { deferred_obligation_identities: declaration.deferredIdentities ?? [] } : {}),
|
|
4555
5205
|
tested_head_sha: null,
|
|
4556
5206
|
artifact: "not_observed",
|
|
4557
5207
|
commands: [],
|
|
@@ -4631,6 +5281,7 @@ async function runCoveredAttempt(params) {
|
|
|
4631
5281
|
verificationFacts = {
|
|
4632
5282
|
provenance: "declared",
|
|
4633
5283
|
declaration_digest: declaration.digest,
|
|
5284
|
+
...(declaration.policyVersion === 2 ? { deferred_obligation_identities: declaration.deferredIdentities ?? [] } : {}),
|
|
4634
5285
|
tested_head_sha: before.head,
|
|
4635
5286
|
artifact,
|
|
4636
5287
|
commands: declaredOrderFacts(checks, runFacts).map(toCompletionCheckFact),
|
|
@@ -4657,11 +5308,22 @@ async function runCoveredAttempt(params) {
|
|
|
4657
5308
|
? { ...ciResult.fields, ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status }
|
|
4658
5309
|
: { ...executorResult, [CI_FIX_CLAIMS_STATUS_KEY]: ciResult.status };
|
|
4659
5310
|
}
|
|
5311
|
+
// BAPI-1426: the server-confirmed delivery outcome, read under the same
|
|
5312
|
+
// absolute deadline as the finalization reads below.
|
|
5313
|
+
const deliveryOutcome = await readDeliveryOutcomeForFinalization({
|
|
5314
|
+
job,
|
|
5315
|
+
branch,
|
|
5316
|
+
deps,
|
|
5317
|
+
seams,
|
|
5318
|
+
remainingMs: () => Math.max(0, plan.deadlineAtMs - deps.now()),
|
|
5319
|
+
resolvedBaseUrl: options.resolvedBaseUrl,
|
|
5320
|
+
});
|
|
4660
5321
|
const finalization = await validateWorkerFinalization({
|
|
4661
5322
|
job,
|
|
4662
5323
|
branch,
|
|
4663
5324
|
worktreePath,
|
|
4664
5325
|
result: genericResult,
|
|
5326
|
+
...(deliveryOutcome !== undefined ? { deliveryOutcome } : {}),
|
|
4665
5327
|
publishRetainedHead: retainedHeadPublisher({ job, branch, worktreePath, prep, deps, ownership, observation, httpClient, options: jobOptions, control,
|
|
4666
5328
|
remainingMs: () => Math.max(0, plan.deadlineAtMs - deps.now()) }),
|
|
4667
5329
|
callTimeoutMs: () => Math.min(120_000, Math.max(0, plan.deadlineAtMs - deps.now())),
|
|
@@ -4672,8 +5334,17 @@ async function runCoveredAttempt(params) {
|
|
|
4672
5334
|
// the existence-only legacy fallback is refused here.
|
|
4673
5335
|
coveredAttempt: true,
|
|
4674
5336
|
});
|
|
4675
|
-
|
|
4676
|
-
|
|
5337
|
+
// BAPI-1426: a server-confirmed delivery hold proved the push (origin equals
|
|
5338
|
+
// the worker HEAD and the server's preserved head) and deliberately left the
|
|
5339
|
+
// pull request unmet. Its completion cause is set on the envelope so the
|
|
5340
|
+
// envelope, `successRefusalReason` and the `/fail` error kind all agree. A
|
|
5341
|
+
// stronger cause the envelope already derived (cleanup, a locally failed
|
|
5342
|
+
// check, unfinished tasks) is kept.
|
|
5343
|
+
const deliveryHold = finalization.ok ? null : deliveryHoldOf(finalization.failure);
|
|
5344
|
+
const facts = deliveryHold
|
|
5345
|
+
? { verdict: "unmet", pushSatisfied: true, prSatisfied: false }
|
|
5346
|
+
: finalizationFacts(finalization.ok ? { ok: true } : { ok: false, errorKind: finalization.failure.error_kind });
|
|
5347
|
+
const derivedEnvelope = envelopeWith({
|
|
4677
5348
|
finalization: facts.verdict,
|
|
4678
5349
|
verification,
|
|
4679
5350
|
missing: [],
|
|
@@ -4681,6 +5352,14 @@ async function runCoveredAttempt(params) {
|
|
|
4681
5352
|
prSatisfied: facts.prSatisfied,
|
|
4682
5353
|
...(verificationFacts ? { verificationFacts } : {}),
|
|
4683
5354
|
});
|
|
5355
|
+
const deliveryHoldCause = deliveryHold
|
|
5356
|
+
? deliveryHold.outcome === "verification_failed"
|
|
5357
|
+
? "required_check_failed"
|
|
5358
|
+
: "required_work_incomplete"
|
|
5359
|
+
: null;
|
|
5360
|
+
const envelope = deliveryHoldCause !== null && derivedEnvelope.cause === null
|
|
5361
|
+
? { ...derivedEnvelope, cause: deliveryHoldCause }
|
|
5362
|
+
: derivedEnvelope;
|
|
4684
5363
|
// --- Phase 5: terminal report -----------------------------------------------------
|
|
4685
5364
|
const lateStronger = await strongerOutcome();
|
|
4686
5365
|
if (lateStronger)
|
|
@@ -4723,6 +5402,11 @@ async function runCoveredAttempt(params) {
|
|
|
4723
5402
|
...observedExitCode(procResult.exitCode),
|
|
4724
5403
|
};
|
|
4725
5404
|
const cause = envelope.cause;
|
|
5405
|
+
if (cause && !finalization.ok && deliveryHold && cause === deliveryHoldCause) {
|
|
5406
|
+
// BAPI-1426: the intentional delivery hold keeps its own bounded message and
|
|
5407
|
+
// hold marker; its error kind already equals the envelope's cause.
|
|
5408
|
+
return finish(await failWith({ ...finalization.failure, error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause], ...observedExit }, envelope));
|
|
5409
|
+
}
|
|
4726
5410
|
if (cause) {
|
|
4727
5411
|
return finish(await failWith({
|
|
4728
5412
|
error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
|