@bridge_gpt/mcp-server 0.2.66 → 0.2.67
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 +252 -77
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +43 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/commands.generated.js +11 -11
- package/build/conductor/bridge-api-client.js +143 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-implementer-cli.js +22 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +18 -2
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +181 -29
- package/build/conductor/recovery-cli.js +200 -8
- package/build/conductor/recovery-operations.js +69 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/docs.generated.js +8 -3
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +85 -54
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +22 -20
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/env.js +19 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/job-errors.js +25 -0
- package/build/executor/job-runner.js +225 -41
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +51 -14
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +8 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/worker-finalization.js +82 -29
- package/build/executor/worker-guard-hook.js +137 -33
- package/build/executor/worktree.js +82 -1
- package/build/index.js +403 -429
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +3 -3
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +18 -24
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +37 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/setup-epic.js +44 -19
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +179 -31
- package/build/tool-error-envelope.js +61 -0
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +3 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +130 -4
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plane restart <member>` — the request/result channel between the CLI and the
|
|
3
|
+
* detached runtime (BAPI-1362).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a file and a signal
|
|
6
|
+
*
|
|
7
|
+
* The process that owns a plane's members is the DETACHED runtime, and
|
|
8
|
+
* `plane restart` is a different process started later from a shell. Nothing is
|
|
9
|
+
* shared between them except the filesystem and signals — which is exactly why
|
|
10
|
+
* the manifest is already the channel for status and shutdown. A restart follows
|
|
11
|
+
* the same rule:
|
|
12
|
+
*
|
|
13
|
+
* 1. the CLI writes a small request file at a FIXED path under `.bridge/plane/`,
|
|
14
|
+
* 2. sends the runtime one `SIGUSR2`,
|
|
15
|
+
* 3. the runtime consumes the request exactly once, replaces the member, and
|
|
16
|
+
* 4. publishes a result file, which the CLI waits for within a bound.
|
|
17
|
+
*
|
|
18
|
+
* The signal carries no data and the file is the message. A second signal for the
|
|
19
|
+
* same request finds the request already consumed and does nothing, so a
|
|
20
|
+
* duplicated delivery can never cause a second restart.
|
|
21
|
+
*
|
|
22
|
+
* ## What this module will not do
|
|
23
|
+
*
|
|
24
|
+
* - **Signal anything it has not validated.** The manifest must parse, the
|
|
25
|
+
* supervisor must be conclusively alive, the member must be one this command
|
|
26
|
+
* supports and the plane actually lists, and the manifest must be new enough to
|
|
27
|
+
* come from a runtime that HANDLES the signal — the default action of `SIGUSR2`
|
|
28
|
+
* terminates a process that does not, so signalling an older runtime would kill
|
|
29
|
+
* the plane it was meant to repair.
|
|
30
|
+
* - **Signal a process group.** Only the supervisor's own pid, never `-pgid`,
|
|
31
|
+
* which would reach every member.
|
|
32
|
+
* - **Fall back to a plane-wide action.** A timeout or a failed readiness is
|
|
33
|
+
* reported and stops there. It never runs `plane down`, never stops the bound
|
|
34
|
+
* run, and never retries.
|
|
35
|
+
* - **Carry a secret.** The request holds a plane id, a request id, a member name,
|
|
36
|
+
* and a timestamp. No environment, no argv, no credential, no path.
|
|
37
|
+
*/
|
|
38
|
+
import { randomUUID } from "crypto";
|
|
39
|
+
import { getPlanePaths, isPlaneMemberName, planeManifestLifecycle, readPlaneManifest, } from "./manifest.js";
|
|
40
|
+
import { PLANE_RESTART_FAILURE_REASONS, PLANE_RESTART_MIN_SCHEMA_VERSION, PLANE_RESTART_SIGNAL, PLANE_RESTARTABLE_MEMBERS, } from "./types.js";
|
|
41
|
+
/** Version of the request and result records. */
|
|
42
|
+
export const PLANE_RESTART_IPC_VERSION = 1;
|
|
43
|
+
/** How often the CLI re-reads the result file while waiting. */
|
|
44
|
+
export const PLANE_RESTART_POLL_MS = 500;
|
|
45
|
+
/**
|
|
46
|
+
* How long the CLI waits for a matching result, per member.
|
|
47
|
+
*
|
|
48
|
+
* Each is the WORST-CASE runtime duration plus slack, because the CLI must not
|
|
49
|
+
* give up on a restart that is still legitimately in progress:
|
|
50
|
+
*
|
|
51
|
+
* - `server` — stop grace (10s) + kill wait (3s) + the TCP readiness budget (60s).
|
|
52
|
+
* - `worker` — the same stop window, plus the foreign-reconciler guard (up to
|
|
53
|
+
* 150s while a just-stopped reconciler's heartbeat goes stale), plus the
|
|
54
|
+
* reconciler's first-heartbeat budget (120s).
|
|
55
|
+
* - `observer` — the stop window and a live-process check; it has no gate.
|
|
56
|
+
*/
|
|
57
|
+
export const PLANE_RESTART_WAIT_MS = {
|
|
58
|
+
server: 100_000,
|
|
59
|
+
worker: 330_000,
|
|
60
|
+
observer: 45_000,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* A request file older than this is stale, not in progress.
|
|
64
|
+
*
|
|
65
|
+
* The longest legitimate wait is {@link PLANE_RESTART_WAIT_MS}'s `worker` entry,
|
|
66
|
+
* and a request is consumed within a signal delivery of being written, so an
|
|
67
|
+
* unconsumed file this old was left by a CLI that died before signalling.
|
|
68
|
+
*/
|
|
69
|
+
export const PLANE_RESTART_REQUEST_STALE_MS = 10 * 60_000;
|
|
70
|
+
/** Every value {@link PlaneRestartRefusalReason} may take, for runtime guards. */
|
|
71
|
+
export const PLANE_RESTART_REFUSAL_REASONS = [
|
|
72
|
+
"plane-starting",
|
|
73
|
+
"plane-stopping",
|
|
74
|
+
"member-not-in-plane",
|
|
75
|
+
"snapshot-invalid",
|
|
76
|
+
"legacy-unverifiable",
|
|
77
|
+
"identity-mismatch",
|
|
78
|
+
];
|
|
79
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
80
|
+
const PLANE_ID_PATTERN = /^[0-9a-f-]{8,}$/i;
|
|
81
|
+
/** Is this one of the members `plane restart` may replace? */
|
|
82
|
+
export function isPlaneRestartableMember(value) {
|
|
83
|
+
return (typeof value === "string" && PLANE_RESTARTABLE_MEMBERS.includes(value));
|
|
84
|
+
}
|
|
85
|
+
function isIsoTimestamp(value) {
|
|
86
|
+
return typeof value === "string" && value.length > 0 && !Number.isNaN(Date.parse(value));
|
|
87
|
+
}
|
|
88
|
+
/** Strictly validate an untrusted parsed-JSON value into a request. */
|
|
89
|
+
export function parsePlaneRestartRequest(value) {
|
|
90
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
91
|
+
return { ok: false, error: "restart request is not an object" };
|
|
92
|
+
}
|
|
93
|
+
const record = value;
|
|
94
|
+
const allowed = new Set(["version", "planeId", "requestId", "member", "requestedAt"]);
|
|
95
|
+
for (const key of Object.keys(record)) {
|
|
96
|
+
if (!allowed.has(key))
|
|
97
|
+
return { ok: false, error: `restart request has an unsupported field '${key}'` };
|
|
98
|
+
}
|
|
99
|
+
if (record.version !== PLANE_RESTART_IPC_VERSION) {
|
|
100
|
+
return { ok: false, error: "restart request version is not supported" };
|
|
101
|
+
}
|
|
102
|
+
if (typeof record.planeId !== "string" || !PLANE_ID_PATTERN.test(record.planeId)) {
|
|
103
|
+
return { ok: false, error: "restart request plane identity is malformed" };
|
|
104
|
+
}
|
|
105
|
+
if (typeof record.requestId !== "string" || !UUID_PATTERN.test(record.requestId)) {
|
|
106
|
+
return { ok: false, error: "restart request identity is malformed" };
|
|
107
|
+
}
|
|
108
|
+
if (!isPlaneRestartableMember(record.member)) {
|
|
109
|
+
return { ok: false, error: "restart request names a member that cannot be restarted" };
|
|
110
|
+
}
|
|
111
|
+
if (!isIsoTimestamp(record.requestedAt)) {
|
|
112
|
+
return { ok: false, error: "restart request timestamp is malformed" };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
request: {
|
|
117
|
+
version: PLANE_RESTART_IPC_VERSION,
|
|
118
|
+
planeId: record.planeId,
|
|
119
|
+
requestId: record.requestId,
|
|
120
|
+
member: record.member,
|
|
121
|
+
requestedAt: record.requestedAt,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Strictly validate an untrusted parsed-JSON value into a result. */
|
|
126
|
+
export function parsePlaneRestartResult(value) {
|
|
127
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
128
|
+
return { ok: false, error: "restart result is not an object" };
|
|
129
|
+
}
|
|
130
|
+
const record = value;
|
|
131
|
+
const allowed = new Set([
|
|
132
|
+
"version",
|
|
133
|
+
"planeId",
|
|
134
|
+
"requestId",
|
|
135
|
+
"member",
|
|
136
|
+
"outcome",
|
|
137
|
+
"reason",
|
|
138
|
+
"pid",
|
|
139
|
+
"completedAt",
|
|
140
|
+
"message",
|
|
141
|
+
]);
|
|
142
|
+
for (const key of Object.keys(record)) {
|
|
143
|
+
if (!allowed.has(key))
|
|
144
|
+
return { ok: false, error: `restart result has an unsupported field '${key}'` };
|
|
145
|
+
}
|
|
146
|
+
if (record.version !== PLANE_RESTART_IPC_VERSION) {
|
|
147
|
+
return { ok: false, error: "restart result version is not supported" };
|
|
148
|
+
}
|
|
149
|
+
if (typeof record.planeId !== "string" || !PLANE_ID_PATTERN.test(record.planeId)) {
|
|
150
|
+
return { ok: false, error: "restart result plane identity is malformed" };
|
|
151
|
+
}
|
|
152
|
+
if (typeof record.requestId !== "string" || !UUID_PATTERN.test(record.requestId)) {
|
|
153
|
+
return { ok: false, error: "restart result identity is malformed" };
|
|
154
|
+
}
|
|
155
|
+
if (!isPlaneRestartableMember(record.member)) {
|
|
156
|
+
return { ok: false, error: "restart result names a member that cannot be restarted" };
|
|
157
|
+
}
|
|
158
|
+
if (record.outcome !== "restarted" && record.outcome !== "refused" && record.outcome !== "failed") {
|
|
159
|
+
return { ok: false, error: "restart result outcome is malformed" };
|
|
160
|
+
}
|
|
161
|
+
const reasons = [
|
|
162
|
+
...PLANE_RESTART_REFUSAL_REASONS,
|
|
163
|
+
...PLANE_RESTART_FAILURE_REASONS,
|
|
164
|
+
];
|
|
165
|
+
if (record.reason !== undefined && (typeof record.reason !== "string" || !reasons.includes(record.reason))) {
|
|
166
|
+
return { ok: false, error: "restart result reason is malformed" };
|
|
167
|
+
}
|
|
168
|
+
if (record.pid !== null && !(typeof record.pid === "number" && Number.isSafeInteger(record.pid) && record.pid > 0)) {
|
|
169
|
+
return { ok: false, error: "restart result pid is malformed" };
|
|
170
|
+
}
|
|
171
|
+
if (!isIsoTimestamp(record.completedAt)) {
|
|
172
|
+
return { ok: false, error: "restart result timestamp is malformed" };
|
|
173
|
+
}
|
|
174
|
+
// The message is RENDERED to an operator's terminal, and this record is a file on
|
|
175
|
+
// disk the CLI did not write. Printable text only: a control character (an ANSI
|
|
176
|
+
// escape, a carriage return that overwrites the line above) could make the CLI
|
|
177
|
+
// print something the runtime never said. The runtime only ever writes fixed
|
|
178
|
+
// prose, so a legitimate message never trips this.
|
|
179
|
+
// eslint-disable-next-line no-control-regex
|
|
180
|
+
if (typeof record.message !== "string" || record.message.length > 1000 || /[\u0000-\u001f\u007f]/.test(record.message)) {
|
|
181
|
+
return { ok: false, error: "restart result message is malformed" };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
result: {
|
|
186
|
+
version: PLANE_RESTART_IPC_VERSION,
|
|
187
|
+
planeId: record.planeId,
|
|
188
|
+
requestId: record.requestId,
|
|
189
|
+
member: record.member,
|
|
190
|
+
outcome: record.outcome,
|
|
191
|
+
...(record.reason !== undefined
|
|
192
|
+
? { reason: record.reason }
|
|
193
|
+
: {}),
|
|
194
|
+
pid: record.pid,
|
|
195
|
+
completedAt: record.completedAt,
|
|
196
|
+
message: record.message,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Read the pending request and remove it in the same step, so it can be acted on
|
|
202
|
+
* exactly once.
|
|
203
|
+
*
|
|
204
|
+
* The unlink happens BEFORE the request is returned. If it happens after, a crash
|
|
205
|
+
* between "act" and "unlink" replays the restart on the next signal; before, the
|
|
206
|
+
* worst case is a request that was consumed and never acted on, which the CLI
|
|
207
|
+
* reports as a timeout. A dropped restart is recoverable by running it again; a
|
|
208
|
+
* repeated one is not distinguishable from a crash loop.
|
|
209
|
+
*/
|
|
210
|
+
export async function consumePlaneRestartRequest(repoRoot, fs) {
|
|
211
|
+
const { restartRequestPath } = getPlanePaths(repoRoot);
|
|
212
|
+
let raw;
|
|
213
|
+
try {
|
|
214
|
+
raw = await fs.readFile(restartRequestPath);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (err?.code === "ENOENT")
|
|
218
|
+
return { kind: "none" };
|
|
219
|
+
return { kind: "invalid" };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await fs.unlink(restartRequestPath);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
// Another consumer won the race (or the CLI cleaned up): not ours to act on.
|
|
226
|
+
if (err?.code === "ENOENT")
|
|
227
|
+
return { kind: "none" };
|
|
228
|
+
return { kind: "invalid" };
|
|
229
|
+
}
|
|
230
|
+
let parsed;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(raw);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { kind: "invalid" };
|
|
236
|
+
}
|
|
237
|
+
const result = parsePlaneRestartRequest(parsed);
|
|
238
|
+
return result.ok ? { kind: "valid", request: result.request } : { kind: "invalid" };
|
|
239
|
+
}
|
|
240
|
+
let resultWriteCounter = 0;
|
|
241
|
+
/**
|
|
242
|
+
* Publish a result through a same-directory temporary file and an atomic rename,
|
|
243
|
+
* so the waiting CLI can never read half of one. The result path is fixed; the
|
|
244
|
+
* temporary name is unique per write and never outlives the rename.
|
|
245
|
+
*/
|
|
246
|
+
export async function writePlaneRestartResult(repoRoot, result, fs) {
|
|
247
|
+
const { restartResultPath } = getPlanePaths(repoRoot);
|
|
248
|
+
resultWriteCounter += 1;
|
|
249
|
+
const tempPath = `${restartResultPath}.${process.pid}.${resultWriteCounter}.tmp`;
|
|
250
|
+
await fs.writeFile(tempPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
251
|
+
await fs.rename(tempPath, restartResultPath);
|
|
252
|
+
}
|
|
253
|
+
function isMissing(err) {
|
|
254
|
+
return err?.code === "ENOENT";
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Ask the detached runtime to replace one member, and wait for the outcome.
|
|
258
|
+
*
|
|
259
|
+
* Every refusal below happens BEFORE a request is written or a signal is sent, so
|
|
260
|
+
* a refused invocation has no side effect on the plane at all.
|
|
261
|
+
*/
|
|
262
|
+
export async function requestPlaneRestart(repoRoot, memberInput, deps) {
|
|
263
|
+
// Member first: it is pure, so a typo costs nothing — not even a manifest read.
|
|
264
|
+
if (!isPlaneRestartableMember(memberInput)) {
|
|
265
|
+
return isPlaneMemberName(memberInput) && memberInput.startsWith("executor-")
|
|
266
|
+
? { kind: "executor-lane", member: memberInput }
|
|
267
|
+
: { kind: "unknown-member", member: memberInput };
|
|
268
|
+
}
|
|
269
|
+
const member = memberInput;
|
|
270
|
+
const read = await readPlaneManifest(repoRoot, deps.fs);
|
|
271
|
+
if (read.kind === "missing")
|
|
272
|
+
return { kind: "no-live-plane", detail: "no-manifest" };
|
|
273
|
+
if (read.kind !== "valid")
|
|
274
|
+
return { kind: "ownership-inconclusive", detail: "manifest" };
|
|
275
|
+
const manifest = read.manifest;
|
|
276
|
+
// BEFORE any liveness probe or signal: an older runtime would be KILLED by the
|
|
277
|
+
// restart signal, not merely ignore it.
|
|
278
|
+
if (manifest.schemaVersion < PLANE_RESTART_MIN_SCHEMA_VERSION) {
|
|
279
|
+
return { kind: "unsupported-runtime", schemaVersion: manifest.schemaVersion };
|
|
280
|
+
}
|
|
281
|
+
if (!manifest.members.some((m) => m.name === member)) {
|
|
282
|
+
return { kind: "member-not-in-plane", member };
|
|
283
|
+
}
|
|
284
|
+
const supervisor = deps.proc.isAlive(manifest.supervisorPid);
|
|
285
|
+
if (supervisor === "dead")
|
|
286
|
+
return { kind: "no-live-plane", detail: "supervisor-dead" };
|
|
287
|
+
if (supervisor === "unknown" || manifest.supervisorPid === (deps.selfPid ?? process.pid)) {
|
|
288
|
+
return { kind: "ownership-inconclusive", detail: "liveness" };
|
|
289
|
+
}
|
|
290
|
+
const lifecycle = planeManifestLifecycle(manifest);
|
|
291
|
+
if (lifecycle !== "ready" && lifecycle !== "lanes-failed") {
|
|
292
|
+
return { kind: "plane-not-steady", lifecycle };
|
|
293
|
+
}
|
|
294
|
+
const paths = getPlanePaths(repoRoot);
|
|
295
|
+
// A result left by an earlier restart must not be mistaken for this one's. The
|
|
296
|
+
// request id inside it is the real guard; this only keeps the file tidy.
|
|
297
|
+
try {
|
|
298
|
+
await deps.fs.unlink(paths.restartResultPath);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
/* absent is the normal case */
|
|
302
|
+
}
|
|
303
|
+
const requestId = (deps.newRequestId ?? randomUUID)();
|
|
304
|
+
const request = {
|
|
305
|
+
version: PLANE_RESTART_IPC_VERSION,
|
|
306
|
+
planeId: manifest.planeId,
|
|
307
|
+
requestId,
|
|
308
|
+
member,
|
|
309
|
+
requestedAt: deps.clock.now().toISOString(),
|
|
310
|
+
};
|
|
311
|
+
const serialized = `${JSON.stringify(request, null, 2)}\n`;
|
|
312
|
+
const written = await writeRequestExclusively(paths.restartRequestPath, serialized, deps);
|
|
313
|
+
if (written === "busy")
|
|
314
|
+
return { kind: "restart-in-progress" };
|
|
315
|
+
if (written === "error")
|
|
316
|
+
return { kind: "request-failed" };
|
|
317
|
+
// The supervisor's OWN pid, positive. Never `-pgid`: that addresses the whole
|
|
318
|
+
// plane, which is the opposite of what a member restart is for.
|
|
319
|
+
const signalled = deps.proc.signal(manifest.supervisorPid, PLANE_RESTART_SIGNAL);
|
|
320
|
+
if (signalled !== "sent") {
|
|
321
|
+
await removeRequest(paths.restartRequestPath, deps);
|
|
322
|
+
return signalled === "no-such-process"
|
|
323
|
+
? { kind: "no-live-plane", detail: "supervisor-dead" }
|
|
324
|
+
: { kind: "request-failed" };
|
|
325
|
+
}
|
|
326
|
+
const budget = deps.waitMs?.[member] ?? PLANE_RESTART_WAIT_MS[member];
|
|
327
|
+
const pollMs = deps.pollIntervalMs ?? PLANE_RESTART_POLL_MS;
|
|
328
|
+
let waited = 0;
|
|
329
|
+
while (waited < budget) {
|
|
330
|
+
const result = await readMatchingResult(paths.restartResultPath, manifest.planeId, requestId, deps);
|
|
331
|
+
if (result !== null) {
|
|
332
|
+
await removeResult(paths.restartResultPath, deps);
|
|
333
|
+
return outcomeFromResult(result);
|
|
334
|
+
}
|
|
335
|
+
await deps.clock.sleep(pollMs);
|
|
336
|
+
waited += pollMs;
|
|
337
|
+
}
|
|
338
|
+
// Take back a request the runtime never consumed, so a late signal cannot run a
|
|
339
|
+
// restart the operator has already been told timed out. If it WAS consumed the
|
|
340
|
+
// unlink is a no-op and the restart may still be finishing — the message says so.
|
|
341
|
+
await removeRequest(paths.restartRequestPath, deps);
|
|
342
|
+
return { kind: "timeout", member };
|
|
343
|
+
}
|
|
344
|
+
async function writeRequestExclusively(requestPath, serialized, deps) {
|
|
345
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
346
|
+
try {
|
|
347
|
+
await deps.fs.createExclusive(requestPath, serialized);
|
|
348
|
+
return "written";
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
if (err?.code !== "EEXIST")
|
|
352
|
+
return "error";
|
|
353
|
+
}
|
|
354
|
+
// Exists. A request is consumed within one signal delivery, so one that has
|
|
355
|
+
// been sitting for a long time was left by a CLI that died before signalling.
|
|
356
|
+
let ageMs;
|
|
357
|
+
try {
|
|
358
|
+
ageMs = deps.clock.now().getTime() - (await deps.fs.stat(requestPath)).mtimeMs;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Gone between our create and our stat: try the create once more.
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (ageMs < PLANE_RESTART_REQUEST_STALE_MS)
|
|
365
|
+
return "busy";
|
|
366
|
+
await removeRequest(requestPath, deps);
|
|
367
|
+
}
|
|
368
|
+
return "busy";
|
|
369
|
+
}
|
|
370
|
+
async function removeRequest(requestPath, deps) {
|
|
371
|
+
try {
|
|
372
|
+
await deps.fs.unlink(requestPath);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* already consumed or never written */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function removeResult(resultPath, deps) {
|
|
379
|
+
try {
|
|
380
|
+
await deps.fs.unlink(resultPath);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* tidy-up only */
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** The result for THIS request, or null when there is none yet (or it is someone else's). */
|
|
387
|
+
async function readMatchingResult(resultPath, planeId, requestId, deps) {
|
|
388
|
+
let raw;
|
|
389
|
+
try {
|
|
390
|
+
raw = await deps.fs.readFile(resultPath);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
if (isMissing(err))
|
|
394
|
+
return null;
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
let parsed;
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(raw);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const result = parsePlaneRestartResult(parsed);
|
|
405
|
+
if (!result.ok)
|
|
406
|
+
return null;
|
|
407
|
+
if (result.result.planeId !== planeId || result.result.requestId !== requestId)
|
|
408
|
+
return null;
|
|
409
|
+
return result.result;
|
|
410
|
+
}
|
|
411
|
+
function outcomeFromResult(result) {
|
|
412
|
+
if (result.outcome === "restarted") {
|
|
413
|
+
return { kind: "restarted", member: result.member, pid: result.pid, message: result.message };
|
|
414
|
+
}
|
|
415
|
+
if (result.outcome === "refused") {
|
|
416
|
+
return {
|
|
417
|
+
kind: "refused",
|
|
418
|
+
member: result.member,
|
|
419
|
+
reason: result.reason ?? "plane-starting",
|
|
420
|
+
message: result.message,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
kind: "failed",
|
|
425
|
+
member: result.member,
|
|
426
|
+
reason: result.reason,
|
|
427
|
+
pid: result.pid,
|
|
428
|
+
message: result.message,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/** Wording every member-scoped outcome repeats, so the safety claim is greppable. */
|
|
432
|
+
export const PLANE_RESTART_RETAINS_LINE = "The plane keeps its pinned code and its bound run; only this member was replaced.";
|
|
433
|
+
/**
|
|
434
|
+
* Render a CLI outcome for the terminal.
|
|
435
|
+
*
|
|
436
|
+
* `ok` is the process exit status. Every message is fixed prose plus a member name
|
|
437
|
+
* and, where known, a pid — nothing here can carry a credential. No failure line
|
|
438
|
+
* recommends `plane down`: a failed member restart leaves the plane as it was.
|
|
439
|
+
*/
|
|
440
|
+
export function formatPlaneRestartOutcome(outcome) {
|
|
441
|
+
switch (outcome.kind) {
|
|
442
|
+
case "restarted":
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
lines: [
|
|
446
|
+
`Restarted ${outcome.member}${outcome.pid !== null ? ` (new pid ${outcome.pid})` : ""} and it passed its readiness check.`,
|
|
447
|
+
PLANE_RESTART_RETAINS_LINE,
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
case "no-live-plane":
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
lines: [
|
|
454
|
+
outcome.detail === "no-manifest"
|
|
455
|
+
? "plane restart: there is no live plane here (no .bridge/plane/plane.json). Nothing was changed."
|
|
456
|
+
: "plane restart: the recorded supervisor is not running, so there is no live plane to restart a member of. Nothing was changed. Run `plane status`, then `plane up` if the plane is gone.",
|
|
457
|
+
],
|
|
458
|
+
};
|
|
459
|
+
case "ownership-inconclusive":
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
lines: [
|
|
463
|
+
outcome.detail === "manifest"
|
|
464
|
+
? "plane restart: the plane manifest could not be validated, so ownership of the plane cannot be established. Nothing was signalled or written."
|
|
465
|
+
: "plane restart: the supervisor's liveness could not be established, so ownership of the plane cannot be confirmed. Nothing was signalled or written.",
|
|
466
|
+
"Inspect it with `plane status`.",
|
|
467
|
+
],
|
|
468
|
+
};
|
|
469
|
+
case "unsupported-runtime":
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
lines: [
|
|
473
|
+
`plane restart: this plane was started by an older build (manifest schema ${outcome.schemaVersion}) whose runtime cannot take a restart request; sending one would terminate it. Nothing was signalled.`,
|
|
474
|
+
"Between runs, `plane down && plane up` moves the plane to new code. While a run is bound, `plane down` STOPS that run — decide that deliberately.",
|
|
475
|
+
],
|
|
476
|
+
};
|
|
477
|
+
case "unknown-member":
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
lines: [
|
|
481
|
+
`plane restart: '${outcome.member}' is not a restartable member. Supported members: ${PLANE_RESTARTABLE_MEMBERS.join(", ")}.`,
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
case "executor-lane":
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
lines: [
|
|
488
|
+
`plane restart: per-member restart is unavailable for executor lane '${outcome.member}'. Executor lanes are scoped to a run, so they are recovered at the run level (re-attach with \`drive-epic\`), not with \`plane restart\`.`,
|
|
489
|
+
],
|
|
490
|
+
};
|
|
491
|
+
case "member-not-in-plane":
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
lines: [
|
|
495
|
+
`plane restart: this plane does not list a '${outcome.member}' member (the observer, for example, is not started on a plane whose executor lanes failed). Nothing was changed.`,
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
case "plane-not-steady":
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
lines: [
|
|
502
|
+
`plane restart: the plane is still starting (${outcome.lifecycle}); restart is only available once it has finished starting. Nothing was changed. Check \`plane status\` and retry.`,
|
|
503
|
+
],
|
|
504
|
+
};
|
|
505
|
+
case "restart-in-progress":
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
lines: [
|
|
509
|
+
"plane restart: another restart request is already pending. Wait for it to finish (`plane status`), then retry. Nothing was changed.",
|
|
510
|
+
],
|
|
511
|
+
};
|
|
512
|
+
case "request-failed":
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
lines: [
|
|
516
|
+
"plane restart: the request could not be delivered to the plane's supervisor. Nothing was changed. Check `plane status`.",
|
|
517
|
+
],
|
|
518
|
+
};
|
|
519
|
+
case "refused":
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
lines: [`plane restart: the plane declined to restart ${outcome.member}: ${outcome.message}`, "No member was stopped."],
|
|
523
|
+
};
|
|
524
|
+
case "failed":
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
lines: [
|
|
528
|
+
`plane restart FAILED for ${outcome.member}: ${outcome.message}${outcome.pid !== null ? ` (replacement pid ${outcome.pid})` : ""}`,
|
|
529
|
+
"The rest of the plane and its bound run were not touched, and nothing was retried. Inspect `plane status` and the member's log under .bridge/plane/, then run `plane restart " +
|
|
530
|
+
`${outcome.member}\` again once the cause is fixed.`,
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
case "timeout":
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
lines: [
|
|
537
|
+
`plane restart: no result for ${outcome.member} arrived within the wait bound. The restart may still be finishing, or it may not have started. Nothing was stopped as a fallback.`,
|
|
538
|
+
"Inspect `plane status` and the member's log under .bridge/plane/ before deciding what to do.",
|
|
539
|
+
],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
package/build/plane/shutdown.js
CHANGED
|
@@ -124,6 +124,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
124
124
|
runShutdown,
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
|
+
messages.push(...(await releaseSnapshotAfterClear(manifest, deps)));
|
|
127
128
|
return {
|
|
128
129
|
ok: true,
|
|
129
130
|
outcome: "already-dead",
|
|
@@ -168,6 +169,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
168
169
|
if (!cleared.ok) {
|
|
169
170
|
return { ok: false, reason: "clear-failed", message: cleared.message, members, runShutdown };
|
|
170
171
|
}
|
|
172
|
+
messages.push(...(await releaseSnapshotAfterClear(manifest, deps)));
|
|
171
173
|
return {
|
|
172
174
|
ok: true,
|
|
173
175
|
outcome: "terminated",
|
|
@@ -178,6 +180,35 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
178
180
|
runShutdown,
|
|
179
181
|
};
|
|
180
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Run the snapshot release for a manifest whose plane is confirmed gone, and say
|
|
185
|
+
* what happened (BAPI-1362). Returns message lines; never throws.
|
|
186
|
+
*
|
|
187
|
+
* A manifest with no pinned snapshot (a legacy plane) and a caller that supplied
|
|
188
|
+
* no release capability both produce no output at all, so every pre-snapshot
|
|
189
|
+
* wind-down reads exactly as it always has.
|
|
190
|
+
*/
|
|
191
|
+
async function releaseSnapshotAfterClear(manifest, deps) {
|
|
192
|
+
if (deps.releaseSnapshot === undefined || manifest.snapshotSha === undefined)
|
|
193
|
+
return [];
|
|
194
|
+
try {
|
|
195
|
+
const { released } = await deps.releaseSnapshot(manifest);
|
|
196
|
+
return released
|
|
197
|
+
? [
|
|
198
|
+
`Pinned code snapshot ${manifest.snapshotSha.slice(0, 12)} unlocked and kept on disk ` +
|
|
199
|
+
"(.bridge/plane/snapshots/).",
|
|
200
|
+
]
|
|
201
|
+
: [
|
|
202
|
+
`Pinned code snapshot ${manifest.snapshotSha.slice(0, 12)} was left in place; its lock ` +
|
|
203
|
+
"release was not confirmed here (it may already have been released by the supervisor).",
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [
|
|
208
|
+
`Pinned code snapshot ${manifest.snapshotSha.slice(0, 12)} was left locked: releasing it failed.`,
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
181
212
|
/**
|
|
182
213
|
* Signal every verified run-scoped executor that targets this plane (BAPI-1279).
|
|
183
214
|
*
|