@bridge_gpt/mcp-server 0.2.65 → 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.
Files changed (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. 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
+ }
@@ -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
  *