@bridge_gpt/mcp-server 0.2.57 → 0.2.59
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 +112 -113
- package/build/agent-registry.js +15 -0
- package/build/agent-utils.js +16 -8
- package/build/claude-user-config-doctor.js +1 -35
- package/build/codex-bridge-profile.js +5 -1
- package/build/codex-launch-scope.js +13 -2
- package/build/commands.generated.js +5 -6
- package/build/conductor/bridge-api-client.js +149 -0
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/claude-hook.js +16 -16
- package/build/conductor/doctor.js +24 -19
- package/build/conductor/epic-implementer-cli.js +38 -5
- package/build/conductor/epic-reconcile.js +1 -1
- package/build/conductor/epic-runtime.js +10 -10
- package/build/conductor/errors.js +3 -3
- package/build/conductor/file-scope-guard.js +4 -4
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-inspection.js +2 -2
- package/build/conductor/install-doctor.js +6 -6
- package/build/conductor/merge-ledger.js +2 -2
- package/build/conductor/pr-ci-producer.js +6 -6
- package/build/conductor/pr-review-producer.js +2 -2
- package/build/conductor/producer-ledger.js +2 -0
- package/build/conductor/readiness-sections.js +7 -7
- package/build/conductor/readiness.js +2 -2
- package/build/conductor/recovery-cli.js +243 -12
- package/build/conductor/recovery-operations.js +56 -4
- package/build/conductor/repair-contract.js +38 -4
- package/build/conductor/spec-review-producer.js +6 -6
- package/build/conductor/store.js +10 -10
- package/build/conductor/supervisor-config.js +14 -14
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-message-relay.js +2 -2
- package/build/conductor/worker-ledger-cli.js +19 -19
- package/build/conductor-bundle-artifacts.js +2 -2
- package/build/cursor-agent-progress.js +72 -0
- package/build/doctor.js +20 -296
- package/build/drive-epic.js +65 -71
- package/build/epic-implementer-bin.js +22 -17
- package/build/epic-implementer-claude-hook-bin.js +4 -2
- package/build/epic-implementer-env.js +155 -0
- package/build/epic-integration-pr.js +2 -2
- package/build/epic-shared/bridge-client.js +773 -0
- package/build/epic-shared/cut-protocol.js +392 -0
- package/build/executor/env.js +19 -8
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/job-errors.js +3 -3
- package/build/executor/job-runner.js +1 -1
- package/build/executor/merge-job.js +35 -3
- package/build/executor/observation.js +1 -1
- package/build/executor/preflight.js +1 -1
- package/build/executor/prompt-spec.js +1 -1
- package/build/executor/reconcile-overlap-job.js +10 -130
- package/build/executor/reconcile-overlap-types.js +1 -1
- package/build/executor/runner.js +1 -1
- package/build/implement-epic/cli.js +1 -1
- package/build/index.js +187 -224
- package/build/init.js +20 -28
- package/build/install-bridge-conductor.js +7 -7
- package/build/install-bridge.js +203 -10
- package/build/install-doctor.js +1 -1
- package/build/mcp-host-entry-adapter.js +6 -0
- package/build/mcp-host-targets.js +2 -0
- package/build/mcp-invoke.js +5 -5
- package/build/mcp-profile.js +2 -2
- package/build/mcp-provisioning.js +2 -2
- package/build/mcp-server-invocation.js +3 -3
- package/build/merge-pull-request.js +15 -0
- package/build/model-routing-host.js +25 -0
- package/build/model-routing-preferences.js +99 -0
- package/build/pipelines.generated.js +11 -11
- package/build/plan-epic-implementer-eligibility.js +215 -0
- package/build/plan-execution-ledger.js +8 -0
- package/build/plane/member-roster.js +5 -5
- package/build/plane/supervisor.js +1 -1
- package/build/plane/types.js +5 -5
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +4 -4
- package/build/setup-epic.js +8 -8
- package/build/start-tickets-conductor.js +32 -32
- package/build/start-tickets-prereqs.js +4 -9
- package/build/start-tickets.js +494 -98
- package/build/version.generated.js +3 -3
- package/docs/CONDUCTOR.md +69 -18
- package/docs/install/mcp-tool-integrations.md +2 -1
- package/package.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/plan-epic.json +2 -2
- package/pipelines/review-ticket.json +1 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared EXACT-CUT protocol and scope-readiness poll (BAPI-843; BAPI-850).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `implement-epic init` so that the LLM-conductor pilot and the v2
|
|
5
|
+
* `setup-epic --feature-branch` entry point drive ONE implementation of the
|
|
6
|
+
* local cut — the same local `git` fetch, the same remote-ref existence check,
|
|
7
|
+
* the same exact-SHA push, and the same mismatch classification. This module is
|
|
8
|
+
* the single owner of those operations; neither CLI re-implements any of them.
|
|
9
|
+
*
|
|
10
|
+
* What the cut is: the server leases a hold on the canonical parse lock and
|
|
11
|
+
* names the commit the canonical index actually covers (`cut/begin`); this
|
|
12
|
+
* module creates `origin/<feature>` at EXACTLY that commit with the operator's
|
|
13
|
+
* own `git` — never the GitHub App, which is `contents: read` and cannot create
|
|
14
|
+
* refs — reads the ref back, and asks the server to record it as the scope's
|
|
15
|
+
* immutable cut (`cut/commit`). The hold is released on every outcome.
|
|
16
|
+
*
|
|
17
|
+
* Contract properties, fixed here and relied on by both CLIs:
|
|
18
|
+
*
|
|
19
|
+
* - **Never force-updates an existing branch.** The push refspec has no leading
|
|
20
|
+
* `+`, so it can only create. A branch that already exists at any commit other
|
|
21
|
+
* than the held cut commit is a refusal, never a repoint.
|
|
22
|
+
* - **Takes an already validated branch and SHA.** Callers validate the branch
|
|
23
|
+
* name (`validateBranchName`) and normalize the candidate SHA before calling;
|
|
24
|
+
* this module re-proves the SHA shape it pushes, but it is not a parser.
|
|
25
|
+
* - **No credential ever reaches argv.** The only subprocess is `git`, and its
|
|
26
|
+
* arguments are refs, remotes, and SHAs. Bridge and GitHub credentials travel
|
|
27
|
+
* only inside the typed client's headers.
|
|
28
|
+
* - **Every failure is classified, never thrown.** Callers get a discriminated
|
|
29
|
+
* outcome with operator-ready sentences and the bounded facts (expected and
|
|
30
|
+
* observed SHAs) they need to report; there is nothing to `try/catch`.
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from "node:child_process";
|
|
33
|
+
import { abandonIndexScopeCut, beginIndexScopeCut, commitIndexScopeCut, getIndexScopeStatus, } from "./bridge-client.js";
|
|
34
|
+
/**
|
|
35
|
+
* Poll bound for a scope bootstrap, shared by `implement-epic init` and
|
|
36
|
+
* `setup-epic`. A seed copies a repository's whole parse cache and the
|
|
37
|
+
* verifying parse then downloads and change-detects it, so the ceiling is
|
|
38
|
+
* generous; the interval is what keeps the poll cheap.
|
|
39
|
+
*/
|
|
40
|
+
export const SCOPE_BOOTSTRAP_POLL_INTERVAL_MS = 5_000;
|
|
41
|
+
export const SCOPE_BOOTSTRAP_MAX_POLLS = 240; // ~20 minutes at the interval above.
|
|
42
|
+
/**
|
|
43
|
+
* Build the default list-argument subprocess runner both CLIs use for `git`.
|
|
44
|
+
*
|
|
45
|
+
* One body rather than two copies: `execFile` with `shell: false`, a generous
|
|
46
|
+
* buffer for porcelain output, and an exit code that never throws — a missing
|
|
47
|
+
* binary resolves to a non-zero code the caller classifies.
|
|
48
|
+
*/
|
|
49
|
+
export function createExecFileRunCommand() {
|
|
50
|
+
return (file, args, options) => new Promise((resolve) => {
|
|
51
|
+
execFile(file, args, {
|
|
52
|
+
cwd: options?.cwd,
|
|
53
|
+
// Git porcelain output for a many-worktree checkout can be large.
|
|
54
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
55
|
+
encoding: "utf-8",
|
|
56
|
+
timeout: options?.timeoutMs,
|
|
57
|
+
// Explicit: arguments are a list, never a concatenated shell string.
|
|
58
|
+
shell: false,
|
|
59
|
+
}, (error, stdout, stderr) => {
|
|
60
|
+
const code = error?.code;
|
|
61
|
+
resolve({
|
|
62
|
+
stdout: stdout ?? "",
|
|
63
|
+
stderr: stderr ?? "",
|
|
64
|
+
exitCode: typeof code === "number" ? code : error ? 1 : 0,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Run `git` in the operator's checkout. */
|
|
70
|
+
export function runGit(deps, args) {
|
|
71
|
+
return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
|
|
72
|
+
}
|
|
73
|
+
/** The single trimmed line a `git rev-parse`-style command produced, or null. */
|
|
74
|
+
export function firstOutputLine(result) {
|
|
75
|
+
const value = result.stdout.split("\n")[0]?.trim() ?? "";
|
|
76
|
+
return value.length === 0 ? null : value;
|
|
77
|
+
}
|
|
78
|
+
/** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
|
|
79
|
+
export function lsRemoteSha(result) {
|
|
80
|
+
const line = firstOutputLine(result);
|
|
81
|
+
if (line === null)
|
|
82
|
+
return null;
|
|
83
|
+
const sha = line.split(/\s+/)[0]?.trim() ?? "";
|
|
84
|
+
return sha.length === 0 ? null : sha;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize a commit SHA to its canonical 40-character lowercase form, or `null`.
|
|
88
|
+
*
|
|
89
|
+
* Mirrors the server's own guard so a malformed value is refused HERE — before it
|
|
90
|
+
* becomes a `git push` refspec — rather than becoming an opaque git error or, far
|
|
91
|
+
* worse, a ref pushed at something that is not a commit.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeCommitSha(value) {
|
|
94
|
+
if (typeof value !== "string")
|
|
95
|
+
return null;
|
|
96
|
+
const normalized = value.trim().toLowerCase();
|
|
97
|
+
return /^[0-9a-f]{40}$/.test(normalized) ? normalized : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read the head of `origin/<branch>` WITHOUT mutating anything.
|
|
101
|
+
*
|
|
102
|
+
* `sha: null` means the branch does not exist on origin. A failed `ls-remote`
|
|
103
|
+
* (no remote, no network, no git) is its own outcome, never "absent": treating an
|
|
104
|
+
* unanswered read as absence is how a push lands on top of a branch nobody saw.
|
|
105
|
+
*/
|
|
106
|
+
export async function readRemoteBranchHead(deps, branch) {
|
|
107
|
+
const result = await runGit(deps, ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
|
|
108
|
+
if (result.exitCode !== 0) {
|
|
109
|
+
return { ok: false, error: `git ls-remote could not read origin/${branch}.` };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, sha: lsRemoteSha(result) };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Make sure the exact cut object is resolvable locally, fetching it if needed.
|
|
115
|
+
*
|
|
116
|
+
* The cut is pushed BY SHA, so the object must exist in the operator's
|
|
117
|
+
* repository. A base-branch fetch usually brings it along; when it did not — the
|
|
118
|
+
* index covers a commit that is no longer an ancestor of the base tip — one
|
|
119
|
+
* targeted, NON-MUTATING fetch of that SHA is attempted (it updates no ref,
|
|
120
|
+
* creates no branch, and checks nothing out). Returns `true` when the commit
|
|
121
|
+
* resolves.
|
|
122
|
+
*/
|
|
123
|
+
export async function ensureCommitResolvableLocally(deps, commitSha) {
|
|
124
|
+
const present = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
|
|
125
|
+
if (present.exitCode === 0)
|
|
126
|
+
return true;
|
|
127
|
+
await runGit(deps, ["fetch", "origin", commitSha]);
|
|
128
|
+
const retry = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
|
|
129
|
+
return retry.exitCode === 0;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Drive the exact cut: `cut/begin` → re-check the remote ref under the hold →
|
|
133
|
+
* create the ref at the held commit (if absent) → read it back → `cut/commit`,
|
|
134
|
+
* releasing the hold on EVERY outcome.
|
|
135
|
+
*
|
|
136
|
+
* Everything between `begin` and the release happens while the SERVER holds the
|
|
137
|
+
* canonical repository's parse lock, so the commit the index covers cannot move
|
|
138
|
+
* underneath the ref this module creates.
|
|
139
|
+
*/
|
|
140
|
+
export async function performExactIndexScopeCut(deps, access, request) {
|
|
141
|
+
const { featureBranch, baseBranch, candidateCommitSha } = request;
|
|
142
|
+
const lease = await beginIndexScopeCut(access, {
|
|
143
|
+
featureBranch,
|
|
144
|
+
baseBranch,
|
|
145
|
+
candidateCommitSha,
|
|
146
|
+
epicRunId: request.epicRunId ?? null,
|
|
147
|
+
}, deps.fetchImpl);
|
|
148
|
+
if (!lease.ok) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
kind: "begin_refused",
|
|
152
|
+
failures: [`The index-scope cut could not begin: ${lease.error}`],
|
|
153
|
+
lease: null,
|
|
154
|
+
expectedSha: candidateCommitSha,
|
|
155
|
+
observedSha: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const cut = lease.value;
|
|
159
|
+
try {
|
|
160
|
+
if (cut.cut_commit_sha !== candidateCommitSha) {
|
|
161
|
+
// The server re-read the canonical snapshot under its own hold and named a
|
|
162
|
+
// different commit. Nothing has been pushed yet, so refusing costs nothing
|
|
163
|
+
// and continuing would cut the epic at a commit the preflight never checked.
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
kind: "canonical_moved",
|
|
167
|
+
failures: [
|
|
168
|
+
`The canonical index moved while init was preparing ` +
|
|
169
|
+
`(preflight saw ${candidateCommitSha}, the cut hold names ${cut.cut_commit_sha}). Re-run init.`,
|
|
170
|
+
],
|
|
171
|
+
lease: cut,
|
|
172
|
+
expectedSha: candidateCommitSha,
|
|
173
|
+
observedSha: cut.cut_commit_sha,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Re-check `origin/<feature>` WHILE the hold is active. A preflight
|
|
177
|
+
// observation is evidence for a refusal, never for a mutation: another
|
|
178
|
+
// operator may have created the ref in between.
|
|
179
|
+
const held = await readRemoteBranchHead(deps, featureBranch);
|
|
180
|
+
if (!held.ok) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
kind: "ls_remote_failed",
|
|
184
|
+
failures: [held.error],
|
|
185
|
+
lease: cut,
|
|
186
|
+
expectedSha: cut.cut_commit_sha,
|
|
187
|
+
observedSha: null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const heldSha = held.sha;
|
|
191
|
+
if (heldSha !== null && heldSha !== cut.cut_commit_sha) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
kind: "existing_ref_mismatch",
|
|
195
|
+
failures: [
|
|
196
|
+
`origin/${featureBranch} exists at ${heldSha}, which is not the canonical indexed ` +
|
|
197
|
+
`commit ${cut.cut_commit_sha}. Delete it or finish the previous run first.`,
|
|
198
|
+
],
|
|
199
|
+
lease: cut,
|
|
200
|
+
expectedSha: cut.cut_commit_sha,
|
|
201
|
+
observedSha: heldSha,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
let branchCreated = false;
|
|
205
|
+
if (heldSha === null) {
|
|
206
|
+
// Create the ref from the EXACT commit, with the operator's own git. No
|
|
207
|
+
// local checkout, no branch, no worktree — and deliberately not the GitHub
|
|
208
|
+
// App, which is `contents: read` and cannot create refs. Not a force push:
|
|
209
|
+
// the refspec has no leading `+`, so it can only create.
|
|
210
|
+
const pushed = await runGit(deps, [
|
|
211
|
+
"push",
|
|
212
|
+
"origin",
|
|
213
|
+
`${cut.cut_commit_sha}:refs/heads/${featureBranch}`,
|
|
214
|
+
]);
|
|
215
|
+
if (pushed.exitCode !== 0) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
kind: "push_failed",
|
|
219
|
+
failures: [
|
|
220
|
+
`Could not create origin/${featureBranch} at the canonical indexed commit ${cut.cut_commit_sha}.`,
|
|
221
|
+
],
|
|
222
|
+
lease: cut,
|
|
223
|
+
expectedSha: cut.cut_commit_sha,
|
|
224
|
+
observedSha: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
branchCreated = true;
|
|
228
|
+
}
|
|
229
|
+
// Read the ref BACK from origin. What matters is what the remote now holds,
|
|
230
|
+
// not what this process intended to push.
|
|
231
|
+
const confirmed = await readRemoteBranchHead(deps, featureBranch);
|
|
232
|
+
const confirmedSha = confirmed.ok ? confirmed.sha : null;
|
|
233
|
+
if (confirmedSha !== cut.cut_commit_sha) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
kind: "confirm_failed",
|
|
237
|
+
failures: [`origin/${featureBranch} did not resolve to ${cut.cut_commit_sha} after the push.`],
|
|
238
|
+
lease: cut,
|
|
239
|
+
expectedSha: cut.cut_commit_sha,
|
|
240
|
+
observedSha: confirmedSha,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const committed = await commitIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id, epicRefCommitSha: confirmedSha }, deps.fetchImpl);
|
|
244
|
+
if (!committed.ok) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
kind: "commit_refused",
|
|
248
|
+
failures: [`The index-scope cut could not be recorded: ${committed.error}`],
|
|
249
|
+
lease: cut,
|
|
250
|
+
expectedSha: cut.cut_commit_sha,
|
|
251
|
+
observedSha: confirmedSha,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, lease: cut, branchCreated, outcome: committed.value.outcome };
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
// The hold is released on EVERY pre-seed outcome, including the success path
|
|
258
|
+
// (where the server already released it — abandon is idempotent). The seed
|
|
259
|
+
// acquires this same canonical lock itself and it is not reentrant, so
|
|
260
|
+
// handing off while still holding it would deadlock the epic against its own
|
|
261
|
+
// seed. A failed release never masks the primary failure: it is reported and
|
|
262
|
+
// the original outcome stands.
|
|
263
|
+
const abandoned = await abandonIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id }, deps.fetchImpl);
|
|
264
|
+
if (!abandoned.ok) {
|
|
265
|
+
deps.errorLog(`announced: the cut hold could not be released cleanly: ${abandoned.error}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Scope-readiness poll (BAPI-843; shared with `setup-epic` by BAPI-850)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
/** The operator-facing label for each lifecycle state the poll reports. */
|
|
273
|
+
export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
|
|
274
|
+
provisioning: "Provisioning",
|
|
275
|
+
seeding: "Seeding",
|
|
276
|
+
verifying: "Verifying",
|
|
277
|
+
ready: "Ready",
|
|
278
|
+
failed: "Failed",
|
|
279
|
+
});
|
|
280
|
+
/**
|
|
281
|
+
* The fixed lifecycle label a heartbeat uses when the status READ itself failed
|
|
282
|
+
* (BAPI-963).
|
|
283
|
+
*
|
|
284
|
+
* A read failure is not a lifecycle state, and the raw error is deliberately not
|
|
285
|
+
* interpolated into a heartbeat: a per-poll line repeated for twenty minutes is
|
|
286
|
+
* the worst possible place to smuggle unbounded server text.
|
|
287
|
+
*/
|
|
288
|
+
export const SCOPE_BOOTSTRAP_UNREADABLE_STATE = "unreadable";
|
|
289
|
+
/**
|
|
290
|
+
* Format one bootstrap heartbeat line (BAPI-963).
|
|
291
|
+
*
|
|
292
|
+
* Shared with `setup-epic` at this seam so both conductors compute progress the
|
|
293
|
+
* same way; RENDERING stays with each caller, because the pilot writes to its own
|
|
294
|
+
* stderr advisory channel and v2 reports progress server-side.
|
|
295
|
+
*
|
|
296
|
+
* The shape is fixed and grep-friendly — elapsed first, state second — because a
|
|
297
|
+
* ~30-minute seed that printed nothing was externally indistinguishable from a
|
|
298
|
+
* hang (sleeping process, 0% CPU, a frozen `updated_at`). Elapsed seconds are
|
|
299
|
+
* clamped at zero so a clock adjustment cannot render a negative age.
|
|
300
|
+
*/
|
|
301
|
+
export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
|
|
302
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
303
|
+
// `lifecycle_state` is a required non-empty string on the wire, not a closed
|
|
304
|
+
// set. Bounding it to the known labels (plus the fixed `unreadable` and a
|
|
305
|
+
// catch-all) keeps an unvalidated server string out of a line that repeats
|
|
306
|
+
// every interval for up to twenty minutes.
|
|
307
|
+
return `Seeding scope: elapsed=${seconds}s state=${boundScopeLifecycleLabel(state)}`;
|
|
308
|
+
}
|
|
309
|
+
/** The label an unrecognized lifecycle state collapses to. Never `null`, never raw. */
|
|
310
|
+
export const SCOPE_BOOTSTRAP_UNKNOWN_STATE = "unknown";
|
|
311
|
+
/**
|
|
312
|
+
* Collapse any lifecycle state to a bounded, safe label (BAPI-1054).
|
|
313
|
+
*
|
|
314
|
+
* `lifecycle_state` is a required non-empty string on the wire, not a closed set,
|
|
315
|
+
* so an unrecognized value is possible and an error-bearing one
|
|
316
|
+
* (`unreadable (<server error>)`) is routine. Extracted from
|
|
317
|
+
* {@link formatScopeBootstrapHeartbeat}, which has always applied this rule, so
|
|
318
|
+
* every surface that renders a state applies the SAME one — a second spelling of
|
|
319
|
+
* "bounded" is how raw server text eventually reaches a terminal.
|
|
320
|
+
*/
|
|
321
|
+
export function boundScopeLifecycleLabel(state) {
|
|
322
|
+
return state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
|
|
323
|
+
? state
|
|
324
|
+
: SCOPE_BOOTSTRAP_UNKNOWN_STATE;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Describe the nominal polling window up front (BAPI-963).
|
|
328
|
+
*
|
|
329
|
+
* Computed from the interval and cap rather than hard-coded, so a change to
|
|
330
|
+
* either constant cannot leave the operator-facing duration claim stale. The
|
|
331
|
+
* window is NOMINAL: the observed pilot seed outran even this bound, which is
|
|
332
|
+
* why the wording promises a poll cadence rather than a completion time.
|
|
333
|
+
*/
|
|
334
|
+
export function describeScopeBootstrapWindow(intervalMs = SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, maxPolls = SCOPE_BOOTSTRAP_MAX_POLLS) {
|
|
335
|
+
const intervalSeconds = Math.max(1, Math.round(intervalMs / 1000));
|
|
336
|
+
const windowMinutes = Math.max(1, Math.round((intervalMs * maxPolls) / 60_000));
|
|
337
|
+
return (`Seeding the epic's index scope. This copies the repository's whole parse cache and ` +
|
|
338
|
+
`verifies it, and commonly takes many minutes. Progress is reported every ` +
|
|
339
|
+
`${intervalSeconds}s; the poll gives up after about ${windowMinutes} minutes.`);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
|
|
343
|
+
* elapses, reporting each NEWLY observed lifecycle transition exactly once, in
|
|
344
|
+
* order, through `onTransition`.
|
|
345
|
+
*
|
|
346
|
+
* Readiness is a server-side fact this poll observes rather than concludes from
|
|
347
|
+
* any request of its own: `ready` is accepted ONLY when the status also proves
|
|
348
|
+
* `indexed_commit_sha == cut_commit_sha`. A `ready` whose watermark disagrees is
|
|
349
|
+
* reported as `ready_mismatch` — the control plane contradicting itself, which
|
|
350
|
+
* is worth refusing rather than proceeding on.
|
|
351
|
+
*
|
|
352
|
+
* A transient read failure is not a verdict: the poll keeps going and lets the
|
|
353
|
+
* bound be the thing that gives up. The interval and cap are the shared
|
|
354
|
+
* {@link SCOPE_BOOTSTRAP_POLL_INTERVAL_MS} / {@link SCOPE_BOOTSTRAP_MAX_POLLS}.
|
|
355
|
+
*/
|
|
356
|
+
export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {}) {
|
|
357
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
358
|
+
const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
|
|
359
|
+
const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
|
|
360
|
+
const now = options.now ?? (() => new Date());
|
|
361
|
+
const startedAtMs = now().getTime();
|
|
362
|
+
let lastState = "unknown";
|
|
363
|
+
let lastStatus = null;
|
|
364
|
+
let lastReportedState = null;
|
|
365
|
+
for (let poll = 0; poll < maxPolls; poll += 1) {
|
|
366
|
+
await sleep(intervalMs);
|
|
367
|
+
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
368
|
+
if (!status.ok) {
|
|
369
|
+
lastState = `unreadable (${status.error})`;
|
|
370
|
+
options.onPoll?.(now().getTime() - startedAtMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
lastStatus = status.value;
|
|
374
|
+
lastState = status.value.lifecycle_state;
|
|
375
|
+
options.onPoll?.(now().getTime() - startedAtMs, lastState);
|
|
376
|
+
if (lastState !== lastReportedState) {
|
|
377
|
+
lastReportedState = lastState;
|
|
378
|
+
options.onTransition?.(lastState, status.value);
|
|
379
|
+
}
|
|
380
|
+
if (lastState === "ready") {
|
|
381
|
+
if (status.value.indexed_commit_sha !== null &&
|
|
382
|
+
status.value.indexed_commit_sha === status.value.cut_commit_sha) {
|
|
383
|
+
return { kind: "ready", status: status.value };
|
|
384
|
+
}
|
|
385
|
+
return { kind: "ready_mismatch", status: status.value };
|
|
386
|
+
}
|
|
387
|
+
if (lastState === "failed") {
|
|
388
|
+
return { kind: "failed", status: status.value, reason: status.value.last_error ?? "unknown" };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { kind: "timeout", lastState, lastStatus };
|
|
392
|
+
}
|
package/build/executor/env.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* written BEFORE spawn — see `../mcp-provisioning.js` wiring in
|
|
9
9
|
* `job-runner.ts`), never via env vars. This builder still omits
|
|
10
10
|
* `BRIDGE_MCP_PROFILE` (an absent profile selects the intended `core` tool
|
|
11
|
-
* group default), every
|
|
11
|
+
* group default), every Epic Implementer env key under BOTH the supported
|
|
12
|
+
* `BAPI_EPIC_IMPLEMENTER_*` prefix and the legacy Conductor prefix (BAPI-1190),
|
|
13
|
+
* the worker Node-path key under both its supported and legacy spellings (the
|
|
12
14
|
* shim's node executable is resolved into the registration's command/args at
|
|
13
15
|
* provisioning time instead), and all secret-bearing keys. It constructs a
|
|
14
16
|
* FRESH object from a strict allowlist of non-secret operational keys — it
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
import { PR_BASE_BRANCH_ENV_VAR, WORKER_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
56
58
|
import { DECLARED_TOUCHED_FILES_ENV, normalizeDeclaredTouchedFiles, } from "../conductor/file-scope-guard.js";
|
|
57
59
|
import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
|
|
60
|
+
import { EPIC_IMPLEMENTER_ENV_PREFIX, EPIC_IMPLEMENTER_NODE_PATH_ENV, LEGACY_EPIC_IMPLEMENTER_ENV_PREFIX, LEGACY_EPIC_IMPLEMENTER_NODE_PATH_ENV, } from "../epic-implementer-env.js";
|
|
58
61
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
59
62
|
const ALLOWED_ENV_KEYS = [
|
|
60
63
|
"PATH",
|
|
@@ -116,7 +119,10 @@ const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
|
|
|
116
119
|
*/
|
|
117
120
|
export const EXPLICIT_DENY_KEYS = [
|
|
118
121
|
"BRIDGE_MCP_PROFILE",
|
|
119
|
-
|
|
122
|
+
EPIC_IMPLEMENTER_NODE_PATH_ENV,
|
|
123
|
+
// BAPI-1190: the legacy spelling stays denied so a stale ambient value from an
|
|
124
|
+
// older package can never reach a worker during a version-skew window.
|
|
125
|
+
LEGACY_EPIC_IMPLEMENTER_NODE_PATH_ENV,
|
|
120
126
|
"BAPI_API_KEY",
|
|
121
127
|
// ANTHROPIC_API_KEY has NO passthrough exception (BAPI-791): it stays denied
|
|
122
128
|
// absolutely, unlike CLAUDE_CODE_OAUTH_TOKEN below, which is denied here too
|
|
@@ -167,12 +173,15 @@ export const EXPLICIT_DENY_KEYS = [
|
|
|
167
173
|
/**
|
|
168
174
|
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
169
175
|
* allowlist is locally auditable/testable. A key is allowed iff it is in the
|
|
170
|
-
* allowlist AND is not explicitly denied, does not begin with
|
|
171
|
-
*
|
|
176
|
+
* allowlist AND is not explicitly denied, does not begin with the supported
|
|
177
|
+
* `BAPI_EPIC_IMPLEMENTER_` prefix or the legacy Conductor prefix, and does not
|
|
178
|
+
* contain a secret-name fragment.
|
|
172
179
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
180
|
+
* Both prefix rejections are WHOLE-PREFIX rules, not enumerated lists, which is
|
|
181
|
+
* what makes them hold for keys added later. BAPI-1190 keeps the legacy prefix
|
|
182
|
+
* rejected alongside the renamed one, so a stale name inherited during a
|
|
183
|
+
* version-skew window is filtered too. BAPI-722's
|
|
184
|
+
* `BAPI_EPIC_IMPLEMENTER_DENY_PROBE_TIMEOUT_MS` is rejected twice over — it is absent from
|
|
176
185
|
* `ALLOWED_ENV_KEYS` and it carries the prefix — so it stays executor-process
|
|
177
186
|
* configuration and can never reach a spawned worker. That is deliberate: a worker
|
|
178
187
|
* runs no deny probe, and letting job-side state influence the executor's own
|
|
@@ -183,7 +192,9 @@ export function isExecutorEnvKeyAllowed(key) {
|
|
|
183
192
|
return false;
|
|
184
193
|
if (EXPLICIT_DENY_KEYS.includes(key))
|
|
185
194
|
return false;
|
|
186
|
-
if (key.startsWith(
|
|
195
|
+
if (key.startsWith(EPIC_IMPLEMENTER_ENV_PREFIX))
|
|
196
|
+
return false;
|
|
197
|
+
if (key.startsWith(LEGACY_EPIC_IMPLEMENTER_ENV_PREFIX))
|
|
187
198
|
return false;
|
|
188
199
|
const upper = key.toUpperCase();
|
|
189
200
|
if (SECRET_NAME_FRAGMENTS.some((fragment) => upper.includes(fragment)))
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* interval. This is TELEMETRY ONLY (R14): the marker is never persisted, never
|
|
23
23
|
* reported as `last_commit_sha`, and never drives an executor decision — the
|
|
24
24
|
* reconciler remains the sole consumer and the sole transition authority
|
|
25
|
-
* (`docs/claude/
|
|
25
|
+
* (`docs/claude/epic-implementer-standing-rules.md`).
|
|
26
26
|
*
|
|
27
27
|
* - SERVER STOP (WS-B executor half). A heartbeat response carrying the
|
|
28
28
|
* server's stop request kills the owned worker like `stale_claim` does, but
|
|
@@ -143,12 +143,12 @@ export const WorkerEnvFilePresent = "ContractError.WorkerEnvFilePresent";
|
|
|
143
143
|
* BAPI-1152 — `reconcile_overlap` failure kinds. Each names ONE way the
|
|
144
144
|
* wrapper's merge-into-child could not reach a verified push, so an operator
|
|
145
145
|
* reading the row knows which fact to go look at. None of them is a verdict: a
|
|
146
|
-
*
|
|
147
|
-
*
|
|
146
|
+
* moved head is NOT a failure — it completes with a fact envelope — so it has
|
|
147
|
+
* no kind here.
|
|
148
148
|
*
|
|
149
149
|
* - `ContractError.ReconcileOverlapPayload` — the payload failed validation
|
|
150
150
|
* before any subprocess ran (missing/blank input SHA, unknown schema version,
|
|
151
|
-
* malformed command
|
|
151
|
+
* malformed command record).
|
|
152
152
|
* - `ContractError.ReconcileOverlapBranchMismatch` — the prepared worktree's
|
|
153
153
|
* branch is not the job's top-level `expected_branch`, so the push destination
|
|
154
154
|
* cannot be trusted.
|
|
@@ -2114,7 +2114,7 @@ async function runPreparedSpawn(params) {
|
|
|
2114
2114
|
}
|
|
2115
2115
|
const git = observation.git();
|
|
2116
2116
|
// BAPI-1152: a driver owns everything after a clean worker exit — staging, the
|
|
2117
|
-
//
|
|
2117
|
+
// commits, the guarded push, and finalization.
|
|
2118
2118
|
if (driver && procResult.classification === "clean_exit") {
|
|
2119
2119
|
await finalizeRegistry();
|
|
2120
2120
|
const phase = await superviseDriverPhase({
|
|
@@ -166,6 +166,30 @@ export const MERGE_RETRYABLE = "MergeRetryable";
|
|
|
166
166
|
export const MERGE_CONFLICT = "MergeConflict";
|
|
167
167
|
export const MERGE_FAILED = "MergeFailed";
|
|
168
168
|
export const MERGE_REF_MISMATCH = "MergeRefMismatch";
|
|
169
|
+
/**
|
|
170
|
+
* BAPI-1234 — the server's migration-graph merge guard refused the merge. Two
|
|
171
|
+
* kinds, kept distinct because they call for different human responses:
|
|
172
|
+
* - {@link MERGE_MIGRATION_GRAPH_INVALID}: merging this head onto the current
|
|
173
|
+
* base produces a broken Alembic graph. A human renumbers the PR's own
|
|
174
|
+
* never-applied migration.
|
|
175
|
+
* - {@link MERGE_MIGRATION_GRAPH_UNREADABLE}: the merged graph could not be
|
|
176
|
+
* proven either way. An operator checks the provider, then unparks.
|
|
177
|
+
* Like {@link MERGE_REF_MISMATCH}, both are deliberate terminal refusals: never
|
|
178
|
+
* retryable, never a rebase target, and reported as `clean_exit`.
|
|
179
|
+
*/
|
|
180
|
+
export const MERGE_MIGRATION_GRAPH_INVALID = "MergeMigrationGraphInvalid";
|
|
181
|
+
export const MERGE_MIGRATION_GRAPH_UNREADABLE = "MergeMigrationGraphUnreadable";
|
|
182
|
+
/** Server refusal reasons mapped to their dedicated terminal error kinds. */
|
|
183
|
+
const MIGRATION_GRAPH_ERROR_KINDS = new Map([
|
|
184
|
+
["migration_graph_invalid", MERGE_MIGRATION_GRAPH_INVALID],
|
|
185
|
+
["migration_graph_unreadable", MERGE_MIGRATION_GRAPH_UNREADABLE],
|
|
186
|
+
]);
|
|
187
|
+
/** Error kinds that are deliberate refusals rather than crashes. */
|
|
188
|
+
const DELIBERATE_REFUSAL_ERROR_KINDS = new Set([
|
|
189
|
+
MERGE_REF_MISMATCH,
|
|
190
|
+
MERGE_MIGRATION_GRAPH_INVALID,
|
|
191
|
+
MERGE_MIGRATION_GRAPH_UNREADABLE,
|
|
192
|
+
]);
|
|
169
193
|
/** Reasons whose failure is transient/infrastructure and safe to retry. */
|
|
170
194
|
const RETRYABLE_MERGE_REASONS = new Set([
|
|
171
195
|
"gh_pr_view_timeout",
|
|
@@ -190,6 +214,9 @@ const RETRYABLE_MERGE_REASONS = new Set([
|
|
|
190
214
|
* variants covered so a distinct reason never accidentally downgrades to
|
|
191
215
|
* {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
|
|
192
216
|
*
|
|
217
|
+
* BAPI-1234: `migration_graph_invalid` / `migration_graph_unreadable` map to
|
|
218
|
+
* their own terminal kinds, in the same early position and for the same reason.
|
|
219
|
+
*
|
|
193
220
|
* BAPI-1021: `ref_mismatch` is classified BEFORE conflict, retryable-prefix, and
|
|
194
221
|
* generic-failure handling, and is deliberately NEVER added to
|
|
195
222
|
* {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
|
|
@@ -201,6 +228,9 @@ export function classifyMergeFailureErrorKind(response) {
|
|
|
201
228
|
const reason = asString(response.reason ?? undefined);
|
|
202
229
|
if (reason === "ref_mismatch")
|
|
203
230
|
return MERGE_REF_MISMATCH;
|
|
231
|
+
const migrationGraphKind = reason ? MIGRATION_GRAPH_ERROR_KINDS.get(reason) : undefined;
|
|
232
|
+
if (migrationGraphKind)
|
|
233
|
+
return migrationGraphKind;
|
|
204
234
|
if (hasConflictEvent || reason === "gh_merge_conflict")
|
|
205
235
|
return MERGE_CONFLICT;
|
|
206
236
|
if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
|
|
@@ -217,8 +247,10 @@ export function classifyMergeFailureErrorKind(response) {
|
|
|
217
247
|
*
|
|
218
248
|
* BAPI-1021: a {@link MERGE_REF_MISMATCH} classifies as `classification:
|
|
219
249
|
* "clean_exit"` rather than `"crashed"` — it is a deliberate refusal the
|
|
220
|
-
* executor made correctly, not a crash.
|
|
221
|
-
*
|
|
250
|
+
* executor made correctly, not a crash. BAPI-1234 adds the two migration-graph
|
|
251
|
+
* refusal kinds to that set: a `crashed` classification would make the
|
|
252
|
+
* backend's merge re-fire lane retry a merge the server will refuse identically.
|
|
253
|
+
* Every other merge outcome keeps its existing `"crashed"` classification.
|
|
222
254
|
*/
|
|
223
255
|
export function buildMergeJobFailure(response) {
|
|
224
256
|
const reason = asString(response.reason ?? undefined) ?? response.status;
|
|
@@ -226,7 +258,7 @@ export function buildMergeJobFailure(response) {
|
|
|
226
258
|
return {
|
|
227
259
|
error_kind: errorKind,
|
|
228
260
|
error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
|
|
229
|
-
classification: errorKind
|
|
261
|
+
classification: DELIBERATE_REFUSAL_ERROR_KINDS.has(errorKind) ? "clean_exit" : "crashed",
|
|
230
262
|
};
|
|
231
263
|
}
|
|
232
264
|
/**
|
|
@@ -291,7 +291,7 @@ function parseRateLimitEvent(obj) {
|
|
|
291
291
|
const RATE_LIMIT_CONSEQUENCE_TEXT = "The executor keeps claiming: a worker that hits the ceiling fails fast and its " +
|
|
292
292
|
"job classifies as a worker failure under the normal retry budget.";
|
|
293
293
|
/** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
|
|
294
|
-
const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-
|
|
294
|
+
const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-implementer-v2-operator-runbook.md §8.";
|
|
295
295
|
/**
|
|
296
296
|
* Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
|
|
297
297
|
*
|
|
@@ -11,7 +11,7 @@ import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED
|
|
|
11
11
|
* time out, and letting a job influence the executor's own safety-probe budget
|
|
12
12
|
* would be a privilege inversion.
|
|
13
13
|
*/
|
|
14
|
-
export const DENY_PROBE_TIMEOUT_ENV_KEY = "
|
|
14
|
+
export const DENY_PROBE_TIMEOUT_ENV_KEY = "BAPI_EPIC_IMPLEMENTER_DENY_PROBE_TIMEOUT_MS";
|
|
15
15
|
/**
|
|
16
16
|
* Accepted bounds for the resolved probe timeout. The lower bound keeps a
|
|
17
17
|
* fat-fingered `1` from turning every probe into an instant HANG (which is FATAL —
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Executor-side structured prompt-spec renderer (BAPI-550, TDD §6/§7).
|
|
3
3
|
*
|
|
4
4
|
* The reconciler ships a server-authored prompt template in a `spec_review` job's
|
|
5
|
-
* `payload.prompt_spec` (from `src/python/llms/agents/
|
|
5
|
+
* `payload.prompt_spec` (from `src/python/llms/agents/epic_implementer_jobs/prompt_specs.py`).
|
|
6
6
|
* This module validates that spec and renders it into the single `claude -p`
|
|
7
7
|
* prompt string, substituting each declared `{{PLACEHOLDER}}` marker from real
|
|
8
8
|
* runtime state (ticket spec, base/work branch, git log, prior revise reasons).
|