@bridge_gpt/mcp-server 0.2.36 → 0.2.37

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.
Files changed (90) hide show
  1. package/README.md +44 -6
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +51 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-projection.js +181 -0
  66. package/build/sfcc/read-result.js +158 -0
  67. package/build/sfcc/reads-custom-object-def.js +29 -18
  68. package/build/sfcc/reads-site-preference.js +75 -29
  69. package/build/sfcc/reads-system-object.js +40 -34
  70. package/build/sfcc/sfcc-result.js +106 -0
  71. package/build/sfcc/tool-wrapper.js +56 -13
  72. package/build/sfcc/write-grants.js +45 -22
  73. package/build/sfcc/write-guard.js +21 -13
  74. package/build/sfcc/write-result.js +61 -14
  75. package/build/sfcc/write-tool-common.js +126 -32
  76. package/build/sfcc/writes-system-object.js +11 -50
  77. package/build/start-tickets-prereqs.js +129 -0
  78. package/build/start-tickets.js +17 -13
  79. package/build/ticket-backend-metadata.js +59 -0
  80. package/build/ticket-key-utils.js +92 -0
  81. package/build/tool-error-envelope.js +71 -0
  82. package/build/tool-surface-gating.js +72 -0
  83. package/build/update-status.js +102 -0
  84. package/build/upgrade-advice.js +47 -0
  85. package/build/upgrade-cli.js +417 -101
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +73 -0
  88. package/docs/CONDUCTOR.md +23 -8
  89. package/package.json +3 -3
  90. package/pipelines/implement-ticket.json +15 -5
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Plane supervision (BAPI-756, AC-6 / AC-7).
3
+ *
4
+ * ## Why there are two processes
5
+ *
6
+ * `plane up` is a **launcher**, and the thing that actually owns the members is
7
+ * a **detached runtime** re-exec of this same compiled entrypoint. The launcher
8
+ * stays attached to the operator's terminal, streams the runtime's event feed,
9
+ * and forwards Ctrl-C.
10
+ *
11
+ * That indirection is not ceremony. Node cannot move itself into a new process
12
+ * group, so a supervisor started from a shell shares that shell's job group —
13
+ * and "signal the plane's process group" would then mean "signal the operator's
14
+ * shell job". Re-execing detached makes the runtime a group leader whose pgid
15
+ * equals its own pid, so the manifest can record one number that `plane down`
16
+ * may safely sweep from an entirely different process.
17
+ *
18
+ * ## Crash posture (R-4)
19
+ *
20
+ * A member exit produces a loud banner and a manifest update, and **nothing
21
+ * else**. There is no respawn, no retry, no backoff, and no timer scheduled on
22
+ * exit. Survivors keep running, and `plane status` shows the hole. The
23
+ * precedent this guards against is BAPI-734's invisible reconciler crash loop:
24
+ * a supervisor that quietly repairs its members converts a defect into a
25
+ * mystery.
26
+ */
27
+ import path from "path";
28
+ import { PLANE_ID_ENV_VAR, PLANE_RUNTIME_LOG_FILENAME, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
29
+ import { getPlanePaths, readPlaneManifest, writePlaneManifest } from "./manifest.js";
30
+ import { attachMemberOutput, openMemberLog, openPlaneRuntimeLog, writeMemberStartupFailure, writeRuntimeTraceLine, PLANE_MEMBER_STARTUP_FAILURES, PLANE_SUPERVISOR_LOG_PREFIX, } from "./member-logs.js";
31
+ import { absoluteLogPath, describePlaneMember } from "./member-roster.js";
32
+ import { shutdownPlane } from "./shutdown.js";
33
+ /** How long the runtime waits for the launcher to record its pid. */
34
+ export const PLANE_HANDSHAKE_TIMEOUT_MS = 10_000;
35
+ /** Handshake poll interval. */
36
+ export const PLANE_HANDSHAKE_POLL_MS = 50;
37
+ /**
38
+ * Private acknowledgement the detached runtime prints once the plane really is
39
+ * up (BAPI-768).
40
+ *
41
+ * `spawn()` returning a pid proves only that the OS created a process — it says
42
+ * nothing about whether that process could resolve its entrypoint, adopt the
43
+ * manifest, or start a single member. Treating a pid as success is what let a
44
+ * runtime die of `ERR_MODULE_NOT_FOUND` under a "Plane up — 3 members" banner.
45
+ * The runtime therefore has to *say* it is ready, after the work is actually
46
+ * done, and the launcher believes nothing until it hears it.
47
+ */
48
+ export const PLANE_READY_MARKER = "__bapi_plane_ready__";
49
+ /** Replacement written wherever a known credential value would have appeared. */
50
+ export const PLANE_REDACTED = "[redacted]";
51
+ /** Marks a stderr excerpt whose head was dropped to stay within its bound. */
52
+ export const PLANE_EXCERPT_TRUNCATION_MARKER = "[...truncated...]";
53
+ /** Default bound on the stderr excerpt retained in memory for reporting. */
54
+ export const PLANE_STDERR_EXCERPT_LIMIT = 2_000;
55
+ /**
56
+ * Shortest value worth redacting.
57
+ *
58
+ * A one- or two-character "secret" would match everywhere and turn the trace
59
+ * into confetti, destroying the diagnostic this file exists to preserve.
60
+ */
61
+ const MIN_REDACTABLE_SECRET_LENGTH = 8;
62
+ /**
63
+ * Build a redactor over known credential VALUES.
64
+ *
65
+ * Value-based, not pattern-based: the launcher already holds the exact strings
66
+ * that must not be written, so guessing at shapes would be strictly worse. The
67
+ * environment itself is never rendered — only child output is filtered.
68
+ */
69
+ export function buildPlaneRedactor(secrets) {
70
+ const values = Array.from(new Set(secrets.filter((s) => typeof s === "string" && s.length >= MIN_REDACTABLE_SECRET_LENGTH))).sort((a, b) => b.length - a.length); // longest first: a shorter secret that
71
+ // is a prefix of a longer one must not consume it and leave the tail exposed.
72
+ if (values.length === 0)
73
+ return (text) => text;
74
+ return (text) => {
75
+ let out = text;
76
+ // `split`/`join` rather than a RegExp: a credential can contain regex
77
+ // metacharacters, and an escaping bug here fails open.
78
+ for (const value of values)
79
+ out = out.split(value).join(PLANE_REDACTED);
80
+ return out;
81
+ };
82
+ }
83
+ /**
84
+ * Split a byte stream into complete lines across chunk boundaries.
85
+ *
86
+ * The previous forwarder split each chunk independently, which is fine for a
87
+ * human-readable feed but cannot recognize a marker that arrives in two pieces —
88
+ * and a 20-byte token landing on a pipe boundary is entirely ordinary.
89
+ */
90
+ export function createLineReader(onLine) {
91
+ let buffer = "";
92
+ return (chunk) => {
93
+ buffer += chunk;
94
+ let index = buffer.indexOf("\n");
95
+ while (index !== -1) {
96
+ const line = buffer.slice(0, index);
97
+ buffer = buffer.slice(index + 1);
98
+ if (line.length > 0)
99
+ onLine(line);
100
+ index = buffer.indexOf("\n");
101
+ }
102
+ };
103
+ }
104
+ /** Render one attributed event line. */
105
+ export function formatMemberEvent(event) {
106
+ const detail = event.detail ? ` ${event.detail}` : "";
107
+ return `[${event.timestamp}] ${event.member.padEnd(12)} ${event.kind.toUpperCase().padEnd(5)}${detail} (${event.logPath})`;
108
+ }
109
+ /** Emit an attributed lifecycle event to the terminal feed. */
110
+ export function emitMemberEvent(sinks, event) {
111
+ sinks.stdout(formatMemberEvent(event));
112
+ }
113
+ /**
114
+ * A member exit, reported loudly.
115
+ *
116
+ * Loud is the requirement, not a style choice: this banner is the ONLY signal
117
+ * that a plane has a hole in it, because nothing restarts the member. It goes
118
+ * to stderr so it survives a stdout redirect of the event feed.
119
+ */
120
+ export function reportMemberCrash(sinks, report) {
121
+ const status = report.exitSignal !== null
122
+ ? `terminated by signal ${report.exitSignal}`
123
+ : `exited with code ${report.exitCode ?? "unknown"}`;
124
+ const rule = "=".repeat(72);
125
+ sinks.stderr(rule);
126
+ sinks.stderr(`!! PLANE MEMBER DOWN: ${report.member} ${status}`);
127
+ sinks.stderr(`!! at ${report.timestamp}`);
128
+ sinks.stderr(`!! log: ${report.logPath}`);
129
+ sinks.stderr("!! It was NOT restarted. Survivors keep running; `plane status` shows the hole.");
130
+ sinks.stderr(rule);
131
+ }
132
+ /**
133
+ * Spawn the detached runtime, record it in the manifest, and stay attached to
134
+ * the terminal until it exits.
135
+ *
136
+ * The plane identity travels in the runtime's environment, not its argv: argv
137
+ * is world-readable through `ps`, and while the identity is not a secret,
138
+ * keeping the launcher→runtime channel entirely inside the environment means
139
+ * there is one rule ("secrets and handshake state go in env, never argv")
140
+ * rather than a per-field judgement call.
141
+ */
142
+ export async function launchPlaneSupervisor(params, deps) {
143
+ const { context, manifest, executors } = params;
144
+ const paths = getPlanePaths(context.repoRoot);
145
+ const tracePath = path.join(paths.planeDir, PLANE_RUNTIME_LOG_FILENAME);
146
+ const redact = buildPlaneRedactor([context.bridgeApiKey, deps.parentEnv.ANTHROPIC_API_KEY]);
147
+ const excerptLimit = deps.stderrExcerptLimit ?? PLANE_STDERR_EXCERPT_LIMIT;
148
+ // Opened BEFORE the spawn is attempted. A spawn that throws has no child, no
149
+ // stderr, and no member — so without a file already open, the most important
150
+ // failure in this function would be the one with nowhere to be recorded.
151
+ const openedTrace = openPlaneRuntimeLog(paths.planeDir, tracePath, deps.log);
152
+ const trace = openedTrace.ok ? openedTrace.stream : null;
153
+ const traceLine = (line) => {
154
+ if (trace)
155
+ writeRuntimeTraceLine(trace, line);
156
+ };
157
+ const closeTrace = () => {
158
+ try {
159
+ trace?.end();
160
+ }
161
+ catch {
162
+ /* closing a diagnostic stream must not change the launcher's outcome */
163
+ }
164
+ };
165
+ const runtimeEnv = {};
166
+ for (const [key, value] of Object.entries(deps.parentEnv)) {
167
+ if (value !== undefined)
168
+ runtimeEnv[key] = value;
169
+ }
170
+ runtimeEnv[PLANE_ID_ENV_VAR] = manifest.planeId;
171
+ runtimeEnv.BAPI_REPO_NAME = context.repoName;
172
+ runtimeEnv.BAPI_BASE_URL = context.baseUrl;
173
+ runtimeEnv.BAPI_API_KEY = context.bridgeApiKey;
174
+ let child;
175
+ try {
176
+ child = deps.spawn(deps.nodeExecutable, [deps.entrypoint, "plane", "__runtime", "--executors", String(executors)], { cwd: context.repoRoot, env: runtimeEnv, detached: true });
177
+ }
178
+ catch {
179
+ // Fixed prose only. The thrown value can carry the spawn's argv and env,
180
+ // which is exactly what must not reach a file on disk.
181
+ const error = "the plane runtime process could not be started";
182
+ traceLine(`${PLANE_SUPERVISOR_LOG_PREFIX} ${error}`);
183
+ closeTrace();
184
+ return { ok: false, error, startup: null };
185
+ }
186
+ if (child.pid === null || child.pid === undefined) {
187
+ const error = "the plane runtime process started without a pid";
188
+ traceLine(`${PLANE_SUPERVISOR_LOG_PREFIX} ${error}`);
189
+ closeTrace();
190
+ return { ok: false, error, startup: null };
191
+ }
192
+ // Detached ⇒ the child is its own process-group leader, so pgid === pid.
193
+ const supervisorPid = child.pid;
194
+ const updated = {
195
+ ...manifest,
196
+ supervisorPid,
197
+ supervisorPgid: supervisorPid,
198
+ updatedAt: deps.clock.now().toISOString(),
199
+ };
200
+ await writePlaneManifest(updated, deps.fs);
201
+ let ready = false;
202
+ let stderrExcerpt = "";
203
+ let stderrTruncated = false;
204
+ // The runtime's stdout feed is the operator's view of the plane. It is
205
+ // streamed through rather than teed: per-member detail already lives in the
206
+ // member logs, and this channel carries only attributed events and banners.
207
+ const readStdout = createLineReader((line) => {
208
+ if (line === PLANE_READY_MARKER) {
209
+ // Private. Forwarding it would put an internal token in the operator's
210
+ // terminal and, worse, invite something downstream to parse for it.
211
+ if (!ready) {
212
+ ready = true;
213
+ deps.onReady?.({ supervisorPid, supervisorPgid: supervisorPid });
214
+ }
215
+ return;
216
+ }
217
+ deps.sinks.stdout(line);
218
+ });
219
+ const readStderr = createLineReader((line) => {
220
+ const safe = redact(line);
221
+ deps.sinks.stderr(safe);
222
+ traceLine(safe);
223
+ if (ready)
224
+ return;
225
+ // Retained only until readiness, and only up to the bound: the trace file is
226
+ // the durable record, so memory here buys nothing beyond a report excerpt.
227
+ stderrExcerpt += `${safe}\n`;
228
+ if (stderrExcerpt.length > excerptLimit) {
229
+ stderrExcerpt = stderrExcerpt.slice(stderrExcerpt.length - excerptLimit);
230
+ stderrTruncated = true;
231
+ }
232
+ });
233
+ const attach = (stream, read) => {
234
+ if (!stream)
235
+ return;
236
+ stream.setEncoding?.("utf8");
237
+ stream.on("data", (chunk) => {
238
+ read(typeof chunk === "string" ? chunk : String(chunk));
239
+ });
240
+ stream.on("error", () => {
241
+ /* a broken feed must not take down a healthy plane */
242
+ });
243
+ };
244
+ attach(child.stdout, readStdout);
245
+ attach(child.stderr, readStderr);
246
+ const outcome = await new Promise((resolve) => {
247
+ const disposers = [];
248
+ const forwardSignal = (signal) => () => {
249
+ deps.sinks.stdout(`Received ${signal} — winding the plane down.`);
250
+ // The runtime owns escalation. Forwarding rather than killing here keeps
251
+ // ONE shutdown primitive in play for both attended Ctrl-C and `plane down`.
252
+ try {
253
+ process.kill(supervisorPid, signal);
254
+ }
255
+ catch {
256
+ /* the runtime may already be gone; `plane down` can still sweep */
257
+ }
258
+ };
259
+ disposers.push(deps.onSignal("SIGINT", forwardSignal("SIGINT")));
260
+ disposers.push(deps.onSignal("SIGTERM", forwardSignal("SIGTERM")));
261
+ child.on("close", ((code, signal) => {
262
+ for (const dispose of disposers)
263
+ dispose();
264
+ resolve({ code, signal, errored: false });
265
+ }));
266
+ child.on("error", (() => {
267
+ for (const dispose of disposers)
268
+ dispose();
269
+ resolve({ code: null, signal: null, errored: true });
270
+ }));
271
+ });
272
+ // The ready marker is the whole classification. Before it, any end of the
273
+ // child is a startup failure however tidy its exit code looked; after it, the
274
+ // plane demonstrably ran, so the same exit is an ordinary termination.
275
+ if (!ready) {
276
+ const error = outcome.errored
277
+ ? "the plane runtime failed to start"
278
+ : "the plane runtime exited before the plane was ready";
279
+ traceLine(`${PLANE_SUPERVISOR_LOG_PREFIX} ${error}`);
280
+ closeTrace();
281
+ return {
282
+ ok: false,
283
+ error,
284
+ startup: {
285
+ exitCode: outcome.code,
286
+ signal: outcome.signal,
287
+ stderrExcerpt: stderrTruncated
288
+ ? `${PLANE_EXCERPT_TRUNCATION_MARKER}${stderrExcerpt}`
289
+ : stderrExcerpt,
290
+ tracePath: PLANE_RUNTIME_LOG_PATH,
291
+ },
292
+ };
293
+ }
294
+ closeTrace();
295
+ if (outcome.errored) {
296
+ deps.sinks.stderr("Plane runtime failed after startup.");
297
+ return { ok: true, ready: true, supervisorPid, supervisorPgid: supervisorPid, exitCode: 1 };
298
+ }
299
+ if (outcome.signal !== null) {
300
+ deps.sinks.stderr(`Plane runtime terminated by signal ${outcome.signal}.`);
301
+ return { ok: true, ready: true, supervisorPid, supervisorPgid: supervisorPid, exitCode: 1 };
302
+ }
303
+ return {
304
+ ok: true,
305
+ ready: true,
306
+ supervisorPid,
307
+ supervisorPgid: supervisorPid,
308
+ exitCode: outcome.code ?? 0,
309
+ };
310
+ }
311
+ /**
312
+ * The detached supervisor loop: adopt the manifest, spawn the roster, watch it,
313
+ * and wind down on a signal.
314
+ */
315
+ export async function runPlaneRuntime(repoRoot, roster, deps) {
316
+ const paths = getPlanePaths(repoRoot);
317
+ // Ownership handshake. Member spawning cannot begin until the launcher has
318
+ // atomically claimed the manifest AND recorded THIS pid in it — otherwise a
319
+ // runtime whose launcher lost the claim race would start a second plane.
320
+ const adopted = await awaitManifestOwnership(repoRoot, deps);
321
+ if (!adopted.ok)
322
+ return { ok: false, error: adopted.error, exitCode: 1 };
323
+ let manifest = {
324
+ ...adopted.manifest,
325
+ members: roster.map((spec) => ({
326
+ name: spec.name,
327
+ pid: null,
328
+ state: "spawning",
329
+ exitCode: null,
330
+ exitSignal: null,
331
+ logPath: spec.logPath,
332
+ })),
333
+ updatedAt: deps.clock.now().toISOString(),
334
+ };
335
+ await writePlaneManifest(manifest, deps.fs);
336
+ const started = [];
337
+ const persist = async () => {
338
+ manifest = { ...manifest, updatedAt: deps.clock.now().toISOString() };
339
+ await writePlaneManifest(manifest, deps.fs);
340
+ };
341
+ const patchMember = (name, patch) => {
342
+ manifest = {
343
+ ...manifest,
344
+ members: manifest.members.map((m) => (m.name === name ? { ...m, ...patch } : m)),
345
+ };
346
+ };
347
+ let shuttingDown = false;
348
+ const requestShutdown = async (signal) => {
349
+ if (shuttingDown)
350
+ return null;
351
+ shuttingDown = true;
352
+ deps.sinks.stdout(`Received ${signal} — winding the plane down.`);
353
+ return shutdownPlane(repoRoot, {
354
+ fs: deps.fs,
355
+ proc: deps.proc,
356
+ clock: deps.clock,
357
+ // The runtime is by definition alive while it winds itself down, so its
358
+ // own liveness must not read as a survivor — and it must not signal
359
+ // itself before it has confirmed the members are gone.
360
+ selfPid: deps.selfPid,
361
+ });
362
+ };
363
+ // ---- Spawn the roster in order -----------------------------------------
364
+ for (const spec of roster) {
365
+ const absoluteLog = absoluteLogPath(repoRoot, spec);
366
+ const opened = openMemberLog(spec.name, paths.planeDir, absoluteLog, deps.log);
367
+ if (!opened.ok) {
368
+ return finishPartialStart(spec.name, `log could not be opened (${opened.error})`);
369
+ }
370
+ let child;
371
+ try {
372
+ child = deps.spawn(spec.command, spec.args, {
373
+ cwd: spec.cwd,
374
+ env: spec.env,
375
+ // Members stay in the RUNTIME's process group, which is what makes one
376
+ // group signal reach the whole plane.
377
+ detached: false,
378
+ });
379
+ }
380
+ catch {
381
+ return finishPartialStart(spec.name, PLANE_MEMBER_STARTUP_FAILURES.spawnThrew, {
382
+ log: opened.stream,
383
+ failure: PLANE_MEMBER_STARTUP_FAILURES.spawnThrew,
384
+ });
385
+ }
386
+ if (child.pid === null || child.pid === undefined) {
387
+ return finishPartialStart(spec.name, PLANE_MEMBER_STARTUP_FAILURES.noPid, {
388
+ log: opened.stream,
389
+ failure: PLANE_MEMBER_STARTUP_FAILURES.noPid,
390
+ });
391
+ }
392
+ attachMemberOutput({ stdout: child.stdout, stderr: child.stderr }, opened.stream);
393
+ started.push(spec.name);
394
+ patchMember(spec.name, { pid: child.pid, state: "running" });
395
+ await persist();
396
+ emitMemberEvent(deps.sinks, {
397
+ member: spec.name,
398
+ kind: "start",
399
+ timestamp: deps.clock.now().toISOString(),
400
+ logPath: spec.logPath,
401
+ detail: describePlaneMember(spec),
402
+ });
403
+ // Exit watching is registered BEFORE readiness so a member that dies during
404
+ // its readiness window is reported as a crash rather than as a silent
405
+ // readiness timeout.
406
+ child.on("close", ((code, signal) => {
407
+ const timestamp = deps.clock.now().toISOString();
408
+ patchMember(spec.name, { state: "exited", exitCode: code, exitSignal: signal });
409
+ void persist();
410
+ emitMemberEvent(deps.sinks, {
411
+ member: spec.name,
412
+ kind: "exit",
413
+ timestamp,
414
+ logPath: spec.logPath,
415
+ detail: signal !== null ? `signal ${signal}` : `code ${code ?? "unknown"}`,
416
+ });
417
+ if (!shuttingDown) {
418
+ reportMemberCrash(deps.sinks, {
419
+ member: spec.name,
420
+ timestamp,
421
+ exitCode: code,
422
+ exitSignal: signal,
423
+ logPath: spec.logPath,
424
+ });
425
+ }
426
+ // NOTHING follows. No respawn, no retry, no backoff timer, and no
427
+ // shutdown of the survivors: one member's crash is one member's crash.
428
+ }));
429
+ child.on("error", (() => {
430
+ /* surfaced through the close handler's exit status */
431
+ }));
432
+ if (spec.readiness) {
433
+ const ready = await waitForMemberReady(spec, deps);
434
+ if (!ready) {
435
+ return finishPartialStart(spec.name, `it did not start listening on ${spec.readiness.host}:${spec.readiness.port} in time`, { log: opened.stream, failure: PLANE_MEMBER_STARTUP_FAILURES.readinessTimeout });
436
+ }
437
+ }
438
+ patchMember(spec.name, { state: "ready" });
439
+ await persist();
440
+ emitMemberEvent(deps.sinks, {
441
+ member: spec.name,
442
+ kind: "ready",
443
+ timestamp: deps.clock.now().toISOString(),
444
+ logPath: spec.logPath,
445
+ });
446
+ }
447
+ // Every member is spawned, alive, and (where it listens) accepting. Only now
448
+ // has the launcher been told anything it may print a success banner over.
449
+ deps.sinks.stdout(PLANE_READY_MARKER);
450
+ // ---- Attended lifetime ---------------------------------------------------
451
+ const exitCode = await new Promise((resolve) => {
452
+ let resolved = false;
453
+ const finish = (code) => {
454
+ if (resolved)
455
+ return;
456
+ resolved = true;
457
+ resolve(code);
458
+ };
459
+ const handle = (signal) => () => {
460
+ void requestShutdown(signal).then((result) => {
461
+ if (result === null)
462
+ return;
463
+ if (!result.ok)
464
+ deps.sinks.stderr(`Wind-down incomplete: ${result.message}`);
465
+ finish(result.ok ? 0 : 1);
466
+ });
467
+ };
468
+ // The handlers stay registered for the whole wind-down. `plane down` (or a
469
+ // forwarded Ctrl-C) may deliver a second signal while the first is being
470
+ // serviced; `requestShutdown` makes that a no-op, whereas an unhandled
471
+ // SIGTERM would kill the runtime before it could confirm the members were
472
+ // gone and clear the manifest.
473
+ deps.onSignal("SIGINT", handle("SIGINT"));
474
+ deps.onSignal("SIGTERM", handle("SIGTERM"));
475
+ });
476
+ return { ok: true, exitCode };
477
+ /**
478
+ * A member failed to start. Every member already started is torn down through
479
+ * the shared shutdown primitive, and the manifest is cleared only if that
480
+ * teardown could confirm nothing survived.
481
+ *
482
+ * The log annotation happens here, before the rollback, so there is one place
483
+ * that guarantees a pre-exec failure leaves a non-empty member log. `annotate`
484
+ * is optional because the one failure that has no stream to write to is the
485
+ * failure to open that stream.
486
+ */
487
+ async function finishPartialStart(member, reason, annotate) {
488
+ if (annotate)
489
+ writeMemberStartupFailure(annotate.log, member, annotate.failure);
490
+ const message = `plane member '${member}' failed to start: ${reason}`;
491
+ deps.sinks.stderr(message);
492
+ if (started.length > 0) {
493
+ deps.sinks.stderr(`Rolling back ${started.length} member(s) that had already started.`);
494
+ shuttingDown = true;
495
+ const result = await shutdownPlane(repoRoot, {
496
+ fs: deps.fs,
497
+ proc: deps.proc,
498
+ clock: deps.clock,
499
+ selfPid: deps.selfPid,
500
+ });
501
+ if (!result.ok)
502
+ deps.sinks.stderr(`Rollback incomplete: ${result.message}`);
503
+ }
504
+ return { ok: false, error: message, exitCode: 1 };
505
+ }
506
+ }
507
+ /**
508
+ * Readiness for a member that listens: poll its port until it accepts a
509
+ * connection or the budget runs out. Members without a listening socket are
510
+ * ready as soon as they are spawned and still alive.
511
+ */
512
+ export async function waitForMemberReady(spec, deps, pollIntervalMs = 250) {
513
+ if (!spec.readiness)
514
+ return true;
515
+ const { host, port, timeoutMs } = spec.readiness;
516
+ let waited = 0;
517
+ while (waited < timeoutMs) {
518
+ const probe = await deps.probePort(host, port, 500);
519
+ if (probe.kind === "connected")
520
+ return true;
521
+ await deps.clock.sleep(pollIntervalMs);
522
+ waited += pollIntervalMs;
523
+ }
524
+ return false;
525
+ }
526
+ /**
527
+ * Poll until the manifest names this plane identity AND this runtime's pid.
528
+ *
529
+ * Both must match. Identity alone would let a runtime adopt a manifest the
530
+ * launcher rewrote for a different (retried) runtime process.
531
+ */
532
+ async function awaitManifestOwnership(repoRoot, deps) {
533
+ const budget = deps.handshakeTimeoutMs ?? PLANE_HANDSHAKE_TIMEOUT_MS;
534
+ let waited = 0;
535
+ let lastError = "the launcher never recorded this runtime in the manifest";
536
+ while (waited <= budget) {
537
+ const read = await readPlaneManifest(repoRoot, deps.fs);
538
+ if (read.kind === "valid") {
539
+ if (read.manifest.planeId === deps.planeId && read.manifest.supervisorPid === deps.selfPid) {
540
+ return { ok: true, manifest: read.manifest };
541
+ }
542
+ lastError =
543
+ read.manifest.planeId === deps.planeId
544
+ ? "the manifest records a different supervisor pid"
545
+ : "the manifest belongs to a different plane";
546
+ }
547
+ else if (read.kind !== "missing") {
548
+ lastError = `the manifest could not be validated (${read.error})`;
549
+ }
550
+ await deps.clock.sleep(PLANE_HANDSHAKE_POLL_MS);
551
+ waited += PLANE_HANDSHAKE_POLL_MS;
552
+ }
553
+ return { ok: false, error: `refusing to spawn plane members: ${lastError}` };
554
+ }
555
+ /**
556
+ * Signal entry point shared by attended Ctrl-C and an external `plane down`.
557
+ *
558
+ * Exposed so the equivalence is testable: both paths must reach the SAME
559
+ * shutdown primitive with the same escalation semantics, rather than growing a
560
+ * bespoke kill sequence on one side.
561
+ */
562
+ export async function handleSupervisorSignal(repoRoot, deps) {
563
+ return shutdownPlane(repoRoot, {
564
+ fs: deps.fs,
565
+ proc: deps.proc,
566
+ clock: deps.clock,
567
+ selfPid: deps.selfPid,
568
+ });
569
+ }