@bridge_gpt/mcp-server 0.2.53 → 0.2.55
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 +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- 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/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- 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/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- 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/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
package/build/plane/manifest.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* argv, and no credential field, and unknown keys are rejected outright.
|
|
18
18
|
*/
|
|
19
19
|
import path from "path";
|
|
20
|
-
import { PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
20
|
+
import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
21
21
|
/**
|
|
22
22
|
* Derive every runtime path beneath a validated repository root.
|
|
23
23
|
*
|
|
@@ -54,7 +54,13 @@ const MANIFEST_KEYS = new Set([
|
|
|
54
54
|
"members",
|
|
55
55
|
// BAPI-872: the optional server-side epic-run binding.
|
|
56
56
|
"epicRunId",
|
|
57
|
+
// BAPI-1102: the two-phase lifecycle state and the claim scope phase two
|
|
58
|
+
// starts its lanes with. Both optional; both absent on a v1/v2 manifest.
|
|
59
|
+
"lifecycle",
|
|
60
|
+
"laneScope",
|
|
57
61
|
]);
|
|
62
|
+
/** Every legal `lifecycle` value, as a set for the strict parse below. */
|
|
63
|
+
const LIFECYCLE_PHASES = new Set(PLANE_LIFECYCLE_PHASES);
|
|
58
64
|
/** Non-blank string, trimmed equal to itself (no leading/trailing whitespace). */
|
|
59
65
|
function isNonBlankTrimmedString(value) {
|
|
60
66
|
return typeof value === "string" && value.trim().length > 0 && value === value.trim();
|
|
@@ -144,6 +150,16 @@ export function parsePlaneManifest(value) {
|
|
|
144
150
|
if (record.epicRunId !== undefined && !isNonBlankTrimmedString(record.epicRunId)) {
|
|
145
151
|
return { ok: false, error: "manifest epic run id is malformed" };
|
|
146
152
|
}
|
|
153
|
+
// BAPI-1102: both are optional and both are validated STRICTLY when present.
|
|
154
|
+
// Absence means a v1/v2 manifest or a single-phase bring-up, and the reader
|
|
155
|
+
// resolves absence to `"ready"` — an older build only ever wrote a manifest for
|
|
156
|
+
// a plane whose whole roster had started.
|
|
157
|
+
if (record.lifecycle !== undefined && !LIFECYCLE_PHASES.has(record.lifecycle)) {
|
|
158
|
+
return { ok: false, error: "manifest lifecycle phase is malformed" };
|
|
159
|
+
}
|
|
160
|
+
const laneScope = parseManifestLaneScope(record.laneScope);
|
|
161
|
+
if (!laneScope.ok)
|
|
162
|
+
return { ok: false, error: laneScope.error };
|
|
147
163
|
const members = [];
|
|
148
164
|
const unrecognizedMemberNames = [];
|
|
149
165
|
const seen = new Set();
|
|
@@ -235,9 +251,63 @@ export function parsePlaneManifest(value) {
|
|
|
235
251
|
updatedAt: record.updatedAt,
|
|
236
252
|
members,
|
|
237
253
|
...(record.epicRunId !== undefined ? { epicRunId: record.epicRunId } : {}),
|
|
254
|
+
...(record.lifecycle !== undefined
|
|
255
|
+
? { lifecycle: record.lifecycle }
|
|
256
|
+
: {}),
|
|
257
|
+
...(laneScope.scope !== undefined ? { laneScope: laneScope.scope } : {}),
|
|
238
258
|
},
|
|
239
259
|
};
|
|
240
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Validate the optional `laneScope` record (BAPI-1102).
|
|
263
|
+
*
|
|
264
|
+
* Strict, and strict for a specific reason: this value crosses a PROCESS
|
|
265
|
+
* boundary. The composed caller writes it and the detached runtime reads it, so
|
|
266
|
+
* it is the one place an untrusted-looking value can become executor argv. Every
|
|
267
|
+
* shape is checked before it is trusted, and a malformed value is a parse
|
|
268
|
+
* failure rather than a coerced default.
|
|
269
|
+
*
|
|
270
|
+
* It carries run ids and a discriminant only. No credential, environment object,
|
|
271
|
+
* or argv array may ever be added here — the manifest lives on disk for the
|
|
272
|
+
* lifetime of the plane, which is the reason the record has always been free of
|
|
273
|
+
* secrets.
|
|
274
|
+
*/
|
|
275
|
+
function parseManifestLaneScope(value) {
|
|
276
|
+
if (value === undefined)
|
|
277
|
+
return { ok: true };
|
|
278
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
279
|
+
return { ok: false, error: "manifest lane scope is not an object" };
|
|
280
|
+
}
|
|
281
|
+
const record = value;
|
|
282
|
+
if (record.kind === "repo-wide") {
|
|
283
|
+
if (Object.keys(record).length !== 1) {
|
|
284
|
+
return { ok: false, error: "manifest lane scope has unsupported fields" };
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, scope: { kind: "repo-wide" } };
|
|
287
|
+
}
|
|
288
|
+
if (record.kind !== "epic-runs") {
|
|
289
|
+
return { ok: false, error: "manifest lane scope kind is malformed" };
|
|
290
|
+
}
|
|
291
|
+
if (Object.keys(record).length !== 2 || !Array.isArray(record.epicRunIds)) {
|
|
292
|
+
return { ok: false, error: "manifest lane scope epic run ids are malformed" };
|
|
293
|
+
}
|
|
294
|
+
const ids = record.epicRunIds;
|
|
295
|
+
if (ids.length === 0 || !ids.every((id) => isNonBlankTrimmedString(id))) {
|
|
296
|
+
return { ok: false, error: "manifest lane scope epic run ids are malformed" };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, scope: { kind: "epic-runs", epicRunIds: ids } };
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The phase this manifest describes, resolving ABSENCE to `"ready"`.
|
|
302
|
+
*
|
|
303
|
+
* The single reader of that rule. A manifest written before BAPI-1102 describes a
|
|
304
|
+
* plane whose entire roster started before the file was last written, so `ready`
|
|
305
|
+
* is the truthful reading — and it is the reading that keeps `plane status` and
|
|
306
|
+
* `plane down` behaving identically on a legacy plane.
|
|
307
|
+
*/
|
|
308
|
+
export function planeManifestLifecycle(manifest) {
|
|
309
|
+
return manifest.lifecycle ?? "ready";
|
|
310
|
+
}
|
|
241
311
|
/**
|
|
242
312
|
* Read and validate the manifest, distinguishing every outcome.
|
|
243
313
|
*
|
|
@@ -463,6 +533,144 @@ export async function bindPlaneManifestEpicRun(repoRoot, planeId, epicRunId, fs)
|
|
|
463
533
|
}
|
|
464
534
|
return { ok: true, alreadyBound: false };
|
|
465
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Read + validate + identity-check the manifest for a guarded write.
|
|
538
|
+
*
|
|
539
|
+
* Shared by every operation below so the three refusals that precede any mutation
|
|
540
|
+
* are stated once. A caller that wrote its own version of this would eventually
|
|
541
|
+
* mutate a record it had not validated.
|
|
542
|
+
*/
|
|
543
|
+
async function readOwnedManifest(repoRoot, planeId, fs) {
|
|
544
|
+
const read = await readPlaneManifest(repoRoot, fs);
|
|
545
|
+
if (read.kind === "missing") {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
reason: "no-manifest",
|
|
549
|
+
message: "no plane manifest found for this repository",
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (read.kind !== "valid") {
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
reason: "unvalidated-manifest",
|
|
556
|
+
message: `plane manifest could not be validated (${read.error})`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (read.manifest.planeId !== planeId || read.manifest.repoRoot !== repoRoot) {
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
reason: "identity-mismatch",
|
|
563
|
+
message: "plane manifest belongs to a different plane identity",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return { ok: true, manifest: read.manifest };
|
|
567
|
+
}
|
|
568
|
+
/** Persist a manifest mutation, converting a filesystem failure into a result. */
|
|
569
|
+
async function writeGuardedManifest(manifest, fs) {
|
|
570
|
+
try {
|
|
571
|
+
const updated = { ...manifest, updatedAt: new Date().toISOString() };
|
|
572
|
+
await writePlaneManifest(updated, fs);
|
|
573
|
+
return { ok: true, manifest: updated };
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
// The CODE only. A thrown filesystem error carries a path, and this message
|
|
577
|
+
// reaches an operator's terminal.
|
|
578
|
+
const code = err?.code;
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
reason: "error",
|
|
582
|
+
message: `plane manifest could not be written${code ? ` (${code})` : ""}`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Move the plane from one lifecycle phase to the next, atomically.
|
|
588
|
+
*
|
|
589
|
+
* `expected` is REQUIRED and is the concurrency control. Writing the phase
|
|
590
|
+
* unconditionally would let a stale writer — a retried request, or a runtime that
|
|
591
|
+
* resumed after a pause — rewind a plane that has already progressed, which for
|
|
592
|
+
* `lanes-ready` → `control-plane-ready` would mean the runtime waiting forever
|
|
593
|
+
* for a phase-two request that had already been served.
|
|
594
|
+
*/
|
|
595
|
+
export async function transitionPlaneManifestLifecycle(repoRoot, planeId, expected, next, fs) {
|
|
596
|
+
const owned = await readOwnedManifest(repoRoot, planeId, fs);
|
|
597
|
+
if (!owned.ok)
|
|
598
|
+
return owned;
|
|
599
|
+
const current = planeManifestLifecycle(owned.manifest);
|
|
600
|
+
if (current !== expected) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
reason: "phase-conflict",
|
|
604
|
+
message: `plane is ${current}, not ${expected}`,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
return writeGuardedManifest({ ...owned.manifest, lifecycle: next }, fs);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Request phase two: bind the run and record the claim scope the lanes will use.
|
|
611
|
+
*
|
|
612
|
+
* The single writer of `laneScope`, and it binds `epicRunId` in the SAME atomic
|
|
613
|
+
* write. Doing them together is what makes "one composed plane serves exactly one
|
|
614
|
+
* run" hold: a plane already bound to a different run is refused here, before any
|
|
615
|
+
* lane exists, rather than being discovered after lanes are already claiming for
|
|
616
|
+
* the wrong run.
|
|
617
|
+
*
|
|
618
|
+
* A composed plane is deliberately SINGLE-RUN. Appending a second run scope to
|
|
619
|
+
* live lanes was considered and rejected: the lanes are already running with a
|
|
620
|
+
* fixed argv, so a second scope would require restarting them, and a restart
|
|
621
|
+
* mid-run is indistinguishable to the reconciler from a lane crash. A second epic
|
|
622
|
+
* gets a second plane.
|
|
623
|
+
*
|
|
624
|
+
* Re-requesting the SAME run is an idempotent no-op when the plane has already
|
|
625
|
+
* moved past `control-plane-ready`, so a retried composed bring-up does not
|
|
626
|
+
* refuse a plane it already set up.
|
|
627
|
+
*/
|
|
628
|
+
export async function requestPlaneManifestLanes(repoRoot, planeId, epicRunId, laneScope, fs) {
|
|
629
|
+
const owned = await readOwnedManifest(repoRoot, planeId, fs);
|
|
630
|
+
if (!owned.ok)
|
|
631
|
+
return owned;
|
|
632
|
+
const manifest = owned.manifest;
|
|
633
|
+
if (manifest.epicRunId !== undefined && manifest.epicRunId !== epicRunId) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
reason: "run-conflict",
|
|
637
|
+
message: `plane is already bound to a different epic run (${manifest.epicRunId})`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const current = planeManifestLifecycle(manifest);
|
|
641
|
+
if (current !== "control-plane-ready") {
|
|
642
|
+
// Already served for THIS run: report success rather than a conflict, so a
|
|
643
|
+
// retry of the composed flow converges instead of failing on its own work.
|
|
644
|
+
if (manifest.epicRunId === epicRunId && manifest.laneScope !== undefined) {
|
|
645
|
+
return { ok: true, manifest };
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
ok: false,
|
|
649
|
+
reason: "phase-conflict",
|
|
650
|
+
message: `plane is ${current}, not control-plane-ready`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
return writeGuardedManifest({ ...manifest, epicRunId, laneScope }, fs);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Append members to a live manifest, guarded by plane identity.
|
|
657
|
+
*
|
|
658
|
+
* Used by the detached runtime as each phase-two cohort begins spawning, so
|
|
659
|
+
* `plane status` and `plane down` can see and signal members that did not exist
|
|
660
|
+
* when the manifest was first claimed. A name already listed is REPLACED rather
|
|
661
|
+
* than duplicated: the manifest's own parse rejects a repeated member name, and
|
|
662
|
+
* producing a record this module's reader would refuse is never the right way to
|
|
663
|
+
* report progress.
|
|
664
|
+
*/
|
|
665
|
+
export async function appendPlaneManifestMembers(repoRoot, planeId, members, fs) {
|
|
666
|
+
const owned = await readOwnedManifest(repoRoot, planeId, fs);
|
|
667
|
+
if (!owned.ok)
|
|
668
|
+
return owned;
|
|
669
|
+
const byName = new Map(owned.manifest.members.map((m) => [m.name, m]));
|
|
670
|
+
for (const member of members)
|
|
671
|
+
byName.set(member.name, member);
|
|
672
|
+
return writeGuardedManifest({ ...owned.manifest, members: [...byName.values()] }, fs);
|
|
673
|
+
}
|
|
466
674
|
/**
|
|
467
675
|
* Take exclusive ownership of `.bridge/plane/plane.json`.
|
|
468
676
|
*
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import path from "path";
|
|
26
26
|
import { PLANE_OBSERVER_MODE_ENV, PLANE_OBSERVER_MODE_VALUE, PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
|
|
27
27
|
import { relativeLogPathFor } from "./manifest.js";
|
|
28
|
+
import { EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE, executorClaimScopeArgs, } from "../executor/claim-scope.js";
|
|
28
29
|
/** How long a member with a readiness probe gets to start listening. */
|
|
29
30
|
export const PLANE_READINESS_TIMEOUT_MS = 60_000;
|
|
30
31
|
/**
|
|
@@ -310,6 +311,18 @@ export function buildPlaneMemberRoster(params) {
|
|
|
310
311
|
timeoutMs: PLANE_RECONCILER_READINESS_TIMEOUT_MS,
|
|
311
312
|
},
|
|
312
313
|
};
|
|
314
|
+
// BAPI-1102 — refused HERE, not merely documented. A lane built without a
|
|
315
|
+
// scope is a lane that exits at startup (BAPI-1026), and a roster that could
|
|
316
|
+
// produce one would turn a missing flag into a member crash and a full plane
|
|
317
|
+
// rollback. The public parser already refuses first; this is the structural
|
|
318
|
+
// backstop for every internal caller.
|
|
319
|
+
if (executors > 0 && params.claimScope === undefined) {
|
|
320
|
+
throw new Error(EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE);
|
|
321
|
+
}
|
|
322
|
+
// Built ONCE, outside the lane loop: every lane of one plane serves the same
|
|
323
|
+
// scope, and rendering it per-lane would be a chance for two lanes of the same
|
|
324
|
+
// plane to disagree.
|
|
325
|
+
const scopeArgs = params.claimScope === undefined ? [] : executorClaimScopeArgs(params.claimScope);
|
|
313
326
|
const executorMembers = [];
|
|
314
327
|
for (let lane = 1; lane <= executors; lane += 1) {
|
|
315
328
|
const name = `executor-${lane}`;
|
|
@@ -334,6 +347,9 @@ export function buildPlaneMemberRoster(params) {
|
|
|
334
347
|
endpoint.baseUrl,
|
|
335
348
|
"--executor-id",
|
|
336
349
|
executorId,
|
|
350
|
+
// Appended LAST and rendered by the executor's OWN argv builder, so the
|
|
351
|
+
// flag spelling here cannot drift from the parser that reads it.
|
|
352
|
+
...scopeArgs,
|
|
337
353
|
],
|
|
338
354
|
cwd,
|
|
339
355
|
env,
|
|
@@ -361,6 +377,60 @@ export function buildPlaneMemberRoster(params) {
|
|
|
361
377
|
}
|
|
362
378
|
return [server, worker, ...executorMembers, observer];
|
|
363
379
|
}
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// BAPI-1102 — the two-phase roster selectors.
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Each is a FILTER over the full roster rather than a second builder, and that
|
|
384
|
+
// is the whole design. Every spec — argv, environment, log path, readiness gate,
|
|
385
|
+
// the executor id minted once and used twice — keeps exactly one definition, so
|
|
386
|
+
// a phase roster cannot drift from the roster the single-phase path builds. It
|
|
387
|
+
// also makes the composition law checkable and cheap:
|
|
388
|
+
//
|
|
389
|
+
// [...controlPlane, ...executorLanes, ...observer] === buildPlaneMemberRoster(...)
|
|
390
|
+
//
|
|
391
|
+
// which a unit test asserts, so the split can never silently drop or reorder a
|
|
392
|
+
// member. A pair of independent builders would have needed that property
|
|
393
|
+
// re-established by inspection after every future edit.
|
|
394
|
+
/** Is this the name of an executor lane? */
|
|
395
|
+
function isExecutorLaneName(name) {
|
|
396
|
+
return name.startsWith("executor-");
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Phase one: the server and the reconciler worker, in that order.
|
|
400
|
+
*
|
|
401
|
+
* ZERO executor lanes, so no claim scope is required or accepted — which is the
|
|
402
|
+
* one legitimate zero-lane roster in the system. It is INTERNAL: there is no
|
|
403
|
+
* public `plane up --executors 0`, because "start a plane that can never do any
|
|
404
|
+
* work" is not a state an operator should be able to ask for by hand.
|
|
405
|
+
*
|
|
406
|
+
* The observer is deliberately NOT here. See {@link PlaneLifecyclePhase}.
|
|
407
|
+
*/
|
|
408
|
+
export function buildPlaneControlPlaneRoster(params) {
|
|
409
|
+
return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "server" || member.name === "worker");
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Phase two, part one: the scoped executor lanes.
|
|
413
|
+
*
|
|
414
|
+
* `claimScope` is REQUIRED by the type, not merely by the runtime check inside
|
|
415
|
+
* the builder — a lane roster is the one thing that cannot be built without a
|
|
416
|
+
* scope, and making that a compile-time fact removes the whole class of caller
|
|
417
|
+
* that forgets.
|
|
418
|
+
*/
|
|
419
|
+
export function buildPlaneExecutorLaneRoster(params) {
|
|
420
|
+
return buildPlaneMemberRoster(params).filter((member) => isExecutorLaneName(member.name));
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Phase two, part two: the dead-man observer, started LAST.
|
|
424
|
+
*
|
|
425
|
+
* Split out so the runtime can start it only after every lane's per-instance
|
|
426
|
+
* heartbeat gate has closed. Starting it at control-plane time would put it on a
|
|
427
|
+
* plane with zero executor lanes for however long run creation takes — precisely
|
|
428
|
+
* the state its executor-component sweep would alert on, and precisely the boot
|
|
429
|
+
* noise the ordering contract above exists to prevent.
|
|
430
|
+
*/
|
|
431
|
+
export function buildPlaneObserverRoster(params) {
|
|
432
|
+
return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "observer");
|
|
433
|
+
}
|
|
364
434
|
/**
|
|
365
435
|
* Deterministic, distinct, CLI-safe executor id for one lane.
|
|
366
436
|
*
|
package/build/plane/preflight.js
CHANGED
|
@@ -15,11 +15,97 @@ import path from "path";
|
|
|
15
15
|
import { PLANE_SERVER_PORT_ENV_VAR, planeCheckFinding, planeCheckPassed, } from "./types.js";
|
|
16
16
|
import { checkPlaneBuildFreshness, checkPlaneRuntimeEntrypoint } from "./build-freshness.js";
|
|
17
17
|
import { checkAlembicHead } from "./alembic-head.js";
|
|
18
|
-
import { manifestHasLiveProcess, readPlaneManifest } from "./manifest.js";
|
|
18
|
+
import { manifestHasLiveProcess, planeManifestLifecycle, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
|
|
19
19
|
import { detectClaudeLogin, formatClaudeLoginAdvisory } from "../claude-login.js";
|
|
20
20
|
import { createReadinessCheck, createReadinessCheckSafely, } from "../readiness-check.js";
|
|
21
21
|
/** Files that must exist for a path to be this repository's root. */
|
|
22
22
|
const REQUIRED_REPO_FILES = ["main.py", "worker.py", "alembic.ini"];
|
|
23
|
+
/** Phases in which a plane already serving a BOUND run can be re-attached to it. */
|
|
24
|
+
const BOUND_RUN_ATTACHABLE_PHASES = [
|
|
25
|
+
"lanes-starting",
|
|
26
|
+
"lanes-ready",
|
|
27
|
+
"ready",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Classify a VALID, LIVE manifest found at `repoRoot` for attachment. Pure.
|
|
31
|
+
*
|
|
32
|
+
* The exact-root comparison is the same one `bindPlaneManifestEpicRun` and the
|
|
33
|
+
* guarded lifecycle writers apply (`manifest.repoRoot !== repoRoot`), so a
|
|
34
|
+
* manifest this repository could not bind or request lanes on is never reported
|
|
35
|
+
* attachable.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyLivePlaneForAttach(repoRoot, manifest, proc) {
|
|
38
|
+
if (manifest.repoRoot !== repoRoot)
|
|
39
|
+
return { kind: "foreign-root" };
|
|
40
|
+
const lifecycle = planeManifestLifecycle(manifest);
|
|
41
|
+
const probes = probeManifestMembers(manifest, proc);
|
|
42
|
+
const memberAlive = (name) => probes.some((probe) => probe.member.name === name && probe.liveness === "alive");
|
|
43
|
+
if (!memberAlive("server") || !memberAlive("worker")) {
|
|
44
|
+
return { kind: "not-attachable", lifecycle };
|
|
45
|
+
}
|
|
46
|
+
if (lifecycle === "control-plane-ready")
|
|
47
|
+
return { kind: "attachable", manifest };
|
|
48
|
+
if (BOUND_RUN_ATTACHABLE_PHASES.includes(lifecycle) &&
|
|
49
|
+
manifest.epicRunId !== undefined &&
|
|
50
|
+
manifest.laneScope !== undefined) {
|
|
51
|
+
return { kind: "attachable", manifest };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "not-attachable", lifecycle };
|
|
54
|
+
}
|
|
55
|
+
/** Fixed remediation for an occupied port that is not this repository's plane. */
|
|
56
|
+
export const PLANE_PORT_UNRELATED_LISTENER_REMEDIATION = "the listener is not a live plane of this repository — it is often a SIBLING WORKTREE's " +
|
|
57
|
+
"server. Stop it (or wind down its plane from its own worktree) and run drive-epic again.";
|
|
58
|
+
/** Fixed remediation for a live manifest naming a different repository root. */
|
|
59
|
+
export const PLANE_FOREIGN_ROOT_REMEDIATION = "the plane manifest here names a different repository root (copied or symlinked). " +
|
|
60
|
+
"Inspect .bridge/plane/plane.json, wind that plane down from its own repository, and run " +
|
|
61
|
+
"drive-epic again.";
|
|
62
|
+
/** Fixed remediation for this repository's live plane that cannot take a run. */
|
|
63
|
+
export const PLANE_NOT_ATTACHABLE_REMEDIATION = "this repository's plane is running but cannot take a new run — it is still starting, its " +
|
|
64
|
+
"server or reconciler is gone, its lanes failed, or its lanes were started by hand with " +
|
|
65
|
+
"their own claim scope. Inspect it with `plane status`; wait for it to finish starting, or " +
|
|
66
|
+
"wind it down with `plane down` and run drive-epic again to cold-start.";
|
|
67
|
+
/** Fixed remediation when this repository's attachable plane was not attached to. */
|
|
68
|
+
export const PLANE_ATTACHABLE_BASE_URL_REMEDIATION = `do not stop it — point BAPI_BASE_URL at the plane endpoint (http://127.0.0.1:<port>, the port ` +
|
|
69
|
+
`${PLANE_SERVER_PORT_ENV_VAR} or the default, with no trailing slash) and run drive-epic again to attach.`;
|
|
70
|
+
/**
|
|
71
|
+
* Describe what occupies the plane port, for a single operator refusal. Pure.
|
|
72
|
+
*
|
|
73
|
+
* Every non-attachable occupant keeps its own classification and fix, but they
|
|
74
|
+
* all render as ONE refusal: the operator has one thing to do, and it depends on
|
|
75
|
+
* which of these it is.
|
|
76
|
+
*/
|
|
77
|
+
export function describePlanePortOccupant(observation) {
|
|
78
|
+
switch (observation?.kind) {
|
|
79
|
+
case "unvalidated":
|
|
80
|
+
return {
|
|
81
|
+
classification: "an existing plane manifest could not be validated",
|
|
82
|
+
remediation: PLANE_MANIFEST_UNREADABLE_REMEDIATION,
|
|
83
|
+
};
|
|
84
|
+
case "foreign-root":
|
|
85
|
+
return {
|
|
86
|
+
classification: "a live plane manifest names a different repository root",
|
|
87
|
+
remediation: PLANE_FOREIGN_ROOT_REMEDIATION,
|
|
88
|
+
};
|
|
89
|
+
case "not-attachable":
|
|
90
|
+
return {
|
|
91
|
+
classification: `this repository's plane is live but not attachable (lifecycle: ${observation.lifecycle})`,
|
|
92
|
+
remediation: PLANE_NOT_ATTACHABLE_REMEDIATION,
|
|
93
|
+
};
|
|
94
|
+
case "attachable":
|
|
95
|
+
// Reached only when the caller declined to attach — the Bridge API it
|
|
96
|
+
// talks to is not this plane. Telling the operator to stop the listener
|
|
97
|
+
// would stop a healthy plane; the fix is the base URL.
|
|
98
|
+
return {
|
|
99
|
+
classification: "this repository's own healthy plane holds the port, but BAPI_BASE_URL does not name it",
|
|
100
|
+
remediation: PLANE_ATTACHABLE_BASE_URL_REMEDIATION,
|
|
101
|
+
};
|
|
102
|
+
default:
|
|
103
|
+
return {
|
|
104
|
+
classification: "the port is held by a listener that is not a live plane of this repository",
|
|
105
|
+
remediation: PLANE_PORT_UNRELATED_LISTENER_REMEDIATION,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
23
109
|
/** Fixed remediation for a `plane up` run started outside the repository root. */
|
|
24
110
|
export const PLANE_REPOSITORY_ROOT_REMEDIATION = "run `plane up` from the Bridge API repository root (the directory holding main.py, worker.py, and alembic.ini).";
|
|
25
111
|
/** Fixed remediation for an unresolvable Bridge repository identity. */
|
|
@@ -148,8 +234,11 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
148
234
|
// An unresolvable override is a configuration failure, not a port failure:
|
|
149
235
|
// there is no port to probe, so the probe is skipped entirely rather than
|
|
150
236
|
// falling back to 8000 and reporting on a port the operator did not ask for.
|
|
237
|
+
const observations = {};
|
|
151
238
|
if (deps.endpoint.ok) {
|
|
152
|
-
|
|
239
|
+
const port = await checkServerPort(deps, deps.endpoint.endpoint);
|
|
240
|
+
record(port);
|
|
241
|
+
observations.serverPort = port.probe;
|
|
153
242
|
}
|
|
154
243
|
else {
|
|
155
244
|
// The endpoint resolver owns this diagnostic's prose; the structured fix is
|
|
@@ -166,7 +255,9 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
166
255
|
fileExists: (filePath) => fileExists(filePath, deps.fs),
|
|
167
256
|
platform: deps.platform,
|
|
168
257
|
}));
|
|
169
|
-
|
|
258
|
+
const existing = await checkExistingPlane(repoRoot, deps);
|
|
259
|
+
record(existing);
|
|
260
|
+
observations.existingPlane = existing.observation;
|
|
170
261
|
const blocking = diagnostics.filter((d) => d.severity === "blocking");
|
|
171
262
|
// `!runtimeEntrypoint.ok` is already covered by the blocking count; it is
|
|
172
263
|
// repeated here so the compiler narrows the union rather than requiring a
|
|
@@ -175,12 +266,13 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
175
266
|
// here, like `!runtimeEntrypoint.ok`, so the compiler narrows the union rather
|
|
176
267
|
// than requiring a non-null assertion on the context field below.
|
|
177
268
|
if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok || !deps.endpoint.ok) {
|
|
178
|
-
return { ok: false, diagnostics, outcomes };
|
|
269
|
+
return { ok: false, diagnostics, outcomes, observations };
|
|
179
270
|
}
|
|
180
271
|
return {
|
|
181
272
|
ok: true,
|
|
182
273
|
diagnostics,
|
|
183
274
|
outcomes,
|
|
275
|
+
observations,
|
|
184
276
|
context: {
|
|
185
277
|
repoRoot,
|
|
186
278
|
repoName: credentials.repoName,
|
|
@@ -559,16 +651,22 @@ function createResolverWarningBuffer() {
|
|
|
559
651
|
* refuse a perfectly good bring-up.
|
|
560
652
|
*/
|
|
561
653
|
export async function checkServerPort(deps, endpoint) {
|
|
654
|
+
// BAPI-1153: the probe's own result kind rides beside the unchanged report, so
|
|
655
|
+
// a composing caller reads what was observed instead of re-probing the port.
|
|
656
|
+
const report = await checkServerPortReport(deps, endpoint);
|
|
657
|
+
return { ...report.report, probe: report.probe };
|
|
658
|
+
}
|
|
659
|
+
async function checkServerPortReport(deps, endpoint) {
|
|
562
660
|
const label = "Local server port";
|
|
563
661
|
const target = `${endpoint.host}:${endpoint.port}`;
|
|
564
662
|
const result = await deps.probePort(endpoint.host, endpoint.port, PLANE_PORT_PROBE_TIMEOUT_MS);
|
|
565
663
|
if (result.kind === "refused") {
|
|
566
|
-
return planeCheckPassed("server-port", label, `${target} is free`);
|
|
664
|
+
return { report: planeCheckPassed("server-port", label, `${target} is free`), probe: "refused" };
|
|
567
665
|
}
|
|
568
666
|
// Severity is unchanged in both branches: `connected` still blocks, an
|
|
569
667
|
// indeterminate probe still only warns.
|
|
570
668
|
if (result.kind === "connected") {
|
|
571
|
-
|
|
669
|
+
const occupied = planeCheckFinding({
|
|
572
670
|
check: "server-port",
|
|
573
671
|
severity: "blocking",
|
|
574
672
|
message: `${target} is already accepting connections. ` +
|
|
@@ -577,8 +675,9 @@ export async function checkServerPort(deps, endpoint) {
|
|
|
577
675
|
"to a free port.",
|
|
578
676
|
remediation: PLANE_SERVER_PORT_OCCUPIED_REMEDIATION,
|
|
579
677
|
}, label, `${target} is already accepting connections`);
|
|
678
|
+
return { report: occupied, probe: "connected" };
|
|
580
679
|
}
|
|
581
|
-
|
|
680
|
+
const indeterminate = planeCheckFinding({
|
|
582
681
|
check: "server-port",
|
|
583
682
|
severity: "warning",
|
|
584
683
|
message: `could not determine whether ${target} is free ` +
|
|
@@ -588,6 +687,7 @@ export async function checkServerPort(deps, endpoint) {
|
|
|
588
687
|
// The probe's own `error` text stays in the legacy message for the operator
|
|
589
688
|
// at the terminal and is not copied into the consolidated detail.
|
|
590
689
|
`could not determine whether ${target} is free`);
|
|
690
|
+
return { report: indeterminate, probe: "error" };
|
|
591
691
|
}
|
|
592
692
|
/**
|
|
593
693
|
* Refuse to start over a plane that is still alive.
|
|
@@ -602,11 +702,18 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
602
702
|
// Three distinct states, three distinct fixes (BAPI-1055): unreadable/malformed,
|
|
603
703
|
// stale, and live. Manifest validation, the liveness probe, and the
|
|
604
704
|
// never-touch-disk rule are all unchanged.
|
|
705
|
+
//
|
|
706
|
+
// BAPI-1153: each arm also returns its ATTACH classification. The diagnostics
|
|
707
|
+
// are untouched — a live plane still blocks `plane up` — and the classification
|
|
708
|
+
// is read from this one manifest read, never a second one.
|
|
605
709
|
if (read.kind === "missing") {
|
|
606
|
-
return
|
|
710
|
+
return {
|
|
711
|
+
...planeCheckPassed("existing-plane", label, "no plane manifest is present"),
|
|
712
|
+
observation: { kind: "none" },
|
|
713
|
+
};
|
|
607
714
|
}
|
|
608
715
|
if (read.kind !== "valid") {
|
|
609
|
-
|
|
716
|
+
const unvalidated = planeCheckFinding({
|
|
610
717
|
check: "existing-plane",
|
|
611
718
|
severity: "blocking",
|
|
612
719
|
message: `an existing .bridge/plane/plane.json could not be validated (${read.error}). ` +
|
|
@@ -614,6 +721,7 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
614
721
|
"it by hand if no plane is running.",
|
|
615
722
|
remediation: PLANE_MANIFEST_UNREADABLE_REMEDIATION,
|
|
616
723
|
}, label, "an existing plane manifest could not be validated");
|
|
724
|
+
return { ...unvalidated, observation: { kind: "unvalidated" } };
|
|
617
725
|
}
|
|
618
726
|
// BAPI-882: a manifest carrying THIS process's own plane id is its own claim,
|
|
619
727
|
// not a competitor. Checked after validation and BEFORE the liveness probe,
|
|
@@ -632,13 +740,17 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
632
740
|
// A live manifest with a DIFFERENT plane id still blocks, unchanged — that is
|
|
633
741
|
// a genuine second plane and the whole point of the check.
|
|
634
742
|
if (deps.ownPlaneId !== undefined && read.manifest.planeId === deps.ownPlaneId) {
|
|
635
|
-
return
|
|
743
|
+
return {
|
|
744
|
+
...planeCheckPassed("existing-plane", label, "the existing manifest carries this launch's own plane id"),
|
|
745
|
+
observation: { kind: "own-plane" },
|
|
746
|
+
};
|
|
636
747
|
}
|
|
637
748
|
if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
|
|
638
749
|
// Stale. Reported as information so the operator understands why an old
|
|
639
750
|
// manifest is about to be replaced; the claim revalidates before it does.
|
|
640
751
|
// No remediation: the operator has nothing to do — the claim replaces it.
|
|
641
752
|
return {
|
|
753
|
+
observation: { kind: "stale" },
|
|
642
754
|
diagnostic: {
|
|
643
755
|
check: "existing-plane",
|
|
644
756
|
severity: "warning",
|
|
@@ -656,7 +768,7 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
656
768
|
],
|
|
657
769
|
};
|
|
658
770
|
}
|
|
659
|
-
|
|
771
|
+
const running = planeCheckFinding({
|
|
660
772
|
check: "existing-plane",
|
|
661
773
|
severity: "blocking",
|
|
662
774
|
message: `a plane is already running (supervisor pid ${read.manifest.supervisorPid}). ` +
|
|
@@ -666,6 +778,10 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
666
778
|
// The supervisor pid stays in the legacy message; the consolidated detail
|
|
667
779
|
// reports the fact, not the process identifier.
|
|
668
780
|
"a plane is already running for this repository");
|
|
781
|
+
return {
|
|
782
|
+
...running,
|
|
783
|
+
observation: classifyLivePlaneForAttach(repoRoot, read.manifest, deps.proc),
|
|
784
|
+
};
|
|
669
785
|
}
|
|
670
786
|
async function fileExists(filePath, fs) {
|
|
671
787
|
try {
|
|
@@ -702,7 +818,7 @@ export const PLANE_READINESS_DESCRIPTORS = [
|
|
|
702
818
|
/** Fixed detail for a plane prerequisite the preflight never reported on. */
|
|
703
819
|
const PLANE_UNREPORTED_DETAIL = "not reported — the plane preflight produced no outcome for this prerequisite";
|
|
704
820
|
/** Fixed remediation for a plane prerequisite the preflight never reported on. */
|
|
705
|
-
const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `
|
|
821
|
+
const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `epic-implementer readiness` again) to re-collect the plane preflight; this " +
|
|
706
822
|
"prerequisite's state is unknown, not healthy.";
|
|
707
823
|
/**
|
|
708
824
|
* Project a plane preflight result into canonical readiness checks.
|
package/build/plane/shutdown.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* liveness is unknown, the manifest is retained so a later `plane down` can
|
|
19
19
|
* retry. Clearing it would orphan the survivor permanently.
|
|
20
20
|
*/
|
|
21
|
-
import { clearPlaneManifest, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
|
|
21
|
+
import { clearPlaneManifest, planeManifestLifecycle, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
|
|
22
22
|
/** SIGTERM→SIGKILL grace period. */
|
|
23
23
|
export const PLANE_SHUTDOWN_GRACE_MS = 10_000;
|
|
24
24
|
/** How long stragglers get after SIGKILL before they are declared unknown. */
|
|
@@ -72,6 +72,19 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
72
72
|
}
|
|
73
73
|
const manifest = read.manifest;
|
|
74
74
|
const messages = [];
|
|
75
|
+
// BAPI-1102 — say WHICH phase is being wound down when it is not a complete
|
|
76
|
+
// plane. Every intermediate phase is windable by exactly the same mechanism as
|
|
77
|
+
// a finished one (the whole plane shares one process group, and members that
|
|
78
|
+
// were never spawned have no pid to probe), so nothing special happens here —
|
|
79
|
+
// but an operator stopping a plane that is still starting deserves to be told
|
|
80
|
+
// that is what they stopped, rather than reading a report that looks like a
|
|
81
|
+
// plane with missing members.
|
|
82
|
+
const lifecycle = planeManifestLifecycle(manifest);
|
|
83
|
+
if (lifecycle !== "ready") {
|
|
84
|
+
messages.push(`This plane had not finished starting (${lifecycle}). Members that had not been ` +
|
|
85
|
+
"spawned yet have no pid and are reported as such; every member that HAD started is " +
|
|
86
|
+
"signalled with the rest of the process group.");
|
|
87
|
+
}
|
|
75
88
|
if (read.unrecognizedMemberNames.length > 0) {
|
|
76
89
|
// Said out loud, never assumed harmless (BAPI-1029). The wind-down still
|
|
77
90
|
// reaches these members — the whole plane shares one process group, and the
|
|
@@ -217,7 +230,7 @@ async function waitForDeath(manifest, deps, budgetMs, pollMs) {
|
|
|
217
230
|
/**
|
|
218
231
|
* Render the server-side run-shutdown outcome (BAPI-872) as its own message
|
|
219
232
|
* lines — never claims signalling local processes changed server-side state,
|
|
220
|
-
* and always names the supported `
|
|
233
|
+
* and always names the supported `epic-implementer stop-run` check/remediation
|
|
221
234
|
* surface for every outcome that is not a fresh committed stop.
|
|
222
235
|
*/
|
|
223
236
|
function formatRunShutdown(outcome) {
|
|
@@ -226,7 +239,7 @@ function formatRunShutdown(outcome) {
|
|
|
226
239
|
return [
|
|
227
240
|
"Run shutdown: no epic run is bound to this plane. If a server-side run is " +
|
|
228
241
|
"active, this command did NOT stop it and its queued work may remain live. " +
|
|
229
|
-
"Run `
|
|
242
|
+
"Run `epic-implementer stop-run --epic-run-id <epic_run_id>` to stop it explicitly.",
|
|
230
243
|
];
|
|
231
244
|
case "committed":
|
|
232
245
|
return [`Run shutdown: stopped run ${outcome.epicRunId} — its queued work was cancelled.`];
|
|
@@ -236,13 +249,13 @@ function formatRunShutdown(outcome) {
|
|
|
236
249
|
return [
|
|
237
250
|
`Run shutdown: run ${outcome.epicRunId} is terminal (${outcome.status}) and cannot be ` +
|
|
238
251
|
"stopped. Signalling local processes does not change this — run " +
|
|
239
|
-
`\`
|
|
252
|
+
`\`epic-implementer stop-run --epic-run-id ${outcome.epicRunId}\` to check its state.`,
|
|
240
253
|
];
|
|
241
254
|
case "unavailable":
|
|
242
255
|
return [
|
|
243
256
|
`Run shutdown: could not reach the Bridge API to stop run ${outcome.epicRunId} ` +
|
|
244
257
|
`(${outcome.message}). The run may remain active with queued work — run ` +
|
|
245
|
-
`\`
|
|
258
|
+
`\`epic-implementer stop-run --epic-run-id ${outcome.epicRunId}\` to retry.`,
|
|
246
259
|
];
|
|
247
260
|
}
|
|
248
261
|
}
|