@bridge_gpt/mcp-server 0.2.41 → 0.2.43
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 +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `drive-epic` — the one universal conductor entry point (BAPI-887).
|
|
3
|
+
*
|
|
4
|
+
* Bridge has two conductors and a standing rule that they must never operate on
|
|
5
|
+
* the same epic: two transition authorities on one epic wedge it permanently.
|
|
6
|
+
*
|
|
7
|
+
* - **v2 engine** — bootstrapped by `setup-epic`, which creates a durable
|
|
8
|
+
* `epic_run` row, stores a plan DAG, and approves it.
|
|
9
|
+
* - **LLM-conductor pilot** — `/loop 5m /conduct-epic <EPIC>`, which creates no
|
|
10
|
+
* run row and keeps its whole memory in a local checkpoint plus GitHub.
|
|
11
|
+
*
|
|
12
|
+
* Asking a prompt to pick correctly every time is not a control. This subcommand
|
|
13
|
+
* makes the choice STRUCTURAL: it reads server-side conductor readiness and
|
|
14
|
+
* routes to exactly one path, so no prompt names either underlying conductor and
|
|
15
|
+
* no prompt can present both. Every ticket-authoring surface hands off to
|
|
16
|
+
* `drive-epic` and to nothing else.
|
|
17
|
+
*
|
|
18
|
+
* ## The output invariant
|
|
19
|
+
*
|
|
20
|
+
* Every branch — including every error path — emits **at most one** conductor
|
|
21
|
+
* invocation. An escalation carries NO alternative conductor command: "v2 failed,
|
|
22
|
+
* try the pilot instead" is precisely the dual-authority hazard this subcommand
|
|
23
|
+
* exists to remove. {@link assertSingleConductorInvocation} enforces this on the
|
|
24
|
+
* way out rather than trusting each branch to have got it right.
|
|
25
|
+
*
|
|
26
|
+
* ## Three-state routing, fail-closed
|
|
27
|
+
*
|
|
28
|
+
* - readiness green -> delegate to the `setup-epic` (v2) flow
|
|
29
|
+
* - readiness read, not green -> print the pilot instruction
|
|
30
|
+
* - readiness UNKNOWN -> escalate to the operator, print no conductor
|
|
31
|
+
*
|
|
32
|
+
* Unknown is never treated as not-ready, and selection never happens by
|
|
33
|
+
* exception handling. An unreachable, unauthorized, or malformed readiness
|
|
34
|
+
* response means we do not know which authority owns this epic, and guessing is
|
|
35
|
+
* the failure mode.
|
|
36
|
+
*
|
|
37
|
+
* ## Consolidation day
|
|
38
|
+
*
|
|
39
|
+
* Two conductors is a transitional state; one is to be eliminated at the end of
|
|
40
|
+
* the current evaluation period. Consolidation is:
|
|
41
|
+
*
|
|
42
|
+
* 1. set {@link DRIVE_EPIC_PREFERRED_CONDUCTOR} to the surviving conductor;
|
|
43
|
+
* 2. delete the losing branch in {@link renderConductorHandoff}.
|
|
44
|
+
*
|
|
45
|
+
* No prompt, bundled doc, command mirror, or posture test changes as a result —
|
|
46
|
+
* that is the whole reason the duality is confined to this one subcommand and
|
|
47
|
+
* this one constant. Epics already assigned to the eliminated conductor must be
|
|
48
|
+
* drained first; that drain is a conductor-lifecycle concern outside this file.
|
|
49
|
+
*
|
|
50
|
+
* No MCP tool is registered here. The `tools/list` token budget is deliberately
|
|
51
|
+
* ratcheted down, and a tool would be useless in a bare-chat session anyway,
|
|
52
|
+
* where the model has no tool call to make.
|
|
53
|
+
*/
|
|
54
|
+
import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
55
|
+
import { runSetupEpicCli } from "./setup-epic.js";
|
|
56
|
+
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
57
|
+
// the printed invocation interpolates it rather than repeating it.
|
|
58
|
+
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
59
|
+
/** Jira epic key shape. Matches `conduct-epic`'s positional rule exactly. */
|
|
60
|
+
const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
61
|
+
/** The two conductor paths. Derived types below come from this constant. */
|
|
62
|
+
export const DRIVE_EPIC_CONDUCTORS = ["v2", "pilot"];
|
|
63
|
+
/**
|
|
64
|
+
* THE consolidation-day edit point.
|
|
65
|
+
*
|
|
66
|
+
* When readiness is green, v2 is preferred and the pilot is the fallback for
|
|
67
|
+
* projects that cannot run it. This is expressed as one named constant rather
|
|
68
|
+
* than as branching scattered through the routing logic precisely so that
|
|
69
|
+
* eliminating a conductor is a one-line change plus one deleted branch.
|
|
70
|
+
*/
|
|
71
|
+
export const DRIVE_EPIC_PREFERRED_CONDUCTOR = "v2";
|
|
72
|
+
/**
|
|
73
|
+
* The v2 readiness predicate, as a list so a failure can name what is missing.
|
|
74
|
+
*
|
|
75
|
+
* These are the facts v2 actually needs to drive an epic: a stored supervisor
|
|
76
|
+
* posture, a reconciler that is ticking, a provisioned and live executor, and
|
|
77
|
+
* complete GitHub App credentials. Each is read from the SERVER's report and
|
|
78
|
+
* never re-derived locally — two implementations of one classification drift,
|
|
79
|
+
* and a CLI that disagreed with the server about whether v2 can run would be
|
|
80
|
+
* worse than no check at all.
|
|
81
|
+
*/
|
|
82
|
+
export const V2_READINESS_REQUIREMENTS = [
|
|
83
|
+
{
|
|
84
|
+
id: "supervisor_setup",
|
|
85
|
+
describe: "supervisor setup stored for this repository",
|
|
86
|
+
satisfied: (r) => r.supervisor.setup_present,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "supervisor_config",
|
|
90
|
+
describe: "supervisor configuration stored for this repository",
|
|
91
|
+
satisfied: (r) => r.supervisor.config_present,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "github_credentials",
|
|
95
|
+
describe: "GitHub App credentials that resolve completely",
|
|
96
|
+
satisfied: (r) => r.github.credentials_complete,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "reconciler_live",
|
|
100
|
+
describe: "a reconciler that is ticking and not stale",
|
|
101
|
+
satisfied: (r) => r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "executor_live",
|
|
105
|
+
describe: "an executor provisioned and reporting ready",
|
|
106
|
+
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
export function getDriveEpicUsage() {
|
|
110
|
+
return [
|
|
111
|
+
"Usage: mcp-server drive-epic [options] <EPIC>",
|
|
112
|
+
"",
|
|
113
|
+
"Drives one epic with the conductor this project can actually run. Reads",
|
|
114
|
+
"conductor readiness from Bridge API and routes to exactly one path — it",
|
|
115
|
+
"never asks you to choose.",
|
|
116
|
+
"",
|
|
117
|
+
"Arguments:",
|
|
118
|
+
" <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
|
|
119
|
+
"",
|
|
120
|
+
"Options:",
|
|
121
|
+
" --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
|
|
122
|
+
" selected, drive-epic runs that bootstrap directly",
|
|
123
|
+
" instead of printing the command to run.",
|
|
124
|
+
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
125
|
+
" -h, --help Show this help",
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse argv. Rejects malformed input BEFORE any side effect — no readiness
|
|
130
|
+
* read, no credential resolution, no delegation.
|
|
131
|
+
*/
|
|
132
|
+
export function parseDriveEpicArgs(argv) {
|
|
133
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
134
|
+
return { status: "help", usage: getDriveEpicUsage() };
|
|
135
|
+
}
|
|
136
|
+
const positionals = [];
|
|
137
|
+
let planFile;
|
|
138
|
+
let repo;
|
|
139
|
+
for (let i = 0; i < argv.length; i++) {
|
|
140
|
+
const arg = argv[i];
|
|
141
|
+
if (arg === "--plan-file" || arg === "--repo") {
|
|
142
|
+
const value = argv[i + 1];
|
|
143
|
+
if (value === undefined || value.startsWith("-")) {
|
|
144
|
+
return { status: "error", message: `${arg} requires a value.` };
|
|
145
|
+
}
|
|
146
|
+
if (arg === "--plan-file")
|
|
147
|
+
planFile = value;
|
|
148
|
+
else
|
|
149
|
+
repo = value;
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const eq = arg.match(/^(--plan-file|--repo)=(.*)$/);
|
|
154
|
+
if (eq) {
|
|
155
|
+
const value = eq[2];
|
|
156
|
+
if (value.trim().length === 0) {
|
|
157
|
+
return { status: "error", message: `${eq[1]} requires a value.` };
|
|
158
|
+
}
|
|
159
|
+
if (eq[1] === "--plan-file")
|
|
160
|
+
planFile = value;
|
|
161
|
+
else
|
|
162
|
+
repo = value;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (arg.startsWith("-")) {
|
|
166
|
+
return { status: "error", message: `Unsupported flag: ${arg}` };
|
|
167
|
+
}
|
|
168
|
+
positionals.push(arg);
|
|
169
|
+
}
|
|
170
|
+
if (positionals.length === 0) {
|
|
171
|
+
return { status: "error", message: "Missing required epic key." };
|
|
172
|
+
}
|
|
173
|
+
if (positionals.length > 1) {
|
|
174
|
+
// Rejected rather than ignored: a second positional means the caller
|
|
175
|
+
// believes something we did not parse, and driving the wrong epic is not
|
|
176
|
+
// recoverable by re-reading the output.
|
|
177
|
+
return {
|
|
178
|
+
status: "error",
|
|
179
|
+
message: `Expected exactly one epic key, got ${positionals.length}: ${positionals.join(", ")}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const epicKey = positionals[0];
|
|
183
|
+
if (!EPIC_KEY_PATTERN.test(epicKey)) {
|
|
184
|
+
return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. BAPI-885.` };
|
|
185
|
+
}
|
|
186
|
+
const options = { epicKey, ...(planFile ? { planFile } : {}), ...(repo ? { repo } : {}) };
|
|
187
|
+
return { status: "ok", options };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Route a readiness report to exactly one conductor.
|
|
191
|
+
*
|
|
192
|
+
* Pure and total: it takes the already-read report and always returns a
|
|
193
|
+
* decision. The unknown case is produced by the caller, which is the only place
|
|
194
|
+
* that can observe a failed read — selection itself never happens by exception
|
|
195
|
+
* handling, and so never needs to report that it could not decide.
|
|
196
|
+
*/
|
|
197
|
+
export function selectConductor(readiness) {
|
|
198
|
+
const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
|
|
199
|
+
if (missing.length === 0) {
|
|
200
|
+
return {
|
|
201
|
+
kind: "selected",
|
|
202
|
+
conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
|
|
203
|
+
reason: "conductor readiness is green",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
kind: "selected",
|
|
208
|
+
conductor: "pilot",
|
|
209
|
+
reason: `the engine path still needs ${missing.map((m) => m.describe).join("; ")}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Render the selected path and only the selected path.
|
|
214
|
+
*
|
|
215
|
+
* Precondition: the caller has already ruled out delegation. The `v2` branch
|
|
216
|
+
* reads as "green, but no plan file was supplied", which is only true once
|
|
217
|
+
* {@link runDriveEpicCli} has declined to delegate — so route first, render
|
|
218
|
+
* second.
|
|
219
|
+
*
|
|
220
|
+
* CONSOLIDATION DAY: delete the branch of the conductor being eliminated and
|
|
221
|
+
* point {@link DRIVE_EPIC_PREFERRED_CONDUCTOR} at the survivor. Nothing outside
|
|
222
|
+
* this file moves.
|
|
223
|
+
*/
|
|
224
|
+
export function renderConductorHandoff(selection, epicKey) {
|
|
225
|
+
if (selection.conductor === "v2") {
|
|
226
|
+
const invocation = `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
|
|
227
|
+
return {
|
|
228
|
+
conductor: "v2",
|
|
229
|
+
invocation,
|
|
230
|
+
lines: [
|
|
231
|
+
`${epicKey}: ${selection.reason}, but no plan file was supplied.`,
|
|
232
|
+
"",
|
|
233
|
+
"The engine path needs a plan DAG, and nothing derives one from an epic key:",
|
|
234
|
+
"the dependency edges and per-ticket touched_files it carries do not exist",
|
|
235
|
+
"anywhere else. Produce one, then re-run this command with it.",
|
|
236
|
+
"",
|
|
237
|
+
` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
238
|
+
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
239
|
+
" If the epic's tickets already exist, run emit-conductor-bundle",
|
|
240
|
+
" finalize instead — it resolves the placeholder keys in an existing",
|
|
241
|
+
" sidecar and attaches touched_files.",
|
|
242
|
+
"",
|
|
243
|
+
` 2. ${invocation}`,
|
|
244
|
+
"",
|
|
245
|
+
`That second command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
|
|
246
|
+
"something you invoke directly.",
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const invocation = `/loop 5m /conduct-epic ${epicKey}`;
|
|
251
|
+
return {
|
|
252
|
+
conductor: "pilot",
|
|
253
|
+
invocation,
|
|
254
|
+
lines: [
|
|
255
|
+
`${epicKey}: ${selection.reason}. Drive it from an interactive session with:`,
|
|
256
|
+
"",
|
|
257
|
+
` ${invocation}`,
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Tokens that identify a conductor path in user-facing output. Used by the
|
|
263
|
+
* output guard and by its test — one list, so a new spelling cannot be added to
|
|
264
|
+
* the renderer without the guard seeing it.
|
|
265
|
+
*/
|
|
266
|
+
export const CONDUCTOR_INVOCATION_TOKENS = ["setup-epic", "conduct-epic"];
|
|
267
|
+
/** The v2 token, named once so prose and the guard cannot drift apart. */
|
|
268
|
+
const SETUP_EPIC_SUBCOMMAND = CONDUCTOR_INVOCATION_TOKENS[0];
|
|
269
|
+
/**
|
|
270
|
+
* Fail closed if a rendered branch names more than one conductor.
|
|
271
|
+
*
|
|
272
|
+
* This is a guard, not decoration: the whole value of a single entry point is
|
|
273
|
+
* that a reader is never handed a choice, and a well-meaning "you could also
|
|
274
|
+
* try..." line added later would silently reintroduce the dual-authority
|
|
275
|
+
* hazard. Checking on the way out costs nothing and cannot be forgotten.
|
|
276
|
+
*/
|
|
277
|
+
export function assertSingleConductorInvocation(text) {
|
|
278
|
+
const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
|
|
279
|
+
if (named.length > 1) {
|
|
280
|
+
throw new Error(`drive-epic emitted more than one conductor invocation (${named.join(", ")}). ` +
|
|
281
|
+
"Exactly one path may ever be presented.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export function createDefaultDriveEpicDeps() {
|
|
285
|
+
return {
|
|
286
|
+
resolveAccess: async (repo) => {
|
|
287
|
+
const result = await resolveConductorBridgeApiAccess(repo ? { repoName: repo } : {});
|
|
288
|
+
return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
|
|
289
|
+
},
|
|
290
|
+
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
291
|
+
runSetupEpic: (argv) => runSetupEpicCli(argv),
|
|
292
|
+
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
293
|
+
// construction, so nothing has claimed stdout for the protocol transport.
|
|
294
|
+
log: (message) => console.log(message),
|
|
295
|
+
errorLog: (message) => console.error(message),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Run the CLI. Returns `0` on a successful handoff and `1` on any validation,
|
|
300
|
+
* readiness, prerequisite, or execution failure. Never throws: the caller
|
|
301
|
+
* dispatches this at module top level, where an escaping exception would take
|
|
302
|
+
* down the process before any diagnostic reached the operator.
|
|
303
|
+
*/
|
|
304
|
+
export async function runDriveEpicCli(argv, overrides = {}) {
|
|
305
|
+
const deps = { ...createDefaultDriveEpicDeps(), ...overrides };
|
|
306
|
+
const parsed = parseDriveEpicArgs(argv);
|
|
307
|
+
if (parsed.status === "help") {
|
|
308
|
+
deps.log(parsed.usage);
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
if (parsed.status === "error") {
|
|
312
|
+
deps.errorLog(parsed.message);
|
|
313
|
+
deps.errorLog("");
|
|
314
|
+
deps.errorLog(getDriveEpicUsage());
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
const { epicKey, planFile, repo } = parsed.options;
|
|
318
|
+
try {
|
|
319
|
+
// --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
|
|
320
|
+
const accessResult = await deps.resolveAccess(repo);
|
|
321
|
+
if (!accessResult.ok) {
|
|
322
|
+
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
323
|
+
}
|
|
324
|
+
// --- Read readiness. Any failure here is UNKNOWN. ------------------------
|
|
325
|
+
let readiness;
|
|
326
|
+
try {
|
|
327
|
+
readiness = await deps.readReadiness(accessResult.access);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
331
|
+
}
|
|
332
|
+
// --- Route. Exactly one path from here on. -------------------------------
|
|
333
|
+
const selection = selectConductor(readiness);
|
|
334
|
+
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
335
|
+
// about a missing plan file, and rendering it on the path that has one
|
|
336
|
+
// would produce a message contradicting what is about to happen.
|
|
337
|
+
if (selection.conductor === "v2" && planFile) {
|
|
338
|
+
// Delegate. `setup-epic` owns branch, run-row, plan, and approval
|
|
339
|
+
// behavior; drive-epic reimplements none of it and prints nothing of its
|
|
340
|
+
// own alongside it, so its output remains the only conductor output.
|
|
341
|
+
const setupArgv = ["--epic-key", epicKey, "--plan-file", planFile, ...(repo ? ["--repo", repo] : [])];
|
|
342
|
+
return await deps.runSetupEpic(setupArgv);
|
|
343
|
+
}
|
|
344
|
+
const handoff = renderConductorHandoff(selection, epicKey);
|
|
345
|
+
const text = handoff.lines.join("\n");
|
|
346
|
+
assertSingleConductorInvocation(text);
|
|
347
|
+
deps.log(text);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
// Last resort. Sanitized, on stderr, converted to an exit code — a
|
|
352
|
+
// credential must never reach this line, and a stringified unknown is
|
|
353
|
+
// exactly how one escapes.
|
|
354
|
+
deps.errorLog(`drive-epic failed: ${safeDiagnosticMessage(err, "unexpected error")}`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
360
|
+
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
361
|
+
* which authority owns an epic must not be nudged toward either one.
|
|
362
|
+
*/
|
|
363
|
+
function escalate(deps, epicKey, reason) {
|
|
364
|
+
const lines = [
|
|
365
|
+
`Cannot determine which conductor owns ${epicKey}: ${reason}`,
|
|
366
|
+
"",
|
|
367
|
+
"This is not a not-ready result — it is an unknown one, and starting the wrong",
|
|
368
|
+
"conductor on an epic wedges it permanently. Restore Bridge API access and run",
|
|
369
|
+
"drive-epic again, or ask an operator to resolve conductor readiness.",
|
|
370
|
+
];
|
|
371
|
+
const text = lines.join("\n");
|
|
372
|
+
assertSingleConductorInvocation(text);
|
|
373
|
+
deps.errorLog(text);
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
package/build/executor/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
|
|
|
14
14
|
import { createDefaultExecutorDeps } from "./deps.js";
|
|
15
15
|
import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
16
16
|
import { createExecutorHttpClient } from "./http-client.js";
|
|
17
|
+
import { startExecutorSleepAssertion } from "./platform.js";
|
|
17
18
|
import { runExecutor } from "./runner.js";
|
|
18
19
|
import { runExecutorWatchCli } from "./watch-cli.js";
|
|
19
20
|
/** Fixed executor timing/behavior defaults. */
|
|
@@ -210,6 +211,21 @@ export function parseExecutorArgs(argv, context) {
|
|
|
210
211
|
};
|
|
211
212
|
return { kind: "ok", options };
|
|
212
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Default signal registrar: `process.on`, disposed with `process.off`.
|
|
216
|
+
*
|
|
217
|
+
* The SAME register-and-return-an-unsubscriber shape the notifications CLI and
|
|
218
|
+
* the plane supervisor already use. Defined here rather than inside `runExecutor`
|
|
219
|
+
* so the runner never touches `process` itself — the runner stays a pure loop
|
|
220
|
+
* over injected boundaries, and this bootstrap file remains the one place that
|
|
221
|
+
* knows the process object exists.
|
|
222
|
+
*/
|
|
223
|
+
function defaultSignalRegistrar(signal, handler) {
|
|
224
|
+
process.on(signal, handler);
|
|
225
|
+
return () => {
|
|
226
|
+
process.off(signal, handler);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
213
229
|
function hasRepoFlag(argv) {
|
|
214
230
|
return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
|
|
215
231
|
}
|
|
@@ -306,12 +322,43 @@ export async function runExecutorCli(argv, overrides = {}) {
|
|
|
306
322
|
// Hand the loop the VALIDATED runtime configuration: the parsed options plus the
|
|
307
323
|
// normalized base URL resolution actually settled on.
|
|
308
324
|
const runtimeOptions = { ...options, resolvedBaseUrl };
|
|
325
|
+
// --- Sleep assertion (BAPI-828) ---------------------------------------
|
|
326
|
+
// Started HERE — after argument parsing, base-URL resolution, credential
|
|
327
|
+
// resolution, and HTTP client construction have all succeeded, and immediately
|
|
328
|
+
// before the loop begins. That position is the whole design: it guarantees the
|
|
329
|
+
// assertion exists before the first claim can spawn a worker, while a `--help`
|
|
330
|
+
// run, an unknown flag, a missing base URL, and a credential failure each
|
|
331
|
+
// return above without ever spawning `caffeinate`. Fail-open throughout — a
|
|
332
|
+
// host that cannot hold the assertion still claims and still works.
|
|
333
|
+
const sleepAssertion = startExecutorSleepAssertion({
|
|
334
|
+
platform: context.platform,
|
|
335
|
+
// The EXECUTOR's environment, read straight from deps. A worker environment
|
|
336
|
+
// never reaches this call, and the opt-out is never forwarded to one.
|
|
337
|
+
env: deps.env,
|
|
338
|
+
cwd: deps.cwd,
|
|
339
|
+
executorPid: context.pid,
|
|
340
|
+
spawnProcess: deps.spawnProcess,
|
|
341
|
+
setTimer: deps.setTimer,
|
|
342
|
+
clearTimer: deps.clearTimer,
|
|
343
|
+
errorLog,
|
|
344
|
+
});
|
|
345
|
+
const runnerSeams = {
|
|
346
|
+
onSignal: overrides.onSignal ?? defaultSignalRegistrar,
|
|
347
|
+
};
|
|
309
348
|
try {
|
|
310
|
-
return await run(runtimeOptions, deps, httpClient);
|
|
349
|
+
return await run(runtimeOptions, deps, httpClient, runnerSeams);
|
|
311
350
|
}
|
|
312
351
|
catch (err) {
|
|
313
352
|
const message = err instanceof Error ? err.message : String(err);
|
|
314
353
|
errorLog(`Error: executor exited unexpectedly: ${message.slice(0, 200)}`);
|
|
315
354
|
return 1;
|
|
316
355
|
}
|
|
356
|
+
finally {
|
|
357
|
+
// Released for a normal completion, a graceful signal shutdown, AND a thrown
|
|
358
|
+
// runner error alike. Without the `finally` the throw path above would return
|
|
359
|
+
// its exit code while still holding a `caffeinate` child — which, being a
|
|
360
|
+
// live child of this process, is exactly the kind of thing that keeps a Node
|
|
361
|
+
// process from exiting when nobody calls `process.exit`.
|
|
362
|
+
await sleepAssertion.release();
|
|
363
|
+
}
|
|
317
364
|
}
|
package/build/executor/env.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* (`Read(~/.config/bridge/**)` in `permissions.ts`).
|
|
41
41
|
*/
|
|
42
42
|
import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
43
|
+
import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
|
|
43
44
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
44
45
|
const ALLOWED_ENV_KEYS = [
|
|
45
46
|
"PATH",
|
|
@@ -92,6 +93,14 @@ export const EXPLICIT_DENY_KEYS = [
|
|
|
92
93
|
"GH_TOKEN",
|
|
93
94
|
"CLAUDE_CONFIG_DIR",
|
|
94
95
|
"XDG_CONFIG_HOME",
|
|
96
|
+
// BAPI-844: the index-scope declaration follows the BAPI_BASE_BRANCH rule —
|
|
97
|
+
// absent from ALLOWED_ENV_KEYS so it is never copied from `parentEnv`, and
|
|
98
|
+
// denied EXPLICITLY here so an operator's ambient `BAPI_INDEX_SCOPE` can never
|
|
99
|
+
// reach a worker. A worker's research index is a run-scoped decision; a value
|
|
100
|
+
// exported in the shell that happened to launch the executor is not that
|
|
101
|
+
// decision, and inheriting one would route an unscoped worker's plan and review
|
|
102
|
+
// at another epic's shadow index while nothing in the job said so.
|
|
103
|
+
INDEX_SCOPE_ENV_VAR,
|
|
95
104
|
];
|
|
96
105
|
/**
|
|
97
106
|
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
@@ -130,10 +139,22 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
|
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
env.BRIDGE_SKIP_PREPUSH = "1";
|
|
142
|
+
env.MAX_MCP_OUTPUT_TOKENS = "50000";
|
|
133
143
|
if (typeof options.effectiveBaseBranch === "string" &&
|
|
134
144
|
options.effectiveBaseBranch.length > 0) {
|
|
135
145
|
env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
|
|
136
146
|
}
|
|
147
|
+
// BAPI-844: the index-scope declaration, set ONLY from the explicit option and
|
|
148
|
+
// only after the shared shape check. A malformed explicit value throws here —
|
|
149
|
+
// before an environment exists — rather than being dropped: silently omitting it
|
|
150
|
+
// would spawn a worker that researches the canonical index while the job it came
|
|
151
|
+
// from declared a scope, which is the one outcome the declaration exists to
|
|
152
|
+
// prevent. An absent option adds no key at all, so the returned object is
|
|
153
|
+
// unchanged for every unscoped worker.
|
|
154
|
+
const indexScope = validateOptionalIndexScope(options.indexScope);
|
|
155
|
+
if (indexScope !== undefined) {
|
|
156
|
+
env[INDEX_SCOPE_ENV_VAR] = indexScope;
|
|
157
|
+
}
|
|
137
158
|
// Nothing agent-specific is added here, and deliberately nothing secret. An
|
|
138
159
|
// adapter that needs to forward an operator-owned credential declares it as a
|
|
139
160
|
// passthrough and copies it onto the object this function returns; see
|
|
@@ -34,6 +34,45 @@ function parseStopRequested(bodyText) {
|
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The claim response header carrying server-derived dispatcher liveness
|
|
39
|
+
* (BAPI-871). Exported so the client and its tests name it once.
|
|
40
|
+
*/
|
|
41
|
+
export const RECONCILER_LIVENESS_HEADER = "X-BAPI-Reconciler-Liveness";
|
|
42
|
+
/** The only values the server is documented to send. */
|
|
43
|
+
const RECONCILER_LIVENESS_VALUES = [
|
|
44
|
+
"fresh",
|
|
45
|
+
"stale",
|
|
46
|
+
"never_seen",
|
|
47
|
+
"unknown",
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Read the liveness header off a claim response, admitting ONLY documented
|
|
51
|
+
* values.
|
|
52
|
+
*
|
|
53
|
+
* Absent, blank, or unrecognized all map to `unknown` — the compatibility path.
|
|
54
|
+
* An executor talking to a backend from before this header existed must keep
|
|
55
|
+
* claiming exactly as it did, and "I have no verdict" is what `unknown` means.
|
|
56
|
+
* A value the server never promised is treated the same way rather than being
|
|
57
|
+
* passed through: an unrecognized string reaching the runner's availability
|
|
58
|
+
* logic could silently become a fifth state nobody handles.
|
|
59
|
+
*
|
|
60
|
+
* Header lookup is case-insensitive via `Headers.get`, which is required by the
|
|
61
|
+
* spec — HTTP header names are not case-sensitive, and proxies do re-case them.
|
|
62
|
+
*/
|
|
63
|
+
export function parseReconcilerLivenessHeader(headers) {
|
|
64
|
+
let raw = null;
|
|
65
|
+
try {
|
|
66
|
+
raw = headers.get(RECONCILER_LIVENESS_HEADER);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "unknown";
|
|
70
|
+
}
|
|
71
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
72
|
+
return RECONCILER_LIVENESS_VALUES.includes(value)
|
|
73
|
+
? value
|
|
74
|
+
: "unknown";
|
|
75
|
+
}
|
|
37
76
|
/**
|
|
38
77
|
* Map a mutation (heartbeat/complete/fail) HTTP response onto the fencing
|
|
39
78
|
* outcome. `allowInvalidResult` maps 422 to `invalid_job_result` (complete only).
|
|
@@ -68,7 +107,9 @@ export function createExecutorHttpClient(config) {
|
|
|
68
107
|
body: JSON.stringify(body),
|
|
69
108
|
});
|
|
70
109
|
const text = await res.text();
|
|
71
|
-
|
|
110
|
+
// `headers` is surfaced (optional, so the many response fakes that omit it
|
|
111
|
+
// stay assignable) purely for the claim path's liveness annotation.
|
|
112
|
+
return { status: res.status, text, headers: res.headers };
|
|
72
113
|
}
|
|
73
114
|
async function mutate(pathSuffix, body, allowInvalidResult, repoName) {
|
|
74
115
|
try {
|
|
@@ -85,24 +126,33 @@ export function createExecutorHttpClient(config) {
|
|
|
85
126
|
async claim(manifest) {
|
|
86
127
|
let status;
|
|
87
128
|
let text;
|
|
129
|
+
let reconcilerLiveness = "unknown";
|
|
88
130
|
try {
|
|
89
131
|
const res = await post("/claim", manifest, manifest.repo_name);
|
|
90
132
|
status = res.status;
|
|
91
133
|
text = res.text;
|
|
134
|
+
// Parsed on EVERY response, then attached only to the successful
|
|
135
|
+
// variants below. A response fake that omits `headers` degrades to
|
|
136
|
+
// `unknown` rather than throwing.
|
|
137
|
+
if (res.headers)
|
|
138
|
+
reconcilerLiveness = parseReconcilerLivenessHeader(res.headers);
|
|
92
139
|
}
|
|
93
140
|
catch (err) {
|
|
94
141
|
const message = err instanceof Error ? err.message : String(err);
|
|
95
142
|
return { kind: "retryable", error: `claim network error: ${boundedDetail(message)}` };
|
|
96
143
|
}
|
|
144
|
+
// Both successful outcomes carry the verdict. Nothing about the liveness
|
|
145
|
+
// value changes the classification below: a 204 is still "no job", and the
|
|
146
|
+
// retryable/fatal split is untouched.
|
|
97
147
|
if (status === 204)
|
|
98
|
-
return { kind: "none" };
|
|
148
|
+
return { kind: "none", reconcilerLiveness };
|
|
99
149
|
if (status === 200) {
|
|
100
150
|
try {
|
|
101
151
|
const job = JSON.parse(text);
|
|
102
152
|
if (!job || typeof job !== "object" || typeof job.claim_token !== "string") {
|
|
103
153
|
return { kind: "fatal", error: "claim returned a malformed job payload" };
|
|
104
154
|
}
|
|
105
|
-
return { kind: "claimed", job };
|
|
155
|
+
return { kind: "claimed", job, reconcilerLiveness };
|
|
106
156
|
}
|
|
107
157
|
catch {
|
|
108
158
|
return { kind: "fatal", error: "claim returned invalid JSON" };
|
|
@@ -142,6 +192,24 @@ export function createExecutorHttpClient(config) {
|
|
|
142
192
|
result.stop_requested = true;
|
|
143
193
|
return result;
|
|
144
194
|
},
|
|
195
|
+
/**
|
|
196
|
+
* Publish per-process liveness (BAPI-871).
|
|
197
|
+
*
|
|
198
|
+
* Authenticated with the manifest's PRIMARY repo key. The server verifies
|
|
199
|
+
* every entry in `repo_names` before writing anything, so a key that does not
|
|
200
|
+
* hold one of them gets a 4xx — reported here as `failed` and nothing more.
|
|
201
|
+
* Delivery is non-critical monitoring: it never throws, never classifies a
|
|
202
|
+
* fencing outcome, and never stops the caller from claiming or running work.
|
|
203
|
+
*/
|
|
204
|
+
async processHeartbeat(request) {
|
|
205
|
+
try {
|
|
206
|
+
const { status, text } = await post("/process-heartbeat", request, request.repo_names[0] ?? "");
|
|
207
|
+
return status >= 200 && status < 300 && isOkBody(text) ? "delivered" : "failed";
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return "failed";
|
|
211
|
+
}
|
|
212
|
+
},
|
|
145
213
|
complete(job, completion) {
|
|
146
214
|
return mutate(`/${job.id}/complete`, {
|
|
147
215
|
repo_name: job.repo_name,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-job index-scope resolution (BAPI-844).
|
|
3
|
+
*
|
|
4
|
+
* The server names the index scope a v2 job's worker must research against, as
|
|
5
|
+
* the top-level `index_scope_id` on the claimed job — the same wire position as
|
|
6
|
+
* `epic_run_id`, and for the same reason: it is a server-minted IDENTITY, not
|
|
7
|
+
* job-type-specific payload data. There is exactly one wire representation, and
|
|
8
|
+
* this module is the only place the executor reads it.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors {@link ../executor/base-branch.ts} deliberately, including its posture:
|
|
11
|
+
*
|
|
12
|
+
* - ABSENT → an unscoped job. The worker's environment, command, and routing are
|
|
13
|
+
* byte-identical to the pre-BAPI-844 ones.
|
|
14
|
+
* - PRESENT and well-formed → carried into the worker environment as an explicit
|
|
15
|
+
* spawn option (never inherited).
|
|
16
|
+
* - PRESENT but malformed → a contract failure, resolved BEFORE any side effect.
|
|
17
|
+
* Dropping it instead would spawn a worker that silently researched the
|
|
18
|
+
* canonical index while the job it came from declared a scope.
|
|
19
|
+
*/
|
|
20
|
+
import { validateOptionalIndexScope } from "../index-scope-contract.js";
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the declared index scope for one executor job.
|
|
23
|
+
*
|
|
24
|
+
* The diagnostic names the contract only. The submitted value never appears in
|
|
25
|
+
* it, because this string reaches `/fail`, the job row, and operator output —
|
|
26
|
+
* all places an opaque routing token must not be persisted.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveExecutorJobIndexScope(job) {
|
|
29
|
+
try {
|
|
30
|
+
const indexScope = validateOptionalIndexScope(job.index_scope_id);
|
|
31
|
+
return indexScope === undefined ? { ok: true } : { ok: true, indexScope };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: "job index_scope_id is present but is not a valid index-scope identity.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
* opened a PR whose base branch is not the run base, so `claude-review.yml`
|
|
21
21
|
* (triggered only on `branches: [main]`) never fires and the `code_review`
|
|
22
22
|
* gate would hang forever; see `worker-finalization.ts`.
|
|
23
|
+
* - `WorkerFinalizationPrNotAttached` — an implementation-style job (BAPI-862)
|
|
24
|
+
* exited cleanly and its exact HEAD is durable on origin, but no usable pull
|
|
25
|
+
* request exists for that branch: none is visible, its URL is blank, its
|
|
26
|
+
* description is blank (BAPI-824 folded in), or it does not yet carry the
|
|
27
|
+
* pushed head. The work is COMPLETE and PUSHED — only the attachment is
|
|
28
|
+
* missing — so this kind is deliberately distinct from every other
|
|
29
|
+
* finalization failure, and its guidance names attachment and warns against
|
|
30
|
+
* deleting or rebasing the branch; see `worker-finalization.ts`.
|
|
23
31
|
* - `WorkerFinalizationSavedButUnfinalized` — an implementation-style job
|
|
24
32
|
* (BAPI-762) exited cleanly, and an origin branch exists at an authoritative
|
|
25
33
|
* tip that does NOT match the worker's own HEAD — durable work exists on
|
|
@@ -98,6 +106,7 @@ export const WorktreeLostBeforePush = "WorktreeLostBeforePush";
|
|
|
98
106
|
export const BranchMismatch = "BranchMismatch";
|
|
99
107
|
export const WorkerFinalizationMissingRemoteBranchAndPr = "WorkerFinalizationMissingRemoteBranchAndPr";
|
|
100
108
|
export const WorkerFinalizationPrBaseMismatch = "WorkerFinalizationPrBaseMismatch";
|
|
109
|
+
export const WorkerFinalizationPrNotAttached = "WorkerFinalizationPrNotAttached";
|
|
101
110
|
export const WorkerFinalizationSavedButUnfinalized = "WorkerFinalizationSavedButUnfinalized";
|
|
102
111
|
export const WorkerConfigIsolation = "ContractError.WorkerConfigIsolation";
|
|
103
112
|
export const PreSpawnVerification = "ContractError.PreSpawnVerification";
|