@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.
Files changed (100) hide show
  1. package/README.md +252 -77
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +43 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/commands.generated.js +11 -11
  8. package/build/conductor/bridge-api-client.js +143 -1
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/doctor.js +3 -3
  11. package/build/conductor/epic-implementer-cli.js +22 -6
  12. package/build/conductor/errors.js +4 -3
  13. package/build/conductor/guided-repair.js +6 -2
  14. package/build/conductor/hook-runtime-qualification.js +1 -1
  15. package/build/conductor/merge-execution-contract.js +27 -8
  16. package/build/conductor/merge-execution-eligibility.js +3 -2
  17. package/build/conductor/pr-ci-producer.js +1 -1
  18. package/build/conductor/readiness-cli.js +18 -2
  19. package/build/conductor/readiness-sections.js +41 -6
  20. package/build/conductor/readiness.js +181 -29
  21. package/build/conductor/recovery-cli.js +200 -8
  22. package/build/conductor/recovery-operations.js +69 -3
  23. package/build/conductor/repair-contract.js +54 -3
  24. package/build/conductor/repository-workflow-remedy.js +125 -0
  25. package/build/conductor/tools.js +2 -2
  26. package/build/conductor-bundle-artifacts.js +8 -0
  27. package/build/docs.generated.js +8 -3
  28. package/build/drive-epic-key.js +6 -5
  29. package/build/drive-epic.js +85 -54
  30. package/build/epic-coherence-input.js +2 -1
  31. package/build/epic-implementer-bin.js +22 -20
  32. package/build/epic-implementer-bundle-cli.js +2 -2
  33. package/build/executor/cli.js +3 -2
  34. package/build/executor/completion-contract.js +48 -5
  35. package/build/executor/completion-supervision.js +4 -0
  36. package/build/executor/env.js +19 -0
  37. package/build/executor/heartbeat.js +1 -1
  38. package/build/executor/http-client.js +20 -0
  39. package/build/executor/job-errors.js +25 -0
  40. package/build/executor/job-runner.js +225 -41
  41. package/build/executor/observation.js +6 -1
  42. package/build/executor/preflight.js +51 -14
  43. package/build/executor/required-work-ledger.js +6 -1
  44. package/build/executor/resume-pre-spawn.js +2 -0
  45. package/build/executor/runner.js +8 -2
  46. package/build/executor/service-unit.js +7 -1
  47. package/build/executor/worker-finalization.js +82 -29
  48. package/build/executor/worker-guard-hook.js +137 -33
  49. package/build/executor/worktree.js +82 -1
  50. package/build/index.js +403 -429
  51. package/build/install-bridge-conductor.js +8 -6
  52. package/build/install-bridge.js +3 -3
  53. package/build/phase-context-git-probe.js +68 -0
  54. package/build/phase-result-artifacts.js +173 -5
  55. package/build/pipelines.generated.js +18 -24
  56. package/build/plane/alembic-head.js +12 -3
  57. package/build/plane/base-worktree-guard.js +147 -0
  58. package/build/plane/cli.js +217 -17
  59. package/build/plane/code-snapshot.js +468 -0
  60. package/build/plane/defaults.js +48 -3
  61. package/build/plane/endpoint.js +41 -0
  62. package/build/plane/manifest.js +120 -1
  63. package/build/plane/member-roster.js +94 -19
  64. package/build/plane/preflight.js +30 -2
  65. package/build/plane/process-cwd.js +67 -0
  66. package/build/plane/restart.js +542 -0
  67. package/build/plane/shutdown.js +31 -0
  68. package/build/plane/status.js +143 -8
  69. package/build/plane/supervisor.js +586 -179
  70. package/build/plane/types.js +54 -2
  71. package/build/printed-remedy-commands.js +37 -0
  72. package/build/readiness-check.js +1 -1
  73. package/build/readme.generated.js +1 -1
  74. package/build/review-generation.js +22 -0
  75. package/build/setup-epic.js +44 -19
  76. package/build/sfcc/config.js +9 -0
  77. package/build/sfcc/credentials.js +100 -38
  78. package/build/sfcc/hostname.js +67 -0
  79. package/build/sfcc/permissions.js +17 -13
  80. package/build/sfcc/register.js +1 -1
  81. package/build/sfcc/tool-wrapper.js +39 -43
  82. package/build/sfcc/write-grants.js +17 -11
  83. package/build/sfcc/write-result.js +4 -3
  84. package/build/sfcc/writes-site-preference.js +20 -8
  85. package/build/start-tickets.js +179 -31
  86. package/build/tool-error-envelope.js +61 -0
  87. package/build/version.generated.js +3 -3
  88. package/build/visual-diff.js +22 -1
  89. package/build/worker-guard-hook-bin.js +3 -6
  90. package/build/worker-guard-hook-main.js +47 -0
  91. package/build/worktree-core.js +130 -4
  92. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  93. package/docs/EPIC-IMPLEMENTER.md +662 -0
  94. package/docs/install/mcp-tool-integrations.md +24 -15
  95. package/docs/install/sfcc-integration.md +12 -4
  96. package/package.json +1 -1
  97. package/pipelines/plan-epic.json +0 -5
  98. package/pipelines/review-ticket.json +1 -1
  99. package/docs/CONDUCTOR.md +0 -605
  100. package/pipelines/idea-to-pr.json +0 -49
@@ -74,13 +74,22 @@ export function parseAlembicRevisions(output) {
74
74
  /**
75
75
  * Compare the repository's migration heads with the database's current
76
76
  * revisions. Blocks only on a confirmed mismatch; warns on anything unverifiable.
77
+ *
78
+ * `codeRoot` (BAPI-1362) is the checkout whose `alembic.ini` and migrations are
79
+ * read — a plane's pinned snapshot — and defaults to `repoRoot`, which keeps every
80
+ * pre-snapshot caller byte-for-byte unchanged. The executable is deliberately
81
+ * NOT resolved from it: the virtualenv is untracked and lives only in the operator
82
+ * checkout, so a snapshot has no `.venv` to find. The check therefore asks "is the
83
+ * database at the head of the code this plane will RUN", using the operator's
84
+ * interpreter and dependencies, which is the question a moving checkout made
85
+ * unanswerable.
77
86
  */
78
- export async function checkAlembicHead(repoRoot, deps) {
87
+ export async function checkAlembicHead(repoRoot, deps, codeRoot = repoRoot) {
79
88
  const alembic = resolveRepositoryAlembicCommand(repoRoot, deps.platform);
80
89
  if (!(await deps.fileExists(alembic))) {
81
90
  return unverified("the repository virtualenv Alembic executable was not found", "the repository virtualenv Alembic executable was not found");
82
91
  }
83
- const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: repoRoot });
92
+ const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: codeRoot });
84
93
  if (!heads.ok) {
85
94
  // The interpolated `error` stays in the legacy message for the operator at
86
95
  // the terminal; the consolidated detail is fixed prose. In production that
@@ -88,7 +97,7 @@ export async function checkAlembicHead(repoRoot, deps) {
88
97
  // must not depend on every caller keeping it that way.
89
98
  return unverified(`\`alembic heads\` did not complete (${heads.error})`, "`alembic heads` did not complete");
90
99
  }
91
- const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: repoRoot });
100
+ const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: codeRoot });
92
101
  if (!current.ok) {
93
102
  return unverified(`\`alembic current\` did not complete (${current.error})`, "`alembic current` did not complete");
94
103
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Base-worktree fast-forward guard (BAPI-1362).
3
+ *
4
+ * Interactive `start-tickets` fast-forwards the local base branch inside the
5
+ * worktree that has it checked out. If a live plane's Python member is running
6
+ * out of that very checkout, the merge replaces code under a running process.
7
+ * This guard decides, immediately before `git merge --ff-only`, whether that is
8
+ * safe.
9
+ *
10
+ * Posture: fail closed where the harm is a moved checkout. Every ambiguous
11
+ * reading (an unreadable or invalid manifest, an unknown liveness, a working
12
+ * directory that cannot be verified or canonicalized) is `protect`. Only a
13
+ * conclusive "nothing live runs from this checkout" is `safe-to-merge`, so stale
14
+ * runtime files never permanently disable a refresh.
15
+ *
16
+ * The manifest is read ONLY from the base worktree passed in. A manifest under
17
+ * the caller's own checkout says nothing about the checkout being moved.
18
+ *
19
+ * Nothing here echoes manifest bytes, environment, or a raw OS error: the reason
20
+ * is one of a few fixed phrases plus, when the manifest validated, its plane id.
21
+ */
22
+ import path from "path";
23
+ import { isCanonicalAbsolutePath, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
24
+ import { readProcessCwd } from "./process-cwd.js";
25
+ /** Members whose code a checkout fast-forward can change under them. */
26
+ const PYTHON_MEMBERS = new Set(["server", "worker", "observer"]);
27
+ /** Canonicalize a path through `realpath`, or fall back lexically when both allow. */
28
+ async function canonical(p, realpath) {
29
+ try {
30
+ const resolved = await realpath(p);
31
+ return isCanonicalAbsolutePath(resolved) ? resolved : null;
32
+ }
33
+ catch {
34
+ // Lexical fallback ONLY for a path that is already its own canonical form.
35
+ return isCanonicalAbsolutePath(p) ? p : null;
36
+ }
37
+ }
38
+ export async function evaluateBaseWorktreeGuard(baseWorktreePath, deps) {
39
+ const manifestLocation = ".bridge/plane/plane.json";
40
+ const protect = (reason, planeId) => ({
41
+ kind: "protect",
42
+ reason,
43
+ manifestLocation,
44
+ ...(planeId !== undefined ? { planeId } : {}),
45
+ });
46
+ if (!path.isAbsolute(baseWorktreePath))
47
+ return protect("base-path-unresolvable");
48
+ let read;
49
+ try {
50
+ read = await readPlaneManifest(baseWorktreePath, deps.fs);
51
+ }
52
+ catch {
53
+ return protect("manifest-unreadable");
54
+ }
55
+ if (read.kind === "missing")
56
+ return { kind: "safe-to-merge" };
57
+ if (read.kind === "unreadable")
58
+ return protect("manifest-unreadable");
59
+ if (read.kind === "malformed")
60
+ return protect("manifest-invalid");
61
+ const manifest = read.manifest;
62
+ const planeId = manifest.planeId;
63
+ // A valid manifest says it belongs here; one copied from elsewhere does not
64
+ // describe this checkout's processes either way, so it cannot vouch for safety.
65
+ if (manifest.repoRoot !== baseWorktreePath) {
66
+ const a = await canonical(manifest.repoRoot, deps.realpath);
67
+ const b = await canonical(baseWorktreePath, deps.realpath);
68
+ if (a === null || b === null || a !== b)
69
+ return protect("manifest-invalid");
70
+ }
71
+ const supervisor = deps.proc.isAlive(manifest.supervisorPid);
72
+ const probes = probeManifestMembers(manifest, deps.proc);
73
+ const anyUnknown = supervisor === "unknown" || probes.some((p) => p.liveness === "unknown");
74
+ const live = probes.filter((p) => p.liveness === "alive" && PYTHON_MEMBERS.has(p.member.name));
75
+ // Conclusively nothing alive anywhere: stale runtime files.
76
+ if (supervisor === "dead" && probes.every((p) => p.liveness === "dead")) {
77
+ return { kind: "safe-to-merge" };
78
+ }
79
+ const base = await canonical(baseWorktreePath, deps.realpath);
80
+ if (base === null)
81
+ return protect("base-path-unresolvable", planeId);
82
+ let sawUnverifiable = false;
83
+ for (const probe of live) {
84
+ let memberCwd = null;
85
+ if (probe.member.cwd !== undefined) {
86
+ memberCwd = await canonical(probe.member.cwd, deps.realpath);
87
+ }
88
+ else if (probe.member.pid !== null) {
89
+ const actual = await deps.readProcessCwd(probe.member.pid);
90
+ if (actual.kind === "cwd")
91
+ memberCwd = await canonical(actual.cwd, deps.realpath);
92
+ }
93
+ if (memberCwd === null) {
94
+ sawUnverifiable = true;
95
+ continue;
96
+ }
97
+ if (memberCwd === base)
98
+ return protect("live-member-in-base-checkout", planeId);
99
+ }
100
+ if (sawUnverifiable)
101
+ return protect("cwd-unverifiable", planeId);
102
+ // A live Python member that could not be probed conclusively is also unsafe.
103
+ if (anyUnknown)
104
+ return protect("liveness-unknown", planeId);
105
+ return { kind: "safe-to-merge" };
106
+ }
107
+ /**
108
+ * The single sanitized warning line for a `protect` decision.
109
+ *
110
+ * Names the plane id when trustworthy, otherwise the manifest location. Never
111
+ * includes the manifest contents.
112
+ */
113
+ export function formatBaseWorktreeGuardWarning(decision, baseBranch) {
114
+ const subject = decision.planeId !== undefined
115
+ ? `plane ${decision.planeId}`
116
+ : `the plane record at ${decision.manifestLocation} in that checkout`;
117
+ return (`Warning: the ${baseBranch} checkout was NOT fast-forwarded because ${subject} may be ` +
118
+ `running from it (${decision.reason}). The ticket worktree is cut from the fetched ` +
119
+ `origin/${baseBranch} instead; the local ${baseBranch} branch was not moved.`);
120
+ }
121
+ /** Production bindings. Lazy imports keep unit tests free of real I/O. */
122
+ export async function createBaseWorktreeGuardDeps() {
123
+ const { readFile, realpath } = await import("fs/promises");
124
+ const { createProcessCwdDeps } = await import("./process-cwd.js");
125
+ const cwdDeps = await createProcessCwdDeps();
126
+ return {
127
+ fs: { readFile: (p) => readFile(p, "utf-8") },
128
+ proc: {
129
+ isAlive: (pid) => {
130
+ try {
131
+ process.kill(pid, 0);
132
+ return "alive";
133
+ }
134
+ catch (err) {
135
+ const code = err?.code;
136
+ if (code === "ESRCH")
137
+ return "dead";
138
+ if (code === "EPERM")
139
+ return "alive";
140
+ return "unknown";
141
+ }
142
+ },
143
+ },
144
+ realpath: (p) => realpath(p),
145
+ readProcessCwd: (pid) => readProcessCwd(pid, cwdDeps),
146
+ };
147
+ }
@@ -12,17 +12,19 @@
12
12
  * if it is ever reused on the server path.
13
13
  */
14
14
  import { randomUUID } from "crypto";
15
- import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
15
+ import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RESTARTABLE_MEMBERS, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
16
16
  import { validateExecutorClaimScope, } from "../executor/claim-scope.js";
17
17
  import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
18
- import { relativeLogPathFor } from "./manifest.js";
19
- import { claimPlaneManifest, requestPlaneManifestLanes } from "./manifest.js";
18
+ import { claimPlaneManifest, clearPlaneManifest, isCanonicalAbsolutePath, planeManifestSnapshot, readPlaneManifest, relativeLogPathFor, requestPlaneManifestLanes, } from "./manifest.js";
19
+ import { lockCodeSnapshot, prepareCodeSnapshot, releaseCodeSnapshotForManifest, revalidateCodeSnapshot, } from "./code-snapshot.js";
20
+ import { checkAlembicHead } from "./alembic-head.js";
21
+ import { formatPlaneRestartOutcome, isPlaneRestartableMember, requestPlaneRestart, } from "./restart.js";
20
22
  import { runPlanePreflight } from "./preflight.js";
21
23
  import { buildPlaneControlPlaneRoster, buildPlaneExecutorLaneRoster, buildPlaneMemberRoster, buildPlaneObserverRoster, resolvePlaneServerEndpoint, } from "./member-roster.js";
22
24
  import { getPlaneStatus, formatPlaneStatus } from "./status.js";
23
25
  import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
24
26
  import { launchPlaneSupervisor, runPlaneRuntime, PLANE_RUNTIME_TWO_PHASE_FLAG, } from "./supervisor.js";
25
- import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
27
+ import { createCodeSnapshotDeps, createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, planeFileExistsSync, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
26
28
  import { resolveBapiCredentials } from "../credential-store.js";
27
29
  import { listRunExecutorRecords, verifyRunExecutorProcess } from "../executor-launch.js";
28
30
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
@@ -39,7 +41,10 @@ export const PLANE_DEFAULT_EXECUTORS = 1;
39
41
  */
40
42
  export function parsePlaneArgs(argv) {
41
43
  if (argv.length === 0) {
42
- return { kind: "error", message: "missing action (expected `up`, `status`, or `down`)" };
44
+ return {
45
+ kind: "error",
46
+ message: "missing action (expected `up`, `status`, `restart`, or `down`)",
47
+ };
43
48
  }
44
49
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
45
50
  return { kind: "help" };
@@ -55,6 +60,38 @@ export function parsePlaneArgs(argv) {
55
60
  }
56
61
  return { kind: action };
57
62
  }
63
+ // BAPI-1362 — `plane restart <server|worker|observer>`. Every rejection is here,
64
+ // in the pure parser, so a malformed invocation has no side effect: no manifest
65
+ // read, no request file, no signal.
66
+ if (action === "restart") {
67
+ if (rest.length === 0) {
68
+ return {
69
+ kind: "error",
70
+ message: `\`plane restart\` requires a member (${PLANE_RESTARTABLE_MEMBERS.join(", ")})`,
71
+ };
72
+ }
73
+ if (rest.length > 1) {
74
+ return {
75
+ kind: "error",
76
+ message: `\`plane restart\` takes exactly one member (got extra argument '${rest[1]}')`,
77
+ };
78
+ }
79
+ const member = rest[0];
80
+ if (isPlaneRestartableMember(member))
81
+ return { kind: "restart", member };
82
+ if (/^executor-[1-9][0-9]*$/.test(member)) {
83
+ return {
84
+ kind: "error",
85
+ message: `per-member restart is unavailable for executor lane '${member}'. Executor lanes are ` +
86
+ "scoped to a run, so they are recovered at the run level (re-attach with `drive-epic`), " +
87
+ "not with `plane restart`",
88
+ };
89
+ }
90
+ return {
91
+ kind: "error",
92
+ message: `'${member}' is not a restartable member (expected ${PLANE_RESTARTABLE_MEMBERS.join(", ")})`,
93
+ };
94
+ }
58
95
  // Private, and deliberately absent from the usage text and from the
59
96
  // expected-action list in the unknown-action error below: it exists for the
60
97
  // bundled-artifact regression guard, not for operators.
@@ -70,7 +107,7 @@ export function parsePlaneArgs(argv) {
70
107
  if (action !== "up" && action !== PLANE_RUNTIME_ACTION) {
71
108
  return {
72
109
  kind: "error",
73
- message: `unknown action '${action}' (expected \`up\`, \`status\`, or \`down\`)`,
110
+ message: `unknown action '${action}' (expected \`up\`, \`status\`, \`restart\`, or \`down\`)`,
74
111
  };
75
112
  }
76
113
  let executors = null;
@@ -203,7 +240,7 @@ function parseExecutorCount(raw) {
203
240
  */
204
241
  export function getPlaneUsage() {
205
242
  return [
206
- "Usage: mcp-server plane <up|status|down> [options]",
243
+ "Usage: mcp-server plane <up|status|restart|down> [options]",
207
244
  "",
208
245
  "Brings up (and winds down) the attended conductor plane in one command:",
209
246
  "the Bridge API server, the dead-man observer, the reconciler worker, and",
@@ -225,12 +262,23 @@ export function getPlaneUsage() {
225
262
  " plane up <scope> [--executors N]",
226
263
  " Preflight, then start the plane. Refuses as a",
227
264
  " whole if any spawn-blocking check fails.",
228
- " plane status Read the manifest and report each member's state.",
265
+ " plane status Read the manifest and report the plane's pinned code and",
266
+ " each member's state, with the safest next action.",
267
+ " plane restart <server|worker|observer>",
268
+ " Replace ONE member of the live plane from its pinned code",
269
+ " (BAPI-1362). Waits for a bounded readiness outcome and",
270
+ " reports it. The pinned commit and the bound run are",
271
+ " retained and the other members are untouched. It is a",
272
+ " manual recovery: nothing is ever restarted automatically.",
273
+ " Executor lanes are recovered at the run level instead.",
229
274
  " plane down Stop the bound server-side epic run FIRST (BAPI-872),",
230
275
  " then signal the recorded process group, escalating",
231
276
  " to SIGKILL, and clear the manifest. Process shutdown",
232
277
  " alone is NOT a run stop — a manifest with no bound",
233
278
  " run, or an unreachable Bridge API, only warns.",
279
+ " `plane down && plane up` moves the plane to NEW code,",
280
+ " so use it between runs (it stops a bound run); to",
281
+ " recover one member, use `plane restart <member>`.",
234
282
  "",
235
283
  "Options:",
236
284
  ` --executors N Executor lanes to start (default ${PLANE_DEFAULT_EXECUTORS}, max ${PLANE_MAX_EXECUTORS}).`,
@@ -247,7 +295,12 @@ export function getPlaneUsage() {
247
295
  " * Executors run from this repository's local build tree",
248
296
  " (mcp_server/build/index.js) — never npx, never a published package.",
249
297
  " * A member that exits is reported loudly and is NOT restarted. There is no",
250
- " auto-restart, retry, or backoff: a crash is a finding, not noise.",
298
+ " auto-restart, retry, or backoff: a crash is a finding, not noise. Recover it",
299
+ " yourself with `plane restart <member>`.",
300
+ " * The Python members (server, worker, observer) run from a PINNED, detached code",
301
+ " snapshot of the commit checked out when the plane came up, under",
302
+ ` ${PLANE_RUNTIME_DIR}/snapshots/<sha>, so moving your checkout (a pull, a`,
303
+ " fast-forward) does not change the code a live plane is running.",
251
304
  " * The dead-man observer runs as an INDEPENDENT same-host OS process, started",
252
305
  " by `plane up`, listed in `plane status`, and stopped by `plane down`. It is",
253
306
  " started before the reconciler worker, so it is already watching while the",
@@ -323,6 +376,8 @@ export async function runPlaneCli(argv, overrides = {}) {
323
376
  return await runStatusAction(sinks, overrides);
324
377
  case "down":
325
378
  return await runDownAction(sinks, overrides);
379
+ case "restart":
380
+ return await runRestartAction(parsed.member, sinks, overrides);
326
381
  case "up":
327
382
  return await runUpAction(parsed.options, sinks, overrides);
328
383
  case "runtime":
@@ -380,6 +435,28 @@ async function runStatusAction(sinks, overrides) {
380
435
  sinks.stdout(text);
381
436
  return 0;
382
437
  }
438
+ /**
439
+ * `plane restart <member>` (BAPI-1362).
440
+ *
441
+ * The CLI half of a member restart: validate, hand the detached runtime one
442
+ * request, and report its bounded outcome. Everything it can refuse, it refuses
443
+ * before writing or signalling (see `requestPlaneRestart`), and no outcome it can
444
+ * report falls back to a plane-wide action.
445
+ */
446
+ async function runRestartAction(member, sinks, overrides) {
447
+ const repoRoot = overrides.cwd ?? process.cwd();
448
+ const outcome = overrides.restart
449
+ ? await overrides.restart(repoRoot, member)
450
+ : await requestPlaneRestart(repoRoot, member, {
451
+ fs: createPlaneFsDeps(),
452
+ proc: createPlaneProcessDeps(),
453
+ clock: createPlaneClock(),
454
+ });
455
+ const rendered = formatPlaneRestartOutcome(outcome);
456
+ for (const line of rendered.lines)
457
+ (rendered.ok ? sinks.stdout : sinks.stderr)(line);
458
+ return rendered.ok ? 0 : 1;
459
+ }
383
460
  async function runDownAction(sinks, overrides) {
384
461
  const repoRoot = overrides.cwd ?? process.cwd();
385
462
  if (overrides.down) {
@@ -391,10 +468,14 @@ async function runDownAction(sinks, overrides) {
391
468
  const proc = createPlaneProcessDeps();
392
469
  const execFile = createPlaneExecFile();
393
470
  const endpoint = resolvePlaneServerEndpoint(process.env);
471
+ const snapshotDeps = createCodeSnapshotDeps(proc);
394
472
  const result = await shutdownPlane(repoRoot, {
395
473
  fs,
396
474
  proc,
397
475
  clock: createPlaneClock(),
476
+ // BAPI-1362 — unlock the pinned snapshot, but only once this wind-down has
477
+ // confirmed every process gone and cleared the manifest. It stays on disk.
478
+ releaseSnapshot: (manifest) => releaseCodeSnapshotForManifest(manifest, snapshotDeps),
398
479
  stopBoundRun: stopBoundEpicRun,
399
480
  // BAPI-1279 — stop the run-scoped executors drive-epic started here that
400
481
  // target THIS plane's endpoint (the same rule `plane up` binds by).
@@ -586,19 +667,56 @@ export function formatPlaneLaunchFailure(launch) {
586
667
  async function launchPlane(preflight, executors, planeId, sinks, env, launchOptions = {}) {
587
668
  const fs = createPlaneFsDeps();
588
669
  const clock = createPlaneClock();
589
- const context = preflight.context;
670
+ const snapshotDeps = createCodeSnapshotDeps();
671
+ let context = preflight.context;
590
672
  const nodeExecutable = resolveNodeExecutable();
673
+ // BAPI-1362 — pin the code the Python members will run BEFORE anything is
674
+ // claimed or spawned. Preflight has already passed (checkout prerequisites and
675
+ // the existing-plane ownership check both run there, read-only), so this is the
676
+ // first step with a side effect, and a refusal here leaves the repository
677
+ // exactly as it found it apart from a snapshot directory a later bring-up
678
+ // reuses. A caller that already carries a snapshot (a test seam) is trusted.
679
+ if (context.snapshot === undefined) {
680
+ const prepared = await prepareCodeSnapshot(context.repoRoot, snapshotDeps);
681
+ if (!prepared.ok) {
682
+ return {
683
+ ok: false,
684
+ error: `${PLANE_SNAPSHOT_REFUSAL}: ${prepared.message}`,
685
+ startup: null,
686
+ };
687
+ }
688
+ // The code-dependent migration check, against the snapshot's own
689
+ // `alembic.ini` and migrations and the operator's virtualenv. Preflight ran
690
+ // the same check against the checkout; the two agree at launch, and only
691
+ // this one survives the checkout moving later.
692
+ const alembic = await checkAlembicHead(context.repoRoot, {
693
+ execFile: createPlaneExecFile(),
694
+ fileExists: async (filePath) => planeFileExistsSync(filePath),
695
+ platform: process.platform,
696
+ }, prepared.snapshot.path);
697
+ if (alembic.diagnostic?.severity === "blocking") {
698
+ return { ok: false, error: alembic.diagnostic.message, startup: null };
699
+ }
700
+ if (alembic.diagnostic)
701
+ sinks.stdout(formatDiagnostic(alembic.diagnostic));
702
+ context = { ...context, snapshot: prepared.snapshot };
703
+ }
704
+ const snapshot = context.snapshot;
591
705
  // BAPI-1102 — in two-phase mode the manifest is claimed listing only the
592
706
  // CONTROL-PLANE members. Listing lanes that have not been asked to start would
593
707
  // make `plane status` report members that do not exist, and `plane down` would
594
708
  // have nothing to signal for them.
709
+ const rosterBase = {
710
+ context,
711
+ parentEnv: env,
712
+ nodeExecutable,
713
+ venv: operatorVenvLookup(context.repoRoot),
714
+ };
595
715
  const roster = launchOptions.twoPhase
596
- ? buildPlaneControlPlaneRoster({ context, parentEnv: env, nodeExecutable })
716
+ ? buildPlaneControlPlaneRoster(rosterBase)
597
717
  : buildPlaneMemberRoster({
598
- context,
718
+ ...rosterBase,
599
719
  executors,
600
- parentEnv: env,
601
- nodeExecutable,
602
720
  ...(launchOptions.claimScope ? { claimScope: launchOptions.claimScope } : {}),
603
721
  });
604
722
  const timestamp = clock.now().toISOString();
@@ -620,7 +738,14 @@ async function launchPlane(preflight, executors, planeId, sinks, env, launchOpti
620
738
  exitCode: null,
621
739
  exitSignal: null,
622
740
  logPath: relativeLogPathFor(spec.name),
741
+ ...(isCanonicalAbsolutePath(spec.cwd) ? { cwd: spec.cwd } : {}),
623
742
  })),
743
+ // The pinned code identity, recorded in the claim so the detached runtime,
744
+ // `plane status`, `plane restart`, and the `start-tickets` guard all read the
745
+ // same answer from the one durable record.
746
+ ...(snapshot !== undefined
747
+ ? { snapshotPath: snapshot.path, snapshotSha: snapshot.sha }
748
+ : {}),
624
749
  },
625
750
  fs,
626
751
  proc: createPlaneProcessDeps(),
@@ -629,6 +754,18 @@ async function launchPlane(preflight, executors, planeId, sinks, env, launchOpti
629
754
  // report — `startup: null` is the honest shape, not a placeholder.
630
755
  if (!claim.ok)
631
756
  return { ok: false, error: claim.message, startup: null };
757
+ // BAPI-1362 — lock the snapshot for this live plane, before any member exists.
758
+ // Taken only AFTER the claim succeeded, so a bring-up that loses the claim race
759
+ // or fails an earlier check never leaves a lock for a plane that did not start.
760
+ // If the lock cannot be taken the claim is released again: a plane whose code
761
+ // could be pruned from under it must not start.
762
+ if (snapshot !== undefined) {
763
+ const locked = await lockCodeSnapshot(context.repoRoot, snapshot, planeId, snapshotDeps);
764
+ if (!locked.ok) {
765
+ await clearPlaneManifest(context.repoRoot, planeId, fs);
766
+ return { ok: false, error: `${PLANE_SNAPSHOT_REFUSAL}: ${locked.message}`, startup: null };
767
+ }
768
+ }
632
769
  return launchPlaneSupervisor({
633
770
  context,
634
771
  manifest: claim.manifest,
@@ -665,6 +802,12 @@ async function launchPlane(preflight, executors, planeId, sinks, env, launchOpti
665
802
  : {}),
666
803
  });
667
804
  }
805
+ /** Stable label so a snapshot refusal is greppable in a terminal scrollback. */
806
+ export const PLANE_SNAPSHOT_REFUSAL = "plane up REFUSED — the code snapshot could not be pinned";
807
+ /** The operator-checkout virtualenv lookup the roster uses for default executables. */
808
+ function operatorVenvLookup(operatorRoot) {
809
+ return { operatorRoot, platform: process.platform, fileExists: planeFileExistsSync };
810
+ }
668
811
  function printStartupBanner(sinks, context, memberCount, executors, launch) {
669
812
  // Deliberately built from fixed labels plus non-secret facts. No environment
670
813
  // object and no credential value is rendered — the resolved key exists only
@@ -675,8 +818,12 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
675
818
  sinks.stdout(` server ${context.endpoint.baseUrl} (no --reload)`);
676
819
  sinks.stdout(` supervisor pid ${launch.supervisorPid}, process group ${launch.supervisorPgid}`);
677
820
  sinks.stdout(` logs ${PLANE_RUNTIME_DIR}/`);
821
+ if (context.snapshot !== undefined) {
822
+ sinks.stdout(` code pinned ${context.snapshot.sha} (${context.snapshot.path})`);
823
+ }
678
824
  sinks.stdout(" observer dead-man-only worker.py, its own OS process (same host)");
679
- sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
825
+ sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly." +
826
+ " Recover one with `plane restart <member>`.");
680
827
  sinks.stdout("");
681
828
  }
682
829
  /**
@@ -830,6 +977,32 @@ async function runRuntimeAction(options, overrides) {
830
977
  sinks.stderr(`plane runtime refused: ${PLANE_ID_ENV_VAR} was not provided by the launcher.`);
831
978
  return 1;
832
979
  }
980
+ // BAPI-1362 — the code this plane was pinned to is what its MANIFEST says, read
981
+ // back from the claim its launcher wrote, and it is revalidated here before any
982
+ // code-dependent check. The operator checkout is deliberately never consulted
983
+ // for code identity: by now it may have moved, which is the whole reason the
984
+ // snapshot exists. A claim with no snapshot (an unpinned launch) resolves to no
985
+ // snapshot and the runtime behaves as it always did.
986
+ const runtimeFs = createPlaneFsDeps();
987
+ const snapshotDeps = createCodeSnapshotDeps();
988
+ let pinnedSnapshot;
989
+ {
990
+ const claimed = await readPlaneManifest(repoRoot, runtimeFs);
991
+ if (claimed.kind === "valid" && claimed.manifest.planeId === planeId) {
992
+ const pinned = planeManifestSnapshot(claimed.manifest);
993
+ if (pinned.kind === "pinned") {
994
+ const revalidated = await revalidateCodeSnapshot(repoRoot, pinned.snapshot, snapshotDeps);
995
+ if (!revalidated.ok) {
996
+ sinks.stderr("");
997
+ sinks.stderr(`plane runtime REFUSED — the plane's pinned code snapshot could not be revalidated: ${revalidated.message}`);
998
+ sinks.stderr("");
999
+ sinks.stderr(`${PLANE_NOTHING_STARTED}.`);
1000
+ return 1;
1001
+ }
1002
+ pinnedSnapshot = pinned.snapshot;
1003
+ }
1004
+ }
1005
+ }
833
1006
  // BAPI-882: hand preflight this runtime's own plane id. The launcher claimed
834
1007
  // `plane.json` with `supervisorPid: process.pid` as a placeholder BEFORE
835
1008
  // spawning us, so a preflight without an identity sees that live claim,
@@ -837,7 +1010,11 @@ async function runRuntimeAction(options, overrides) {
837
1010
  // deadlocking on its own launcher. `planeId` is validated non-empty above.
838
1011
  const preflight = overrides.preflight
839
1012
  ? await overrides.preflight(repoRoot)
840
- : await runPlanePreflight(repoRoot, { ...buildPreflightDeps(env), ownPlaneId: planeId });
1013
+ : await runPlanePreflight(repoRoot, {
1014
+ ...buildPreflightDeps(env),
1015
+ ownPlaneId: planeId,
1016
+ ...(pinnedSnapshot !== undefined ? { codeSnapshot: pinnedSnapshot } : {}),
1017
+ });
841
1018
  if (!preflight.ok) {
842
1019
  // The launcher already ran preflight; reaching here means the world genuinely
843
1020
  // changed between claim and spawn. Refuse rather than start a half-valid
@@ -855,7 +1032,16 @@ async function runRuntimeAction(options, overrides) {
855
1032
  return 1;
856
1033
  }
857
1034
  const nodeExecutable = resolveNodeExecutable();
858
- const rosterBase = { context: preflight.context, parentEnv: env, nodeExecutable };
1035
+ const rosterBase = {
1036
+ // The context carries the pinned snapshot when preflight was given one, so a
1037
+ // test seam that replaced preflight still gets the manifest's identity.
1038
+ context: pinnedSnapshot !== undefined && preflight.context.snapshot === undefined
1039
+ ? { ...preflight.context, snapshot: pinnedSnapshot }
1040
+ : preflight.context,
1041
+ parentEnv: env,
1042
+ nodeExecutable,
1043
+ venv: operatorVenvLookup(preflight.context.repoRoot),
1044
+ };
859
1045
  // BAPI-1102 — in two-phase mode the runtime is handed the CONTROL-PLANE roster
860
1046
  // and the two builders it will need later, rather than a finished roster it
861
1047
  // cannot build yet: the lane roster depends on a claim scope that does not
@@ -888,6 +1074,20 @@ async function runRuntimeAction(options, overrides) {
888
1074
  hostname: resolveHostname,
889
1075
  selfPid: process.pid,
890
1076
  planeId,
1077
+ // BAPI-1362 — unlock the pinned snapshot after a confirmed wind-down or
1078
+ // rollback, and let `plane restart` revalidate it (or, for an unpinned plane,
1079
+ // confirm its launch directory) before replacing anything.
1080
+ releaseSnapshot: (manifest) => releaseCodeSnapshotForManifest(manifest, snapshotDeps),
1081
+ revalidateSnapshot: (snapshot) => revalidateCodeSnapshot(repoRoot, snapshot, snapshotDeps),
1082
+ checkLaunchDirectory: async (directory) => {
1083
+ try {
1084
+ const stat = await snapshotDeps.fs.lstat(directory);
1085
+ return stat !== null && stat.isDirectory && !stat.isSymbolicLink;
1086
+ }
1087
+ catch {
1088
+ return false;
1089
+ }
1090
+ },
891
1091
  ...(options.twoPhase
892
1092
  ? {
893
1093
  twoPhase: {