@bridge_gpt/mcp-server 0.2.51 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +130 -28
- package/build/executor/merge-job.js +67 -16
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +514 -121
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +5 -3
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +560 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +3 -2
|
@@ -101,6 +101,44 @@ export function createLineReader(onLine) {
|
|
|
101
101
|
}
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Rebuild the instance identity a spawned member publishes under.
|
|
106
|
+
*
|
|
107
|
+
* Mirrors `_resolve_process_instance_id` in
|
|
108
|
+
* `api/library/epic_conductor/reconciler.py` exactly, because the string has to
|
|
109
|
+
* MATCH: `DYNO` when that variable is set and non-blank after trimming,
|
|
110
|
+
* otherwise `<hostname>:<pid>`, truncated to 128 characters. Python resolves it
|
|
111
|
+
* once at import from the process's own environment and pid; this rebuilds it
|
|
112
|
+
* from the environment the supervisor handed that same process and the pid
|
|
113
|
+
* `spawn` returned.
|
|
114
|
+
*
|
|
115
|
+
* Returns `undefined` — meaning "do not scope, use the unscoped verdict" —
|
|
116
|
+
* rather than guessing, when no hostname capability was injected. A wrong
|
|
117
|
+
* identity would be worse than no scoping: it would report `never_seen` forever
|
|
118
|
+
* and time out a perfectly healthy member.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately NOT called for a member whose spec already carries an
|
|
121
|
+
* `instanceId`: an executor lane's identity is minted by the plane itself and is
|
|
122
|
+
* known before the spawn.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveMemberInstanceId(env, childPid, hostname) {
|
|
125
|
+
const dyno = (env.DYNO ?? "").trim();
|
|
126
|
+
if (dyno)
|
|
127
|
+
return dyno.slice(0, 128);
|
|
128
|
+
if (!hostname)
|
|
129
|
+
return undefined;
|
|
130
|
+
let host;
|
|
131
|
+
try {
|
|
132
|
+
host = hostname();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Python falls back to the literal "unknown" when `gethostname` raises, and
|
|
136
|
+
// an identity that merely MATCHES a real writer is the whole point — so this
|
|
137
|
+
// reproduces that fallback rather than abandoning scoping.
|
|
138
|
+
host = "unknown";
|
|
139
|
+
}
|
|
140
|
+
return `${host}:${childPid}`.slice(0, 128);
|
|
141
|
+
}
|
|
104
142
|
/** Render one attributed event line. */
|
|
105
143
|
export function formatMemberEvent(event) {
|
|
106
144
|
const detail = event.detail ? ` ${event.detail}` : "";
|
|
@@ -415,6 +453,14 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
|
|
|
415
453
|
members: manifest.members.map((m) => (m.name === name ? { ...m, ...patch } : m)),
|
|
416
454
|
};
|
|
417
455
|
};
|
|
456
|
+
/**
|
|
457
|
+
* Has this member's `close` handler already fired?
|
|
458
|
+
*
|
|
459
|
+
* Read off the manifest projection the handler patches, rather than a second
|
|
460
|
+
* parallel set: one record of member lifecycle means a readiness wait and
|
|
461
|
+
* `plane status` can never disagree about whether a child is still alive.
|
|
462
|
+
*/
|
|
463
|
+
const memberExited = (name) => manifest.members.some((m) => m.name === name && m.state === "exited");
|
|
418
464
|
let shuttingDown = false;
|
|
419
465
|
const requestShutdown = async (signal) => {
|
|
420
466
|
if (shuttingDown)
|
|
@@ -442,6 +488,45 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
|
|
|
442
488
|
if (!opened.ok) {
|
|
443
489
|
return finishPartialStart(spec.name, `log could not be opened (${opened.error})`);
|
|
444
490
|
}
|
|
491
|
+
// Probed BEFORE the spawn, for every heartbeat-gated member (BAPI-1036;
|
|
492
|
+
// BAPI-1029 took one such probe, for the reconciler only, and used it merely
|
|
493
|
+
// to disclose an ambiguity). The baseline is what turns "a fresh heartbeat
|
|
494
|
+
// exists" into "a heartbeat landed after we started this process" — see
|
|
495
|
+
// `PlaneHeartbeatBaseline`.
|
|
496
|
+
//
|
|
497
|
+
// Taken AFTER the member's log is opened, so a terminal baseline failure is
|
|
498
|
+
// annotated in the log an operator will actually open for that member —
|
|
499
|
+
// still before the spawn, which is the property that matters.
|
|
500
|
+
//
|
|
501
|
+
// An executor lane is scoped here by the identity the roster already minted;
|
|
502
|
+
// the reconciler's local identity needs a pid, so its pre-spawn baseline is
|
|
503
|
+
// the UNSCOPED observation, which is a sound lower bound: the unscoped read
|
|
504
|
+
// returns the newest row across the deployment, so any row this member later
|
|
505
|
+
// publishes must be at least as new. A `DYNO` deployment scopes exactly,
|
|
506
|
+
// because that identity needs no pid.
|
|
507
|
+
let baseline = { observedAtMs: null };
|
|
508
|
+
let baselineElapsedMs = 0;
|
|
509
|
+
if (spec.readiness?.kind === "process-heartbeat") {
|
|
510
|
+
const readiness = spec.readiness;
|
|
511
|
+
const baselineInstanceId = readiness.instanceId ?? resolveMemberInstanceId(spec.env, 0, undefined);
|
|
512
|
+
const outcome = await probeHeartbeatBaseline({
|
|
513
|
+
deps,
|
|
514
|
+
spec,
|
|
515
|
+
readiness,
|
|
516
|
+
instanceId: baselineInstanceId,
|
|
517
|
+
});
|
|
518
|
+
if (outcome.kind === "failed") {
|
|
519
|
+
// Terminal before the spawn: a rejected credential or a moved contract
|
|
520
|
+
// cannot be waited out, and starting the member first would leave a live
|
|
521
|
+
// process to roll back for no reason.
|
|
522
|
+
return finishPartialStart(spec.name, describeBaselineFailure(outcome.reason), {
|
|
523
|
+
log: opened.stream,
|
|
524
|
+
failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
baseline = outcome.baseline;
|
|
528
|
+
baselineElapsedMs = outcome.elapsedMs;
|
|
529
|
+
}
|
|
445
530
|
let child;
|
|
446
531
|
try {
|
|
447
532
|
child = deps.spawn(spec.command, spec.args, {
|
|
@@ -508,9 +593,49 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
|
|
|
508
593
|
/* surfaced through the close handler's exit status */
|
|
509
594
|
}));
|
|
510
595
|
if (spec.readiness) {
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
596
|
+
let resolvedInstanceId;
|
|
597
|
+
if (spec.readiness.kind === "process-heartbeat") {
|
|
598
|
+
// Resolved HERE, not on the roster: an executor lane's identity is
|
|
599
|
+
// minted before the spawn, but the local reconciler's is
|
|
600
|
+
// `<hostname>:<pid>` and the pid only exists now. The window between
|
|
601
|
+
// `spawn` returning and this probe is milliseconds, and the child cannot
|
|
602
|
+
// publish inside it — it has to open its database pools and start its
|
|
603
|
+
// scheduler first — but it is a window, and it is documented rather than
|
|
604
|
+
// asserted away. Supplying the identity TO the child would be race-free
|
|
605
|
+
// and would change a durable heartbeat writer, which this work forbids.
|
|
606
|
+
resolvedInstanceId =
|
|
607
|
+
spec.readiness.instanceId ??
|
|
608
|
+
resolveMemberInstanceId(spec.env, child.pid, deps.hostname);
|
|
609
|
+
// Announced BEFORE the wait, not after it. This is the slowest step in
|
|
610
|
+
// bring-up by an order of magnitude, and an operator watching a silent
|
|
611
|
+
// terminal for two minutes has no way to tell waiting from wedged. The
|
|
612
|
+
// wording is the status itself — no colour, no spinner, no symbol — so
|
|
613
|
+
// it survives a pipe, a CI log, and a screen reader identically. The
|
|
614
|
+
// resolved identity is NOT rendered: it is scoping, not news.
|
|
615
|
+
emitMemberEvent(deps.sinks, {
|
|
616
|
+
member: spec.name,
|
|
617
|
+
kind: "start",
|
|
618
|
+
timestamp: deps.clock.now().toISOString(),
|
|
619
|
+
logPath: spec.logPath,
|
|
620
|
+
detail: `Starting — awaiting this member's first post-start ${spec.readiness.component} ` +
|
|
621
|
+
`heartbeat (expected within ${Math.round(spec.readiness.timeoutMs / 1000)}s; watch ${spec.logPath})`,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
// `memberExited` is read through the closure the `close` handler patches,
|
|
625
|
+
// so a child that dies mid-wait ends the wait immediately instead of
|
|
626
|
+
// burning the remaining budget and reporting a timeout it did not have.
|
|
627
|
+
const outcome = await waitForMemberReady(spec, deps, 250, () => !memberExited(spec.name), {
|
|
628
|
+
baseline,
|
|
629
|
+
instanceId: resolvedInstanceId,
|
|
630
|
+
elapsedMs: baselineElapsedMs,
|
|
631
|
+
});
|
|
632
|
+
if (outcome.kind !== "ready") {
|
|
633
|
+
return finishPartialStart(spec.name, describeReadinessFailure(spec, outcome), {
|
|
634
|
+
log: opened.stream,
|
|
635
|
+
failure: spec.readiness.kind === "process-heartbeat"
|
|
636
|
+
? PLANE_MEMBER_STARTUP_FAILURES.heartbeatReadinessTimeout
|
|
637
|
+
: PLANE_MEMBER_STARTUP_FAILURES.readinessTimeout,
|
|
638
|
+
});
|
|
514
639
|
}
|
|
515
640
|
}
|
|
516
641
|
patchMember(spec.name, { state: "ready" });
|
|
@@ -584,23 +709,227 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
|
|
|
584
709
|
}
|
|
585
710
|
}
|
|
586
711
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
712
|
+
* The pre-spawn baseline for one heartbeat-gated member, or a terminal reason.
|
|
713
|
+
*
|
|
714
|
+
* Bounded by the member's OWN readiness budget and it reports how much of that
|
|
715
|
+
* budget it consumed, because the baseline and the wait are two halves of one
|
|
716
|
+
* gate: giving each a full timeout would let a member take twice its documented
|
|
717
|
+
* budget to fail.
|
|
590
718
|
*/
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
719
|
+
async function probeHeartbeatBaseline(input) {
|
|
720
|
+
const { deps, spec, readiness, instanceId } = input;
|
|
721
|
+
const pollIntervalMs = input.pollIntervalMs ?? 250;
|
|
722
|
+
// Read from the member's OWN environment at call time, exactly as the wait
|
|
723
|
+
// does. The key is never copied onto the readiness spec or into a local that
|
|
724
|
+
// outlives this call.
|
|
725
|
+
const apiKey = spec.env.BAPI_API_KEY ?? "";
|
|
595
726
|
let waited = 0;
|
|
727
|
+
while (waited < readiness.timeoutMs) {
|
|
728
|
+
const observation = await deps.probeHealth({
|
|
729
|
+
baseUrl: readiness.baseUrl,
|
|
730
|
+
repoName: readiness.repoName,
|
|
731
|
+
apiKey,
|
|
732
|
+
component: readiness.component,
|
|
733
|
+
instanceId,
|
|
734
|
+
});
|
|
735
|
+
const result = observation.result;
|
|
736
|
+
if (result.kind === "state") {
|
|
737
|
+
// Every state is a usable baseline. `never_seen` and `unknown` carry no
|
|
738
|
+
// observation and mean "nothing to beat", which the first timestamped
|
|
739
|
+
// reading advances past; `fresh` and `stale` carry the time to beat.
|
|
740
|
+
return {
|
|
741
|
+
kind: "baseline",
|
|
742
|
+
baseline: { observedAtMs: observation.observedAtMs },
|
|
743
|
+
elapsedMs: waited,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (result.kind === "unauthorized" || result.kind === "malformed") {
|
|
747
|
+
// Terminal by construction, and terminal BEFORE the spawn: waiting cannot
|
|
748
|
+
// fix a rejected credential or a contract this code does not understand,
|
|
749
|
+
// and there is no point starting a process only to roll it back.
|
|
750
|
+
return { kind: "failed", reason: result.kind };
|
|
751
|
+
}
|
|
752
|
+
// `unavailable` only: the server member is up but its route may still be
|
|
753
|
+
// warming, which is ordinary on a cold start and worth retrying.
|
|
754
|
+
await deps.clock.sleep(pollIntervalMs);
|
|
755
|
+
waited += pollIntervalMs;
|
|
756
|
+
}
|
|
757
|
+
// The budget ran out with the route never answering. An EMPTY baseline is the
|
|
758
|
+
// safe reading: it can only make the gate stricter, since every timestamped
|
|
759
|
+
// observation advances past it.
|
|
760
|
+
return { kind: "baseline", baseline: { observedAtMs: null }, elapsedMs: waited };
|
|
761
|
+
}
|
|
762
|
+
/** Operator-facing reason a baseline could not be established. Fixed prose. */
|
|
763
|
+
function describeBaselineFailure(reason) {
|
|
764
|
+
return reason === "unauthorized"
|
|
765
|
+
? "the Bridge API rejected the plane's credential during the automation " +
|
|
766
|
+
"health check taken before this member started (401/403), so its " +
|
|
767
|
+
"readiness baseline could not be established"
|
|
768
|
+
: "the automation health check taken before this member started answered " +
|
|
769
|
+
"with a body this launcher does not recognize, so its readiness " +
|
|
770
|
+
"baseline could not be established";
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Readiness for one member, dispatched on the readiness discriminator.
|
|
774
|
+
*
|
|
775
|
+
* A member with no readiness spec is ready as soon as it is spawned and still
|
|
776
|
+
* alive — the observer, and since BAPI-1036 ONLY the observer. A `tcp` member
|
|
777
|
+
* polls its port exactly as before. A `process-heartbeat` member waits for the
|
|
778
|
+
* backend to report `fresh` FOR ITS OWN IDENTITY, with an observation time
|
|
779
|
+
* strictly later than the baseline taken before it started.
|
|
780
|
+
*
|
|
781
|
+
* That last clause is the whole difference between this gate and a freshness
|
|
782
|
+
* check. The heartbeat upsert arbitrates on `(component, instance_id)` and
|
|
783
|
+
* preserves `started_at` on conflict, and `buildExecutorId` is deterministic
|
|
784
|
+
* across runs — so a previous run of the same lane leaves a row under the very
|
|
785
|
+
* identity this gate asks about. Accepting "fresh" alone would declare a lane
|
|
786
|
+
* that just crashed on boot ready, off its own predecessor's heartbeat, for as
|
|
787
|
+
* long as that row stayed inside the staleness window.
|
|
788
|
+
*
|
|
789
|
+
* `isAlive` lets the wait end the moment the watched child dies instead of
|
|
790
|
+
* sitting out the remaining budget. A worker that crashed on its first tick is
|
|
791
|
+
* the case this readiness gate exists for, and reporting it two minutes late as
|
|
792
|
+
* a timeout would describe the symptom rather than the cause.
|
|
793
|
+
*/
|
|
794
|
+
export async function waitForMemberReady(spec, deps, pollIntervalMs = 250, isAlive = () => true, heartbeat = {}) {
|
|
795
|
+
if (!spec.readiness)
|
|
796
|
+
return { kind: "ready" };
|
|
797
|
+
if (spec.readiness.kind === "tcp") {
|
|
798
|
+
const { host, port, timeoutMs } = spec.readiness;
|
|
799
|
+
let waited = 0;
|
|
800
|
+
while (waited < timeoutMs) {
|
|
801
|
+
if (!isAlive())
|
|
802
|
+
return { kind: "member-exited" };
|
|
803
|
+
const probe = await deps.probePort(host, port, 500);
|
|
804
|
+
if (probe.kind === "connected")
|
|
805
|
+
return { kind: "ready" };
|
|
806
|
+
await deps.clock.sleep(pollIntervalMs);
|
|
807
|
+
waited += pollIntervalMs;
|
|
808
|
+
}
|
|
809
|
+
return { kind: "timeout", state: null };
|
|
810
|
+
}
|
|
811
|
+
const { baseUrl, repoName, component, timeoutMs } = spec.readiness;
|
|
812
|
+
// Read from the member's OWN environment at call time. The key is not carried
|
|
813
|
+
// on the readiness spec, is not copied into a local that outlives this call,
|
|
814
|
+
// and is never passed to anything that formats a message.
|
|
815
|
+
const apiKey = spec.env.BAPI_API_KEY ?? "";
|
|
816
|
+
const baselineAtMs = heartbeat.baseline?.observedAtMs ?? null;
|
|
817
|
+
const instanceId = heartbeat.instanceId ?? spec.readiness.instanceId;
|
|
818
|
+
let lastState = null;
|
|
819
|
+
let sawUnadvancedFresh = false;
|
|
820
|
+
// The baseline phase and this wait share ONE per-member budget: a member that
|
|
821
|
+
// spent time waiting for the route to answer before it started does not get a
|
|
822
|
+
// second full timeout afterwards.
|
|
823
|
+
let waited = Math.max(0, heartbeat.elapsedMs ?? 0);
|
|
596
824
|
while (waited < timeoutMs) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
825
|
+
if (!isAlive())
|
|
826
|
+
return { kind: "member-exited" };
|
|
827
|
+
const observation = await deps.probeHealth({
|
|
828
|
+
baseUrl,
|
|
829
|
+
repoName,
|
|
830
|
+
apiKey,
|
|
831
|
+
component,
|
|
832
|
+
instanceId,
|
|
833
|
+
});
|
|
834
|
+
const probe = observation.result;
|
|
835
|
+
if (probe.kind === "state") {
|
|
836
|
+
lastState = probe.state;
|
|
837
|
+
// ONLY `fresh` can be ready. `stale` and `never_seen` are the states a
|
|
838
|
+
// process that has not published yet legitimately passes through on its
|
|
839
|
+
// way up, and `unknown` means the durable source could not be read —
|
|
840
|
+
// none of the three is evidence of readiness, and none is evidence of
|
|
841
|
+
// permanent failure either, so all three keep waiting.
|
|
842
|
+
if (probe.state === "fresh") {
|
|
843
|
+
const observedAtMs = observation.observedAtMs;
|
|
844
|
+
if (observedAtMs !== null && (baselineAtMs === null || observedAtMs > baselineAtMs)) {
|
|
845
|
+
// Strictly later than the baseline — or the first timestamped
|
|
846
|
+
// observation at all, when nothing had ever published under this
|
|
847
|
+
// identity. Either way, this heartbeat cannot be a leftover.
|
|
848
|
+
return { kind: "ready" };
|
|
849
|
+
}
|
|
850
|
+
// Fresh, but not NEW. Almost always the previous run of this same member
|
|
851
|
+
// still inside its staleness window. Keep waiting: the running process
|
|
852
|
+
// will overwrite that row on its own next publication.
|
|
853
|
+
sawUnadvancedFresh = true;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else if (probe.kind === "unauthorized") {
|
|
857
|
+
// Terminal by construction: the credential in the member environment is
|
|
858
|
+
// rejected, and no amount of further polling changes that.
|
|
859
|
+
return { kind: "unauthorized" };
|
|
860
|
+
}
|
|
861
|
+
else if (probe.kind === "malformed") {
|
|
862
|
+
return { kind: "malformed" };
|
|
863
|
+
}
|
|
864
|
+
// `unavailable` falls through: the server member is up but the route may
|
|
865
|
+
// still be warming, which is ordinary and retryable.
|
|
600
866
|
await deps.clock.sleep(pollIntervalMs);
|
|
601
867
|
waited += pollIntervalMs;
|
|
602
868
|
}
|
|
603
|
-
return
|
|
869
|
+
return { kind: "timeout", state: lastState, sawUnadvancedFresh };
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The operator-facing reason one member never became ready.
|
|
873
|
+
*
|
|
874
|
+
* Built from fixed prose plus a closed-vocabulary state name. Nothing here is
|
|
875
|
+
* derived from a response body, a header, or an exception, so no arm of this
|
|
876
|
+
* function can carry a credential into a terminal or a trace file.
|
|
877
|
+
*/
|
|
878
|
+
export function describeReadinessFailure(spec, outcome) {
|
|
879
|
+
if (outcome.kind === "member-exited") {
|
|
880
|
+
return "the process exited while it was still starting up";
|
|
881
|
+
}
|
|
882
|
+
if (outcome.kind === "unauthorized") {
|
|
883
|
+
return ("the Bridge API rejected the plane's credential during the automation " +
|
|
884
|
+
"health check (401/403), so this member's readiness cannot be established");
|
|
885
|
+
}
|
|
886
|
+
if (outcome.kind === "malformed") {
|
|
887
|
+
return ("the automation health check answered with a body this launcher does not " +
|
|
888
|
+
"recognize, so this member's readiness cannot be established");
|
|
889
|
+
}
|
|
890
|
+
if (spec.readiness?.kind === "tcp") {
|
|
891
|
+
return `it did not start listening on ${spec.readiness.host}:${spec.readiness.port} in time`;
|
|
892
|
+
}
|
|
893
|
+
const budgetSeconds = Math.round((spec.readiness?.timeoutMs ?? 0) / 1000);
|
|
894
|
+
// Drawn from the closed component vocabulary, never from a response. The whole
|
|
895
|
+
// reason this function builds prose from fixed strings plus a state name is
|
|
896
|
+
// that it runs on the failure paths, where a body or an exception message is
|
|
897
|
+
// exactly what would carry a credential into a terminal and a log file.
|
|
898
|
+
const component = spec.readiness?.kind === "process-heartbeat" ? spec.readiness.component : null;
|
|
899
|
+
// `ready` never reaches here (the caller only describes failures), but the
|
|
900
|
+
// narrowing is written rather than asserted so a new arm added to
|
|
901
|
+
// `PlaneMemberReadiness` becomes a compile error instead of silent prose.
|
|
902
|
+
const lastState = outcome.kind === "timeout" ? outcome.state : null;
|
|
903
|
+
const sawUnadvancedFresh = outcome.kind === "timeout" && outcome.sawUnadvancedFresh === true;
|
|
904
|
+
if (sawUnadvancedFresh) {
|
|
905
|
+
// The BAPI-1036 case, and the one an operator is most likely to misread: the
|
|
906
|
+
// row is fresh, `plane status` looks healthy, and the member is dead. Say
|
|
907
|
+
// which heartbeat was seen and why it did not count.
|
|
908
|
+
return (`no post-start durable ${component ?? "process"} heartbeat was observed within ` +
|
|
909
|
+
`${budgetSeconds}s. A fresh heartbeat for this member's identity was present, ` +
|
|
910
|
+
"but it never advanced past the reading taken before the member started — " +
|
|
911
|
+
"so it belongs to an earlier run, not to this process");
|
|
912
|
+
}
|
|
913
|
+
switch (lastState) {
|
|
914
|
+
case "fresh":
|
|
915
|
+
// Unreachable in practice — an advanced `fresh` returns ready and an
|
|
916
|
+
// unadvanced one is handled above — but a switch that silently fell
|
|
917
|
+
// through to "never reported" would misdescribe it.
|
|
918
|
+
return `it reported a fresh ${component ?? "process"} heartbeat too late to be accepted`;
|
|
919
|
+
case "stale":
|
|
920
|
+
return (`its durable ${component ?? "process"} heartbeat was still STALE after ` +
|
|
921
|
+
`${budgetSeconds}s. The process started but is not publishing`);
|
|
922
|
+
case "never_seen":
|
|
923
|
+
return (`no durable ${component ?? "process"} heartbeat was EVER recorded for this ` +
|
|
924
|
+
`member within ${budgetSeconds}s. The process started but never reached its ` +
|
|
925
|
+
"first publication");
|
|
926
|
+
case "unknown":
|
|
927
|
+
return (`the durable ${component ?? "process"} heartbeat could not be read within ` +
|
|
928
|
+
`${budgetSeconds}s. This reports a failed read, not a dead process`);
|
|
929
|
+
default:
|
|
930
|
+
return (`the automation health check was unreachable for the whole ${budgetSeconds}s ` +
|
|
931
|
+
"readiness budget, so this member's liveness was never established");
|
|
932
|
+
}
|
|
604
933
|
}
|
|
605
934
|
/**
|
|
606
935
|
* Poll until the manifest names this plane identity AND this runtime's pid.
|
|
@@ -150,6 +150,49 @@ export function createFakeProcess(options) {
|
|
|
150
150
|
export function createFakePortProbe(result) {
|
|
151
151
|
return async () => (typeof result === "function" ? result() : result);
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* An automation-health probe returning scripted results (BAPI-1029).
|
|
155
|
+
*
|
|
156
|
+
* A single result is returned forever; an ARRAY is consumed one call at a time
|
|
157
|
+
* and the last entry then repeats, which is how a test scripts a reconciler
|
|
158
|
+
* that boots — `never_seen`, `stale`, then `fresh` — without reaching for a
|
|
159
|
+
* timer. Every call is recorded so a test can assert the probe was given the
|
|
160
|
+
* member's own credential and never a hard-coded one.
|
|
161
|
+
*/
|
|
162
|
+
export function createFakeHealthProbe(script) {
|
|
163
|
+
const calls = [];
|
|
164
|
+
const queue = Array.isArray(script) ? [...script] : null;
|
|
165
|
+
return {
|
|
166
|
+
calls,
|
|
167
|
+
probe: async (request) => {
|
|
168
|
+
calls.push(request);
|
|
169
|
+
const next = queue === null ? script : queue.length > 1 ? queue.shift() : queue[0];
|
|
170
|
+
return asObservation(next);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Accept either shape in a script (BAPI-1036).
|
|
176
|
+
*
|
|
177
|
+
* Most tests care only about the classification and were written before the
|
|
178
|
+
* observation existed; those keep passing bare `{kind: …}` results and get an
|
|
179
|
+
* observation with a monotonically increasing timestamp, so a `fresh` reading
|
|
180
|
+
* always ADVANCES and the pre-BAPI-1036 expectations still hold. A test about
|
|
181
|
+
* the advance rule itself passes a full observation and pins the number.
|
|
182
|
+
*/
|
|
183
|
+
function asObservation(value) {
|
|
184
|
+
if ("result" in value)
|
|
185
|
+
return value;
|
|
186
|
+
return {
|
|
187
|
+
result: value,
|
|
188
|
+
observedAtMs: value.kind === "state" && value.state !== "never_seen" ? nextAutoObservation() : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
let autoObservationMs = 1_000_000;
|
|
192
|
+
function nextAutoObservation() {
|
|
193
|
+
autoObservationMs += 1_000;
|
|
194
|
+
return autoObservationMs;
|
|
195
|
+
}
|
|
153
196
|
/** An `execFile` fake keyed on the last argument (`heads` / `current`). */
|
|
154
197
|
export function createFakeExecFile(responses) {
|
|
155
198
|
const calls = [];
|
package/build/plane/types.js
CHANGED
|
@@ -2,23 +2,59 @@
|
|
|
2
2
|
* Typed contracts for the conductor plane supervisor (BAPI-756, slice C4).
|
|
3
3
|
*
|
|
4
4
|
* The plane is the attended set of processes a conductor run needs: the Bridge
|
|
5
|
-
* API server, the reconciler worker,
|
|
6
|
-
* module carries every boundary type so the runtime
|
|
7
|
-
* manifest, roster, supervisor, status, shutdown) stay
|
|
8
|
-
* with injected dependencies and never reach for real
|
|
5
|
+
* API server, the reconciler worker, one executor per parallel lane, and the
|
|
6
|
+
* dead-man observer. This module carries every boundary type so the runtime
|
|
7
|
+
* modules (preflight, manifest, roster, supervisor, status, shutdown) stay
|
|
8
|
+
* independently testable with injected dependencies and never reach for real
|
|
9
|
+
* I/O in a unit test.
|
|
9
10
|
*
|
|
10
11
|
* Two ratified requirements shape these types and must not be softened:
|
|
11
12
|
*
|
|
12
|
-
* - **R-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - **R-1, the dead-man observer is a same-host, separate-process member**
|
|
14
|
+
* (BAPI-1029, superseding R-3). The observer runs `worker.py` with
|
|
15
|
+
* `CONDUCTOR_DEAD_MAN_ONLY=true` in its OWN OS process, owned by the plane
|
|
16
|
+
* lifecycle: it has a member name, a log path, and a manifest record, and
|
|
17
|
+
* `plane status` / `plane down` treat it like every other member. Its failure
|
|
18
|
+
* domain is independent at the PROCESS level — killing the reconciler leaves
|
|
19
|
+
* the observer alive to report the death, which is the whole point. It is NOT
|
|
20
|
+
* separate-host isolation; that is A2/R36 and is deliberately out of scope.
|
|
21
|
+
* The previous R-3 arrangement (observer absent by construction, started
|
|
22
|
+
* out-of-band) could not detect its own death and is retired.
|
|
16
23
|
* - **R-4, crashes are never auto-restarted.** No type here carries a restart
|
|
17
24
|
* count, a backoff delay, or a retry budget, because no such machinery
|
|
18
25
|
* exists. A member exit is a finding to report, not a condition to repair.
|
|
26
|
+
* The observer inherits this unchanged: it is spawned once and never revived.
|
|
19
27
|
*/
|
|
20
|
-
/**
|
|
21
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Manifest schema version WRITTEN by this build. Bump only with a matching
|
|
30
|
+
* parser change.
|
|
31
|
+
*
|
|
32
|
+
* Bumped to 2 by BAPI-1029, which added the `observer` member. The member set
|
|
33
|
+
* widened rather than the record shape, so the tempting conclusion was that no
|
|
34
|
+
* bump was needed — but that reasoning only covers the direction nobody is hurt
|
|
35
|
+
* by. Old manifests stay readable by a new binary; a NEW manifest is
|
|
36
|
+
* unreadable by an OLD one, because `isPlaneMemberName` there does not know
|
|
37
|
+
* `observer` and a single unrecognized name aborted the whole parse. `plane
|
|
38
|
+
* down` on the old build then returns `unvalidated-manifest` having signalled
|
|
39
|
+
* nothing, orphaning a fully detached plane and telling the operator to delete
|
|
40
|
+
* the file by hand.
|
|
41
|
+
*
|
|
42
|
+
* The bump does not fix that for an already-shipped old binary — nothing can —
|
|
43
|
+
* but it makes the failure legible ("schema version is not supported" instead
|
|
44
|
+
* of what reads like a corrupt file), and it pairs with the member-scoped
|
|
45
|
+
* rejection in `manifest.ts` so the next member added never reproduces it.
|
|
46
|
+
*/
|
|
47
|
+
export const PLANE_MANIFEST_SCHEMA_VERSION = 2;
|
|
48
|
+
/**
|
|
49
|
+
* Every schema version this build can READ.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately wider than what it writes. A plane already running under the
|
|
52
|
+
* previous build has a version-1 manifest on disk, and refusing it would
|
|
53
|
+
* orphan that plane on upgrade — the same failure this bump exists to make
|
|
54
|
+
* legible, inflicted from the other side. Accepting 1 costs nothing: the two
|
|
55
|
+
* versions differ only in whether `observer` may appear among the members.
|
|
56
|
+
*/
|
|
57
|
+
export const PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS = [1, 2];
|
|
22
58
|
/** Runtime artifact directory, relative to the repository root. */
|
|
23
59
|
export const PLANE_RUNTIME_DIR = ".bridge/plane";
|
|
24
60
|
/** Manifest filename inside {@link PLANE_RUNTIME_DIR}. */
|
|
@@ -87,9 +123,44 @@ export const PLANE_ENTRYPOINT_ACTION = "__entrypoint";
|
|
|
87
123
|
* together with the runtime's own pid.
|
|
88
124
|
*/
|
|
89
125
|
export const PLANE_ID_ENV_VAR = "BAPI_PLANE_ID";
|
|
90
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Operator-facing prose for the dead-man observer, as a copy-pasteable shell
|
|
128
|
+
* line.
|
|
129
|
+
*
|
|
130
|
+
* This is DOCUMENTATION, not an invocation. `plane up` spawns the observer with
|
|
131
|
+
* a shell-free executable plus argument vector and an observer-only child
|
|
132
|
+
* environment (see `member-roster.ts#buildPlaneObserverEnv`) — a shell string is
|
|
133
|
+
* never handed to `spawn`. The constant survives because the documented
|
|
134
|
+
* session-background FALLBACK still starts the observer by hand, and because an
|
|
135
|
+
* operator debugging the plane needs to know exactly what the observer member is.
|
|
136
|
+
*/
|
|
91
137
|
export const PLANE_OBSERVER_COMMAND = "CONDUCTOR_DEAD_MAN_ONLY=true python worker.py";
|
|
138
|
+
/** Env var that puts `worker.py` into dead-man-only mode. Observer member ONLY. */
|
|
139
|
+
export const PLANE_OBSERVER_MODE_ENV = "CONDUCTOR_DEAD_MAN_ONLY";
|
|
140
|
+
/** The exact value {@link PLANE_OBSERVER_MODE_ENV} is set to for the observer. */
|
|
141
|
+
export const PLANE_OBSERVER_MODE_VALUE = "true";
|
|
92
142
|
/** Env var naming the observer's alert channel type. */
|
|
93
143
|
export const PLANE_OBSERVER_CHANNEL_TYPE_ENV = "CONDUCTOR_DEADMAN_CHANNEL_TYPE";
|
|
94
144
|
/** Env var naming the env var that holds the observer's destination URL. */
|
|
95
145
|
export const PLANE_OBSERVER_DESTINATION_ENV = "CONDUCTOR_DEADMAN_DESTINATION_REF";
|
|
146
|
+
/**
|
|
147
|
+
* Every value {@link PlaneHeartbeatComponent} may take, for runtime guards.
|
|
148
|
+
*
|
|
149
|
+
* A cross-language contract, exactly like the state vocabulary below it: this
|
|
150
|
+
* string is sent to `GET /automation/health` as `component`, and the route
|
|
151
|
+
* refuses any value outside the closed set Python declares in
|
|
152
|
+
* `src/python/llms/process_heartbeats.py`. A component added on one side alone
|
|
153
|
+
* is a 422 on every probe, so the two declarations are pinned together by
|
|
154
|
+
* `tests/pytest/mcp_server/test_plane_liveness_vocabulary_pin.py`.
|
|
155
|
+
*/
|
|
156
|
+
export const PLANE_HEARTBEAT_COMPONENTS = [
|
|
157
|
+
"reconciler",
|
|
158
|
+
"executor",
|
|
159
|
+
];
|
|
160
|
+
/** Every value {@link PlaneHeartbeatHealthState} may take, for runtime guards. */
|
|
161
|
+
export const PLANE_HEARTBEAT_HEALTH_STATES = [
|
|
162
|
+
"fresh",
|
|
163
|
+
"stale",
|
|
164
|
+
"never_seen",
|
|
165
|
+
"unknown",
|
|
166
|
+
];
|
|
@@ -17,6 +17,26 @@
|
|
|
17
17
|
* for the worker. Consumed by the worker as `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
18
18
|
*/
|
|
19
19
|
export const PR_BASE_BRANCH_ENV_VAR = "BAPI_BASE_BRANCH";
|
|
20
|
+
/**
|
|
21
|
+
* The environment variable carrying THIS JOB's own branch (BAPI-1020).
|
|
22
|
+
*
|
|
23
|
+
* Read by the deterministic PreToolUse worker guard
|
|
24
|
+
* (`executor/worker-guard-hook.ts`), which cannot otherwise answer the two
|
|
25
|
+
* questions a deny glob is incapable of expressing: "is this `git push`
|
|
26
|
+
* destination the worker's own branch?" and "is this `git reset --hard` ref the
|
|
27
|
+
* worker's own ref?". A glob sees a string; the guard needs the identity.
|
|
28
|
+
*
|
|
29
|
+
* Declared HERE, beside {@link PR_BASE_BRANCH_ENV_VAR}, rather than as a bare
|
|
30
|
+
* string literal at the injection site, so the producing and consuming sides
|
|
31
|
+
* cannot drift apart on the spelling.
|
|
32
|
+
*
|
|
33
|
+
* It is set from the EXPLICIT job value only and is on `EXPLICIT_DENY_KEYS` in
|
|
34
|
+
* `executor/env.ts`, which matters more here than for the base branch: an ambient
|
|
35
|
+
* `BAPI_WORKER_BRANCH` exported in the operator's shell would otherwise reach a
|
|
36
|
+
* worker and REDEFINE which branch the guard considers safe to push to — turning
|
|
37
|
+
* the guard's own input into an attack surface.
|
|
38
|
+
*/
|
|
39
|
+
export const WORKER_BRANCH_ENV_VAR = "BAPI_WORKER_BRANCH";
|
|
20
40
|
/**
|
|
21
41
|
* A SINGLE-LINE, secret-free worker launch instruction telling the worker to
|
|
22
42
|
* open its PR against the injected run base. Kept free of newlines, `;`, single
|