@bridge_gpt/mcp-server 0.2.53 → 0.2.54
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 +86 -10
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/commands.generated.js +1 -1
- package/build/conductor/bridge-api-client.js +36 -8
- package/build/conductor/epic-runtime.js +133 -97
- package/build/conductor/readiness.js +85 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +5 -5
- package/build/doctor.js +68 -1
- package/build/drive-epic.js +287 -51
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/job-runner.js +60 -0
- package/build/index.js +128 -400
- package/build/local-artifact-storage.js +130 -0
- package/build/pipelines.generated.js +16 -9
- package/build/plane/cli.js +285 -36
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/shutdown.js +14 -1
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +514 -23
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +1 -1
- package/pipelines/review-ticket.json +17 -4
package/build/doctor.js
CHANGED
|
@@ -161,6 +161,13 @@ export function getDoctorUsage() {
|
|
|
161
161
|
"deletes a branch, or repairs any git state, and never changes the exit code.",
|
|
162
162
|
"Without --stale-branch it runs no git probe at all and prints no section.",
|
|
163
163
|
"",
|
|
164
|
+
"The 'operational advisories' section also reports RETIRED local drive helpers",
|
|
165
|
+
"(~/.local/bin/bapi-runtime-up, ~/.local/bin/bapi-drive) when they are still on",
|
|
166
|
+
"this machine (BAPI-1102). That is a PATH-PRESENCE check only: doctor does not",
|
|
167
|
+
"run, read, source, chmod, or delete either file, and cannot remove them for you",
|
|
168
|
+
"— they are outside this repository, so their removal is an operator step. The",
|
|
169
|
+
"row is advisory and never changes the exit code.",
|
|
170
|
+
"",
|
|
164
171
|
"Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
|
|
165
172
|
].join("\n");
|
|
166
173
|
}
|
|
@@ -1413,6 +1420,24 @@ export function formatConductEpicDiagnosticReport(diagnostic) {
|
|
|
1413
1420
|
lines.push(" lock, rewrites no checkpoint, and never changes the exit code.");
|
|
1414
1421
|
return lines.join("\n");
|
|
1415
1422
|
}
|
|
1423
|
+
/**
|
|
1424
|
+
* The transitional local helpers BAPI-1102 retires.
|
|
1425
|
+
*
|
|
1426
|
+
* `~/.local/bin` scripts, never shipped in this package and not in this
|
|
1427
|
+
* repository, so nothing here can delete them — removal is an OPERATOR step. What
|
|
1428
|
+
* the repository CAN do is notice them and say so, which is the difference
|
|
1429
|
+
* between a documented retirement and a silent one: an operator whose muscle
|
|
1430
|
+
* memory still types `bapi-drive` would otherwise keep driving epics through a
|
|
1431
|
+
* path that predates the scoped-lane bring-up, and diagnose the resulting
|
|
1432
|
+
* unscoped run as a server problem.
|
|
1433
|
+
*
|
|
1434
|
+
* Relative to the home directory, joined at collection time so no absolute path
|
|
1435
|
+
* is baked into the module.
|
|
1436
|
+
*/
|
|
1437
|
+
export const LEGACY_DRIVE_HELPER_RELATIVE_PATHS = [
|
|
1438
|
+
".local/bin/bapi-runtime-up",
|
|
1439
|
+
".local/bin/bapi-drive",
|
|
1440
|
+
];
|
|
1416
1441
|
/**
|
|
1417
1442
|
* Collect the operational advisories, read-only.
|
|
1418
1443
|
*
|
|
@@ -1514,7 +1539,30 @@ export async function collectOperationalAdvisories(deps) {
|
|
|
1514
1539
|
conductorObservation = "observed";
|
|
1515
1540
|
}
|
|
1516
1541
|
}
|
|
1517
|
-
|
|
1542
|
+
// BAPI-1102 — a pure path-presence check, using the SAME injected `stat` and
|
|
1543
|
+
// `homedir` seams every other advisory here uses, so it stays deterministic
|
|
1544
|
+
// under test and reaches no real filesystem in one. A `stat` that throws is
|
|
1545
|
+
// "absent"; it is never retried, and a permission error is not reported as a
|
|
1546
|
+
// finding, because "I could not stat it" is not evidence that a stale helper is
|
|
1547
|
+
// in use.
|
|
1548
|
+
const legacyDriveHelpers = [];
|
|
1549
|
+
for (const relative of LEGACY_DRIVE_HELPER_RELATIVE_PATHS) {
|
|
1550
|
+
const absolute = path.join(deps.homedir(), relative);
|
|
1551
|
+
try {
|
|
1552
|
+
await deps.stat(absolute);
|
|
1553
|
+
legacyDriveHelpers.push(absolute);
|
|
1554
|
+
}
|
|
1555
|
+
catch {
|
|
1556
|
+
/* absent, or unreadable — either way, nothing to report */
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
dispatcher,
|
|
1561
|
+
conductorObservation,
|
|
1562
|
+
conductors,
|
|
1563
|
+
indeterminateLocks,
|
|
1564
|
+
legacyDriveHelpers,
|
|
1565
|
+
};
|
|
1518
1566
|
}
|
|
1519
1567
|
/** Render the operational-advisories section. Advisory: never changes the exit code. */
|
|
1520
1568
|
export function formatOperationalAdvisoryReport(diagnostic) {
|
|
@@ -1554,6 +1602,21 @@ export function formatOperationalAdvisoryReport(diagnostic) {
|
|
|
1554
1602
|
lines.push(` ${diagnostic.indeterminateLocks} local lock(s) could not be judged (remote host,\n` +
|
|
1555
1603
|
" malformed, or unreadable) and are excluded from the live count.");
|
|
1556
1604
|
}
|
|
1605
|
+
// BAPI-1102. A WARN, never a failure, and deliberately separate from every
|
|
1606
|
+
// readiness signal above: a stale helper says nothing about whether this
|
|
1607
|
+
// repository's conductor works, so classifying it as a readiness problem would
|
|
1608
|
+
// report a perfectly healthy install as runtime-broken and could block a setup
|
|
1609
|
+
// that has nothing wrong with it.
|
|
1610
|
+
if (diagnostic.legacyDriveHelpers.length > 0) {
|
|
1611
|
+
lines.push(` WARN Legacy local drive helpers detected: ${diagnostic.legacyDriveHelpers.length} file(s).`);
|
|
1612
|
+
for (const helper of diagnostic.legacyDriveHelpers) {
|
|
1613
|
+
lines.push(` ${helper}`);
|
|
1614
|
+
}
|
|
1615
|
+
lines.push(" These predate the scoped two-phase bring-up and are retired. Confirm\n" +
|
|
1616
|
+
" `drive-epic <EPIC> --plan-file <dag>` works for you, then remove them.\n" +
|
|
1617
|
+
" doctor performed a PATH-PRESENCE check only: it did not run, read, or\n" +
|
|
1618
|
+
" modify either file, and it cannot delete them for you.");
|
|
1619
|
+
}
|
|
1557
1620
|
lines.push(" Read-only: this section issues two Bridge GETs and reads local lock state. It");
|
|
1558
1621
|
lines.push(" runs NO command probe, repairs no scheduler, and never changes the exit code.");
|
|
1559
1622
|
return lines.join("\n");
|
|
@@ -1927,6 +1990,10 @@ export async function runDoctorCli(argv, overrides = {}) {
|
|
|
1927
1990
|
conductorObservation: "unavailable",
|
|
1928
1991
|
conductors: [],
|
|
1929
1992
|
indeterminateLocks: 0,
|
|
1993
|
+
// Empty, not omitted: a collection that failed has not established
|
|
1994
|
+
// that a legacy helper is absent, and a fallback that CLAIMED to have
|
|
1995
|
+
// checked would be the one kind of wrong answer worse than silence.
|
|
1996
|
+
legacyDriveHelpers: [],
|
|
1930
1997
|
}));
|
|
1931
1998
|
}
|
|
1932
1999
|
}
|
package/build/drive-epic.js
CHANGED
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
import { readFile } from "node:fs/promises";
|
|
55
55
|
import { dirname, join } from "node:path";
|
|
56
56
|
import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
57
|
-
import {
|
|
58
|
-
import { formatPlaneDiagnostic, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
57
|
+
import { runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
58
|
+
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
59
59
|
import { PLANE_SERVER_PORT_ENV_VAR } from "./plane/types.js";
|
|
60
60
|
import { validateBranchName } from "./base-ref.js";
|
|
61
61
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
@@ -110,15 +110,62 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
110
110
|
describe: "an executor provisioned and reporting ready",
|
|
111
111
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
112
112
|
},
|
|
113
|
+
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
114
|
+
// refuses them, so readiness and the refusal name the same first blocker.
|
|
115
|
+
//
|
|
116
|
+
// Each reads the SERVER's `unattended` block and re-derives nothing. A server
|
|
117
|
+
// that does not report the block (older than BAPI-1102) leaves every one of
|
|
118
|
+
// them UNSATISFIED: "could not be read" is not "holds", and these authorize an
|
|
119
|
+
// unattended merge.
|
|
120
|
+
//
|
|
121
|
+
// The FIRST of them therefore carries the older-server remedy too, because it
|
|
122
|
+
// is the one an unreported block always blocks on, and telling that operator
|
|
123
|
+
// to set a consent flag would send them to fix something that may already be
|
|
124
|
+
// set — the server simply never said (BAPI-1102 review).
|
|
125
|
+
{
|
|
126
|
+
id: "unattended_consent",
|
|
127
|
+
describe: "repository consent to unattended conductor runs",
|
|
128
|
+
unattendedOnly: true,
|
|
129
|
+
// No fixed `remedy`: this requirement's action always depends on WHY.
|
|
130
|
+
remedyFor: (r) => r.unattended === null || r.unattended === undefined
|
|
131
|
+
? "This Bridge API deploy does not report a repository's unattended " +
|
|
132
|
+
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
133
|
+
"deploy, or pass --attended."
|
|
134
|
+
: "Set `unattended_conductor_allowed` for this repository " +
|
|
135
|
+
"(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
|
|
136
|
+
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "unattended_notify_default",
|
|
140
|
+
describe: "a verified notify webhook default for escalation",
|
|
141
|
+
unattendedOnly: true,
|
|
142
|
+
remedy: "Supply a --policy-file declaring notify.webhook_url, or pass --attended. " +
|
|
143
|
+
"notify.local_sink does not satisfy this.",
|
|
144
|
+
satisfied: (r) => r.unattended?.notify_webhook_default_declared === true,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "repository_readiness",
|
|
148
|
+
describe: "confirmed review and conductor-CI workflows on the default branch",
|
|
149
|
+
unattendedOnly: true,
|
|
150
|
+
remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
|
|
151
|
+
satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
|
|
152
|
+
},
|
|
113
153
|
];
|
|
114
154
|
/**
|
|
115
155
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
116
156
|
*
|
|
117
|
-
* Both are liveness of a process this machine starts.
|
|
118
|
-
* `supervisor_setup`, `supervisor_config`, `github_credentials
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
157
|
+
* Both are liveness of a process this machine starts. Everything else —
|
|
158
|
+
* `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
|
|
159
|
+
* `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
|
|
160
|
+
* stored server-side state, credentials, or repository configuration, and no
|
|
161
|
+
* amount of starting processes creates any of it. Naming the repairable set
|
|
162
|
+
* here, once, is what keeps the composition from ever attempting a bring-up that
|
|
163
|
+
* could not have helped.
|
|
164
|
+
*
|
|
165
|
+
* This list must NOT grow for the BAPI-1102 prerequisites. A repository that has
|
|
166
|
+
* not consented is `operator_blocked`, and starting a plane for it would leave a
|
|
167
|
+
* running plane behind and change nothing — which is the precise failure the
|
|
168
|
+
* classification exists to prevent.
|
|
122
169
|
*/
|
|
123
170
|
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
124
171
|
/**
|
|
@@ -133,13 +180,51 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
|
133
180
|
* three competing actions has to work out which to do first, and the order here
|
|
134
181
|
* already encodes that (setup, then config, then credentials).
|
|
135
182
|
*/
|
|
136
|
-
export function classifyV2Readiness(readiness) {
|
|
137
|
-
|
|
183
|
+
export function classifyV2Readiness(readiness, options = {}) {
|
|
184
|
+
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
|
|
185
|
+
// requirements INAPPLICABLE, not merely tolerated. They authorize unattended
|
|
186
|
+
// operation and automatic merge behavior; an attended run asks for neither, so
|
|
187
|
+
// refusing it for a missing consent flag would block a configuration that is
|
|
188
|
+
// entirely safe (clar-004).
|
|
189
|
+
// BAPI-1102 — the escalation channel is satisfiable per RUN as well as per
|
|
190
|
+
// repository, and no column supplies a repository default today. A
|
|
191
|
+
// `--policy-file` may therefore declare `notify.webhook_url` and satisfy the
|
|
192
|
+
// prerequisite outright, which this layer cannot see: the file stays opaque
|
|
193
|
+
// here and is composed and read by `setup-epic`. Refusing on the repository
|
|
194
|
+
// field alone would refuse every run supplying its own channel — the very form
|
|
195
|
+
// the remedy names — so the requirement is DEFERRED to the composing step,
|
|
196
|
+
// which reads the policy and refuses if it declares none. Nothing is weakened:
|
|
197
|
+
// `setup-epic` gates before the create, and the server re-checks the predicate
|
|
198
|
+
// at approval and probes the URL there.
|
|
199
|
+
const deferred = options.hasPolicyFile
|
|
200
|
+
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
|
|
201
|
+
: V2_READINESS_REQUIREMENTS;
|
|
202
|
+
// `--into-base` is the second opt-out, and it is structural rather than
|
|
203
|
+
// declared: it selects the branch-silent composition, whose policy carries no
|
|
204
|
+
// posture and no auto-merge authorization at all. Such a run is not unattended
|
|
205
|
+
// and never becomes so, so holding it to prerequisites that authorize
|
|
206
|
+
// unattended operation would block a configuration these checks have nothing
|
|
207
|
+
// to say about — the same reasoning `--attended` rests on, reached by a
|
|
208
|
+
// different route. See `composeDefaultSetupEpicPolicy`.
|
|
209
|
+
const optedOut = options.attended === true || options.intoBase === true;
|
|
210
|
+
const applicable = optedOut
|
|
211
|
+
? deferred.filter((req) => req.unattendedOnly !== true)
|
|
212
|
+
: deferred;
|
|
213
|
+
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
138
214
|
if (unmet.length === 0)
|
|
139
215
|
return { kind: "green" };
|
|
140
216
|
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
141
|
-
if (blocker)
|
|
142
|
-
return {
|
|
217
|
+
if (blocker) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "operator_blocked",
|
|
220
|
+
id: blocker.id,
|
|
221
|
+
describe: blocker.describe,
|
|
222
|
+
...(() => {
|
|
223
|
+
const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
|
|
224
|
+
return remedy ? { remedy } : {};
|
|
225
|
+
})(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
143
228
|
return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
|
|
144
229
|
}
|
|
145
230
|
export function getDriveEpicUsage() {
|
|
@@ -159,12 +244,22 @@ export function getDriveEpicUsage() {
|
|
|
159
244
|
"strategy itself is decided by the bootstrap this command delegates to, never",
|
|
160
245
|
"here.",
|
|
161
246
|
"",
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"that
|
|
247
|
+
"Posture (v2, BAPI-1102): a run that selects an epic branch is created",
|
|
248
|
+
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
249
|
+
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
250
|
+
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
251
|
+
"unattended run has two one-time repository prerequisites (consent, and a",
|
|
252
|
+
"verified notify webhook default) that readiness lists with their state and that",
|
|
253
|
+
"are refused BY NAME before any run is created.",
|
|
254
|
+
"",
|
|
255
|
+
"One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
|
|
256
|
+
"starts the conductor runtime for you when readiness is missing ONLY the",
|
|
257
|
+
"reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
|
|
258
|
+
"reconciler), creates and approves the run, then starts executor lanes SCOPED to",
|
|
259
|
+
"that run, and starts the dead-man observer last — one invocation, no manual",
|
|
260
|
+
"`plane up` first. A report missing supervisor setup, supervisor configuration,",
|
|
261
|
+
"GitHub App credentials, or an unattended prerequisite is NOT started: those are",
|
|
262
|
+
"operator-owned, and drive-epic exits with that one named reason instead.",
|
|
168
263
|
"",
|
|
169
264
|
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
170
265
|
"work tree, and the local server port must be free. No worktree is created for",
|
|
@@ -185,6 +280,9 @@ export function getDriveEpicUsage() {
|
|
|
185
280
|
" repository base branch (the pre-BAPI-1009 behavior).",
|
|
186
281
|
" Contradictory with --feature-branch; passing both is a",
|
|
187
282
|
" parse error.",
|
|
283
|
+
" --attended OPT OUT of the unattended default. Forwarded verbatim to",
|
|
284
|
+
" the bootstrap, which owns what it composes; drive-epic",
|
|
285
|
+
" reads it only to skip the unattended prerequisites.",
|
|
188
286
|
"",
|
|
189
287
|
"Policy (forwarded verbatim; drive-epic interprets none of it):",
|
|
190
288
|
" --policy-file <path> JSON file holding the COMPLETE run policy.",
|
|
@@ -230,12 +328,20 @@ export function parseDriveEpicArgs(argv) {
|
|
|
230
328
|
featureBranch = trimmed;
|
|
231
329
|
return trimmed;
|
|
232
330
|
};
|
|
331
|
+
let attended = false;
|
|
233
332
|
for (let i = 0; i < argv.length; i++) {
|
|
234
333
|
const arg = argv[i];
|
|
235
334
|
if (arg === "--into-base") {
|
|
236
335
|
intoBase = true;
|
|
237
336
|
continue;
|
|
238
337
|
}
|
|
338
|
+
// BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
|
|
339
|
+
// exactly one thing: making the unattended readiness prerequisites
|
|
340
|
+
// inapplicable. drive-epic reads no policy.
|
|
341
|
+
if (arg === "--attended") {
|
|
342
|
+
attended = true;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
239
345
|
if (arg === "--replace-policy") {
|
|
240
346
|
replacePolicy = true;
|
|
241
347
|
continue;
|
|
@@ -330,6 +436,8 @@ export function parseDriveEpicArgs(argv) {
|
|
|
330
436
|
...(repo ? { repo } : {}),
|
|
331
437
|
...(featureBranch ? { featureBranch } : {}),
|
|
332
438
|
...(intoBase ? { intoBase: true } : {}),
|
|
439
|
+
// Added only when supplied, so the no-flag options object stays byte-identical.
|
|
440
|
+
...(attended ? { attended: true } : {}),
|
|
333
441
|
...(policyFile ? { policyFile } : {}),
|
|
334
442
|
...(reviewPolicy ? { reviewPolicy } : {}),
|
|
335
443
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
@@ -343,9 +451,23 @@ export function parseDriveEpicArgs(argv) {
|
|
|
343
451
|
* decision. The unknown case is produced by the caller, which is the only place
|
|
344
452
|
* that can observe a failed read — selection itself never happens by exception
|
|
345
453
|
* handling, and so never needs to report that it could not decide.
|
|
454
|
+
*
|
|
455
|
+
* BAPI-1102 — the `unattendedOnly` requirements are EXCLUDED from routing, and
|
|
456
|
+
* the exclusion is load-bearing rather than tidy. This function answers "can the
|
|
457
|
+
* v2 engine drive an epic at all?", and the answer does not depend on whether
|
|
458
|
+
* the repository has consented to UNATTENDED operation: an attended v2 run on a
|
|
459
|
+
* non-consenting repository is a perfectly ordinary, fully supported run.
|
|
460
|
+
* Including them would route every such repository to the pilot conductor the
|
|
461
|
+
* moment this ticket deployed — a silent, repository-wide change of conductor for
|
|
462
|
+
* a reason that has nothing to do with the choice, and the exact opposite of
|
|
463
|
+
* AC-3's "nothing changes for an existing run".
|
|
464
|
+
*
|
|
465
|
+
* They still gate the COMPOSED bring-up, through
|
|
466
|
+
* {@link classifyV2Readiness}, which is where an unattended run is actually
|
|
467
|
+
* about to be created.
|
|
346
468
|
*/
|
|
347
469
|
export function selectConductor(readiness) {
|
|
348
|
-
const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
|
|
470
|
+
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
|
|
349
471
|
if (missing.length === 0) {
|
|
350
472
|
return {
|
|
351
473
|
kind: "selected",
|
|
@@ -372,7 +494,7 @@ export function selectConductor(readiness) {
|
|
|
372
494
|
* no quoting to get wrong.
|
|
373
495
|
*/
|
|
374
496
|
export function buildSetupEpicArgv(options) {
|
|
375
|
-
const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
|
|
497
|
+
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
|
|
376
498
|
return [
|
|
377
499
|
"--epic-key",
|
|
378
500
|
epicKey,
|
|
@@ -380,6 +502,9 @@ export function buildSetupEpicArgv(options) {
|
|
|
380
502
|
...(repo ? ["--repo", repo] : []),
|
|
381
503
|
...(featureBranch ? ["--feature-branch", featureBranch] : []),
|
|
382
504
|
...(intoBase ? ["--into-base"] : []),
|
|
505
|
+
// Appended beside the other branch/posture pass-throughs, and after
|
|
506
|
+
// `--into-base` so the forwarded order matches the declaration order above.
|
|
507
|
+
...(attended ? ["--attended"] : []),
|
|
383
508
|
...(policyFile ? ["--policy-file", policyFile] : []),
|
|
384
509
|
...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
|
|
385
510
|
...(replacePolicy ? ["--replace-policy"] : []),
|
|
@@ -596,10 +721,23 @@ export function createDefaultDriveEpicDeps() {
|
|
|
596
721
|
return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
|
|
597
722
|
},
|
|
598
723
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
599
|
-
runSetupEpic: (argv) =>
|
|
724
|
+
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
600
725
|
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
601
726
|
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
602
727
|
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
728
|
+
launchControlPlane: ({ preflight, executors }) => launchPlaneControlPlane({
|
|
729
|
+
preflight,
|
|
730
|
+
executors,
|
|
731
|
+
// stdout/stderr, not the injected log seams: the plane streams its own
|
|
732
|
+
// member events through these for the plane's whole lifetime, and
|
|
733
|
+
// drive-epic's `log` is a per-message reporter, not a stream sink.
|
|
734
|
+
sinks: {
|
|
735
|
+
stdout: (line) => console.log(line),
|
|
736
|
+
stderr: (line) => console.error(line),
|
|
737
|
+
},
|
|
738
|
+
env: process.env,
|
|
739
|
+
}),
|
|
740
|
+
requestScopedLanes: (args) => requestPlaneScopedLanes(args),
|
|
603
741
|
cwd: () => process.cwd(),
|
|
604
742
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
605
743
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
@@ -672,12 +810,22 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
672
810
|
// the runtime facts is not a "use the other conductor" situation — it is a
|
|
673
811
|
// plane that has not been started yet.
|
|
674
812
|
if (planFile) {
|
|
675
|
-
|
|
813
|
+
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended
|
|
814
|
+
// prerequisites inapplicable. Both inputs are FLAG checks, not policy
|
|
815
|
+
// reads: whatever a `--policy-file` says stays opaque here, exactly as it
|
|
816
|
+
// always has. Its mere PRESENCE defers the escalation-channel requirement
|
|
817
|
+
// to `setup-epic`, which composes the policy and can actually read it.
|
|
818
|
+
const classification = classifyV2Readiness(readiness, {
|
|
819
|
+
attended: parsed.options.attended === true,
|
|
820
|
+
intoBase: parsed.options.intoBase === true,
|
|
821
|
+
hasPolicyFile: parsed.options.policyFile !== undefined,
|
|
822
|
+
});
|
|
676
823
|
const setupArgv = buildSetupEpicArgv(parsed.options);
|
|
677
824
|
// Green: delegate exactly as before. No plane invocation, no preflight,
|
|
678
|
-
// and byte-identical argv for the no-flag case.
|
|
825
|
+
// and byte-identical argv for the no-flag case. Only the structured
|
|
826
|
+
// result's exit code is returned, so the public contract is unchanged.
|
|
679
827
|
if (classification.kind === "green") {
|
|
680
|
-
return await deps.runSetupEpic(setupArgv);
|
|
828
|
+
return (await deps.runSetupEpic(setupArgv)).exitCode;
|
|
681
829
|
}
|
|
682
830
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
683
831
|
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
@@ -688,7 +836,11 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
688
836
|
`(${classification.id}).`,
|
|
689
837
|
"",
|
|
690
838
|
"This is not something starting the runtime can fix, so nothing was started.",
|
|
691
|
-
|
|
839
|
+
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
840
|
+
// run `install-bridge conductor` for a missing consent flag sends them
|
|
841
|
+
// to a command that cannot set it.
|
|
842
|
+
classification.remedy ??
|
|
843
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
692
844
|
].join("\n");
|
|
693
845
|
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
694
846
|
// because handing an operator a second authority is the hazard this
|
|
@@ -716,42 +868,126 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
716
868
|
deps.errorLog(refusal);
|
|
717
869
|
return 1;
|
|
718
870
|
}
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
871
|
+
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
872
|
+
//
|
|
873
|
+
// BAPI-1102 reordered this whole block. It used to be `plane up` (which
|
|
874
|
+
// spawned executor lanes) and THEN `setup-epic`, which cannot work in two
|
|
875
|
+
// independent ways: the lanes were spawned with no claim scope, so since
|
|
876
|
+
// BAPI-1026 they exit at startup; and `plane up` stays attached for the
|
|
877
|
+
// plane's lifetime, so the composed flow could never reach `setup-epic` at
|
|
878
|
+
// all. Both are why the BAPI-1061 and BAPI-1085 drivers fell back to
|
|
879
|
+
// hand-written helpers.
|
|
880
|
+
//
|
|
881
|
+
// The new order is the only one that can work: the server has to be up
|
|
882
|
+
// before a run can be created, and the run has to exist before a lane can
|
|
883
|
+
// be scoped to it.
|
|
884
|
+
deps.log(`${epicKey}: the conductor runtime is not live; starting its control plane before creating the run.`);
|
|
885
|
+
// The SAME preflight result is handed to the launch, so the port that was
|
|
886
|
+
// probed is the port that gets launched and the two cannot disagree.
|
|
887
|
+
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
888
|
+
if (!controlPlane.ok) {
|
|
889
|
+
// The launch already reported why, in its own words, and it rolls its own
|
|
890
|
+
// startup back. Nothing to add, and no run was created here.
|
|
891
|
+
for (const line of controlPlane.lines)
|
|
892
|
+
deps.errorLog(line);
|
|
729
893
|
return 1;
|
|
730
894
|
}
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
//
|
|
895
|
+
// Held so the launcher's lifetime promise is never unobserved. It settles
|
|
896
|
+
// when the PLANE comes down, which is long after this command returns —
|
|
897
|
+
// the detached runtime is its own process-group leader and outlives us by
|
|
898
|
+
// design, which is what "the plane is left running" means.
|
|
899
|
+
void controlPlane.lifetime.catch(() => undefined);
|
|
900
|
+
// Readiness is NOT re-read as a gate here, and that has not changed:
|
|
901
|
+
// `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
|
|
902
|
+
// and `executor_jobs` rows — tables that are EMPTY on a repository that has
|
|
903
|
+
// never run an epic — so gating on a re-read would hang the one-command
|
|
904
|
+
// flow permanently on exactly the first-time-operator case it exists to
|
|
905
|
+
// serve. A successful bring-up IS the runtime fact.
|
|
906
|
+
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
907
|
+
const setup = await deps.runSetupEpic(setupArgv);
|
|
908
|
+
if (setup.exitCode !== 0) {
|
|
909
|
+
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
910
|
+
// was spawned — there is nothing to claim a job that may not exist.
|
|
911
|
+
// Winding down automatically would stop the very run `setup-epic` may
|
|
912
|
+
// have just created, because `plane down` stops the manifest-bound run
|
|
913
|
+
// before terminating processes.
|
|
746
914
|
deps.errorLog([
|
|
747
915
|
"",
|
|
748
|
-
`${epicKey}: the conductor
|
|
916
|
+
`${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
|
|
917
|
+
"No executor lane was spawned, so nothing is claiming jobs.",
|
|
749
918
|
"It was not wound down automatically, because doing so would stop a run that may",
|
|
750
919
|
"already have been created and approved. Inspect it with `plane status`, and wind it",
|
|
751
920
|
"down with `plane down` once you have decided what to do with the run.",
|
|
752
921
|
].join("\n"));
|
|
922
|
+
return setup.exitCode;
|
|
753
923
|
}
|
|
754
|
-
|
|
924
|
+
if (setup.epicRunId === undefined) {
|
|
925
|
+
// Exit zero with no run id is an ORCHESTRATION failure, not a success:
|
|
926
|
+
// there is no authoritative identity to scope lanes to, and the one thing
|
|
927
|
+
// this reordering exists to prevent is a lane started against a run
|
|
928
|
+
// nobody named. Guessing — a repository-wide lookup, say — could scope
|
|
929
|
+
// lanes to a DIFFERENT run on the same repository.
|
|
930
|
+
deps.errorLog([
|
|
931
|
+
"",
|
|
932
|
+
`${epicKey}: setup reported success but returned no epic run id, so no executor lane`,
|
|
933
|
+
"could be scoped to this run. NO LANE WAS SPAWNED. The control plane is still",
|
|
934
|
+
"running: inspect it with `plane status` and wind it down with `plane down`.",
|
|
935
|
+
].join("\n"));
|
|
936
|
+
return 1;
|
|
937
|
+
}
|
|
938
|
+
// ---- PHASE TWO, part two: scoped lanes, then the observer -------------
|
|
939
|
+
const laneRequest = await deps.requestScopedLanes({
|
|
940
|
+
repoRoot,
|
|
941
|
+
planeId: controlPlane.planeId,
|
|
942
|
+
epicRunId: setup.epicRunId,
|
|
943
|
+
claimScope: { kind: "epic-runs", epicRunIds: [setup.epicRunId] },
|
|
944
|
+
});
|
|
945
|
+
if (!laneRequest.ok) {
|
|
946
|
+
deps.errorLog([
|
|
947
|
+
"",
|
|
948
|
+
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
949
|
+
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
950
|
+
laneRequest.reason === "run-conflict"
|
|
951
|
+
? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
|
|
952
|
+
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
953
|
+
"The run itself is untouched and still committed server-side.",
|
|
954
|
+
].join("\n"));
|
|
955
|
+
return 1;
|
|
956
|
+
}
|
|
957
|
+
const planeReady = await controlPlane.awaitPlaneReady();
|
|
958
|
+
if (!planeReady.ok) {
|
|
959
|
+
// No rollback of the RUN, deliberately. A lane that failed to start says
|
|
960
|
+
// nothing about whether the run is valid, and stopping a committed,
|
|
961
|
+
// approved run because a local process died would destroy state the
|
|
962
|
+
// operator may well want to retry into.
|
|
963
|
+
for (const line of planeReady.lines)
|
|
964
|
+
deps.errorLog(line);
|
|
965
|
+
deps.errorLog([
|
|
966
|
+
"",
|
|
967
|
+
`${epicKey}: no executor lane became ready, and the dead-man observer was NOT started.`,
|
|
968
|
+
"The control plane and the committed run are both intact and were not rolled back:",
|
|
969
|
+
"the runtime stops the lane cohort it started and keeps supervising the server and",
|
|
970
|
+
"reconciler, so nothing is claiming jobs and the run keeps its server-side state.",
|
|
971
|
+
"Read the per-lane logs under .bridge/plane/, then wind the plane down with",
|
|
972
|
+
"`plane down` and re-run this command once the cause is fixed. A plane cannot be",
|
|
973
|
+
"asked for lanes a second time: the runtime waits for that request exactly once.",
|
|
974
|
+
].join("\n"));
|
|
975
|
+
return 1;
|
|
976
|
+
}
|
|
977
|
+
// Plane readiness is reported SEPARATELY from run dispatchability, and the
|
|
978
|
+
// separation is the point: every process is up, but the reconciler still
|
|
979
|
+
// holds dispatch while a feature branch's index scope finishes preparing.
|
|
980
|
+
// An operator told "ready" who then sees no work start would go looking for
|
|
981
|
+
// a broken plane that is working perfectly.
|
|
982
|
+
deps.log([
|
|
983
|
+
"",
|
|
984
|
+
`${epicKey}: the plane is READY — control plane, scoped executor lane(s) for run ` +
|
|
985
|
+
`${setup.epicRunId}, and the dead-man observer are all up.`,
|
|
986
|
+
"That reports PROCESSES. If this run cuts an epic branch, the reconciler holds ticket",
|
|
987
|
+
"dispatch until its index scope finishes preparing; `plane status` and the run's own",
|
|
988
|
+
"status report that separately.",
|
|
989
|
+
].join("\n"));
|
|
990
|
+
return 0;
|
|
755
991
|
}
|
|
756
992
|
const handoff = renderConductorHandoff(selection, epicKey);
|
|
757
993
|
const text = handoff.lines.join("\n");
|