omp-conductor 0.17.0 → 0.18.0

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 (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
@@ -10,37 +10,70 @@
10
10
  * prompt's shape and default is a decision worth keeping, so none of them were
11
11
  * reworded in the move.
12
12
  */
13
- import { existsSync, readFileSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
14
15
  import { platform } from "node:os";
15
16
  import { dirname, isAbsolute, join } from "node:path";
16
17
  import {
18
+ configBackupDir,
17
19
  configPath,
18
20
  expandHome,
19
21
  findProject,
20
22
  loadConfig,
21
23
  resolveCaps,
22
24
  saveConfig,
25
+ stateDir,
26
+ writeConfigRaw,
23
27
  } from "./config.ts";
24
28
  import { claimedTelegramTopics } from "./escalate.ts";
25
29
  import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
26
30
  import {
27
- prepareConductor,
31
+ daemonGeneration,
32
+ isPaused,
33
+ pausedPath,
34
+ pauseInstance,
35
+ pauseSourceToken,
28
36
  previewProject,
37
+ readAdmissionAck,
29
38
  setPaused,
39
+ wakeDaemon,
40
+ type AdmissionAckRecord,
30
41
  type QueuePreview,
31
42
  } from "./daemon.ts";
32
- import { armTicks, telegramStateDir } from "./fleet.ts";
33
- import { restartDaemon, type RestartResult } from "./lifecycle.ts";
43
+ import { armedMarkerPath, armTicks, fleetLayers, telegramStateDir } from "./fleet.ts";
44
+ import {
45
+ healthCheck,
46
+ healthServesProject,
47
+ livingDaemon,
48
+ probeUnit,
49
+ recordPath,
50
+ restartDaemon,
51
+ SYSTEMD_UNIT,
52
+ type RestartResult,
53
+ } from "./lifecycle.ts";
54
+ import { dbPath, liveWorkersReadOnly, openStore, vacuumInto } from "./store.ts";
55
+ import {
56
+ restartFenceProblem,
57
+ type DaemonIdentity,
58
+ type DrainDeps,
59
+ type RestartBegun,
60
+ type UpgradeScope,
61
+ } from "./upgrade.ts";
34
62
  import { defaultGraphRoot, graphRepos } from "./graph.ts";
35
63
  import {
64
+ capturePathState,
36
65
  formatHostRuntimePlan,
37
66
  planHostRuntime,
38
67
  totalConfiguredWorkers,
68
+ restorePathState,
39
69
  runSetupSmoke,
40
70
  SYSTEMD_UNIT_DIR,
41
71
  tickCwdForProject,
42
72
  writeHostRuntime,
73
+ type CapturedPathState,
43
74
  type HostRuntimePlan,
75
+ type HostRuntimeWrite,
76
+ type RestorePathOptions,
44
77
  type SetupSmokeResult,
45
78
  } from "./setup-host.ts";
46
79
  import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts";
@@ -52,6 +85,7 @@ import {
52
85
  } from "./setup-discover.ts";
53
86
  import {
54
87
  AMEND_AREAS,
88
+ ARM_PROOF_CHOICES,
55
89
  BASE_FRESHNESS_CHOICES,
56
90
  BEHIND_BASE_CHOICES,
57
91
  DRAFT_POLICY_CHOICES,
@@ -59,6 +93,7 @@ import {
59
93
  POLICY_BRIEF_NAME,
60
94
  RELEASE_REQUIREMENT_CHOICES,
61
95
  REPORT_SCOPE_CHOICES,
96
+ REVIEW_STRICTNESS_CHOICES,
62
97
  SETUP_DEFAULTS,
63
98
  amendChoices,
64
99
  answersFromProject,
@@ -68,6 +103,7 @@ import {
68
103
  checkTokenScopes,
69
104
  createMissingLabels,
70
105
  defaultAnswers,
106
+ deleteCreatedLabels,
71
107
  detectTelegram,
72
108
  formatGates,
73
109
  orchestratorBriefPath,
@@ -86,14 +122,20 @@ import {
86
122
  type ProbedProse,
87
123
  } from "./setup.ts";
88
124
  import {
125
+ ARM_PROOFS,
89
126
  BASE_FRESHNESS,
90
127
  BEHIND_BASE_ACTIONS,
91
128
  DEFAULT_CAPS,
129
+ DEFAULT_REVIEW_MAX_ROUNDS,
130
+ DEFAULT_REVIEW_STRICTNESS,
92
131
  DENIED_RELEASE_GRANTS,
93
132
  DRAFT_POLICIES,
94
133
  RELEASE_REQUIREMENTS,
95
134
  INTERRUPT_CATEGORIES,
96
135
  RELEASE_SHAPES,
136
+ REVIEW_MAX_ROUNDS_MAX,
137
+ REVIEW_MAX_ROUNDS_MIN,
138
+ REVIEW_STRICTNESS,
97
139
  WEEKDAYS,
98
140
  type AuthorityHolder,
99
141
  type Caps,
@@ -106,6 +148,7 @@ import {
106
148
  type ReleaseRequirement,
107
149
  type ReportScopeChoice,
108
150
  type ResolvedGrants,
151
+ type ReviewPolicy,
109
152
  } from "./types.ts";
110
153
  import { withProgress } from "./ui/progress.ts";
111
154
  import type { WizardUi } from "./wizard-ui.ts";
@@ -1334,10 +1377,94 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1334
1377
  /** What a merge and a release must satisfy. Asked straight after the grants:
1335
1378
  * who may act, then under what conditions. The release half is only asked
1336
1379
  * when the orchestrator cuts releases — with humans releasing it has no gates
1337
- * to configure, so it would be a dead prompt (#368). */
1380
+ * to configure, so it would be a dead prompt (#368). The arming proof is
1381
+ * asked after the preconditions: it gates `arm`, not merge or release, but it
1382
+ * is the same kind of declared policy, so it rides the same area (#613).
1383
+ *
1384
+ * The `claim-only` consequence is stated in the option's own description so an
1385
+ * operator chooses it rather than discovering it: `arm` is already privileged,
1386
+ * and `claim-only` means anything that can invoke it can start dispatch once
1387
+ * the live claim and poller pass.
1388
+ */
1389
+ /**
1390
+ * How many review rounds one PR lifecycle may be returned at most — the hard
1391
+ * bound against endless polishing (#678). Validated in the dialog against the
1392
+ * same integer range the loader validates against, so an out-of-range answer
1393
+ * is re-asked rather than written for the daemon to reject. The consequence of
1394
+ * the ceiling is stated in the title: at it, the PR is left open and escalated
1395
+ * once, never returned again.
1396
+ */
1397
+ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1398
+ // Bounded like askValid above: a dialog that cannot be escaped is worse than
1399
+ // one that gives up and leaves the config alone. The `--answers` path
1400
+ // matters here specifically — answersUi is a map, not a consumer, so every
1401
+ // retry reads the same value and an out-of-range file used to recurse
1402
+ // forever instead of failing closed (#678).
1403
+ for (let attempt = 0; attempt < 3; attempt++) {
1404
+ const raw = await ask(
1405
+ ui,
1406
+ "review-max-rounds",
1407
+ `Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` +
1408
+ `at the ceiling the PR is left open and escalated once`,
1409
+ String(current),
1410
+ );
1411
+ const value = Number(raw);
1412
+ if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) {
1413
+ return value;
1414
+ }
1415
+ ui.notify(
1416
+ `Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` +
1417
+ `— shown the current value again.`,
1418
+ "warning",
1419
+ );
1420
+ }
1421
+ throw new Cancelled();
1422
+ }
1423
+
1424
+ /**
1425
+ * How green PRs are reviewed (#678), asked with the merge preconditions and
1426
+ * the arming proof: they are the same kind of declared policy — a typed,
1427
+ * mechanical boundary on what may happen to a PR — so they ride the same area.
1428
+ *
1429
+ * The three levels and their bars are shown before the select, with the
1430
+ * recommended level marked, so an operator chooses between named thresholds
1431
+ * rather than between three words. The select cursor opens on the current
1432
+ * answer (the configured level on a re-run, the recommended default on a
1433
+ * first run); the rounds are a validated integer within the same range the
1434
+ * loader enforces.
1435
+ */
1436
+ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1437
+ ui.notify(
1438
+ REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join(
1439
+ "\n",
1440
+ ),
1441
+ "info",
1442
+ );
1443
+ return {
1444
+ strictness: await askLiteral(
1445
+ ui,
1446
+ "review-strictness",
1447
+ "Review strictness for green PRs",
1448
+ REVIEW_STRICTNESS,
1449
+ REVIEW_STRICTNESS_CHOICES,
1450
+ a.review.strictness,
1451
+ ),
1452
+ maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1453
+ };
1454
+ }
1455
+
1338
1456
  const askPolicy: AreaAsker = async (ui, a) => ({
1339
1457
  ...a,
1340
1458
  policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
1459
+ armProof: await askLiteral(
1460
+ ui,
1461
+ "arm-proof",
1462
+ "Arming — how should `arm` prove a human approved dispatch?",
1463
+ ARM_PROOFS,
1464
+ ARM_PROOF_CHOICES,
1465
+ a.armProof,
1466
+ ),
1467
+ review: await askReviewPolicy(ui, a),
1341
1468
  });
1342
1469
 
1343
1470
  /** How a stuck run reaches a human, and who triages it when it does. */
@@ -1395,14 +1522,25 @@ const askEscalation: AreaAsker = async (ui, a) => {
1395
1522
  /**
1396
1523
  * Forum topic for tier-2 pages. Claimed threads from omp-telegram's
1397
1524
  * `threads.json` are offered when readable; a missing file is silent and the
1398
- * operator can still type an id or keep flat chat (#318).
1525
+ * operator can still type an id or keep flat chat (#318). A registry that is
1526
+ * *present but unreadable* is the opposite: the bridge is in a state the
1527
+ * wizard cannot read, so the manual id would be a guess against unverifiable
1528
+ * state — surface the problem before the fallback instead of silently offering
1529
+ * either (#626).
1399
1530
  */
1400
1531
  async function askTelegramTopicId(
1401
1532
  ui: WizardUi,
1402
1533
  stateDir: string,
1403
1534
  prior: number | undefined,
1404
1535
  ): Promise<number | undefined> {
1405
- const claimed = claimedTelegramTopics(stateDir);
1536
+ const result = claimedTelegramTopics(stateDir);
1537
+ if (result.kind === "unavailable") {
1538
+ ui.notify(
1539
+ `Cannot offer claimed topics: omp-telegram's claim registry is unreadable (${result.problem}) — check the bridge state before typing a topic id, or keep flat chat`,
1540
+ "warning",
1541
+ );
1542
+ }
1543
+ const claimed = result.kind === "ok" ? result.claims : [];
1406
1544
  const manual = "Enter thread id manually";
1407
1545
  const none = "None — flat chat (0.13 behaviour)";
1408
1546
  if (claimed.length > 0) {
@@ -2027,10 +2165,39 @@ export interface SetupApplyDeps {
2027
2165
  labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
2028
2166
  preview: (project: ProjectConfig) => Promise<QueuePreview>;
2029
2167
  createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
2168
+ /**
2169
+ * Compensation half of {@link createLabels}: deletes exactly the labels a
2170
+ * failed apply created, so the tracker returns to its pre-entry state.
2171
+ * Called only on rollback, where a deletion failure is a reported
2172
+ * restoration failure — the orphaned label is named, never silently left
2173
+ * behind (#652).
2174
+ */
2175
+ deleteLabels: (trackerRepo: string, created: string[]) => Promise<void>;
2176
+ /**
2177
+ * Proves a healthy runtime generation serves the COMPLETE prior configured
2178
+ * project set after a failed apply rolled the disk state back (#650). The
2179
+ * surviving entry-generation process predates the rollback: its store
2180
+ * handle points at the file the rollback replaced (writes diverge into the
2181
+ * unlinked inode until its next restart) and its in-memory config may be
2182
+ * the rejected generation — so "proving health" is never the proof. The
2183
+ * default implementation restarts through the same lifecycle seam the
2184
+ * apply's own restart leg uses and then proves /healthz names every prior
2185
+ * project. Returns false when the proof cannot be made — the caller then
2186
+ * stays fail-closed (a setup hold stays in force, with the recovery
2187
+ * command named) rather than resuming dispatch onto an unproven runtime.
2188
+ */
2189
+ proveRuntime: (priorProjects: readonly string[]) => Promise<boolean>;
2190
+ /**
2191
+ * The lifecycle/drain seam the acknowledged quiescence barrier runs on —
2192
+ * the same `DrainDeps` `upgrade`, `restart` and `setup host` use, so the
2193
+ * freeze, its token proof and its staleness checks are the seam's, never a
2194
+ * second counter-only fence (#618). Injectable so a regression can drive
2195
+ * the exact admission interleaving through real barrier code.
2196
+ */
2197
+ drain: DrainDeps;
2030
2198
  smoke: (project: string) => Promise<SetupSmokeResult>;
2031
2199
  restart: (o: { project?: string }) => Promise<RestartResult>;
2032
2200
  arm: (project: string) => Promise<string>;
2033
- resume: (project: string) => void;
2034
2201
  hostInstall: (
2035
2202
  project: ProjectConfig,
2036
2203
  caps: Caps,
@@ -2044,20 +2211,593 @@ export interface SetupApplyDeps {
2044
2211
  ) => Promise<InstallOutcome>;
2045
2212
  }
2046
2213
 
2214
+ /**
2215
+ * The default drain accessors the setup apply barrier runs on, wired exactly
2216
+ * like `setup host`'s (`setup-install.ts`) — except the pause names `setup`
2217
+ * as the actor and the barrier refuses on live workers instead of waiting for
2218
+ * a drain.
2219
+ *
2220
+ * Two deliberate divergences from the drop-in lifecycle accessors, both
2221
+ * because a setup apply owns a *host-global* transaction rather than the one
2222
+ * project a restart drains:
2223
+ *
2224
+ * - `snapshot` counts live workers through {@link liveWorkersReadOnly}, never
2225
+ * `statusSnapshot`: the observation that gates the barrier must be
2226
+ * read-only, because database preparation is itself one of the mutations
2227
+ * the barrier precedes (#651 review #2).
2228
+ * - `layers` reports the *global* sentinel's paused flag, not the project
2229
+ * bare-read `fleetLayers` derives — the barrier freezes the whole host
2230
+ * through the one global pause sentinel (`scope.pauseKey === undefined`),
2231
+ * so the fence it must re-prove is that sentinel, never a per-project one
2232
+ * (#651 review #4).
2233
+ */
2234
+ function defaultSetupDrain(): DrainDeps {
2235
+ return {
2236
+ snapshot: (project) => ({ liveWorkers: liveWorkersReadOnly(project) }),
2237
+ layers: () => ({ ...fleetLayers(), paused: isPaused() }),
2238
+ projectNames: () => loadConfig().projects.map((p) => p.name),
2239
+ daemonIdentity: setupDaemonIdentity,
2240
+ pauseState: (project) => pauseInstance(project),
2241
+ setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup"), reason: "setup apply fence" }, project),
2242
+ // The daemon-side admission acknowledgement: the file the running daemon
2243
+ // itself writes when it observes the fence at an admission boundary, so
2244
+ // the barrier never mistakes a second synchronous count for the daemon's
2245
+ // own word that it will claim nothing (#651 review #3).
2246
+ admissionAck: () => readAdmissionAck(),
2247
+ sleep: Bun.sleep,
2248
+ log: () => {},
2249
+ };
2250
+ }
2251
+
2252
+ /**
2253
+ * The daemon identity the setup barrier acknowledges and re-proves: the known
2254
+ * pidfile record when one is live, else an ACTIVE `omp-conductor.service`
2255
+ * unit whose runtime record went missing — a supervised daemon is still a
2256
+ * running daemon without its record, and its MainPID is the generation that
2257
+ * identifies the exact instance (#651, review #2). An UNKNOWN systemd query
2258
+ * throws: "dbus blipped" is not "no daemon", and the barrier must fail closed
2259
+ * rather than acknowledge quiescence it cannot prove.
2260
+ */
2261
+ function setupDaemonIdentity(): DaemonIdentity {
2262
+ const ownership = probeUnit(SYSTEMD_UNIT);
2263
+ if (ownership.kind === "unknown") {
2264
+ throw new Error(
2265
+ `cannot prove whether the daemon is running: systemd ownership probe failed (${ownership.reason})`,
2266
+ );
2267
+ }
2268
+ const daemon = livingDaemon();
2269
+ if (daemon !== undefined) {
2270
+ // The record names the exact instance; `daemonGeneration` formats the
2271
+ // same generation the daemon's own admission acknowledgement records, so
2272
+ // the two sides of the acknowledged fence always compare like for like.
2273
+ return { running: true, project: daemon.project, generation: daemonGeneration() };
2274
+ }
2275
+ if (ownership.kind === "active") {
2276
+ // The unit owns a live MainPID but the record is absent (a crash raced a
2277
+ // re-record, an unmanaged start never wrote one): the daemon is running,
2278
+ // and the MainPID is the generation that lets the fence spot a restart.
2279
+ return { running: true, generation: `systemd:${ownership.pid}` };
2280
+ }
2281
+ return { running: false };
2282
+ }
2283
+
2047
2284
  export const DEFAULT_APPLY: SetupApplyDeps = {
2048
2285
  scopes: checkTokenScopes,
2049
2286
  labels: planLabels,
2050
2287
  preview: previewProject,
2051
2288
  createLabels: createMissingLabels,
2289
+ deleteLabels: deleteCreatedLabels,
2290
+ proveRuntime: async (priorProjects) => {
2291
+ // No short-circuit on a healthy daemon: whatever process is still
2292
+ // answering predates the rollback, so it holds a handle on the store file
2293
+ // the rollback replaced and may serve the rejected config. Restart
2294
+ // through the same fail-closed lifecycle seam the apply's own restart leg
2295
+ // uses — whose `waitForOwnedDaemon` proves MainPID + /healthz and that a
2296
+ // fresh boot loaded the restored/rolled-back state — then prove the new
2297
+ // generation serves EVERY prior configured project, not just health on
2298
+ // one (#650, PR #700 review #1).
2299
+ try {
2300
+ const restarted = await restartDaemon({});
2301
+ const health = await healthCheck(restarted.record.port);
2302
+ if (!health.ok) return false;
2303
+ return priorProjects.every((project) => healthServesProject(health.body, project));
2304
+ } catch {
2305
+ return false;
2306
+ }
2307
+ },
2308
+ drain: defaultSetupDrain(),
2052
2309
  smoke: runSetupSmoke,
2053
2310
  restart: (o) => restartDaemon(o),
2054
2311
  arm: (project) => ensureSetupArm(project),
2055
- resume: (project) => setPaused(false, undefined, project),
2056
2312
  hostInstall: (project, caps, telegramStateDir, ui) =>
2057
2313
  runHostInstall(project, caps, telegramStateDir, ui),
2058
2314
  graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
2059
2315
  };
2060
2316
 
2317
+ /** One pause sentinel's pre-entry state, four-valued so absence, readable-valid
2318
+ * bytes, malformed bytes and unreadability are never conflated.
2319
+ * - `{ kind: "absent" }` — no file on entry; rollback removes one the barrier
2320
+ * created.
2321
+ * - `{ kind: "bytes", bytes }` — readable AND valid on entry; rollback writes
2322
+ * these bytes back verbatim, so the pre-entry owner token and timestamp
2323
+ * survive a refused apply instead of being replaced by a fresh equivalent
2324
+ * pause (#618, continuation #7).
2325
+ * - `{ kind: "malformed", bytes, path }` — readable but not a valid pause
2326
+ * instance (an unparseable timestamp or provenance line). This is
2327
+ * unreadable-as-state: the barrier must fail closed BEFORE changing
2328
+ * anything and never delete it, because collapsing it to absence would
2329
+ * remove an operator hold it could not prove (#650).
2330
+ * - `{ kind: "unreadable", path }` — the file exists but cannot be read. The
2331
+ * barrier must fail closed BEFORE changing anything: collapsing this to
2332
+ * absence and later deleting the path would remove an operator hold it
2333
+ * never saw (#651 review #5). */
2334
+ type CapturedPause =
2335
+ | { kind: "absent" }
2336
+ | { kind: "bytes"; bytes: string }
2337
+ | { kind: "malformed"; bytes: string; path: string }
2338
+ | { kind: "unreadable"; path: string };
2339
+
2340
+ /** Whether one sentinel's bytes parse as a valid pause instance — the exact
2341
+ * grammar {@link pauseInstance} reads in `daemon.ts` (a parseable ISO
2342
+ * timestamp line, then a `source=` provenance line). Readable bytes that
2343
+ * fail this are an unreadable-as-state malformed hold: valid bytes are
2344
+ * restored verbatim, malformed ones are refused at entry and never deleted
2345
+ * (#650). */
2346
+ function isValidPauseBytes(bytes: string): boolean {
2347
+ const [line1, line2] = bytes.split("\n");
2348
+ if (!Number.isFinite(Date.parse(line1?.trim() ?? ""))) return false;
2349
+ if (line2 === undefined) return false;
2350
+ return /^source=(\S+)(?: reason="(.*)")?$/.test(line2.trim());
2351
+ }
2352
+
2353
+ /** Reads one pause sentinel, keeping absence, readable-valid bytes, malformed
2354
+ * bytes and unreadability apart — never conflated: collapsing malformed to
2355
+ * absence would let a refusal delete an operator hold it could not parse. */
2356
+ function readSentinel(p: string): CapturedPause {
2357
+ if (!existsSync(p)) return { kind: "absent" };
2358
+ try {
2359
+ const bytes = readFileSync(p, "utf8");
2360
+ return isValidPauseBytes(bytes) ? { kind: "bytes", bytes } : { kind: "malformed", bytes, path: p };
2361
+ } catch {
2362
+ return { kind: "unreadable", path: p };
2363
+ }
2364
+ }
2365
+
2366
+ /**
2367
+ * The pre-entry pause sentinel the setup apply itself can change. Setup
2368
+ * freezes the whole host through the one *global* sentinel — it must never
2369
+ * write a project's pause instead, which left the wrong project held when
2370
+ * adding a neighbour next to a daemon recorded for the existing project
2371
+ * (#651 review #4) — so the captured path is the global one, its scope is
2372
+ * preserved exactly (a successful apply or a refusal restores that same
2373
+ * path's pre-entry bytes, never a project-scoped sentinel), and an entry
2374
+ * that is already paused — valid, malformed or unreadable — fails closed in
2375
+ * its own way rather than being rebuilt or deleted (#650).
2376
+ */
2377
+ function capturePause(): CapturedPause {
2378
+ return readSentinel(pausedPath());
2379
+ }
2380
+
2381
+ /** Restores the sentinel {@link capturePause} captured — but only while the
2382
+ * sentinel on disk is still the fence THIS transaction froze (the bytes it
2383
+ * wrote, or the pre-entry hold it inherited). A `resume` or a newer operator
2384
+ * hold that replaced the setup freeze is a takeover the stale transaction
2385
+ * must preserve: writing the entry bytes back over it, or removing the file
2386
+ * it owns, would delete a safety hold a later actor wrote in the meantime
2387
+ * (#651 review #3). An unreadable or malformed current sentinel is also left
2388
+ * alone — overwriting what cannot be read (or parsed) is the same
2389
+ * overwrite-of-the-unseen this guard exists to prevent. Never called with a
2390
+ * malformed entry state: the barrier fails closed before mutating then; the
2391
+ * branch exists so a malformed state would still be restored byte for byte
2392
+ * rather than deleted, never treated as absence. */
2393
+ function restorePause(state: CapturedPause, owned: CapturedPause): void {
2394
+ const p = pausedPath();
2395
+ const current = readSentinel(p);
2396
+ // Only this transaction's own fence may be undone. An unreadable or
2397
+ // malformed sentinel is left alone (overwriting what cannot be read or
2398
+ // parsed is overwriting a hold the barrier never saw); a sentinel that no
2399
+ // longer matches the bytes this transaction froze was replaced by a takeover
2400
+ // — a resume or a newer hold — and that takeover stands.
2401
+ let isOwnFence = false;
2402
+ if (current.kind === "bytes" && owned.kind === "bytes") {
2403
+ isOwnFence = current.bytes === owned.bytes;
2404
+ } else if (current.kind === "absent" && owned.kind === "absent") {
2405
+ isOwnFence = true;
2406
+ }
2407
+ if (!isOwnFence) return;
2408
+ if (state.kind === "bytes" || state.kind === "malformed") {
2409
+ mkdirSync(dirname(p), { recursive: true });
2410
+ writeFileSync(p, state.bytes);
2411
+ } else {
2412
+ rmSync(p, { force: true });
2413
+ }
2414
+ }
2415
+
2416
+ // -------------------------------------------------- the mutation inventory --
2417
+ //
2418
+ // Child 2 of the setup-transaction decomposition (#652): before the first
2419
+ // mutation the apply records the COMPLETE inventory of every surface it can
2420
+ // write — config, briefs, staged units/scripts/tick, the AGENTS link, the run
2421
+ // store, the daemon record, the arm marker, the config backups and the
2422
+ // tracker labels — with byte-exact pre-entry state. Any later exception or
2423
+ // failed smoke restores every one of them (or removes what a first install
2424
+ // created), compensates the tracker exactly, and reports the first
2425
+ // restoration failure honestly instead of pretending the prior state is
2426
+ // coherent. The one surface a rollback never undoes blindly is the pause
2427
+ // sentinel: {@link restorePause} keeps child 1's ownership rule — only the
2428
+ // fence THIS transaction froze is undone, and a newer actor's hold stands.
2429
+
2430
+ /** The run store's pre-entry state: a byte-consistent snapshot when the
2431
+ * store exists, absence on a first install. */
2432
+ interface StoreCapture {
2433
+ path: string;
2434
+ kind: "absent" | "snapshot";
2435
+ snapshotPath?: string;
2436
+ }
2437
+
2438
+ /** The complete pre-entry mutation inventory of one setup apply (#652). */
2439
+ interface SetupInventory {
2440
+ /** `config.json` pre-entry state (bytes, absence, or unreadable). */
2441
+ config: CapturedPathState;
2442
+ /** The pre-entry configured project set — what the prior runtime generation
2443
+ * must be proven to serve after a rollback restores the config (#650). */
2444
+ priorProjects: string[];
2445
+ /** The brief files the apply will write: `POLICY.md` + `ORCHESTRATOR.md`. */
2446
+ briefs: CapturedPathState[];
2447
+ /** Every staged host-runtime path the apply may write (units, scripts, tick). */
2448
+ runtime: CapturedPathState[];
2449
+ /** The AGENTS.md brief link the apply will create/update, when planned. */
2450
+ briefLink?: CapturedPathState;
2451
+ /** The run store: byte-consistent snapshot when it exists, absence on a first install. */
2452
+ store: StoreCapture;
2453
+ /** The daemon pidfile record the smoke/restart legs write. */
2454
+ record: CapturedPathState;
2455
+ /** The external-heartbeat arm marker, when an external orchestrator is planned. */
2456
+ armMarker?: CapturedPathState;
2457
+ /** Directories the apply may create (workspace/tick cwds), absent at entry;
2458
+ * rollback removes them only while still empty, so operator content that
2459
+ * landed in one is never destroyed. */
2460
+ dirs: string[];
2461
+ /** Pre-entry listing of the config backup dir; backups the apply created
2462
+ * are removed on rollback so the backups return to their entry set. */
2463
+ backups: { dir: string; files: string[] };
2464
+ }
2465
+
2466
+ /** A byte-consistent pre-entry copy of the run store: VACUUM INTO reads main
2467
+ * file + WAL as one, so the snapshot holds every committed row even while a
2468
+ * paused daemon holds a writer handle. The snapshot lives under the state
2469
+ * dir for the transaction's lifetime and is removed on commit and rollback
2470
+ * alike; a partial snapshot from a throwing VACUUM is removed immediately,
2471
+ * never left as a stray temp copy in the state dir (#650, review #2). */
2472
+ function captureStore(path: string): StoreCapture {
2473
+ if (!existsSync(path)) return { path, kind: "absent" };
2474
+ const snapshotPath = join(stateDir(), `.conductor.db.setup-${process.pid}.${randomUUID()}.snapshot`);
2475
+ try {
2476
+ vacuumInto(path, snapshotPath);
2477
+ } catch (err) {
2478
+ rmSync(snapshotPath, { force: true });
2479
+ throw err;
2480
+ }
2481
+ return { path, kind: "snapshot", snapshotPath };
2482
+ }
2483
+
2484
+ /** The backup dir listing, or the empty set when the dir does not exist yet. */
2485
+ function readdirIfPresent(dir: string): string[] {
2486
+ try {
2487
+ return readdirSync(dir).sort();
2488
+ } catch {
2489
+ return [];
2490
+ }
2491
+ }
2492
+
2493
+ /**
2494
+ * The full pre-entry mutation inventory of one apply, read BEFORE the first
2495
+ * mutation. Throws when any captured path exists but cannot be read: an
2496
+ * unreadable pre-entry path is a fault, never absence — collapsing it would
2497
+ * let a rollback delete state the apply never saw, the same fail-closed rule
2498
+ * the pause sentinel capture already applies (#651 review #5).
2499
+ *
2500
+ * `priorProjectNames` is the pre-entry configured project set (from the
2501
+ * config `setup()` loaded before the interview): the set a failed apply must
2502
+ * prove the restored runtime serves again. It comes from the entry load, not
2503
+ * a post-rollback re-read, so an un-restored config cannot make the proof
2504
+ * measure the wrong set (#650).
2505
+ */
2506
+ function captureInventory(
2507
+ plan: DerivedPlan,
2508
+ answers: SetupAnswers,
2509
+ priorProjectNames: string[],
2510
+ ): SetupInventory {
2511
+ const runtime: CapturedPathState[] = [];
2512
+ const captureIfWritten = (w: { action: string; path: string } | undefined): void => {
2513
+ // Only paths the plan says will actually be written: a `keep` path is
2514
+ // untouched by the apply and needs no pre-entry state.
2515
+ if (w !== undefined && w.action !== "keep") runtime.push(capturePathState(w.path));
2516
+ };
2517
+ captureIfWritten(plan.runtime.service);
2518
+ captureIfWritten(plan.runtime.herdrUnit);
2519
+ captureIfWritten(plan.runtime.herdrConfig);
2520
+ captureIfWritten(plan.runtime.herdrEnv);
2521
+ captureIfWritten(plan.runtime.recoverUnit);
2522
+ captureIfWritten(plan.runtime.recoverScript);
2523
+ captureIfWritten(plan.runtime.tick);
2524
+
2525
+ const briefs = answers.writeOrchestratorBrief
2526
+ ? [policyPathForProject(plan.project), briefPathForProject(plan.project)].map(capturePathState)
2527
+ : [];
2528
+ const briefLink =
2529
+ plan.runtime.briefLink !== undefined &&
2530
+ (plan.runtime.briefLink.action === "create" || plan.runtime.briefLink.action === "update")
2531
+ ? capturePathState(plan.runtime.briefLink.path)
2532
+ : undefined;
2533
+ const record = capturePathState(recordPath());
2534
+ const armMarker =
2535
+ plan.project.escalation.orchestrator === "external"
2536
+ ? capturePathState(armedMarkerPath(plan.project.name))
2537
+ : undefined;
2538
+
2539
+ // Unreadable check across every captured path, before anything is written.
2540
+ const all = [
2541
+ capturePathState(configPath()),
2542
+ ...briefs,
2543
+ ...runtime,
2544
+ ...(briefLink === undefined ? [] : [briefLink]),
2545
+ record,
2546
+ ...(armMarker === undefined ? [] : [armMarker]),
2547
+ ];
2548
+ const unreadable = all.find((c) => c.kind === "unreadable");
2549
+ if (unreadable !== undefined) {
2550
+ throw new Error(
2551
+ `the path ${unreadable.path} exists but could not be read — refusing to start the apply rather than ` +
2552
+ "risk overwriting state it never saw",
2553
+ );
2554
+ }
2555
+
2556
+ // Directories the apply can create (the workspace root via the brief write,
2557
+ // the fleet cwd via the AGENTS link and the tick config). Only absent ones
2558
+ // are recorded; rollback removes them only while still empty.
2559
+ const dirs = new Set<string>();
2560
+ if (answers.writeOrchestratorBrief) dirs.add(dirname(briefPathForProject(plan.project)));
2561
+ if (briefLink !== undefined) dirs.add(dirname(briefLink.path));
2562
+ if (plan.runtime.tick !== undefined && plan.runtime.tick.action !== "keep") {
2563
+ dirs.add(dirname(plan.runtime.tick.path));
2564
+ }
2565
+
2566
+ return {
2567
+ config: all[0]!,
2568
+ priorProjects: priorProjectNames,
2569
+ briefs,
2570
+ runtime,
2571
+ ...(briefLink === undefined ? {} : { briefLink }),
2572
+ store: captureStore(dbPath()),
2573
+ record,
2574
+ ...(armMarker === undefined ? {} : { armMarker }),
2575
+ dirs: [...dirs].filter((d) => !existsSync(d)),
2576
+ backups: { dir: configBackupDir(), files: readdirIfPresent(configBackupDir()) },
2577
+ };
2578
+ }
2579
+
2580
+ /**
2581
+ * Restores every captured surface on a failed apply (#652): the config and
2582
+ * the backups the apply created, the briefs, the staged runtime files, the
2583
+ * AGENTS link, the run store, the daemon record and the arm marker; removes
2584
+ * what a first install created; and compensates tracker labels exactly.
2585
+ * Returns every restoration failure — the first included — so the caller
2586
+ * reports the rollback honestly instead of implying the prior state is
2587
+ * coherent. Never touches the pause sentinel: that restore keeps child 1's
2588
+ * ownership rule and is the caller's.
2589
+ */
2590
+ async function restoreInventory(
2591
+ inventory: SetupInventory,
2592
+ trackerRepo: string,
2593
+ createdLabels: string[],
2594
+ deleteLabels: SetupApplyDeps["deleteLabels"],
2595
+ ): Promise<string[]> {
2596
+ const failures: string[] = [];
2597
+ const restore = (state: CapturedPathState | undefined, opts?: RestorePathOptions): void => {
2598
+ if (state === undefined) return;
2599
+ const failure = restorePathState(state, opts);
2600
+ if (failure !== undefined) failures.push(failure);
2601
+ };
2602
+
2603
+ // The config first — the file everything else derives from. Restored
2604
+ // through the atomic temp-then-rename writer the upgrade rollback uses, so
2605
+ // a crash mid-restore cannot leave a truncated config; the backup that
2606
+ // writer makes of the failed config is pruned with the rest of the apply's
2607
+ // new backups below, returning the backup dir to its entry listing.
2608
+ if (inventory.config.kind === "bytes") {
2609
+ try {
2610
+ writeConfigRaw(inventory.config.bytes);
2611
+ } catch (err) {
2612
+ failures.push(
2613
+ `could not restore config.json: ${err instanceof Error ? err.message : String(err)}`,
2614
+ );
2615
+ }
2616
+ } else {
2617
+ restore(inventory.config);
2618
+ }
2619
+ const currentBackups = readdirIfPresent(inventory.backups.dir);
2620
+ for (const name of currentBackups) {
2621
+ if (inventory.backups.files.includes(name)) continue;
2622
+ try {
2623
+ rmSync(join(inventory.backups.dir, name), { force: true });
2624
+ } catch (err) {
2625
+ failures.push(
2626
+ `could not remove backup ${join(inventory.backups.dir, name)}: ${err instanceof Error ? err.message : String(err)}`,
2627
+ );
2628
+ }
2629
+ }
2630
+
2631
+ for (const brief of inventory.briefs) restore(brief);
2632
+ for (const path of inventory.runtime) restore(path);
2633
+ // The AGENTS link: only a symlink is ever removed or re-linked, so an
2634
+ // operator's regular file that appeared mid-flight survives — reported as a
2635
+ // failure, because the prior state is then not coherent.
2636
+ restore(inventory.briefLink, { preserveRegularFile: true });
2637
+
2638
+ // The run store: replace from the byte-consistent snapshot (or remove it
2639
+ // entirely on a first-install rollback).
2640
+ try {
2641
+ rmSync(inventory.store.path, { force: true });
2642
+ rmSync(`${inventory.store.path}-wal`, { force: true });
2643
+ rmSync(`${inventory.store.path}-shm`, { force: true });
2644
+ if (inventory.store.kind === "snapshot") {
2645
+ copyFileSync(inventory.store.snapshotPath!, inventory.store.path);
2646
+ }
2647
+ } catch (err) {
2648
+ failures.push(
2649
+ `could not restore the run store at ${inventory.store.path}: ${err instanceof Error ? err.message : String(err)}`,
2650
+ );
2651
+ }
2652
+
2653
+ // The daemon record: only while no daemon is live. A live process owns its
2654
+ // record — restoring the entry bytes over it would describe a daemon that
2655
+ // no longer runs, or remove the record of one that does.
2656
+ if (livingDaemon() === undefined) restore(inventory.record);
2657
+ restore(inventory.armMarker);
2658
+
2659
+ // Directories the apply created: removed only while empty (rmdirSync, never
2660
+ // a recursive rm — operator content that landed in one must survive).
2661
+ for (const dir of inventory.dirs) {
2662
+ try {
2663
+ rmdirSync(dir);
2664
+ } catch {
2665
+ // Non-empty, missing, or busy — leave it; this is not a restoration failure.
2666
+ }
2667
+ }
2668
+
2669
+ // Tracker compensation: exactly the labels THIS apply created.
2670
+ if (createdLabels.length > 0) {
2671
+ try {
2672
+ await deleteLabels(trackerRepo, createdLabels);
2673
+ } catch (err) {
2674
+ failures.push(err instanceof Error ? err.message : String(err));
2675
+ }
2676
+ }
2677
+ return failures;
2678
+ }
2679
+
2680
+ /**
2681
+ * The host-global scope a setup apply's barrier covers: every configured
2682
+ * project served by the daemon, and the global pause sentinel so one write
2683
+ * pauses the whole host. Deliberately NOT {@link resolveScope}'s
2684
+ * project-narrowing — which would pause the daemon's recorded project's
2685
+ * sentinel instead of the host-wide one when adding a neighbour next to it
2686
+ * (review #4). A first install has no config and no daemon, so the scope names
2687
+ * the bare selector and the observation proves zero without any reader.
2688
+ */
2689
+ function setupScope(drain: DrainDeps): UpgradeScope {
2690
+ let configured: readonly string[] = [];
2691
+ try {
2692
+ configured = drain.projectNames();
2693
+ } catch (err) {
2694
+ // An existing config that cannot be enumerated is a fault, never absence:
2695
+ // collapsing it to the bare selector would read "zero workers" for a fleet
2696
+ // the config still describes, then overwrite the unreadable file (#651,
2697
+ // review #2). Only a truly absent config legitimately yields the bare
2698
+ // selector (a first install), and even there the observation still fails
2699
+ // closed against the daemon identity.
2700
+ if (existsSync(configPath())) {
2701
+ throw new Error(
2702
+ `the configured projects could not be read: ${err instanceof Error ? err.message : String(err)} — ` +
2703
+ "zero workers cannot be proven while the config is unreadable",
2704
+ );
2705
+ }
2706
+ }
2707
+ // A daemon serves projects from its own memory, not from the on-disk
2708
+ // config: a project the config no longer names (or never named — a neighbour
2709
+ // this host is mid-migrating to) still owns live runs the daemon admits and
2710
+ // sweeps. If the daemon's recorded project is not among the configured
2711
+ // ones, the host-global scope must still cover it, or its workers are
2712
+ // silently skipped while setup mutates underneath them (#651 review #3). An
2713
+ // identity that cannot be proven throws here — before any freeze — so the
2714
+ // scope never reads "zero" for a fleet it cannot see.
2715
+ const daemon = drain.daemonIdentity();
2716
+ if (daemon.running && daemon.project !== undefined && !configured.includes(daemon.project)) {
2717
+ configured = [...configured, daemon.project];
2718
+ }
2719
+ return {
2720
+ selectors: configured.length === 0 ? [undefined] : configured,
2721
+ pauseKey: undefined,
2722
+ };
2723
+ }
2724
+
2725
+ /** Live worker runs across every project a host-wide transaction covers.
2726
+ * Throws — refusing — when the fleet cannot be proven quiescent: a missing
2727
+ * config does not prove zero workers while a daemon runs from its own
2728
+ * in-memory config and still owns runs (review #3), and an unreadable store
2729
+ * is a fault, never absence. */
2730
+ function liveWorkersInScope(drain: DrainDeps, scope: UpgradeScope): number {
2731
+ if (!existsSync(configPath()) && drain.daemonIdentity().running) {
2732
+ throw new Error(
2733
+ "config.json is absent while a daemon is running, so zero workers cannot be proven; " +
2734
+ "the daemon serves projects from memory",
2735
+ );
2736
+ }
2737
+ return scope.selectors.reduce((n, s) => n + drain.snapshot(s).liveWorkers, 0);
2738
+ }
2739
+
2740
+ /** How long the barrier waits — after one best-effort daemon wake — for the
2741
+ * running daemon to acknowledge the setup fence before refusing with nothing
2742
+ * written. Bounded and fail-closed: an unacknowledged fence is an unproven
2743
+ * one, and the answer is a re-run, never an unproven proceed. */
2744
+ const FENCE_ACK_WAIT_MS = 60_000;
2745
+ const FENCE_ACK_POLL_MS = 250;
2746
+
2747
+ /**
2748
+ * Why the daemon's admission acknowledgement no longer covers the fence the
2749
+ * barrier froze, or `undefined` when it does: the acknowledgement must name
2750
+ * the exact pause instance the barrier proved (not some other pause, however
2751
+ * similar) and the exact daemon generation the barrier began with (not an
2752
+ * older or newer instance's word) (#651 review #3).
2753
+ */
2754
+ function fenceAckProblem(ack: AdmissionAckRecord | undefined, begun: RestartBegun): string | undefined {
2755
+ if (ack === undefined) return "the running daemon has not acknowledged the setup admission fence";
2756
+ if (
2757
+ ack.pause.source !== begun.pauseToken.source ||
2758
+ ack.pause.since !== begun.pauseToken.since ||
2759
+ ack.pause.reason !== begun.pauseToken.reason
2760
+ ) {
2761
+ return "the running daemon acknowledged a different pause than the setup admission fence";
2762
+ }
2763
+ if (ack.daemon !== begun.daemon.generation) {
2764
+ return "the running daemon's acknowledgement belongs to a different daemon generation";
2765
+ }
2766
+ return undefined;
2767
+ }
2768
+
2769
+ /**
2770
+ * Why the acknowledged barrier no longer holds, or `undefined` when it does:
2771
+ * a live worker anywhere in scope, the daemon-side admission acknowledgement
2772
+ * no longer covering the frozen fence (when one is required), or the fence
2773
+ * its own pause/daemon-generation check (the reused lifecycle seam,
2774
+ * {@link restartFenceProblem}) says was lifted or replaced. The acknowledged
2775
+ * quiescence barrier re-runs this after every async boundary it must not
2776
+ * proceed across (#651 review #6).
2777
+ */
2778
+ function barrierDispute(
2779
+ drain: DrainDeps,
2780
+ scope: UpgradeScope,
2781
+ begun: RestartBegun,
2782
+ requireAck: boolean,
2783
+ ): string | undefined {
2784
+ try {
2785
+ const live = liveWorkersInScope(drain, scope);
2786
+ if (live > 0) return `${live} live worker(s) appeared under the setup admission freeze`;
2787
+ if (requireAck && begun.daemon.running) {
2788
+ const problem = fenceAckProblem(drain.admissionAck?.(scope.pauseKey), begun);
2789
+ if (problem !== undefined) return problem;
2790
+ }
2791
+ // The fence's own pause/daemon-generation proof (the reused lifecycle
2792
+ // seam) — wrapped with the worker count so a throwing accessor (an
2793
+ // unknown systemd probe in the identity, say) is a dispute to refuse on,
2794
+ // never an exception escaping with the freeze still written.
2795
+ return restartFenceProblem(drain, scope, begun, "setup");
2796
+ } catch (err) {
2797
+ return err instanceof Error ? err.message : String(err);
2798
+ }
2799
+ }
2800
+
2061
2801
  /**
2062
2802
  * The post-apply code-graph offer. Shared by the tail and the live-worker early
2063
2803
  * return, so neither path can silently leave the graph install unreferenced.
@@ -2111,7 +2851,7 @@ export async function setup(
2111
2851
  }
2112
2852
  // `answers` is mutable below: the review loop re-asks one area at a time and
2113
2853
  // replaces only the fields that area owns, so everything else carries across.
2114
- const { amend, added } = collected;
2854
+ const { amend } = collected;
2115
2855
  let answers = collected.answers;
2116
2856
 
2117
2857
  const scopes = await apply.scopes();
@@ -2262,7 +3002,12 @@ export async function setup(
2262
3002
  : "",
2263
3003
  d.project.escalation.orchestrator === "external"
2264
3004
  ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
2265
- : "Dispatch resumes after the smoke succeeds.",
3005
+ : "Dispatch resumes after the smoke succeeds and the daemon is proven to serve the config it was just written.",
3006
+ // The acknowledged quiescence barrier (#618): the whole sequence refuses
3007
+ // before the first write while any configured project has a live worker
3008
+ // and until the running daemon has acknowledged the admission fence, so
3009
+ // the consent names the gate the apply runs behind.
3010
+ "Refuses before any write while any configured project has a live worker, and until the running daemon acknowledges the admission fence.",
2266
3011
  "Issues are only claimed after every setup gate succeeds.",
2267
3012
  ]
2268
3013
  .filter((s) => s.length > 0)
@@ -2351,91 +3096,525 @@ export async function setup(
2351
3096
  ui.notify(planBlock(plan), "info");
2352
3097
  }
2353
3098
 
2354
- // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
2355
- // partially applied setup unable to claim work. Everything below reads the
2356
- // last re-derivation: the loop above can only break here on a final "Apply",
2357
- // so this one-writer sequence runs exactly once per setup run.
2358
- prepareConductor(plan.project.name);
2359
- const created = await withProgress("Creating tracker labels", "Tracker labels ready", () =>
2360
- apply.createLabels(answers.trackerRepo, plan.labels),
2361
- );
2362
- saveConfig(plan.nextConfig);
2363
- const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
2364
- const runtimeFiles = writeHostRuntime(plan.runtime);
2365
- for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
2366
- const smoke = await withProgress("Running setup smoke", "Setup smoke passed", () =>
2367
- apply.smoke(plan.project.name),
2368
- );
2369
- let smokeLine =
2370
- `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
2371
- `stored status for ${smoke.status.project}`;
3099
+ // ------------------------------------------------------------------ #618 --
3100
+ // The acknowledged quiescence barrier. A setup apply replaces config and
3101
+ // runs a paused-daemon smoke that is itself a dispatcher (it settles rows,
3102
+ // projects labels, salvages workers), and every write below happens
3103
+ // underneath a daemon that may still admit or own workers. The fail-closed
3104
+ // envelope, reusing the same lifecycle/drain seam `upgrade`, `restart` and
3105
+ // `setup host` run on rather than a second counter-only fence: refuse before
3106
+ // the first mutation when any configured project has a live worker; freeze
3107
+ // admission host-wide — through the one global sentinel, never a project's —
3108
+ // and prove the freeze is in force (#552); then acknowledge — re-check every
3109
+ // project in scope under the proven freeze, and prove the fence still holds.
3110
+ // A pass admitted between the first zero-worker observation and that
3111
+ // acknowledgement refuses with the exact pre-entry pause restored and
3112
+ // nothing else written. After the acknowledgement, every awaited mutation/
3113
+ // lifecycle step re-proves the fence and the zero-worker scope before it
3114
+ // acts (a concurrent `resume` during the awaited label I/O is the shape that
3115
+ // used to slip through and orphan an admitted run).
3116
+ //
3117
+ // The pre-acknowledgement refusal paths are all pre-write, so "nothing has
3118
+ // been changed" is literal: no label, no config byte, no brief, no runtime
3119
+ // file, no store write, no smoke and no service mutation. The observation
3120
+ // that gates the barrier is read-only — it can never be the thing that
3121
+ // creates or upgrades the store, because database preparation is itself one
3122
+ // of the mutations the barrier precedes.
3123
+
3124
+ // The exact pre-entry host-global pause sentinel (owner token and timestamp
3125
+ // byte for byte), captured before anything mutates so a refusal — or the end
3126
+ // of a successful apply — restores it verbatim. Setup freezes the whole host
3127
+ // through the one global sentinel, so that is the only path it may change
3128
+ // (review #4); an unreadable pre-existing sentinel fails closed here, before
3129
+ // any change, rather than being read as absence and deleted (review #5).
3130
+ let scope: UpgradeScope;
3131
+ try {
3132
+ scope = setupScope(apply.drain);
3133
+ } catch (err) {
3134
+ ui.notify(
3135
+ `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` +
3136
+ "Nothing has been changed; fix the config and re-run setup.",
3137
+ "error",
3138
+ );
3139
+ return false;
3140
+ }
3141
+ const priorPause = capturePause();
3142
+ if (priorPause.kind === "unreadable") {
3143
+ ui.notify(
3144
+ `Setup stopped before writing anything: the pause sentinel at ${priorPause.path} exists but ` +
3145
+ "could not be read — refusing rather than risk overwriting an operator hold. Nothing has been changed.",
3146
+ "error",
3147
+ );
3148
+ return false;
3149
+ }
3150
+ // A malformed sentinel is unreadable-as-state: readable bytes that parse as
3151
+ // no pause instance are still an operator's hold, and rebuilding them as a
3152
+ // fresh setup pause would lose the owner identity the fence preserves. Fail
3153
+ // closed before any change and never delete the file (#650).
3154
+ if (priorPause.kind === "malformed") {
3155
+ ui.notify(
3156
+ `Setup stopped before writing anything: the pause sentinel at ${priorPause.path} exists but is ` +
3157
+ "malformed (not a readable pause instance) — refusing rather than rebuild an operator hold setup " +
3158
+ "cannot prove. Nothing has been changed.",
3159
+ "error",
3160
+ );
3161
+ return false;
3162
+ }
3163
+
3164
+ // First refusal — before ANY durable mutation, the freeze included. A live
3165
+ // worker in any project the daemon serves would be orphaned, salvaged and
3166
+ // requeued by the config write and the paused-daemon smoke that follow. The
3167
+ // observation is read-only (it never creates or upgrades the store) and
3168
+ // fails closed when quiescence cannot be proven — including when the scope
3169
+ // itself cannot be enumerated (an unreadable config is a fault, never bare
3170
+ // scope, review #2) or the daemon identity cannot be proven (an unknown
3171
+ // systemd probe, review #2).
3172
+ let entryWorkers: number;
3173
+ try {
3174
+ entryWorkers = liveWorkersInScope(apply.drain, scope);
3175
+ } catch (err) {
3176
+ ui.notify(
3177
+ `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` +
3178
+ "Nothing has been changed; re-run setup once the fleet is provably quiet.",
3179
+ "error",
3180
+ );
3181
+ return false;
3182
+ }
3183
+ if (entryWorkers > 0) {
3184
+ ui.notify(
3185
+ [
3186
+ `Setup stopped before writing anything: ${entryWorkers} live worker(s) run under the active daemon.`,
3187
+ "A setup apply replaces config and runs a paused-daemon smoke that would orphan, salvage and requeue those runs.",
3188
+ "Let the workers finish, then re-run setup — nothing has been changed.",
3189
+ ].join("\n"),
3190
+ "error",
3191
+ );
3192
+ return false;
3193
+ }
3194
+
3195
+ // Freeze admission host-wide, then prove the freeze is in force before
3196
+ // anything is re-checked (#552): a pause that cannot be read back as an
3197
+ // instance is a fence that cannot be acknowledged, so the barrier restores
3198
+ // the exact entry pause and refuses instead of proceeding unproven.
3199
+ const initialPaused = apply.drain.layers(scope.pauseKey).paused;
3200
+ if (!initialPaused) apply.drain.setPaused(true, scope.pauseKey);
3201
+ // The sentinel THIS transaction now owns — the bytes it just wrote, or the
3202
+ // pre-entry hold it inherited. Every later restore only touches the disk
3203
+ // while it still matches these exact bytes, so a takeover (a resume, a
3204
+ // newer hold) is preserved rather than overwritten (#651 review #3).
3205
+ const ownedFence = readSentinel(pausedPath(scope.pauseKey));
3206
+ const pauseToken = apply.drain.pauseState(scope.pauseKey);
3207
+ if (pauseToken === undefined) {
3208
+ restorePause(priorPause, ownedFence);
3209
+ ui.notify(
3210
+ "Setup stopped before writing anything: the setup freeze could not be proven and was released. " +
3211
+ "Nothing has been changed; re-run setup once dispatch is quiet.",
3212
+ "error",
3213
+ );
3214
+ return false;
3215
+ }
3216
+
3217
+ // Acknowledge the barrier: every project in scope is re-checked under the
3218
+ // proven freeze, and the fence must still hold — the pause token we proved
3219
+ // and the daemon generation we began with. A worker admitted after the
3220
+ // first zero-worker observation but before this acknowledgement (a dispatch
3221
+ // pass that passed its own pause gate before the freeze landed), a `resume`
3222
+ // that lifted the freeze, or an identity that can no longer be proven (an
3223
+ // unknown systemd probe) refuses here with the exact pre-entry pause
3224
+ // restored and nothing else written.
3225
+ let begun: RestartBegun;
3226
+ try {
3227
+ begun = { pauseToken, daemon: apply.drain.daemonIdentity() };
3228
+ } catch (err) {
3229
+ restorePause(priorPause, ownedFence);
3230
+ ui.notify(
3231
+ `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` +
3232
+ "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.",
3233
+ "error",
3234
+ );
3235
+ return false;
3236
+ }
3237
+
3238
+ // The daemon-side admission acknowledgement (#651 review #3): the fence is
3239
+ // acknowledged only when the daemon ITSELF has observed it at an admission
3240
+ // boundary and written that observation down — a second synchronous worker
3241
+ // count is not an acknowledgement, because a tick already past its own
3242
+ // pause gate can claim after it and before setup mutates. A running daemon
3243
+ // that has not yet acknowledged is woken to prompt an immediate pass; one
3244
+ // that cannot be reached (its record is missing) or that still has not
3245
+ // acknowledged by the bounded deadline refuses before anything is written.
3246
+ let requireAck = true;
3247
+ if (begun.daemon.running) {
3248
+ let ack = apply.drain.admissionAck?.(scope.pauseKey);
3249
+ let acknowledged = fenceAckProblem(ack, begun) === undefined;
3250
+ if (!acknowledged) {
3251
+ const reachable = livingDaemon();
3252
+ if (reachable !== undefined) {
3253
+ ui.notify(
3254
+ "The running daemon has not yet acknowledged the setup admission fence — waking it to prompt a pass.",
3255
+ "info",
3256
+ );
3257
+ void wakeDaemon(reachable.port);
3258
+ // `OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS` is the #399 test seam: a
3259
+ // regression proving this refusal burns the short test deadline
3260
+ // instead of the production 60s.
3261
+ const deadline =
3262
+ Date.now() + Number(process.env["OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS"] ?? FENCE_ACK_WAIT_MS);
3263
+ while (!acknowledged && Date.now() < deadline) {
3264
+ await apply.drain.sleep(FENCE_ACK_POLL_MS);
3265
+ ack = apply.drain.admissionAck?.(scope.pauseKey);
3266
+ acknowledged = fenceAckProblem(ack, begun) === undefined;
3267
+ }
3268
+ }
3269
+ }
3270
+ if (!acknowledged) {
3271
+ restorePause(priorPause, ownedFence);
3272
+ ui.notify(
3273
+ "Setup stopped before writing anything: the running daemon has not acknowledged the setup admission " +
3274
+ "fence, so quiescence cannot be proven. Nothing has been changed; stop the daemon, or re-run setup " +
3275
+ "once it has acknowledged the fence.",
3276
+ "error",
3277
+ );
3278
+ return false;
3279
+ }
3280
+ }
3281
+ const ackDispute = barrierDispute(apply.drain, scope, begun, requireAck);
3282
+ if (ackDispute !== undefined) {
3283
+ restorePause(priorPause, ownedFence);
3284
+ ui.notify(
3285
+ `Setup stopped before writing anything: ${ackDispute}. ` +
3286
+ "Nothing has been changed; re-run setup once dispatch is quiet.",
3287
+ "error",
3288
+ );
3289
+ return false;
3290
+ }
3291
+
3292
+ // The re-proof every later awaited-mutation/lifecycle step stands on: the
3293
+ // acknowledged fence must still hold (pause owned, acknowledgement current,
3294
+ // daemon generation unchanged) and no worker may have appeared. A concurrent
3295
+ // `resume` while label creation is pending can reopen admission, after which
3296
+ // config/runtime/smoke would proceed with no further token or worker check
3297
+ // and orphan the admitted run (review #6) — so every one of them re-runs
3298
+ // `barrierDispute` and restores the exact entry pause on a dispute.
3299
+ const barrierHolds = (needAck: boolean): string | undefined =>
3300
+ barrierDispute(apply.drain, scope, begun, needAck);
3301
+
3302
+ // The after-acknowledgement crosscheck: a claim that lands after the
3303
+ // acknowledgement and before the first mutation (a daemon tick that passed
3304
+ // its own pause gate before the freeze and reaches its claim now) must
3305
+ // refuse here with NOTHING written — this read is the one that makes the
3306
+ // `[0, 0, 1]` interleaving a pre-write refusal, not a post-store one
3307
+ // (#651 review #3). The acknowledgement itself cannot see it: the ack is
3308
+ // the daemon's word, and this synchronous crosscheck is the last moment
3309
+ // the envelope can stay literal about "nothing has been changed".
3310
+ const preMutationReproof = barrierHolds(requireAck);
3311
+ if (preMutationReproof !== undefined) {
3312
+ restorePause(priorPause, ownedFence);
3313
+ ui.notify(
3314
+ `Setup stopped before writing anything: ${preMutationReproof}. ` +
3315
+ "Nothing has been changed; re-run setup once dispatch is quiet.",
3316
+ "error",
3317
+ );
3318
+ return false;
3319
+ }
3320
+
3321
+ // The one-writer transaction — runs exactly once per setup run, only after
3322
+ // the loop above broke on a final "Apply", and only under the acknowledged
3323
+ // barrier held above. Database preparation is the first mutation, in the
3324
+ // order the envelope promises: barrier first, then store, config,
3325
+ // brief/runtime files, smoke, service activation, tracker labels, the
3326
+ // heartbeat proof last — immediately before the commit. The labels sit
3327
+ // BEFORE the heartbeat proof so the deliberate arm-failure hold keeps the
3328
+ // tracker's planned labels while it holds the fleet (#650, PR #700 review
3329
+ // #3); every rollback path after them compensates exactly through the
3330
+ // inventory, and every failure path before them has created none (#652). A
3331
+ // throw anywhere in this leg, or a re-proof dispute after the first
3332
+ // mutation, rolls the WHOLE inventory back as one unit; the pre-write
3333
+ // refusal paths above are untouched.
3334
+ let created: string[] = [];
3335
+ let briefPath: string | undefined;
3336
+ let runtimeFiles: HostRuntimeWrite = { wrote: [], warnings: [] };
3337
+ let smoke: SetupSmokeResult | undefined;
3338
+ let smokeLine = "";
2372
3339
  let restartVia: "systemctl" | "cli" | undefined;
2373
- if (smoke.mode === "existing") {
2374
- if (smoke.status.liveWorkers > 0) {
2375
- // Runs are live: never yank the daemon. Print the reload command; the new
2376
- // project only joins the multi-tenant loop after restart (#319).
3340
+ let armLine = "embedded orchestrator — no heartbeat arm marker";
3341
+
3342
+ // The complete pre-entry mutation inventory (#652): every path the apply
3343
+ // can write config, briefs, staged units/scripts/tick, the AGENTS link,
3344
+ // the run store, the daemon record, the arm marker, the config backups —
3345
+ // with byte-exact pre-entry state, read BEFORE the first mutation so a
3346
+ // failure anywhere in the write/smoke/activation leg can restore all of
3347
+ // them. An unreadable pre-entry path refuses here: nothing has been changed.
3348
+ let inventory: SetupInventory;
3349
+ try {
3350
+ inventory = captureInventory(plan, answers, existing?.projects.map((p) => p.name) ?? []);
3351
+ } catch (err) {
3352
+ // The freeze was already written by this point, so it is released with
3353
+ // the refusal — an unreadable pre-entry path must not leave a setup-owned
3354
+ // sentinel wedging dispatch for every project (review #2).
3355
+ restorePause(priorPause, ownedFence);
3356
+ ui.notify(
3357
+ `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` +
3358
+ "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.",
3359
+ "error",
3360
+ );
3361
+ return false;
3362
+ }
3363
+ // Whether a daemon was live at entry, captured before the first mutation: a
3364
+ // failed restart leg can stop that daemon without proving a replacement, so
3365
+ // only a run that entered with one must prove the runtime back before
3366
+ // dispatch may resume (#652). The capture lives INSIDE the transaction try
3367
+ // below so its refusal path is covered by the same `finally` that removes
3368
+ // the store snapshot — the byte-consistent capture the inventory just made
3369
+ // must never leak a stray temp copy in the state dir when the identity
3370
+ // cannot be proven (#650, PR #700 review #2).
3371
+ let hadLiveDaemonAtEntry = false;
3372
+ try {
3373
+ try {
3374
+ hadLiveDaemonAtEntry = apply.drain.daemonIdentity().running;
3375
+ } catch (err) {
3376
+ // The freeze was already written by this point, so it is released with
3377
+ // the refusal — an identity that cannot be proven must not leave a
3378
+ // setup-owned sentinel wedging dispatch for every project (review #2).
3379
+ restorePause(priorPause, ownedFence);
2377
3380
  ui.notify(
2378
- [
2379
- `Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
2380
- "Dispatch remains paused. Let those workers finish.",
2381
- "Then run `omp-conductor restart --now`.",
2382
- plan.project.escalation.orchestrator === "external"
2383
- ? `Run \`omp-conductor arm --project ${plan.project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${plan.project.name}\`.`
2384
- : `Then run \`omp-conductor resume --project ${plan.project.name}\`.`,
2385
- ].join("\n"),
2386
- "warning",
3381
+ `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` +
3382
+ "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.",
3383
+ "error",
2387
3384
  );
2388
- // The early return must not swallow the graph offer. This host is the
2389
- // live reproduction: the shared reindex units were already installed by
2390
- // the first project, so only the clone+seed are outstanding — and nothing
2391
- // above names them. Offer regardless of live workers: cloning seeds and
2392
- // enables the timer, which does not touch the running daemon.
2393
- await offerCodeGraph(plan.project, ui, apply.graphInstall);
2394
- ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2395
3385
  return false;
2396
3386
  }
2397
- // No live workers: still prefer an explicit reload when this run *added* a
2398
- // neighbour — auto-restart would bounce every other project's heartbeat for
2399
- // a config change they did not ask for. Amends of the only/same project keep
2400
- // the old auto-restart so a first-time install still comes up alone.
2401
- if (added === true) {
3387
+ openStore(dbPath()).close();
3388
+ saveConfig(plan.nextConfig);
3389
+ briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
3390
+ runtimeFiles = writeHostRuntime(plan.runtime);
3391
+ for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
3392
+
3393
+ // Re-prove before the smoke: the paused-daemon smoke is itself a dispatcher,
3394
+ // so it must never run under a reopened fence or beside a worker admitted
3395
+ // since the acknowledgement (review #6). The store/config/brief/runtime
3396
+ // writes above are rolled back with the whole inventory (review #652).
3397
+ const smokeReproof = barrierHolds(requireAck);
3398
+ if (smokeReproof !== undefined) {
3399
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels);
3400
+ restorePause(priorPause, ownedFence);
2402
3401
  ui.notify(
2403
- [
2404
- "Setup files are updated. The running daemon does not serve the new project until it reloads.",
2405
- "No live workers safe to reload now:",
2406
- " omp-conductor restart --now",
2407
- ].join("\n"),
2408
- "info",
3402
+ `Setup stopped before the daemon smoke: ${smokeReproof}. ` +
3403
+ (restoreFailures.length === 0
3404
+ ? "The prior state was restored."
3405
+ : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`),
3406
+ "error",
2409
3407
  );
2410
- } else {
3408
+ return false;
3409
+ }
3410
+ smoke = await withProgress("Running setup smoke", "Setup smoke ran", () =>
3411
+ apply.smoke(plan.project.name),
3412
+ );
3413
+
3414
+ // Re-prove AFTER the awaited smoke too (review #2): the smoke is itself a
3415
+ // dispatcher, and a fence resumed while it ran — or a worker it admitted —
3416
+ // must stop the restart and heartbeat actions that follow. The restart,
3417
+ // if any, is the very next action, so this single re-proof covers it.
3418
+ const postSmokeReproof = barrierHolds(requireAck);
3419
+ if (postSmokeReproof !== undefined) {
3420
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels);
3421
+ restorePause(priorPause, ownedFence);
3422
+ ui.notify(
3423
+ `Setup stopped after the daemon smoke: ${postSmokeReproof}. ` +
3424
+ (restoreFailures.length === 0
3425
+ ? "The prior state was restored."
3426
+ : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`),
3427
+ "error",
3428
+ );
3429
+ return false;
3430
+ }
3431
+
3432
+ smokeLine =
3433
+ smoke.mode === "temporary"
3434
+ ? `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
3435
+ `stored status for ${smoke.status.project}`
3436
+ : // An existing daemon (record or active unit) never ran a `--once` tick
3437
+ // and never staged a temporary one — the restart line below replaces
3438
+ // this one whenever the restart runs.
3439
+ `existing daemon; stored status for ${smoke.status.project}`;
3440
+ if (smoke.mode === "existing") {
3441
+ // The barrier acknowledged zero workers in every project under a proven
3442
+ // host-wide freeze before the first write, so a live worker here can only
3443
+ // mean the fence itself was bypassed. The old soft return — applied config
3444
+ // and a wedged setup pause left underneath that worker, with a "let them
3445
+ // finish" note — is the exact incident shape (#618) and is refused instead,
3446
+ // with the whole inventory rolled back (review #2, #652).
3447
+ if (smoke.status.liveWorkers > 0) {
3448
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels);
3449
+ restorePause(priorPause, ownedFence);
3450
+ ui.notify(
3451
+ `${smoke.status.liveWorkers} live worker(s) appeared under the setup admission freeze — ` +
3452
+ "refusing to apply over a bypassed fence. " +
3453
+ (restoreFailures.length === 0
3454
+ ? "The prior state was restored."
3455
+ : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`),
3456
+ "error",
3457
+ );
3458
+ return false;
3459
+ }
3460
+ // The added-project contract (#650): the newly loaded runtime must be
3461
+ // proven to serve the added project BEFORE setup reports success — and
3462
+ // more generally, an "existing" smoke means a daemon is serving the
3463
+ // config that was just replaced, so it must reload the applied config
3464
+ // through the lifecycle seam. The deferral to a manual `restart --now`
3465
+ // an operator may never run left dispatch ready on a daemon that did
3466
+ // not serve the config it was just written, so it is gone: every
3467
+ // existing-mode apply restarts, even when the smoke proved health, and
3468
+ // the seam's `waitForOwnedDaemon` proves MainPID + /healthz + that the
3469
+ // (added) project is served before the apply reports success.
2411
3470
  const restarted = await apply.restart({ project: plan.project.name });
2412
3471
  restartVia = restarted.via;
2413
3472
  smokeLine =
2414
3473
  `existing /healthz and stored status; restarted through ${restarted.via}; ` +
2415
3474
  `new /healthz on :${restarted.record.port}`;
3475
+ // The intentional restart is the generation boundary: from here the
3476
+ // fence protects the daemon we just started, not the one we began with.
3477
+ // Refresh the acknowledged world — pause re-proven, identity re-captured
3478
+ // — and drop the acknowledgement requirement, because a daemon this
3479
+ // apply itself started cannot have been mid-claim when the fence landed
3480
+ // (its first admission boundary is the tick gate, above routing); its
3481
+ // own first gate re-acknowledges on the next pass. A fence that vanished
3482
+ // while the restart ran (a concurrent resume) refuses here instead of
3483
+ // proceeding under an unproven freeze (review #3).
3484
+ const refreshedPause = apply.drain.pauseState(scope.pauseKey);
3485
+ if (refreshedPause === undefined) {
3486
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels);
3487
+ restorePause(priorPause, ownedFence);
3488
+ ui.notify(
3489
+ "Setup stopped after the daemon restart: the setup pause was lifted while the daemon restarted, so " +
3490
+ "the acknowledged world no longer exists. " +
3491
+ (restoreFailures.length === 0
3492
+ ? "The prior state was restored."
3493
+ : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`),
3494
+ "error",
3495
+ );
3496
+ return false;
3497
+ }
3498
+ begun = { pauseToken: refreshedPause, daemon: apply.drain.daemonIdentity() };
3499
+ requireAck = false;
2416
3500
  }
2417
- }
2418
3501
 
2419
- let armLine = "embedded orchestrator no heartbeat arm marker";
2420
- if (plan.project.escalation.orchestrator === "external") {
2421
- ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
2422
- try {
2423
- armLine = await apply.arm(plan.project.name);
2424
- } catch (err) {
3502
+ // The tracker labelsbefore the heartbeat proof, so the deliberate
3503
+ // arm-failure hold (which keeps its writes by design) leaves the tracker
3504
+ // with the planned labels instead of a config whose routing keys and
3505
+ // queue label do not exist yet (#650, PR #700 review #3). Every rollback
3506
+ // path AFTER this point compensates them exactly through the inventory;
3507
+ // every failure path before it has created none.
3508
+ created = await withProgress("Creating tracker labels", "Tracker labels ready", () =>
3509
+ apply.createLabels(answers.trackerRepo, plan.labels),
3510
+ );
3511
+
3512
+ if (plan.project.escalation.orchestrator === "external") {
2425
3513
  ui.notify(
2426
- [
2427
- "Setup files passed the paused daemon smoke, but the fleet remains held.",
2428
- err instanceof Error ? err.message : String(err),
2429
- `Start the external orchestrator in ${plan.project.workspaceRoot}, then run \`omp-conductor arm --project ${plan.project.name}\`.`,
2430
- "After the arm proof succeeds, run `omp-conductor resume`.",
2431
- ].join("\n"),
2432
- "warning",
3514
+ smoke.healthProven
3515
+ ? "Setup smoke passed. Proving the external heartbeat channel…"
3516
+ : "Setup smoke staged — the restart proved /healthz. Proving the external heartbeat channel…",
3517
+ "info",
3518
+ );
3519
+ try {
3520
+ armLine = await apply.arm(plan.project.name);
3521
+ } catch (err) {
3522
+ ui.notify(
3523
+ [
3524
+ "Setup files passed the paused daemon smoke, but the fleet remains held.",
3525
+ created.length > 0 ? `Tracker labels were created (${created.join(", ")}).` : "Planned labels already existed.",
3526
+ err instanceof Error ? err.message : String(err),
3527
+ `Start the external orchestrator in ${plan.project.workspaceRoot}, then run \`omp-conductor arm --project ${plan.project.name}\`.`,
3528
+ "After the arm proof succeeds, run `omp-conductor resume`.",
3529
+ ].join("\n"),
3530
+ "warning",
3531
+ );
3532
+ ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
3533
+ return false;
3534
+ }
3535
+ }
3536
+
3537
+ // The final barrier boundary: the awaited label and heartbeat I/O are the
3538
+ // last steps before the commit, and a fence resumed while either ran — or
3539
+ // a worker admitted — must refuse with the tracker compensated and every
3540
+ // captured path restored.
3541
+ const finalReproof = barrierHolds(requireAck);
3542
+ if (finalReproof !== undefined) {
3543
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels);
3544
+ restorePause(priorPause, ownedFence);
3545
+ ui.notify(
3546
+ `Setup stopped before committing: ${finalReproof}. ` +
3547
+ (restoreFailures.length === 0
3548
+ ? "The prior state was restored."
3549
+ : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`),
3550
+ "error",
2433
3551
  );
2434
- ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2435
3552
  return false;
2436
3553
  }
3554
+ } catch (err) {
3555
+ // Roll the whole apply back as one transaction (#652): every captured
3556
+ // path restored byte-for-byte (or removed on a first install), the
3557
+ // tracker compensated exactly, and — because a failed restart leg may
3558
+ // have stopped the entry daemon without proving a replacement — a healthy
3559
+ // daemon proven to serve the restored config before dispatch may resume.
3560
+ // If that proof cannot be made, stay fail-closed: a setup hold stays in
3561
+ // force with the recovery command named, and the thrown error reports the
3562
+ // first restoration failure honestly rather than implying the prior state
3563
+ // is coherent.
3564
+ //
3565
+ // The labels created before a mid-way createLabels throw ride on the
3566
+ // error (createMissingLabels attaches its partial list), so compensation
3567
+ // stays exact even when the tracker step itself failed.
3568
+ const createdLabels = created.length > 0 ? created : ((err as { created?: string[] }).created ?? []);
3569
+ const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, createdLabels, apply.deleteLabels);
3570
+ let runtimeProved = !hadLiveDaemonAtEntry;
3571
+ if (hadLiveDaemonAtEntry) {
3572
+ // The proof targets the PRE-ENTRY configured set the rollback restored,
3573
+ // never the rejected generation's: the restarted runtime must be proven
3574
+ // to serve every project the prior runtime served (#650).
3575
+ runtimeProved = await apply.proveRuntime(inventory.priorProjects).catch(() => false);
3576
+ }
3577
+ // The pause ownership rule (#651 review #3): only the fence this
3578
+ // transaction froze is undone; a takeover stands.
3579
+ restorePause(priorPause, ownedFence);
3580
+ const failures = [...restoreFailures];
3581
+ if (!runtimeProved) {
3582
+ // Fail-closed: only an entry that was actually unpaused needs a hold
3583
+ // re-asserted — an entry already held keeps that exact sentinel, and a
3584
+ // takeover that replaced the fence is preserved, never overwritten.
3585
+ // The re-asserted hold is ALWAYS the host-global sentinel, never a
3586
+ // project-scoped one: a fresh `paused-<project>` written under a
3587
+ // failure that entered under a global pause is the silent fake this
3588
+ // slice exists to forbid — when the global hold later clears, the
3589
+ // hidden project pause would remain and deadlock the fleet (#650).
3590
+ if (priorPause.kind === "absent" && readSentinel(pausedPath(scope.pauseKey)).kind === "absent") {
3591
+ setPaused(true, {
3592
+ source: pauseSourceToken("setup"),
3593
+ reason: "setup apply failed and no healthy daemon could be proven",
3594
+ });
3595
+ }
3596
+ failures.push(
3597
+ `no healthy daemon could be proven after the rollback — the fleet stays held; ` +
3598
+ `run \`omp-conductor restart --project ${plan.project.name}\` to recover`,
3599
+ );
3600
+ }
3601
+ if (failures.length > 0) {
3602
+ throw new Error(
3603
+ `setup apply failed (${err instanceof Error ? err.message : String(err)}) and the prior state could not ` +
3604
+ `be fully restored: ${failures.join("; ")}`,
3605
+ );
3606
+ }
3607
+ throw err;
3608
+ } finally {
3609
+ // The byte-consistent store snapshot exists only for this transaction.
3610
+ if (inventory.store.kind === "snapshot") rmSync(inventory.store.snapshotPath!, { force: true });
2437
3611
  }
2438
- apply.resume(plan.project.name);
3612
+ // Lift the setup freeze and restore the exact pre-entry pause: an entry that
3613
+ // was already paused (an operator hold, say) keeps that sentinel byte for
3614
+ // byte, and an entry that was unpaused resumes dispatch exactly as the old
3615
+ // `resume` did. A takeover that replaced the setup fence meanwhile is
3616
+ // preserved, never overwritten (#651 review #3).
3617
+ restorePause(priorPause, ownedFence);
2439
3618
 
2440
3619
  ui.notify(
2441
3620
  [
@@ -2447,7 +3626,7 @@ export async function setup(
2447
3626
  runtimeFiles.wrote.length === 0
2448
3627
  ? "Host runtime files were already current."
2449
3628
  : `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
2450
- `Smoke passed: ${smokeLine}.`,
3629
+ `Smoke ${smoke.healthProven ? "passed" : "staged; the restart proved /healthz"}: ${smokeLine}.`,
2451
3630
  `Heartbeat: ${armLine}.`,
2452
3631
  "",
2453
3632
  "Use the documented toy-issue drill to prove one complete worker path.",