@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
package/build/plane/cli.js
CHANGED
|
@@ -134,8 +134,11 @@ export function getPlaneUsage() {
|
|
|
134
134
|
" plane up [--executors N] Preflight, then start the plane. Refuses as a",
|
|
135
135
|
" whole if any spawn-blocking check fails.",
|
|
136
136
|
" plane status Read the manifest and report each member's state.",
|
|
137
|
-
" plane down
|
|
138
|
-
"
|
|
137
|
+
" plane down Stop the bound server-side epic run FIRST (BAPI-872),",
|
|
138
|
+
" then signal the recorded process group, escalating",
|
|
139
|
+
" to SIGKILL, and clear the manifest. Process shutdown",
|
|
140
|
+
" alone is NOT a run stop — a manifest with no bound",
|
|
141
|
+
" run, or an unreachable Bridge API, only warns.",
|
|
139
142
|
"",
|
|
140
143
|
"Options:",
|
|
141
144
|
` --executors N Executor lanes to start (default ${PLANE_DEFAULT_EXECUTORS}, max ${PLANE_MAX_EXECUTORS}).`,
|
|
@@ -272,11 +275,53 @@ async function runDownAction(sinks, overrides) {
|
|
|
272
275
|
fs: createPlaneFsDeps(),
|
|
273
276
|
proc: createPlaneProcessDeps(),
|
|
274
277
|
clock: createPlaneClock(),
|
|
278
|
+
stopBoundRun: stopBoundEpicRun,
|
|
275
279
|
});
|
|
276
280
|
const text = formatPlaneShutdown(result);
|
|
277
281
|
(result.ok ? sinks.stdout : sinks.stderr)(text);
|
|
278
282
|
return result.ok ? 0 : 1;
|
|
279
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* `plane down`'s pre-signal run-stop capability (BAPI-872).
|
|
286
|
+
*
|
|
287
|
+
* Resolves Bridge access scoped to the MANIFEST's own repository root (never
|
|
288
|
+
* the process's `cwd`, which is not necessarily the same directory) and calls
|
|
289
|
+
* the shared `stopEpicRunRecovery` — the identical stop semantics
|
|
290
|
+
* `conductor stop-run` uses, so the two surfaces can never drift apart.
|
|
291
|
+
*
|
|
292
|
+
* Never throws. `shutdownPlane` awaits this directly with no try/catch of its
|
|
293
|
+
* own, so a credential, transport, or unexpected failure here is converted
|
|
294
|
+
* into a fixed `unavailable` outcome rather than propagating — a failure to
|
|
295
|
+
* stop the run must never prevent the validated process-group shutdown that
|
|
296
|
+
* follows it.
|
|
297
|
+
*/
|
|
298
|
+
async function stopBoundEpicRun(epicRunId, manifest) {
|
|
299
|
+
try {
|
|
300
|
+
const { resolveConductorBridgeApiAccess } = await import("../conductor/bridge-api-client.js");
|
|
301
|
+
const { stopEpicRunRecovery } = await import("../conductor/recovery-operations.js");
|
|
302
|
+
const accessResult = await resolveConductorBridgeApiAccess({ cwd: manifest.repoRoot });
|
|
303
|
+
if (!accessResult.ok) {
|
|
304
|
+
return { kind: "unavailable", epicRunId, message: accessResult.error };
|
|
305
|
+
}
|
|
306
|
+
const result = await stopEpicRunRecovery(accessResult.access, { epicRunId });
|
|
307
|
+
if (result.ok) {
|
|
308
|
+
return result.kind === "committed"
|
|
309
|
+
? { kind: "committed", epicRunId }
|
|
310
|
+
: { kind: "already-stopped", epicRunId };
|
|
311
|
+
}
|
|
312
|
+
if (result.kind === "terminal") {
|
|
313
|
+
return { kind: "terminal", epicRunId, status: result.status };
|
|
314
|
+
}
|
|
315
|
+
return { kind: "unavailable", epicRunId, message: result.message };
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return {
|
|
319
|
+
kind: "unavailable",
|
|
320
|
+
epicRunId,
|
|
321
|
+
message: err instanceof Error ? err.constructor.name : "run-stop failed unexpectedly",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
280
325
|
async function runUpAction(executors, sinks, overrides) {
|
|
281
326
|
const repoRoot = overrides.cwd ?? process.cwd();
|
|
282
327
|
const env = overrides.env ?? process.env;
|
|
@@ -424,7 +469,12 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
424
469
|
return overrides.runtime(repoRoot, executors);
|
|
425
470
|
const env = overrides.env ?? process.env;
|
|
426
471
|
const planeId = env[PLANE_ID_ENV_VAR];
|
|
427
|
-
|
|
472
|
+
// BAPI-882: honour injected sinks. The runtime writes straight to the real
|
|
473
|
+
// process streams in production, but a test that cannot capture this output
|
|
474
|
+
// cannot assert the refusal at all — which is why the refusal below had never
|
|
475
|
+
// executed under test and its message string appeared nowhere else in the
|
|
476
|
+
// repository.
|
|
477
|
+
const sinks = overrides.sinks ?? {
|
|
428
478
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
429
479
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
430
480
|
};
|
|
@@ -432,11 +482,27 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
432
482
|
sinks.stderr(`plane runtime refused: ${PLANE_ID_ENV_VAR} was not provided by the launcher.`);
|
|
433
483
|
return 1;
|
|
434
484
|
}
|
|
435
|
-
|
|
485
|
+
// BAPI-882: hand preflight this runtime's own plane id. The launcher claimed
|
|
486
|
+
// `plane.json` with `supervisorPid: process.pid` as a placeholder BEFORE
|
|
487
|
+
// spawning us, so a preflight without an identity sees that live claim,
|
|
488
|
+
// classifies it as "another plane is running", and refuses — the runtime
|
|
489
|
+
// deadlocking on its own launcher. `planeId` is validated non-empty above.
|
|
490
|
+
const preflight = overrides.preflight
|
|
491
|
+
? await overrides.preflight(repoRoot)
|
|
492
|
+
: await runPlanePreflight(repoRoot, { ...buildPreflightDeps(env), ownPlaneId: planeId });
|
|
436
493
|
if (!preflight.ok) {
|
|
437
|
-
// The launcher already ran preflight; reaching here means the world
|
|
438
|
-
// between claim and spawn. Refuse rather than start a half-valid
|
|
439
|
-
|
|
494
|
+
// The launcher already ran preflight; reaching here means the world genuinely
|
|
495
|
+
// changed between claim and spawn. Refuse rather than start a half-valid
|
|
496
|
+
// plane — but say WHICH check failed. Naming only "preflight no longer
|
|
497
|
+
// passes" threw away diagnostics that had just been computed and left the
|
|
498
|
+
// operator with nothing to act on.
|
|
499
|
+
sinks.stderr("");
|
|
500
|
+
sinks.stderr("plane runtime REFUSED — preflight found blocking problems:");
|
|
501
|
+
for (const diagnostic of preflight.diagnostics.filter((d) => d.severity === "blocking")) {
|
|
502
|
+
sinks.stderr(formatDiagnostic(diagnostic));
|
|
503
|
+
}
|
|
504
|
+
sinks.stderr("");
|
|
505
|
+
sinks.stderr(`${PLANE_NOTHING_STARTED}.`);
|
|
440
506
|
return 1;
|
|
441
507
|
}
|
|
442
508
|
const roster = buildPlaneMemberRoster({
|
package/build/plane/defaults.js
CHANGED
|
@@ -39,7 +39,10 @@ export function createPlaneFsDeps() {
|
|
|
39
39
|
},
|
|
40
40
|
stat: async (filePath) => {
|
|
41
41
|
const result = await stat(filePath);
|
|
42
|
-
|
|
42
|
+
// `mode` is passed through unchanged. Substituting a "safe" default here
|
|
43
|
+
// would hide a world-readable credentials file from the permission
|
|
44
|
+
// advisory that exists to report it (BAPI-773).
|
|
45
|
+
return { mtimeMs: result.mtimeMs, mode: result.mode };
|
|
43
46
|
},
|
|
44
47
|
readdir: async (dirPath) => {
|
|
45
48
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
@@ -51,13 +54,23 @@ export function createPlaneFsDeps() {
|
|
|
51
54
|
},
|
|
52
55
|
};
|
|
53
56
|
}
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Wall-clock time and real sleeps.
|
|
59
|
+
*
|
|
60
|
+
* The sleep timer is deliberately NOT `.unref()`'d. `plane down`'s wait loop
|
|
61
|
+
* (`shutdown.ts#waitForDeath`) awaits this from a short-lived CLI process's
|
|
62
|
+
* top-level `await` (`index.ts`) — an unref'd timer lets Node consider the
|
|
63
|
+
* event loop empty while that await is still pending, which Node reports as
|
|
64
|
+
* an "unsettled top-level await" and answers by forcing exit code 13,
|
|
65
|
+
* corrupting the CLI's real exit code and losing its buffered stdout. A
|
|
66
|
+
* long-running `plane up` supervisor is unaffected either way: it stays alive
|
|
67
|
+
* on its own child-process and signal-handler activity regardless of this
|
|
68
|
+
* timer's ref state.
|
|
69
|
+
*/
|
|
55
70
|
export function createPlaneClock() {
|
|
56
71
|
return {
|
|
57
72
|
now: () => new Date(),
|
|
58
|
-
sleep: (ms) => new Promise((resolve) =>
|
|
59
|
-
setTimeout(resolve, ms).unref?.();
|
|
60
|
-
}),
|
|
73
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
61
74
|
};
|
|
62
75
|
}
|
|
63
76
|
/** Liveness and signal delivery bound to `process.kill`. */
|
package/build/plane/manifest.js
CHANGED
|
@@ -52,7 +52,13 @@ const MANIFEST_KEYS = new Set([
|
|
|
52
52
|
"createdAt",
|
|
53
53
|
"updatedAt",
|
|
54
54
|
"members",
|
|
55
|
+
// BAPI-872: the optional server-side epic-run binding.
|
|
56
|
+
"epicRunId",
|
|
55
57
|
]);
|
|
58
|
+
/** Non-blank string, trimmed equal to itself (no leading/trailing whitespace). */
|
|
59
|
+
function isNonBlankTrimmedString(value) {
|
|
60
|
+
return typeof value === "string" && value.trim().length > 0 && value === value.trim();
|
|
61
|
+
}
|
|
56
62
|
const MEMBER_KEYS = new Set([
|
|
57
63
|
"name",
|
|
58
64
|
"pid",
|
|
@@ -113,6 +119,14 @@ export function parsePlaneManifest(value) {
|
|
|
113
119
|
if (!Array.isArray(record.members) || record.members.length === 0) {
|
|
114
120
|
return { ok: false, error: "manifest members are missing" };
|
|
115
121
|
}
|
|
122
|
+
// BAPI-872: `epicRunId` is optional — absent means a legacy or still-unbound
|
|
123
|
+
// manifest, which is a perfectly valid state. When present it must be a
|
|
124
|
+
// genuine non-blank, untrimmed-clean string; anything else is rejected here
|
|
125
|
+
// rather than silently coerced, because a malformed value would otherwise
|
|
126
|
+
// reach `plane down`'s run-stop lookup as an unvalidated identifier.
|
|
127
|
+
if (record.epicRunId !== undefined && !isNonBlankTrimmedString(record.epicRunId)) {
|
|
128
|
+
return { ok: false, error: "manifest epic run id is malformed" };
|
|
129
|
+
}
|
|
116
130
|
const members = [];
|
|
117
131
|
const seen = new Set();
|
|
118
132
|
for (const raw of record.members) {
|
|
@@ -169,6 +183,7 @@ export function parsePlaneManifest(value) {
|
|
|
169
183
|
createdAt: record.createdAt,
|
|
170
184
|
updatedAt: record.updatedAt,
|
|
171
185
|
members,
|
|
186
|
+
...(record.epicRunId !== undefined ? { epicRunId: record.epicRunId } : {}),
|
|
172
187
|
},
|
|
173
188
|
};
|
|
174
189
|
}
|
|
@@ -294,6 +309,81 @@ export async function clearPlaneManifest(repoRoot, planeId, fs) {
|
|
|
294
309
|
};
|
|
295
310
|
}
|
|
296
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Atomically bind `epicRunId` onto the plane manifest (BAPI-872).
|
|
314
|
+
*
|
|
315
|
+
* The one and only writer of `PlaneManifest.epicRunId`. Fully validates the
|
|
316
|
+
* manifest AND the calling plane's identity before ANY mutation — a
|
|
317
|
+
* malformed/untrusted record is refused rather than bound, and a manifest that
|
|
318
|
+
* legitimately belongs to a DIFFERENT plane (a stale record from an earlier
|
|
319
|
+
* run at the same repository root) is refused rather than silently claimed.
|
|
320
|
+
*
|
|
321
|
+
* Binding the SAME `epicRunId` a second time is a safe, idempotent no-op
|
|
322
|
+
* (`alreadyBound: true`) — a re-run of `setup-epic` reusing a live run must
|
|
323
|
+
* not fail here. Binding a DIFFERENT `epicRunId` onto an already-bound
|
|
324
|
+
* manifest is refused (`conflict`): this function never repoints a plane at a
|
|
325
|
+
* different run, because guessing which one is right is exactly the ambiguity
|
|
326
|
+
* this binding exists to remove.
|
|
327
|
+
*/
|
|
328
|
+
export async function bindPlaneManifestEpicRun(repoRoot, planeId, epicRunId, fs) {
|
|
329
|
+
const read = await readPlaneManifest(repoRoot, fs);
|
|
330
|
+
if (read.kind === "missing") {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
reason: "no-manifest",
|
|
334
|
+
message: "no plane manifest found for this repository",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (read.kind !== "valid") {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
reason: "unvalidated-manifest",
|
|
341
|
+
message: `plane manifest could not be validated (${read.error})`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const manifest = read.manifest;
|
|
345
|
+
if (manifest.planeId !== planeId) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: "identity-mismatch",
|
|
349
|
+
message: "plane manifest belongs to a different plane identity",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (manifest.repoRoot !== repoRoot) {
|
|
353
|
+
// Defense in depth: the manifest is already located BY `repoRoot` (it can
|
|
354
|
+
// only be found at `<repoRoot>/.bridge/plane/plane.json`), so this fires
|
|
355
|
+
// only for a manifest file that was copied or symlinked from elsewhere and
|
|
356
|
+
// still carries its origin's `repoRoot`. Refusing here keeps that stale
|
|
357
|
+
// self-description from ever being trusted as this repository's own.
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
reason: "identity-mismatch",
|
|
361
|
+
message: "plane manifest belongs to a different repository root",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (manifest.epicRunId === epicRunId) {
|
|
365
|
+
return { ok: true, alreadyBound: true };
|
|
366
|
+
}
|
|
367
|
+
if (manifest.epicRunId !== undefined) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: "conflict",
|
|
371
|
+
message: `plane manifest is already bound to a different epic run (${manifest.epicRunId})`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await writePlaneManifest({ ...manifest, epicRunId, updatedAt: new Date().toISOString() }, fs);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const code = err?.code;
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
reason: "error",
|
|
382
|
+
message: `could not bind the epic run to the plane manifest (${typeof code === "string" ? code : "write failed"})`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return { ok: true, alreadyBound: false };
|
|
386
|
+
}
|
|
297
387
|
/**
|
|
298
388
|
* Take exclusive ownership of `.bridge/plane/plane.json`.
|
|
299
389
|
*
|
package/build/plane/preflight.js
CHANGED
|
@@ -44,6 +44,11 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
44
44
|
return { ok: false, diagnostics };
|
|
45
45
|
add(await checkClaudeLogin(deps));
|
|
46
46
|
const credentials = await checkBridgeCredentials(repoRoot, deps);
|
|
47
|
+
// Advisories first, then the failure. An insecure credentials file and an
|
|
48
|
+
// unresolvable credential are independent facts and can both be true, so the
|
|
49
|
+
// advisory is appended unconditionally rather than only on the success path.
|
|
50
|
+
for (const advisory of credentials.advisories)
|
|
51
|
+
add(advisory);
|
|
47
52
|
if (!credentials.ok)
|
|
48
53
|
add(credentials.diagnostic);
|
|
49
54
|
add(await checkPlaneBuildFreshness(repoRoot, { fs: deps.fs }));
|
|
@@ -145,6 +150,24 @@ export async function checkClaudeLogin(deps) {
|
|
|
145
150
|
* Both go through the shared resolvers. This is the shell-spawned credential
|
|
146
151
|
* rule: a plane member is launched from a spawned shell, which never sees
|
|
147
152
|
* `.mcp.json` env — that file is visible only to the MCP server process.
|
|
153
|
+
*
|
|
154
|
+
* ## Permission policy stays with the shared resolver (BAPI-773)
|
|
155
|
+
*
|
|
156
|
+
* The resolver owns the group/world-permission rule and the prose it warns
|
|
157
|
+
* with; plane neither re-implements that policy nor duplicates its threshold.
|
|
158
|
+
* What plane owns is *delivery*: the resolver writes its advisory to a `stderr`
|
|
159
|
+
* sink, and an unstructured write straight to the process stream would either
|
|
160
|
+
* be lost among the structured diagnostics `plane up` renders or interleave
|
|
161
|
+
* with them unpredictably. So the sink here is a line buffer, and each captured
|
|
162
|
+
* line becomes a non-blocking `"warning"` diagnostic that flows through the
|
|
163
|
+
* same rendering path as every other preflight finding.
|
|
164
|
+
*
|
|
165
|
+
* The resolver's warning is only as good as the mode it is given, which is why
|
|
166
|
+
* the stat adapter forwards the real one. When a successful stat carries no
|
|
167
|
+
* mode at all, `0o777` is substituted *deliberately*: an unknown mode is
|
|
168
|
+
* reported rather than silently treated as safe. A stat that rejects is left to
|
|
169
|
+
* reject — fabricating a successful stat out of a filesystem failure would
|
|
170
|
+
* invent a permission fact that was never observed.
|
|
148
171
|
*/
|
|
149
172
|
export async function checkBridgeCredentials(repoRoot, deps) {
|
|
150
173
|
const repo = await deps.resolveRepoName({
|
|
@@ -153,8 +176,11 @@ export async function checkBridgeCredentials(repoRoot, deps) {
|
|
|
153
176
|
readFile: deps.fs.readFile,
|
|
154
177
|
});
|
|
155
178
|
if (!repo.ok) {
|
|
179
|
+
// Nothing has called the credential resolver yet, so there is no advisory
|
|
180
|
+
// source to have captured from.
|
|
156
181
|
return {
|
|
157
182
|
ok: false,
|
|
183
|
+
advisories: [],
|
|
158
184
|
diagnostic: {
|
|
159
185
|
check: "bridge-credentials",
|
|
160
186
|
severity: "blocking",
|
|
@@ -163,6 +189,7 @@ export async function checkBridgeCredentials(repoRoot, deps) {
|
|
|
163
189
|
},
|
|
164
190
|
};
|
|
165
191
|
}
|
|
192
|
+
const warnings = createResolverWarningBuffer();
|
|
166
193
|
let result;
|
|
167
194
|
try {
|
|
168
195
|
result = await deps.resolveCredentials(repo.repoName, {
|
|
@@ -171,23 +198,22 @@ export async function checkBridgeCredentials(repoRoot, deps) {
|
|
|
171
198
|
platform: deps.platform,
|
|
172
199
|
readFile: deps.fs.readFile,
|
|
173
200
|
stat: async (filePath) => {
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return { mode: 0o600 };
|
|
180
|
-
},
|
|
181
|
-
stderr: () => {
|
|
182
|
-
/* the resolver's advisory warnings are not part of plane output */
|
|
201
|
+
// A rejection propagates untouched: only a *successful* stat that
|
|
202
|
+
// happens to carry no mode falls back, and it falls back to fully open
|
|
203
|
+
// so the resolver warns instead of staying silent about an unknown.
|
|
204
|
+
const statResult = await deps.fs.stat(filePath);
|
|
205
|
+
return { mode: statResult.mode !== undefined ? statResult.mode : 0o777 };
|
|
183
206
|
},
|
|
207
|
+
stderr: warnings.write,
|
|
184
208
|
});
|
|
185
209
|
}
|
|
186
210
|
catch {
|
|
187
211
|
// Sanitized: a resolver throw becomes a category, never exception text
|
|
188
|
-
// (which could echo file contents).
|
|
212
|
+
// (which could echo file contents). Advisories captured before the throw
|
|
213
|
+
// are still real observations and are kept.
|
|
189
214
|
return {
|
|
190
215
|
ok: false,
|
|
216
|
+
advisories: warnings.drain(),
|
|
191
217
|
diagnostic: {
|
|
192
218
|
check: "bridge-credentials",
|
|
193
219
|
severity: "blocking",
|
|
@@ -199,6 +225,7 @@ export async function checkBridgeCredentials(repoRoot, deps) {
|
|
|
199
225
|
if (!result.ok) {
|
|
200
226
|
return {
|
|
201
227
|
ok: false,
|
|
228
|
+
advisories: warnings.drain(),
|
|
202
229
|
diagnostic: {
|
|
203
230
|
check: "bridge-credentials",
|
|
204
231
|
severity: "blocking",
|
|
@@ -213,6 +240,50 @@ export async function checkBridgeCredentials(repoRoot, deps) {
|
|
|
213
240
|
repoName: repo.repoName,
|
|
214
241
|
apiKey: result.credentials.apiKey,
|
|
215
242
|
source: result.credentials.source,
|
|
243
|
+
advisories: warnings.drain(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Line buffer for the shared credential resolver's `stderr` sink.
|
|
248
|
+
*
|
|
249
|
+
* The resolver is free to emit a single warning across several callback
|
|
250
|
+
* invocations, so a chunk is not a line: text is accumulated and only split on
|
|
251
|
+
* a newline boundary. Whatever remains unterminated when the resolver finishes
|
|
252
|
+
* is flushed as a final line by `drain`, which is why a resolver that writes
|
|
253
|
+
* `"third warning"` with no trailing newline still produces a diagnostic.
|
|
254
|
+
*
|
|
255
|
+
* Nothing here writes to a process stream. That is the entire point.
|
|
256
|
+
*/
|
|
257
|
+
function createResolverWarningBuffer() {
|
|
258
|
+
const lines = [];
|
|
259
|
+
let pending = "";
|
|
260
|
+
const push = (line) => {
|
|
261
|
+
const trimmed = line.replace(/\r$/, "");
|
|
262
|
+
if (trimmed.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
lines.push(trimmed);
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
write: (message) => {
|
|
268
|
+
pending += message;
|
|
269
|
+
const segments = pending.split("\n");
|
|
270
|
+
// The final segment is whatever followed the last newline — possibly the
|
|
271
|
+
// start of a line the resolver has not finished writing yet.
|
|
272
|
+
pending = segments.pop() ?? "";
|
|
273
|
+
for (const segment of segments)
|
|
274
|
+
push(segment);
|
|
275
|
+
},
|
|
276
|
+
drain: () => {
|
|
277
|
+
if (pending.length > 0) {
|
|
278
|
+
push(pending);
|
|
279
|
+
pending = "";
|
|
280
|
+
}
|
|
281
|
+
return lines.map((message) => ({
|
|
282
|
+
check: "bridge-credentials",
|
|
283
|
+
severity: "warning",
|
|
284
|
+
message,
|
|
285
|
+
}));
|
|
286
|
+
},
|
|
216
287
|
};
|
|
217
288
|
}
|
|
218
289
|
/**
|
|
@@ -265,6 +336,25 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
265
336
|
"it by hand if no plane is running.",
|
|
266
337
|
};
|
|
267
338
|
}
|
|
339
|
+
// BAPI-882: a manifest carrying THIS process's own plane id is its own claim,
|
|
340
|
+
// not a competitor. Checked after validation and BEFORE the liveness probe,
|
|
341
|
+
// because the launcher writes `supervisorPid: process.pid` as a placeholder
|
|
342
|
+
// before spawning the runtime — so the process the probe finds alive is the
|
|
343
|
+
// very launcher that started us.
|
|
344
|
+
//
|
|
345
|
+
// Plane ids are minted per launch (`randomUUID` in cli.ts), so the only
|
|
346
|
+
// process that can have written a manifest bearing this id is the launcher
|
|
347
|
+
// that spawned this runtime. That makes identity, not liveness, the correct
|
|
348
|
+
// discriminator here. This is not a new predicate: `clearPlaneManifest` and
|
|
349
|
+
// `bindPlaneManifestEpicRun` already gate on exactly `manifest.planeId ===
|
|
350
|
+
// <own id>`, and `runPlaneRuntime` applies it one layer later. This moves the
|
|
351
|
+
// recognition earlier, to the only place that was still missing it.
|
|
352
|
+
//
|
|
353
|
+
// A live manifest with a DIFFERENT plane id still blocks, unchanged — that is
|
|
354
|
+
// a genuine second plane and the whole point of the check.
|
|
355
|
+
if (deps.ownPlaneId !== undefined && read.manifest.planeId === deps.ownPlaneId) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
268
358
|
if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
|
|
269
359
|
// Stale. Reported as information so the operator understands why an old
|
|
270
360
|
// manifest is about to be replaced; the claim revalidates before it does.
|
package/build/plane/shutdown.js
CHANGED
|
@@ -42,11 +42,13 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
42
42
|
manifestCleared: false,
|
|
43
43
|
members: [],
|
|
44
44
|
messages: ["No plane manifest found — nothing to wind down."],
|
|
45
|
+
runShutdown: { kind: "unbound" },
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
if (read.kind !== "valid") {
|
|
48
49
|
// Deliberately before any liveness probe: an unvalidated record must never
|
|
49
|
-
// become a signal target, not even a signal-zero one
|
|
50
|
+
// become a signal target, not even a signal-zero one — and, as of BAPI-872,
|
|
51
|
+
// must never authorize a run-stop lookup either.
|
|
50
52
|
return {
|
|
51
53
|
ok: false,
|
|
52
54
|
reason: "unvalidated-manifest",
|
|
@@ -54,10 +56,25 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
54
56
|
"signalled. Inspect the file and remove it by hand once you have confirmed no plane " +
|
|
55
57
|
"is running.",
|
|
56
58
|
members: [],
|
|
59
|
+
runShutdown: { kind: "unbound" },
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
const manifest = read.manifest;
|
|
60
63
|
const messages = [];
|
|
64
|
+
// BAPI-872: the server-side run stop, computed ONCE right after the manifest
|
|
65
|
+
// has passed every validation and identity check above — strictly before the
|
|
66
|
+
// first process-group signal in every branch that sends one (the sole branch
|
|
67
|
+
// that never signals, `already-dead`, still runs it first since it is the
|
|
68
|
+
// earliest safe point and the outcome is reused unchanged in every return
|
|
69
|
+
// below). A manifest with no bound run never invokes the capability at all.
|
|
70
|
+
const runShutdown = manifest.epicRunId
|
|
71
|
+
? await (deps.stopBoundRun?.(manifest.epicRunId, manifest) ??
|
|
72
|
+
Promise.resolve({
|
|
73
|
+
kind: "unavailable",
|
|
74
|
+
epicRunId: manifest.epicRunId,
|
|
75
|
+
message: "no run-stop capability was configured for this wind-down",
|
|
76
|
+
}))
|
|
77
|
+
: { kind: "unbound" };
|
|
61
78
|
if (!isAnythingAlive(manifest, deps.proc, deps.selfPid)) {
|
|
62
79
|
const cleared = await clearPlaneManifest(repoRoot, manifest.planeId, deps.fs);
|
|
63
80
|
if (!cleared.ok) {
|
|
@@ -66,6 +83,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
66
83
|
reason: "clear-failed",
|
|
67
84
|
message: cleared.message,
|
|
68
85
|
members: reportMembers(manifest, deps.proc),
|
|
86
|
+
runShutdown,
|
|
69
87
|
};
|
|
70
88
|
}
|
|
71
89
|
return {
|
|
@@ -75,6 +93,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
75
93
|
manifestCleared: cleared.removed,
|
|
76
94
|
members: reportMembers(manifest, deps.proc),
|
|
77
95
|
messages: ["Every recorded plane process was already gone; manifest cleared."],
|
|
96
|
+
runShutdown,
|
|
78
97
|
};
|
|
79
98
|
}
|
|
80
99
|
messages.push(deps.selfPid === undefined
|
|
@@ -104,11 +123,12 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
104
123
|
message: `wind-down could not confirm every process was terminated: ${survivors.join(", ")}. ` +
|
|
105
124
|
"The manifest was RETAINED so `plane down` can be retried.",
|
|
106
125
|
members,
|
|
126
|
+
runShutdown,
|
|
107
127
|
};
|
|
108
128
|
}
|
|
109
129
|
const cleared = await clearPlaneManifest(repoRoot, manifest.planeId, deps.fs);
|
|
110
130
|
if (!cleared.ok) {
|
|
111
|
-
return { ok: false, reason: "clear-failed", message: cleared.message, members };
|
|
131
|
+
return { ok: false, reason: "clear-failed", message: cleared.message, members, runShutdown };
|
|
112
132
|
}
|
|
113
133
|
return {
|
|
114
134
|
ok: true,
|
|
@@ -117,6 +137,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
117
137
|
manifestCleared: cleared.removed,
|
|
118
138
|
members,
|
|
119
139
|
messages,
|
|
140
|
+
runShutdown,
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
/**
|
|
@@ -171,9 +192,56 @@ async function waitForDeath(manifest, deps, budgetMs, pollMs) {
|
|
|
171
192
|
waited += pollMs;
|
|
172
193
|
}
|
|
173
194
|
}
|
|
174
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Render the server-side run-shutdown outcome (BAPI-872) as its own message
|
|
197
|
+
* lines — never claims signalling local processes changed server-side state,
|
|
198
|
+
* and always names the supported `conductor stop-run` check/remediation
|
|
199
|
+
* surface for every outcome that is not a fresh committed stop.
|
|
200
|
+
*/
|
|
201
|
+
function formatRunShutdown(outcome) {
|
|
202
|
+
switch (outcome.kind) {
|
|
203
|
+
case "unbound":
|
|
204
|
+
return [
|
|
205
|
+
"Run shutdown: no epic run is bound to this plane. If a server-side run is " +
|
|
206
|
+
"active, this command did NOT stop it and its queued work may remain live. " +
|
|
207
|
+
"Run `conductor stop-run --epic-run-id <epic_run_id>` to stop it explicitly.",
|
|
208
|
+
];
|
|
209
|
+
case "committed":
|
|
210
|
+
return [`Run shutdown: stopped run ${outcome.epicRunId} — its queued work was cancelled.`];
|
|
211
|
+
case "already-stopped":
|
|
212
|
+
return [`Run shutdown: run ${outcome.epicRunId} was already stopped; no further action.`];
|
|
213
|
+
case "terminal":
|
|
214
|
+
return [
|
|
215
|
+
`Run shutdown: run ${outcome.epicRunId} is terminal (${outcome.status}) and cannot be ` +
|
|
216
|
+
"stopped. Signalling local processes does not change this — run " +
|
|
217
|
+
`\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to check its state.`,
|
|
218
|
+
];
|
|
219
|
+
case "unavailable":
|
|
220
|
+
return [
|
|
221
|
+
`Run shutdown: could not reach the Bridge API to stop run ${outcome.epicRunId} ` +
|
|
222
|
+
`(${outcome.message}). The run may remain active with queued work — run ` +
|
|
223
|
+
`\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to retry.`,
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Render a wind-down result for the terminal.
|
|
229
|
+
*
|
|
230
|
+
* BAPI-872: "Run shutdown" (the server-side epic run) is rendered as its own
|
|
231
|
+
* section BEFORE "Process shutdown" (the local plane processes) — the two are
|
|
232
|
+
* independent outcomes, and the ordering makes it visible even when the
|
|
233
|
+
* process section reads as an unqualified success. The one exception is an
|
|
234
|
+
* unvalidated manifest: there is no trustworthy record to read a run binding
|
|
235
|
+
* from, so nothing is claimed about run shutdown at all.
|
|
236
|
+
*/
|
|
175
237
|
export function formatPlaneShutdown(result) {
|
|
176
238
|
const lines = [];
|
|
239
|
+
const skipRunSection = !result.ok && result.reason === "unvalidated-manifest";
|
|
240
|
+
if (!skipRunSection) {
|
|
241
|
+
lines.push(...formatRunShutdown(result.runShutdown));
|
|
242
|
+
lines.push("");
|
|
243
|
+
lines.push("Process shutdown:");
|
|
244
|
+
}
|
|
177
245
|
if (!result.ok) {
|
|
178
246
|
lines.push(`plane down FAILED: ${result.message}`);
|
|
179
247
|
}
|
|
@@ -16,6 +16,7 @@ export function dirent(name, kind) {
|
|
|
16
16
|
export function createFakeFs(options = {}) {
|
|
17
17
|
const files = new Map(Object.entries(options.files ?? {}));
|
|
18
18
|
const mtimes = new Map(Object.entries(options.mtimes ?? {}));
|
|
19
|
+
const modes = new Map(Object.entries(options.modes ?? {}));
|
|
19
20
|
const dirs = new Map(Object.entries(options.dirs ?? {}));
|
|
20
21
|
const readErrors = new Map(Object.entries(options.readErrors ?? {}));
|
|
21
22
|
const calls = {
|
|
@@ -29,6 +30,7 @@ export function createFakeFs(options = {}) {
|
|
|
29
30
|
return {
|
|
30
31
|
files,
|
|
31
32
|
mtimes,
|
|
33
|
+
modes,
|
|
32
34
|
calls,
|
|
33
35
|
failNextCreateWith: (code) => {
|
|
34
36
|
pendingCreateFailure = code;
|
|
@@ -80,7 +82,13 @@ export function createFakeFs(options = {}) {
|
|
|
80
82
|
throw errno(failure);
|
|
81
83
|
if (!files.has(filePath) && !dirs.has(filePath))
|
|
82
84
|
throw errno("ENOENT");
|
|
83
|
-
|
|
85
|
+
const mode = modes.get(filePath);
|
|
86
|
+
const result = { mtimeMs: mtimes.get(filePath) ?? 0 };
|
|
87
|
+
// Assigned only when the fixture supplied one: an absent `mode` must stay
|
|
88
|
+
// absent, not become `undefined`-shaped-but-present.
|
|
89
|
+
if (mode !== undefined)
|
|
90
|
+
result.mode = mode;
|
|
91
|
+
return result;
|
|
84
92
|
},
|
|
85
93
|
readdir: async (dirPath) => {
|
|
86
94
|
const entries = dirs.get(dirPath);
|