@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/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/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
|
package/build/plane/status.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* a hole in the roster (a member that crashed and was deliberately not
|
|
7
7
|
* restarted) is precisely what this command exists to *show*.
|
|
8
8
|
*/
|
|
9
|
-
import { probeManifestMembers, readPlaneManifest, } from "./manifest.js";
|
|
9
|
+
import { planeManifestLifecycle, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
|
|
10
10
|
import { PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
|
|
11
11
|
/** Read the manifest and probe every recorded pid. Strictly read-only. */
|
|
12
12
|
export async function getPlaneStatus(repoRoot, deps) {
|
|
@@ -32,6 +32,8 @@ export async function getPlaneStatus(repoRoot, deps) {
|
|
|
32
32
|
supervisorPgid: manifest.supervisorPgid,
|
|
33
33
|
supervisorLiveness: deps.proc.isAlive(manifest.supervisorPid),
|
|
34
34
|
unrecognizedMemberNames: read.unrecognizedMemberNames,
|
|
35
|
+
lifecycle: planeManifestLifecycle(manifest),
|
|
36
|
+
epicRunId: manifest.epicRunId ?? null,
|
|
35
37
|
createdAt: manifest.createdAt,
|
|
36
38
|
updatedAt: manifest.updatedAt,
|
|
37
39
|
members: probeManifestMembers(manifest, deps.proc).map((probe) => ({
|
|
@@ -45,6 +47,36 @@ export async function getPlaneStatus(repoRoot, deps) {
|
|
|
45
47
|
})),
|
|
46
48
|
};
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The lifecycle phase, in operational terms rather than as a bare enum value.
|
|
52
|
+
*
|
|
53
|
+
* Each line says what has happened AND what is being waited for, because the
|
|
54
|
+
* intermediate phases are the ones an operator sees when something is wrong and
|
|
55
|
+
* "lanes-starting" alone does not say whether that is normal.
|
|
56
|
+
*
|
|
57
|
+
* `ready` deliberately does NOT claim the run is dispatchable. A plane can be
|
|
58
|
+
* fully up while the reconciler still holds dispatch — feature-branch index
|
|
59
|
+
* preparation is the common case — and a status line that conflated "every
|
|
60
|
+
* process is up" with "work is flowing" would send an operator hunting for a
|
|
61
|
+
* problem in the plane that is not there.
|
|
62
|
+
*/
|
|
63
|
+
function describeLifecycle(phase) {
|
|
64
|
+
switch (phase) {
|
|
65
|
+
case "control-plane-starting":
|
|
66
|
+
return "control plane starting (server, reconciler)";
|
|
67
|
+
case "control-plane-ready":
|
|
68
|
+
return "control plane ready — waiting for a scoped executor-lane request";
|
|
69
|
+
case "lanes-starting":
|
|
70
|
+
return "executor lanes starting — each waits for its own heartbeat gate";
|
|
71
|
+
case "lanes-ready":
|
|
72
|
+
return "executor lanes ready — starting the dead-man observer";
|
|
73
|
+
case "ready":
|
|
74
|
+
return "plane ready (this reports PROCESSES, not whether the run is dispatchable)";
|
|
75
|
+
case "lanes-failed":
|
|
76
|
+
return ("executor lanes FAILED — the lane cohort was stopped and the control plane was " +
|
|
77
|
+
"left running; nothing is claiming jobs. Wind it down with `plane down`.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
48
80
|
/** One line per member: name, state, exit status where known, log path. */
|
|
49
81
|
export function formatPlaneStatus(result) {
|
|
50
82
|
if (!result.ok)
|
|
@@ -55,6 +87,8 @@ export function formatPlaneStatus(result) {
|
|
|
55
87
|
const lines = [
|
|
56
88
|
`Plane ${result.planeId}`,
|
|
57
89
|
` supervisor pid ${result.supervisorPid} (group ${result.supervisorPgid}) — ${result.supervisorLiveness}`,
|
|
90
|
+
` lifecycle ${describeLifecycle(result.lifecycle)}`,
|
|
91
|
+
...(result.epicRunId !== null ? [` epic run ${result.epicRunId}`] : []),
|
|
58
92
|
` started ${result.createdAt}`,
|
|
59
93
|
` updated ${result.updatedAt}`,
|
|
60
94
|
"",
|