@bridge_gpt/mcp-server 0.2.62 → 0.2.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -132,3 +132,145 @@ export function commitResidue(telemetry) {
|
|
|
132
132
|
}
|
|
133
133
|
return out;
|
|
134
134
|
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// BAPI-1296 — the CI-only fixed-path result reader.
|
|
137
|
+
//
|
|
138
|
+
// A `ci_fix` worker writes `<worktree>/.conductor/result.json` with its per-check
|
|
139
|
+
// mapping (and, optionally, an external-attribution diagnosis). Before this
|
|
140
|
+
// ticket the executor never read that file for `ci_fix`, so the mapping the
|
|
141
|
+
// server's exit contract compares against never arrived and every clean exit
|
|
142
|
+
// looked incomplete. This reader transports it — bounded, allowlisted, and
|
|
143
|
+
// read only after THIS attempt's worker exited (the locked pre-spawn cleanup in
|
|
144
|
+
// `job-runner.ts` removed any earlier attempt's file) — and nothing else.
|
|
145
|
+
//
|
|
146
|
+
// It is TELEMETRY transport, not certification: the executor never decides
|
|
147
|
+
// that coverage is complete because the process exited cleanly, never lets the
|
|
148
|
+
// file supply branch/head/PR identifiers, and never lets it name a transition.
|
|
149
|
+
// The server re-derives completeness from the job row's dispatched set.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
/** Largest `ci_fix` result artifact read, in bytes. */
|
|
152
|
+
export const CI_FIX_RESULT_MAX_BYTES = 16_384;
|
|
153
|
+
/** Most entries in `check_fix_claims` (mirrors the dispatch cap). */
|
|
154
|
+
export const CI_FIX_CLAIMS_MAX_ENTRIES = 32;
|
|
155
|
+
/** Longest single claim value, in UTF-8 bytes. */
|
|
156
|
+
export const CI_FIX_CLAIM_MAX_BYTES = 512;
|
|
157
|
+
/** Longest attribution reason, in UTF-8 bytes. */
|
|
158
|
+
export const CI_FIX_ATTRIBUTION_REASON_MAX_BYTES = 300;
|
|
159
|
+
/** The per-check mapping key (Python `CI_FIX_CHECK_CLAIMS_KEY`). */
|
|
160
|
+
export const CI_FIX_CHECK_CLAIMS_KEY = "check_fix_claims";
|
|
161
|
+
/** The attribution keys and sentinel (Python `CI_FIX_ATTRIBUTION_*`). */
|
|
162
|
+
export const CI_FIX_ATTRIBUTION_KEY = "ci_failure_attribution";
|
|
163
|
+
export const CI_FIX_ATTRIBUTION_REASON_KEY = "ci_failure_attribution_reason";
|
|
164
|
+
export const CI_FIX_ATTRIBUTION_EXTERNAL = "external";
|
|
165
|
+
/**
|
|
166
|
+
* Executor-owned status of the artifact, carried at `result.check_fix_claims_status`
|
|
167
|
+
* so an operator can tell "the worker wrote nothing" from "the worker wrote
|
|
168
|
+
* something unusable" from "the worker covered only part of the set". It is the
|
|
169
|
+
* executor's own observation, never a worker-supplied value.
|
|
170
|
+
*/
|
|
171
|
+
export const CI_FIX_CLAIMS_STATUS_KEY = "check_fix_claims_status";
|
|
172
|
+
const CI_FIX_RESULT_ALLOWED_KEYS = new Set([
|
|
173
|
+
CI_FIX_CHECK_CLAIMS_KEY,
|
|
174
|
+
CI_FIX_ATTRIBUTION_KEY,
|
|
175
|
+
CI_FIX_ATTRIBUTION_REASON_KEY,
|
|
176
|
+
]);
|
|
177
|
+
function isPlainRecord(value) {
|
|
178
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Read and validate `<worktree>/.conductor/result.json` for a `ci_fix` job.
|
|
182
|
+
*
|
|
183
|
+
* `requiredChecks` is the executor-validated dispatched union. A claim for any
|
|
184
|
+
* name outside it, an extra top-level key, an oversized/blank value, an unpaired
|
|
185
|
+
* attribution, a non-regular file, a file over {@link CI_FIX_RESULT_MAX_BYTES},
|
|
186
|
+
* or invalid JSON makes the whole artifact `invalid` — nothing from it is
|
|
187
|
+
* forwarded. A valid artifact that covers only part of the set is `partial` and
|
|
188
|
+
* IS forwarded, so the server's contract check sees exactly what was claimed.
|
|
189
|
+
* Never throws; `code` is a fixed, secret-free token.
|
|
190
|
+
*/
|
|
191
|
+
export async function readCiFixResultArtifact(worktreePath, requiredChecks, deps) {
|
|
192
|
+
const artifactPath = path.join(worktreePath, ".conductor", "result.json");
|
|
193
|
+
if (deps.lstatPath) {
|
|
194
|
+
let meta;
|
|
195
|
+
try {
|
|
196
|
+
meta = await deps.lstatPath(artifactPath);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return { status: "invalid", code: "artifact_unreadable" };
|
|
200
|
+
}
|
|
201
|
+
if (meta === null)
|
|
202
|
+
return { status: "missing", code: "artifact_absent" };
|
|
203
|
+
if (meta.isDirectory || meta.isSymbolicLink) {
|
|
204
|
+
return { status: "invalid", code: "artifact_not_regular_file" };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await deps.readFile(artifactPath);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
213
|
+
return code === "ENOENT"
|
|
214
|
+
? { status: "missing", code: "artifact_absent" }
|
|
215
|
+
: { status: "invalid", code: "artifact_unreadable" };
|
|
216
|
+
}
|
|
217
|
+
if (typeof raw !== "string")
|
|
218
|
+
return { status: "invalid", code: "artifact_unreadable" };
|
|
219
|
+
if (byteLength(raw) > CI_FIX_RESULT_MAX_BYTES) {
|
|
220
|
+
return { status: "invalid", code: "artifact_oversized" };
|
|
221
|
+
}
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(raw);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { status: "invalid", code: "artifact_not_json" };
|
|
228
|
+
}
|
|
229
|
+
if (!isPlainRecord(parsed))
|
|
230
|
+
return { status: "invalid", code: "artifact_not_object" };
|
|
231
|
+
for (const key of Object.keys(parsed)) {
|
|
232
|
+
if (!CI_FIX_RESULT_ALLOWED_KEYS.has(key)) {
|
|
233
|
+
return { status: "invalid", code: "artifact_unexpected_key" };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const claims = parsed[CI_FIX_CHECK_CLAIMS_KEY];
|
|
237
|
+
if (!isPlainRecord(claims))
|
|
238
|
+
return { status: "invalid", code: "claims_not_object" };
|
|
239
|
+
const claimNames = Object.keys(claims);
|
|
240
|
+
if (claimNames.length > CI_FIX_CLAIMS_MAX_ENTRIES) {
|
|
241
|
+
return { status: "invalid", code: "claims_too_many" };
|
|
242
|
+
}
|
|
243
|
+
const required = new Set(requiredChecks);
|
|
244
|
+
const validatedClaims = {};
|
|
245
|
+
for (const name of claimNames) {
|
|
246
|
+
const value = claims[name];
|
|
247
|
+
if (!required.has(name))
|
|
248
|
+
return { status: "invalid", code: "claims_undispatched_check" };
|
|
249
|
+
if (typeof value !== "string" ||
|
|
250
|
+
value.trim().length === 0 ||
|
|
251
|
+
byteLength(value) > CI_FIX_CLAIM_MAX_BYTES) {
|
|
252
|
+
return { status: "invalid", code: "claims_value_invalid" };
|
|
253
|
+
}
|
|
254
|
+
validatedClaims[name] = value;
|
|
255
|
+
}
|
|
256
|
+
const fields = { [CI_FIX_CHECK_CLAIMS_KEY]: validatedClaims };
|
|
257
|
+
const hasAttribution = Object.prototype.hasOwnProperty.call(parsed, CI_FIX_ATTRIBUTION_KEY);
|
|
258
|
+
const hasReason = Object.prototype.hasOwnProperty.call(parsed, CI_FIX_ATTRIBUTION_REASON_KEY);
|
|
259
|
+
if (hasAttribution || hasReason) {
|
|
260
|
+
const attribution = parsed[CI_FIX_ATTRIBUTION_KEY];
|
|
261
|
+
const reason = parsed[CI_FIX_ATTRIBUTION_REASON_KEY];
|
|
262
|
+
if (!hasAttribution ||
|
|
263
|
+
!hasReason ||
|
|
264
|
+
typeof attribution !== "string" ||
|
|
265
|
+
attribution.trim().toLowerCase() !== CI_FIX_ATTRIBUTION_EXTERNAL ||
|
|
266
|
+
typeof reason !== "string" ||
|
|
267
|
+
reason.trim().length === 0 ||
|
|
268
|
+
byteLength(reason) > CI_FIX_ATTRIBUTION_REASON_MAX_BYTES) {
|
|
269
|
+
return { status: "invalid", code: "attribution_invalid" };
|
|
270
|
+
}
|
|
271
|
+
fields[CI_FIX_ATTRIBUTION_KEY] = CI_FIX_ATTRIBUTION_EXTERNAL;
|
|
272
|
+
fields[CI_FIX_ATTRIBUTION_REASON_KEY] = reason;
|
|
273
|
+
}
|
|
274
|
+
const covered = requiredChecks.every((name) => Object.prototype.hasOwnProperty.call(validatedClaims, name));
|
|
275
|
+
return { status: covered ? "complete" : "partial", fields };
|
|
276
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* §16 — the amended proposal explicitly rejected `fetch && reset --hard`).
|
|
15
15
|
*/
|
|
16
16
|
import { createWorktreeForTicket } from "../worktree-core.js";
|
|
17
|
-
import { ExecutorNamedError, WorktreeLostBeforePush } from "./job-errors.js";
|
|
17
|
+
import { ContinuationLineageMismatch, ContinuationWorktreeMissing, ExecutorNamedError, WorktreeLostBeforePush, } from "./job-errors.js";
|
|
18
18
|
import { assertWorktreeOnBranch, listGitWorktrees, findWorktreeByBranch, remoteBranchExists as remoteBranchExistsDefault, } from "./worktree-inspection.js";
|
|
19
19
|
/** The canonical WIP checkpoint commit message (executor-run, deterministic). */
|
|
20
20
|
export const RESUME_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before resume";
|
|
@@ -119,13 +119,13 @@ export async function recreateResumeWorktreeFromPushedBranch(job, expectedBranch
|
|
|
119
119
|
* is always clean and complete. No-op cleanly when the tree is already clean.
|
|
120
120
|
* NEVER discards work.
|
|
121
121
|
*/
|
|
122
|
-
export async function autoCheckpointDirtyState(worktreePath, seams) {
|
|
122
|
+
export async function autoCheckpointDirtyState(worktreePath, seams, message = RESUME_WIP_COMMIT_MESSAGE) {
|
|
123
123
|
const status = await seams.runCommand("git", ["status", "--porcelain"], { cwd: worktreePath });
|
|
124
124
|
if (status.stdout.trim().length === 0) {
|
|
125
125
|
return { committed: false };
|
|
126
126
|
}
|
|
127
127
|
await seams.runCommand("git", ["add", "-A"], { cwd: worktreePath });
|
|
128
|
-
await seams.runCommand("git", ["commit", "-m",
|
|
128
|
+
await seams.runCommand("git", ["commit", "-m", message], {
|
|
129
129
|
cwd: worktreePath,
|
|
130
130
|
});
|
|
131
131
|
return { committed: true };
|
|
@@ -144,6 +144,75 @@ export async function readResumeGitDiff(worktreePath, baseBranch, seams) {
|
|
|
144
144
|
export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
|
|
145
145
|
return template.split("{{GIT_LOG}}").join(gitLog).split("{{GIT_DIFF}}").join(gitDiff);
|
|
146
146
|
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// BAPI-1300 — parked-child continuation: retained-work verification
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
/** The deterministic WIP checkpoint message for a continuation. */
|
|
151
|
+
export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
|
|
152
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
153
|
+
/** Read the continuation binding from a claimed job's payload (pure). */
|
|
154
|
+
export function readContinuationBinding(job) {
|
|
155
|
+
const payload = job.payload && typeof job.payload === "object" ? job.payload : null;
|
|
156
|
+
const raw = payload?.continuation;
|
|
157
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
158
|
+
return null;
|
|
159
|
+
const block = raw;
|
|
160
|
+
if (block.retained_worktree !== true)
|
|
161
|
+
return null;
|
|
162
|
+
const receiptId = asString(block.receipt_id);
|
|
163
|
+
if (!receiptId)
|
|
164
|
+
return null;
|
|
165
|
+
const head = asString(block.bound_head_sha)?.toLowerCase() ?? null;
|
|
166
|
+
return {
|
|
167
|
+
receiptId,
|
|
168
|
+
branch: asString(block.branch) ?? null,
|
|
169
|
+
boundHeadSha: head !== null && FULL_SHA.test(head) ? head : null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Prove the retained worktree's HEAD descends from the authorized head.
|
|
174
|
+
*
|
|
175
|
+
* Run BEFORE the WIP checkpoint: a checkpoint commits onto whatever HEAD is, so
|
|
176
|
+
* checking afterwards would already have written onto an unrelated history. A
|
|
177
|
+
* HEAD that equals the bound head, or has it as an ancestor (the worker's own
|
|
178
|
+
* later local commits), is accepted; anything else — including a bound head the
|
|
179
|
+
* worktree does not contain at all — is refused. No bound head (nothing was ever
|
|
180
|
+
* pushed) leaves only the branch check, which the caller has already made.
|
|
181
|
+
*/
|
|
182
|
+
export async function verifyContinuationHeadLineage(worktreePath, boundHeadSha, seams) {
|
|
183
|
+
if (boundHeadSha === null)
|
|
184
|
+
return;
|
|
185
|
+
const result = await seams.runCommand("git", ["merge-base", "--is-ancestor", boundHeadSha, "HEAD"], { cwd: worktreePath });
|
|
186
|
+
if (result.exitCode !== 0) {
|
|
187
|
+
throw new ExecutorNamedError(ContinuationLineageMismatch, `continuation worktree HEAD does not descend from the authorized head ${boundHeadSha.slice(0, 12)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Locate and verify a continuation's RETAINED worktree, then checkpoint it.
|
|
192
|
+
*
|
|
193
|
+
* Order: resolve the existing worktree (never recreate) -> verify its branch ->
|
|
194
|
+
* verify its head lineage -> WIP-checkpoint dirty state. A missing worktree is a
|
|
195
|
+
* refusal ({@link ContinuationWorktreeMissing}), not a recreation from origin:
|
|
196
|
+
* recreation cannot restore unpushed commits, and continuing without them would
|
|
197
|
+
* silently discard exactly the work the continuation exists to carry. The
|
|
198
|
+
* checkpoint is recorded as lineage of the authorized head; it never adopts an
|
|
199
|
+
* arbitrary head. No destructive Git operation is ever issued.
|
|
200
|
+
*/
|
|
201
|
+
export async function prepareRetainedContinuationWorktree(job, binding, seams) {
|
|
202
|
+
const fields = readResumePayloadFields(job);
|
|
203
|
+
const expectedBranch = asString(job.expected_branch) ?? fields.expectedBranch ?? binding.branch ?? undefined;
|
|
204
|
+
if (!expectedBranch) {
|
|
205
|
+
throw new ExecutorNamedError("ResumeMissingExpectedBranch", `continuation job ${job.id} has no expected_branch`);
|
|
206
|
+
}
|
|
207
|
+
const worktreePath = await resolveExistingResumeWorktreePath(fields, expectedBranch, seams);
|
|
208
|
+
if (worktreePath === null) {
|
|
209
|
+
throw new ExecutorNamedError(ContinuationWorktreeMissing, `continuation for '${expectedBranch}' found no retained worktree; it is never recreated from origin because unpushed work cannot be recovered that way`);
|
|
210
|
+
}
|
|
211
|
+
await assertWorktreeOnBranch(seams.runCommand, worktreePath, expectedBranch);
|
|
212
|
+
await verifyContinuationHeadLineage(worktreePath, binding.boundHeadSha, seams);
|
|
213
|
+
const checkpoint = await autoCheckpointDirtyState(worktreePath, seams, CONTINUATION_WIP_COMMIT_MESSAGE);
|
|
214
|
+
return { worktreePath, branch: expectedBranch, checkpointed: checkpoint.committed };
|
|
215
|
+
}
|
|
147
216
|
/**
|
|
148
217
|
* Orchestrate the resume pre-spawn protocol in the required order:
|
|
149
218
|
* 1. read payload fields
|
|
@@ -165,14 +234,28 @@ export async function prepareResumeSpawn(job, options, seams) {
|
|
|
165
234
|
// Fail loud on a missing/empty prompt template BEFORE any worktree resolution
|
|
166
235
|
// or WIP-checkpoint commit — never spawn an empty-prompt worker.
|
|
167
236
|
const promptTemplate = resolveResumePromptTemplate(job, fields);
|
|
237
|
+
// BAPI-1300: a resume dispatched as a parked-child continuation carries a
|
|
238
|
+
// binding and follows the retained-work protocol — never recreated from origin,
|
|
239
|
+
// and its head lineage is proven before the checkpoint. Every other resume job
|
|
240
|
+
// keeps the original protocol below unchanged.
|
|
241
|
+
const continuation = readContinuationBinding(job);
|
|
168
242
|
let worktreePath = await resolveExistingResumeWorktreePath(fields, expectedBranch, seams);
|
|
169
243
|
if (worktreePath === null) {
|
|
244
|
+
if (continuation !== null) {
|
|
245
|
+
throw new ExecutorNamedError(ContinuationWorktreeMissing, `continuation for '${expectedBranch}' found no retained worktree; it is never recreated from origin because unpushed work cannot be recovered that way`);
|
|
246
|
+
}
|
|
170
247
|
worktreePath = await recreateResumeWorktreeFromPushedBranch(job, expectedBranch, seams);
|
|
171
248
|
}
|
|
172
249
|
// Branch verification BEFORE any local mutation — never checkpoint/commit on
|
|
173
250
|
// the wrong branch.
|
|
174
251
|
await assertWorktreeOnBranch(seams.runCommand, worktreePath, expectedBranch);
|
|
175
|
-
|
|
252
|
+
if (continuation !== null) {
|
|
253
|
+
await verifyContinuationHeadLineage(worktreePath, continuation.boundHeadSha, seams);
|
|
254
|
+
await autoCheckpointDirtyState(worktreePath, seams, CONTINUATION_WIP_COMMIT_MESSAGE);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
await autoCheckpointDirtyState(worktreePath, seams);
|
|
258
|
+
}
|
|
176
259
|
const gitLog = await readResumeGitLog(worktreePath, baseBranch, seams);
|
|
177
260
|
const gitDiff = await readResumeGitDiff(worktreePath, baseBranch, seams);
|
|
178
261
|
const prompt = fillResumePromptPlaceholders(promptTemplate, gitLog, gitDiff);
|
package/build/executor/runner.js
CHANGED
|
@@ -10,10 +10,14 @@ import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } f
|
|
|
10
10
|
import { runClaimedJob, runIntegrationCorrectionSpawn } from "./job-runner.js";
|
|
11
11
|
import { appendToActiveJobLogs } from "./job-log-registry.js";
|
|
12
12
|
import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
|
|
13
|
+
import os from "node:os";
|
|
13
14
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
15
|
+
import { createMacProcessInspector, hostIdentityDigest, recoverInterruptedOwnership, } from "./process-containment.js";
|
|
16
|
+
import { resolveExecutorStateDir } from "./job-log-registry.js";
|
|
14
17
|
import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
|
|
15
18
|
import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
|
|
16
19
|
import { validateBranchName } from "../base-ref.js";
|
|
20
|
+
import { isTerminalEpicRunStatus } from "../epic-run-status.js";
|
|
17
21
|
import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
18
22
|
/**
|
|
19
23
|
* Signals that request a graceful executor shutdown (BAPI-828).
|
|
@@ -96,6 +100,16 @@ export function formatProcessHeartbeatFailureDiagnostic() {
|
|
|
96
100
|
export function formatProcessHeartbeatRecoveredDiagnostic() {
|
|
97
101
|
return "executor: process-heartbeat delivery has recovered.";
|
|
98
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* BAPI-1279 — the diagnostic for a failed run-state read under
|
|
105
|
+
* `--exit-when-run-terminal`. Fixed text plus a closed reason token: no body,
|
|
106
|
+
* no URL, no credential. The executor keeps claiming; a failed read never
|
|
107
|
+
* causes an exit.
|
|
108
|
+
*/
|
|
109
|
+
export function formatRunTerminalReadFailureDiagnostic(reason) {
|
|
110
|
+
return (`executor: could not read the scoped run's state (reason=${reason}); ` +
|
|
111
|
+
"staying up and claiming until it can be read.");
|
|
112
|
+
}
|
|
99
113
|
/** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
|
|
100
114
|
function correctionTimeoutSeconds(deadlineAt, nowMs) {
|
|
101
115
|
const deadline = Date.parse(deadlineAt);
|
|
@@ -103,6 +117,23 @@ function correctionTimeoutSeconds(deadlineAt, nowMs) {
|
|
|
103
117
|
return 60;
|
|
104
118
|
return Math.max(60, Math.floor((deadline - nowMs) / 1000) - 60);
|
|
105
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* BAPI-1301: the read/remove-only record filesystem startup recovery uses, built
|
|
122
|
+
* from the executor's injected deps. Without a `readdir` seam there is nothing
|
|
123
|
+
* to list, so recovery is a no-op; recovery never writes a record.
|
|
124
|
+
*/
|
|
125
|
+
function recoveryRecordFs(deps) {
|
|
126
|
+
const refuseWrite = async () => {
|
|
127
|
+
throw new Error("startup ownership recovery never writes records");
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
mkdir: refuseWrite,
|
|
131
|
+
writeFile: refuseWrite,
|
|
132
|
+
readFile: (filePath) => deps.readFile(filePath),
|
|
133
|
+
readdir: deps.readdir ? (dirPath) => deps.readdir(dirPath) : async () => [],
|
|
134
|
+
removeFile: deps.removeFile ? (filePath) => deps.removeFile(filePath) : async () => { },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
106
137
|
/**
|
|
107
138
|
* Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
|
|
108
139
|
* in continuous mode it does not return (the process is long-lived).
|
|
@@ -121,6 +152,40 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
121
152
|
statMtimeMs: deps.statMtimeMs ?? (async () => null),
|
|
122
153
|
platform: deps.platform,
|
|
123
154
|
}));
|
|
155
|
+
// BAPI-1301 — before the first claim, inspect ownership records left by a
|
|
156
|
+
// previous executor process on this host. Each record holds the real per-job
|
|
157
|
+
// token, so the sweep can only ever reach processes that job tagged. The
|
|
158
|
+
// SERVER still parks such a job as `cleanup_unconfirmed` (it cannot know this
|
|
159
|
+
// inspection happened); the verdict here is logged for the operator.
|
|
160
|
+
const recoverOwnedProcesses = seams.recoverOwnedProcesses ??
|
|
161
|
+
(async () => {
|
|
162
|
+
const results = await recoverInterruptedOwnership({
|
|
163
|
+
stateDir: resolveExecutorStateDir({ env: deps.env, tmpdir: () => os.tmpdir(), platform: deps.platform }),
|
|
164
|
+
inspector: createMacProcessInspector({
|
|
165
|
+
runCommand: deps.runCommand,
|
|
166
|
+
selfPid: process.pid,
|
|
167
|
+
platform: deps.platform,
|
|
168
|
+
}),
|
|
169
|
+
deps: { now: deps.now, sleep: deps.sleep, log: deps.errorLog },
|
|
170
|
+
// Recovery only lists, reads and removes records, so it goes through the
|
|
171
|
+
// executor's own filesystem seams: an embedded caller or a test with
|
|
172
|
+
// in-memory deps never touches the real state directory.
|
|
173
|
+
fsDeps: recoveryRecordFs(deps),
|
|
174
|
+
deadlineMs: 30_000,
|
|
175
|
+
hostDigest: hostIdentityDigest(os.hostname()),
|
|
176
|
+
selfPid: process.pid,
|
|
177
|
+
});
|
|
178
|
+
for (const r of results) {
|
|
179
|
+
deps.errorLog(`[executor] startup ownership inspection: job ${r.jobId} cleanup ${r.verdict.verdict} ` +
|
|
180
|
+
`(survivors=${r.verdict.survivors})`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
await recoverOwnedProcesses();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
deps.errorLog(`[executor] startup ownership inspection failed (non-fatal): ${err instanceof Error ? err.message.slice(0, 200) : "unknown error"}`);
|
|
188
|
+
}
|
|
124
189
|
const active = new Map();
|
|
125
190
|
// BAPI-1259 — integration actions share `active` (and therefore the same
|
|
126
191
|
// capacity budget and shutdown drain) with ordinary jobs, keyed by negative
|
|
@@ -293,6 +358,50 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
293
358
|
deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
|
|
294
359
|
}
|
|
295
360
|
};
|
|
361
|
+
// BAPI-1279 — `--exit-when-run-terminal`. Only ever true for a process scoped
|
|
362
|
+
// to exactly one run; the CLI refuses the flag otherwise, and this re-check
|
|
363
|
+
// keeps an embedded caller from arming it on an ambiguous scope.
|
|
364
|
+
const exitWhenRunTerminal = options.exitWhenRunTerminal === true && observedEpicRunId !== null;
|
|
365
|
+
let runTerminal = false;
|
|
366
|
+
// The last failure reason reported, so a long outage logs once per change
|
|
367
|
+
// rather than once per poll.
|
|
368
|
+
let lastRunStateFailure = null;
|
|
369
|
+
/**
|
|
370
|
+
* Is the scoped run finished? Called only between claim cycles while this
|
|
371
|
+
* process holds NO job. The wind-down observer runs FIRST, so a run that just
|
|
372
|
+
* reached `done` still gets its integration-PR ready request before the
|
|
373
|
+
* executor retires. Any failed read returns `false`: an executor that cannot
|
|
374
|
+
* see its run keeps claiming rather than guessing it is done.
|
|
375
|
+
*/
|
|
376
|
+
const scopedRunIsTerminal = async () => {
|
|
377
|
+
if (!exitWhenRunTerminal || observedEpicRunId === null)
|
|
378
|
+
return false;
|
|
379
|
+
await observeEpicRunCompletion();
|
|
380
|
+
const readState = httpClient.readEpicRunState;
|
|
381
|
+
if (typeof readState !== "function")
|
|
382
|
+
return false;
|
|
383
|
+
let reason = null;
|
|
384
|
+
let terminal = false;
|
|
385
|
+
try {
|
|
386
|
+
const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
|
|
387
|
+
if (result.ok)
|
|
388
|
+
terminal = isTerminalEpicRunStatus(result.state.status);
|
|
389
|
+
else
|
|
390
|
+
reason = result.reason;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
reason = "unexpected";
|
|
394
|
+
}
|
|
395
|
+
if (reason !== null) {
|
|
396
|
+
if (reason !== lastRunStateFailure) {
|
|
397
|
+
deps.errorLog(formatRunTerminalReadFailureDiagnostic(reason));
|
|
398
|
+
}
|
|
399
|
+
lastRunStateFailure = reason;
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
lastRunStateFailure = null;
|
|
403
|
+
return terminal;
|
|
404
|
+
};
|
|
296
405
|
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
297
406
|
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
298
407
|
// the loop would be discarded every cycle and re-probe exactly as before; a
|
|
@@ -461,6 +570,24 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
461
570
|
component: "executor",
|
|
462
571
|
instance_id: options.executorId,
|
|
463
572
|
repo_names: repoNames,
|
|
573
|
+
// BAPI-1279 — the claim MODE, so the server can report whether a durable
|
|
574
|
+
// repo-wide executor covers the repository.
|
|
575
|
+
//
|
|
576
|
+
// BAPI-1282 — plus the full run id, but ONLY for an executor scoped to
|
|
577
|
+
// exactly one run. This deliberately reverses BAPI-1279's "never the run
|
|
578
|
+
// ids": the server needs the complete id to check, per repository, that
|
|
579
|
+
// the run this executor served is `done`/`abandoned`, so a cleanly retired
|
|
580
|
+
// executor's stale row stops reading as a lost executor — even when this
|
|
581
|
+
// process never reaches the server again. The id is one bounded, non-secret,
|
|
582
|
+
// server-minted UUID the operator already passed as `--epic-run-id`. The
|
|
583
|
+
// 8-character prefix inside `drive-epic-<runid8>-<host>` is not a substitute:
|
|
584
|
+
// it is ambiguous, and absent for a hand-chosen `--executor-id`. A
|
|
585
|
+
// multi-run or repo-wide executor publishes no binding.
|
|
586
|
+
metadata: options.epicRunIds === undefined
|
|
587
|
+
? { claim_mode: "repo_wide" }
|
|
588
|
+
: observedEpicRunId !== null
|
|
589
|
+
? { claim_mode: "scoped", epic_run_id: observedEpicRunId }
|
|
590
|
+
: { claim_mode: "scoped" },
|
|
464
591
|
};
|
|
465
592
|
// Suppression state: one message per CHANGE of delivery state, so a long
|
|
466
593
|
// outage produces one line rather than one per minute.
|
|
@@ -758,6 +885,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
758
885
|
await maybeSweepWorktrees();
|
|
759
886
|
}
|
|
760
887
|
}
|
|
888
|
+
// BAPI-1279 — retire once the scoped run is finished and nothing is held.
|
|
889
|
+
// Checked whether or not preflight passed: an executor that preflight
|
|
890
|
+
// keeps refusing still holds a sleep assertion and a record, and must not
|
|
891
|
+
// outlive its run. `break` takes the SAME path a `--once` pass does:
|
|
892
|
+
// drain, stop the heartbeat loop, unsubscribe, exit 0.
|
|
893
|
+
if (!shutdownRequested && active.size === 0 && (await scopedRunIsTerminal())) {
|
|
894
|
+
runTerminal = true;
|
|
895
|
+
deps.errorLog(`executor: run ${observedEpicRunId} is finished and no job is held; exiting.`);
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
761
898
|
if (options.once || shutdownRequested)
|
|
762
899
|
break;
|
|
763
900
|
// Sample the clock immediately around the ACTUAL sleep, so the measured gap
|
|
@@ -807,6 +944,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
807
944
|
}
|
|
808
945
|
}
|
|
809
946
|
}
|
|
947
|
+
if (runTerminal && observedEpicRunId !== null && seams.onRunTerminal) {
|
|
948
|
+
try {
|
|
949
|
+
await seams.onRunTerminal(observedEpicRunId);
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
/* record cleanup must never change a clean terminal exit */
|
|
953
|
+
}
|
|
954
|
+
}
|
|
810
955
|
// A graceful signal shutdown is a NORMAL executor exit, not an error: the run did
|
|
811
956
|
// exactly what it was asked to do. A distinct nonzero code here would make every
|
|
812
957
|
// deliberate operator stop look like a crash to launchd, systemd, and the CLI
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test support (BAPI-1302): build a required-verification declaration exactly as
|
|
3
|
+
* the server's `declaration_from_plan_node` seals it, so executor tests exercise
|
|
4
|
+
* the real strict reader rather than a hand-written shape.
|
|
5
|
+
*/
|
|
6
|
+
import { computeDeclarationDigest, declaredCommandIdentity } from "./completion-supervision.js";
|
|
7
|
+
export function sealDeclaration(commands, provenance = commands.length > 0 ? "declared" : "absent") {
|
|
8
|
+
const body = {
|
|
9
|
+
schema_version: 1,
|
|
10
|
+
provenance,
|
|
11
|
+
plan_version: 7,
|
|
12
|
+
plan_hash: "a".repeat(64),
|
|
13
|
+
commands: commands.map((c, position) => ({
|
|
14
|
+
identity: declaredCommandIdentity(position, c),
|
|
15
|
+
position,
|
|
16
|
+
...c,
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
return { ...body, digest: computeDeclarationDigest(body) };
|
|
20
|
+
}
|
package/build/executor/types.js
CHANGED
|
@@ -42,6 +42,18 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
|
|
|
42
42
|
"no_progress",
|
|
43
43
|
"lease_expired",
|
|
44
44
|
];
|
|
45
|
+
/**
|
|
46
|
+
* BAPI-1311 — the capability an executor declares when it can run a FOCUSED
|
|
47
|
+
* merge-stage `rebase`: validate `payload.focused_rebase` before any worker
|
|
48
|
+
* effect, use the server-rendered prompt verbatim, and supervise the post-push
|
|
49
|
+
* mergeability/required-check wait under the job's own claim and deadline.
|
|
50
|
+
* Mirrors `FOCUSED_REBASE_CAPABILITY` in
|
|
51
|
+
* `src/python/llms/executor_completion_contract.py`. Independent of completion
|
|
52
|
+
* evidence: `rebase` is not a covered job type.
|
|
53
|
+
*/
|
|
54
|
+
export const FOCUSED_REBASE_CAPABILITY = "focused_rebase.v1";
|
|
55
|
+
/** The focused context version; the same string as the capability on purpose. */
|
|
56
|
+
export const FOCUSED_REBASE_CONTEXT_VERSION = FOCUSED_REBASE_CAPABILITY;
|
|
45
57
|
/**
|
|
46
58
|
* BAPI-1258 — the terminal provider-capacity protocol contract, mirroring
|
|
47
59
|
* `src/python/llms/executor_capacity_contract.py`. Closed values: an executor
|
|
@@ -17,13 +17,21 @@
|
|
|
17
17
|
* distinct from the argv vocabulary is what stops a future flag rename in one
|
|
18
18
|
* agent's CLI from becoming an operator-visible policy break across all of them.
|
|
19
19
|
*/
|
|
20
|
-
import { ExecutorNamedError } from "./job-errors.js";
|
|
20
|
+
import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
|
|
21
21
|
import { isRecoveryJobType } from "./job-types.js";
|
|
22
|
+
/**
|
|
23
|
+
* BAPI-1296: `ci_fix` is the one recovery job with a FOCUSED prompt route. It
|
|
24
|
+
* renders its server-authored `payload.prompt_spec` (the dispatched failing and
|
|
25
|
+
* missing checks) in `job-runner.ts`, and is never given the full recipe.
|
|
26
|
+
*/
|
|
27
|
+
export const CI_FIX_JOB_TYPE = "ci_fix";
|
|
22
28
|
/**
|
|
23
29
|
* Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
|
|
24
30
|
* Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
|
|
25
|
-
* `implement` and the recovery jobs, builds the
|
|
26
|
-
* `/implement-ticket <ticket_key> --auto` prompt.
|
|
31
|
+
* `implement` and the `remediate`/`rebase` recovery jobs, builds the
|
|
32
|
+
* conservative `/implement-ticket <ticket_key> --auto` prompt. `ci_fix` never
|
|
33
|
+
* resolves here (BAPI-1296): it is refused if it carries an explicit prompt and
|
|
34
|
+
* otherwise falls through to its focused prompt-spec route.
|
|
27
35
|
*
|
|
28
36
|
* Structured `payload.prompt_spec` rendering is NOT done here: it needs the
|
|
29
37
|
* prepared worktree/git state, so `job-runner.ts` renders it in the prepared-spawn
|
|
@@ -40,19 +48,46 @@ import { isRecoveryJobType } from "./job-types.js";
|
|
|
40
48
|
/**
|
|
41
49
|
* True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
|
|
42
50
|
* explicit `payload.prompt` is present: the first-pass `implement` plus the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
51
|
+
* `remediate`/`rebase` recovery jobs, which re-enter the SAME correction loop on
|
|
52
|
+
* the ticket's existing branch/PR. Recovery classification is shared with
|
|
53
|
+
* dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`) so
|
|
54
|
+
* eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
|
|
47
55
|
* they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
|
|
56
|
+
*
|
|
57
|
+
* BAPI-1296: `ci_fix` is EXCLUDED even though it stays a recovery job (branch
|
|
58
|
+
* reuse, worktree lock, and finalization policy are unchanged). Re-entering the
|
|
59
|
+
* whole recipe to repair a dispatched check repeated planning and implementation
|
|
60
|
+
* the server had already finished; `ci_fix` now renders its focused prompt spec
|
|
61
|
+
* or is refused by name, and never falls back to this synthesis.
|
|
48
62
|
*/
|
|
49
|
-
function synthesizesImplementPrompt(jobType) {
|
|
63
|
+
export function synthesizesImplementPrompt(jobType) {
|
|
64
|
+
if (jobType === CI_FIX_JOB_TYPE)
|
|
65
|
+
return false;
|
|
50
66
|
return jobType === "implement" || isRecoveryJobType(jobType);
|
|
51
67
|
}
|
|
52
68
|
export function resolveExecutorPrompt(job) {
|
|
53
69
|
const payload = job.payload && typeof job.payload === "object"
|
|
54
70
|
? job.payload
|
|
55
71
|
: {};
|
|
72
|
+
// BAPI-1296: `ci_fix` is decided FIRST, ahead of both explicit-prompt
|
|
73
|
+
// precedence and synthesis. An explicit prompt on a ci_fix job — including an
|
|
74
|
+
// in-flight legacy `/implement-ticket <KEY> --auto` — is refused by name rather
|
|
75
|
+
// than run, so an old full-recipe prompt cannot silently defeat the focused
|
|
76
|
+
// route. Otherwise the non-ok result tells `prepareSpawn` to take the focused
|
|
77
|
+
// prompt-spec route. The message never echoes the prompt text.
|
|
78
|
+
if (job.job_type === CI_FIX_JOB_TYPE) {
|
|
79
|
+
if (typeof payload.prompt === "string" && payload.prompt.trim().length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
errorKind: CiFixContext,
|
|
83
|
+
error: "ci_fix job refused before worker effects (reason=explicit_prompt_unsupported)",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: "ci_fix renders its focused payload.prompt_spec; no prompt is synthesized",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
56
91
|
// An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
|
|
57
92
|
// whitespace-only string is not a usable worker instruction — treat it as
|
|
58
93
|
// absent so a recovery/implement job still falls back to the synthesized
|
|
@@ -62,11 +97,12 @@ export function resolveExecutorPrompt(job) {
|
|
|
62
97
|
return { ok: true, prompt: explicit };
|
|
63
98
|
}
|
|
64
99
|
// `/implement-ticket <KEY> --auto` is the full correction engine, not just a
|
|
65
|
-
// first-pass implementer: its post-PR loop owns
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
100
|
+
// first-pass implementer: its post-PR loop owns review-change addressing and
|
|
101
|
+
// merge-conflict resolution (see .claude/commands/implement-ticket.md "Clean
|
|
102
|
+
// session exit"). So the recovery jobs that operate on the ticket's existing
|
|
103
|
+
// branch/PR — `remediate`/`rebase` — resume by RE-ENTERING THE COMPLETE RECIPE
|
|
104
|
+
// from step 1, not a distinct resume path. (`ci_fix` left this path in
|
|
105
|
+
// BAPI-1296 and returned above: CI failures are repaired by a focused worker.)
|
|
70
106
|
// That re-entry is safe (BAPI-746), backed by re-entry handling already
|
|
71
107
|
// present in three of the recipe's own steps, not a new assumption: plan
|
|
72
108
|
// generation is idempotent because the server reuses a fresh existing plan
|