omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -21,6 +21,7 @@
21
21
  * only runtime imports are leaves (journal, config, store).
22
22
  */
23
23
 
24
+ import { readFileSync } from "node:fs";
24
25
  import { loadConfig, stateDir } from "./config.ts";
25
26
  import { DEFAULT_PORT } from "./lifecycle.ts";
26
27
  import { dbPath, openStore } from "./store.ts";
@@ -168,6 +169,11 @@ export interface PendingUpgradeRequest {
168
169
  selectors: readonly (string | undefined)[];
169
170
  initial?: UpgradeJournalEntry["initial"];
170
171
  configBackup?: string;
172
+ /**
173
+ * Epoch-ms when the install began (the journal snapshot's write time): the
174
+ * deadline the live orchestrator session must have restarted after (#832).
175
+ */
176
+ reloadAfterMs?: number;
171
177
  }
172
178
 
173
179
  /** The newest request plus everything journaled after it: the view one
@@ -189,6 +195,7 @@ export function pendingUpgradeRequest(
189
195
  const request = progression[0]!;
190
196
  if (request.ok === false) return undefined;
191
197
  const snapshot = progression.find((entry) => entry.kind === "snapshot");
198
+ const snapshotAt = snapshot === undefined ? undefined : Date.parse(snapshot.at);
192
199
  return {
193
200
  version: request.version ?? "unknown",
194
201
  gitHead: request.gitHead,
@@ -197,13 +204,164 @@ export function pendingUpgradeRequest(
197
204
  selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
198
205
  initial: snapshot?.initial,
199
206
  configBackup: snapshot?.configBackup,
207
+ ...(snapshotAt !== undefined && Number.isFinite(snapshotAt) ? { reloadAfterMs: snapshotAt } : {}),
200
208
  };
201
209
  }
202
210
 
203
211
  // ---------------------------------------------------------------------------
204
- // The post-restart verifier
212
+ // The live-orchestrator session attestation (#832)
205
213
  // ---------------------------------------------------------------------------
206
214
 
215
+ /**
216
+ * The live orchestrator surface one project's `/healthz` carries: the mode the
217
+ * fleet runs, and — when the daemon hosts the session itself — the extension
218
+ * version that session process actually LOADED. `undefined` means the daemon
219
+ * carried no attestation at all: it predates the contract, or the body is
220
+ * unreadable, and that is "not attested", never "the newest".
221
+ */
222
+ export interface LiveOrchestratorFacts {
223
+ mode: "embedded" | "external" | "failed";
224
+ loaded?: string;
225
+ }
226
+
227
+ /**
228
+ * Parse one project's `orchestrator` block off a daemon `/healthz` body.
229
+ * Absent or malformed answers are `undefined` — the caller decides what
230
+ * missing evidence means (for a post-upgrade daemon: unprovable reload).
231
+ */
232
+ export function orchestratorFactsFromHealth(
233
+ body: string | undefined,
234
+ project: string,
235
+ ): LiveOrchestratorFacts | undefined {
236
+ if (body === undefined) return undefined;
237
+ let parsed: unknown;
238
+ try {
239
+ parsed = JSON.parse(body);
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
244
+ const projects = Reflect.get(parsed, "projects");
245
+ if (!Array.isArray(projects)) return undefined;
246
+ const entry = projects.find(
247
+ (candidate) =>
248
+ candidate !== null &&
249
+ typeof candidate === "object" &&
250
+ !Array.isArray(candidate) &&
251
+ Reflect.get(candidate as object, "project") === project,
252
+ );
253
+ if (entry === undefined) return undefined;
254
+ const raw = Reflect.get(entry as object, "orchestrator");
255
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
256
+ const record = raw as Record<string, unknown>;
257
+ const mode = record["mode"];
258
+ if (mode !== "embedded" && mode !== "external" && mode !== "failed") return undefined;
259
+ const loaded = record["loaded"];
260
+ return {
261
+ mode,
262
+ ...(typeof loaded === "string" ? { loaded } : {}),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Epoch-ms when one pid started, read off `/proc` — the live-process fact an
268
+ * external (pane-owned) orchestrator attestation is built from. `undefined`
269
+ * when the answer cannot be read; absence is never "it restarted".
270
+ */
271
+ export function processStartTimeMs(pid: number): number | undefined {
272
+ try {
273
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
274
+ // Field 22 (`starttime`) counts clock ticks since boot; the comm field can
275
+ // contain `)`, so split after the last one. Dropping the `pid (comm)`
276
+ // prefix shifts every index by three, so full-field 22 is `fields[19]` —
277
+ // `fields[21]` would be `rss`, an ever-present constant that never times a
278
+ // live restart. CLK_TCK is 100 on every Linux host herdr targets.
279
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
280
+ const startTicks = Number(fields[19]);
281
+ const boot = readFileSync("/proc/stat", "utf8");
282
+ const bootSeconds = Number(/^btime\s+(\d+)/m.exec(boot)?.[1]);
283
+ if (!Number.isFinite(startTicks) || !Number.isFinite(bootSeconds)) return undefined;
284
+ return (bootSeconds + startTicks / 100) * 1000;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ /** The omp processes herdr claims for the fleet pane resolved to start times,
291
+ * or why they could not be read. The probe seam keeps the upgrade hermetic:
292
+ * the pure verdict below compares the starts against the install deadline. */
293
+ export type PaneOmpProbe = { starts: readonly number[] } | { problem: string };
294
+
295
+ /**
296
+ * What a live pane session must prove after an upgrade: every omp process the
297
+ * fleet pane currently runs must have STARTED at or after the instant the
298
+ * install began — a pane process still running from before the install loaded
299
+ * the pre-upgrade extension, and an empty pane is exactly the stale-or-missing
300
+ * reload the upgrade must not claim success over. `undefined` when the pane
301
+ * proves the reload.
302
+ */
303
+ export function paneRestartProblem(starts: readonly number[], reloadAfterMs: number): string | undefined {
304
+ const stale = starts.find((startedAt) => startedAt < reloadAfterMs);
305
+ if (stale !== undefined) {
306
+ const at = new Date(stale).toISOString();
307
+ const deadline = new Date(reloadAfterMs).toISOString();
308
+ return `the fleet pane is still running the omp process it started at ${at} — before the install began at ${deadline}; its loaded conductor extension predates this upgrade`;
309
+ }
310
+ if (starts.length === 0) {
311
+ return "no live omp process is claimed by the fleet pane — the orchestrator session did not reload";
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ /**
317
+ * What one project's live session must prove after the reloads. `undefined`
318
+ * is the pass; a string is the problem that must fail the transaction.
319
+ *
320
+ * - `embedded` — the daemon hosts the session: the live process ATTESTED the
321
+ * extension version it loaded at start, so the loaded version must equal
322
+ * the target release. A session that survived the upgrade answers its old
323
+ * version and fails here.
324
+ * - `failed` — the embedded session failed to start: no live session exists
325
+ * to have reloaded, and the upgrade must not claim one.
326
+ * - `external` — the fleet pane owns the session and the daemon cannot
327
+ * attest its loaded code, so the reload is proven from the pane's live
328
+ * process start times (via the pane probe): every omp process the pane
329
+ * runs must have started at/after the install began. A herdr restart that
330
+ * never happened (`herdrRestarted` false) leaves the pane untouched and
331
+ * this leg unattestable — the transaction skips it rather than failing a
332
+ * pane nothing was supposed to reload.
333
+ */
334
+ export function sessionReloadProblem(o: {
335
+ facts: LiveOrchestratorFacts | undefined;
336
+ targetVersion: string;
337
+ reloadAfterMs: number;
338
+ herdrRestarted: boolean;
339
+ pane: PaneOmpProbe | undefined;
340
+ }): string | undefined {
341
+ if (o.facts === undefined) {
342
+ return "the restarted daemon did not attest its orchestrator session — /healthz carried no orchestrator fact; the live extension's code cannot be proven";
343
+ }
344
+ if (o.facts.mode === "embedded") {
345
+ if (o.facts.loaded !== o.targetVersion) {
346
+ return `the live orchestrator session loaded omp-conductor ${o.facts.loaded ?? "(unattested)"}, not ${o.targetVersion} — it was not reloaded by this upgrade`;
347
+ }
348
+ return undefined;
349
+ }
350
+ if (o.facts.mode === "failed") {
351
+ return "the embedded orchestrator session failed to start after the restart — there is no live session on the new extension";
352
+ }
353
+ // external: the pane owns the session; the daemon cannot attest the pane's
354
+ // loaded code, so the transaction proves the reload from the pane's live
355
+ // process start times — a pane process that survived the herdr restart
356
+ // loaded the pre-upgrade extension.
357
+ if (!o.herdrRestarted) return undefined;
358
+ if (o.pane === undefined) {
359
+ return "the fleet pane could not be probed — its reload cannot be proven";
360
+ }
361
+ if ("problem" in o.pane) return `the fleet pane could not be probed: ${o.pane.problem}`;
362
+ return paneRestartProblem(o.pane.starts, o.reloadAfterMs) ?? undefined;
363
+ }
364
+
207
365
  /**
208
366
  * Every injectable process/state seam the post-restart verifier touches.
209
367
  *
@@ -232,6 +390,18 @@ export interface UpgradeVerifyDeps {
232
390
  launchRollback(
233
391
  version: string,
234
392
  ): Promise<{ ok: true; unit: string } | { ok: false; stderr: string }>;
393
+ /**
394
+ * The herdr session the fleet pane lives in — the pane-side name for
395
+ * resolving the external orchestrator's live processes (#832).
396
+ */
397
+ herdrSession: string;
398
+ /**
399
+ * The live omp processes the fleet pane claims, resolved to their start
400
+ * times — the evidence an external orchestrator's reload is judged on
401
+ * (#832). `{ problem }` means the pane surface answered unreadably; the
402
+ * upgrade must not invent a pass from an unreadable pane.
403
+ */
404
+ probePaneOmp(session: string): Promise<PaneOmpProbe>;
235
405
  log(message: string): void;
236
406
  now?(): number;
237
407
  }
@@ -430,12 +600,49 @@ export async function runUpgradeChecks(
430
600
  checks.push({ name: "herdr", ok: layers.herdr === "active", detail: layers.herdr });
431
601
  }
432
602
  }
603
+ // One probe feeds both the health check and the session attestation below:
604
+ // the body the daemon answered is the same read the session check parses.
605
+ const health = layers.daemon.running
606
+ ? await liveHealth(deps, layers.daemon.port ?? DEFAULT_PORT)
607
+ : undefined;
433
608
  checks.push({
434
609
  name: "health",
435
- ok: layers.daemon.running && (await liveHealth(deps, layers.daemon.port ?? DEFAULT_PORT)).ok,
610
+ ok: health?.ok === true,
436
611
  detail: layers.daemon.running ? undefined : "no daemon record",
437
612
  });
438
613
 
614
+ // The live-orchestrator session (#832): a successful install must leave the
615
+ // orchestrator process executing the newly installed extension, and package
616
+ // identities on disk say nothing about what the running session loaded. The
617
+ // restarted daemon attests its hosted session's loaded version on /healthz;
618
+ // an external (pane-owned) session is proven from the pane's live process
619
+ // start times. A reload that cannot be proven fails the transaction — the
620
+ // verifier rolls back rather than report a success the fleet is not running.
621
+ const healthBody = health?.body;
622
+ const herdrRestarted = request.initial?.herdr === "active";
623
+ const sessionRequired = herdrRestarted || (request.initial?.dispatch ?? "running") !== "stopped";
624
+ let sessionProblem = "";
625
+ if (sessionRequired && (layers.daemon.running || herdrRestarted)) {
626
+ const pane = await deps.probePaneOmp(deps.herdrSession).catch(
627
+ (err): PaneOmpProbe => ({
628
+ problem: err instanceof Error ? err.message : String(err),
629
+ }),
630
+ );
631
+ sessionProblem =
632
+ sessionReloadProblem({
633
+ facts: orchestratorFactsFromHealth(healthBody, deps.projectName),
634
+ targetVersion: request.version,
635
+ reloadAfterMs: request.reloadAfterMs ?? Date.now(),
636
+ herdrRestarted,
637
+ pane,
638
+ }) ?? "";
639
+ }
640
+ checks.push({
641
+ name: "session",
642
+ ok: sessionProblem === "",
643
+ ...(sessionProblem === "" ? {} : { detail: sessionProblem }),
644
+ });
645
+
439
646
  const doctor = await deps.doctor(deps.projectName);
440
647
  const failing = doctor.findings.filter((finding) => finding.status === "fail");
441
648
  const drift = failing.filter((finding) => PRIVILEGED_INSTALL_DRIFT.has(finding.id));
package/src/upgrade.ts CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  resolveHerdrSession,
10
10
  telegramStateDir,
11
11
  } from "./fleet.ts";
12
+ import { herdrPaneOmpStarts } from "./fleet.ts";
12
13
  import type { DispatchLayer, FleetLayers } from "./status-render.ts";
13
- import { livingDaemon, restartDaemon } from "./lifecycle.ts";
14
+ import { DEFAULT_PORT, healthCheck, livingDaemon, restartDaemon } from "./lifecycle.ts";
14
15
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
15
16
  import { renderBriefForProject } from "./setup.ts";
16
17
  import {
@@ -26,10 +27,15 @@ import {
26
27
  } from "./upgrade-journal.ts";
27
28
  import {
28
29
  enqueueUpgradeReport,
30
+ orchestratorFactsFromHealth,
31
+ paneRestartProblem,
29
32
  pendingUpgradeRequest,
33
+ processStartTimeMs,
30
34
  reportProject,
31
35
  requestProgression,
32
36
  runCommand,
37
+ sessionReloadProblem,
38
+ type PaneOmpProbe,
33
39
  type PendingUpgradeRequest,
34
40
  type UpgradeCommandResult,
35
41
  } from "./upgrade-verify.ts";
@@ -95,6 +101,25 @@ export interface UpgradeDeps {
95
101
  sleep(ms: number): Promise<void>;
96
102
  env: NodeJS.ProcessEnv;
97
103
  log(message: string): void;
104
+ /**
105
+ * Probe the running daemon's `/healthz`. The post-reload session
106
+ * verification reads the live orchestrator attestation off the answer
107
+ * (#832): the restarted daemon says which conductor version its orchestrator
108
+ * session process actually loaded.
109
+ */
110
+ health(port: number): Promise<{ ok: boolean; body?: string }>;
111
+ /**
112
+ * The herdr session the fleet pane lives in, for the external-orchestrator
113
+ * pane probe (#832).
114
+ */
115
+ herdrSession(): string;
116
+ /**
117
+ * Every omp process the fleet pane currently claims by herdr, resolved to
118
+ * its start time — the evidence an external (pane-owned) orchestrator
119
+ * reloaded after the install began (#832). `{ problem }` when the pane
120
+ * surface answers unreadably: absence of evidence is never a reload.
121
+ */
122
+ probePaneOmp(session: string): Promise<PaneOmpProbe>;
98
123
  /**
99
124
  * The host runtime this version's package renders, compared against what is
100
125
  * actually installed: the daemon and herdr units, the recovery unit and its
@@ -146,6 +171,9 @@ export const DEFAULT_DEPS: UpgradeDeps = {
146
171
  restartDaemon: async () => {
147
172
  await restartDaemon({});
148
173
  },
174
+ health: async (port) => healthCheck(port),
175
+ herdrSession: () => resolveHerdrSession(process.env),
176
+ probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
149
177
  // The bare host-global plan — the same render the advisory's `setup host`
150
178
  // command would install: no per-project tail, the recovery unit encoding no
151
179
  // one project's name, and FLEET_CWDS derived from every configured project.
@@ -668,6 +696,133 @@ async function waitForRecovery(
668
696
  throw new Error(`upgrade verification failed: ${problem}`);
669
697
  }
670
698
 
699
+ // ---------------------------------------------------------------------------
700
+ // The live orchestrator session reload (#832)
701
+ // ---------------------------------------------------------------------------
702
+
703
+ /** One pass of the post-reload session verification (#832): read the restarted
704
+ * daemon's attestation (embedded orchestrator: the live session's loaded
705
+ * version; external: the pane probe's process starts) and answer whether
706
+ * every in-scope live session proves it reloaded the target release after
707
+ * the install began. `undefined` is the pass — every project the daemon
708
+ * serves must have attested, because the restart covered the whole daemon at
709
+ * once.
710
+ *
711
+ * The embedded leg is gated on the dispatch restart: a daemon that was
712
+ * already stopped when the transaction began never hosted a session the
713
+ * transaction could have reloaded, so its absence is not a failure. The
714
+ * external leg is gated on the herdr restart: a transaction that left the
715
+ * pane alone has no reason to demand proof the pane did anything.
716
+ */
717
+ async function sessionVerifyProblem(
718
+ deps: UpgradeDeps,
719
+ targetVersion: string,
720
+ reloadAfterMs: number,
721
+ initial: FleetLayers,
722
+ ): Promise<string | undefined> {
723
+ const dispatchRestarted = initial.dispatch !== "stopped";
724
+ const herdrRestarted = initial.herdr === "active";
725
+ if (!dispatchRestarted && !herdrRestarted) return undefined;
726
+ if (!dispatchRestarted) {
727
+ // No daemon to attest: only the pane could have reloaded anything, so the
728
+ // pane probe decides alone, exactly as the external leg of the detached
729
+ // verifier does.
730
+ const pane = await deps.probePaneOmp(deps.herdrSession());
731
+ if ("problem" in pane) return `the fleet pane could not be probed: ${pane.problem}`;
732
+ return paneRestartProblem(pane.starts, reloadAfterMs) ?? undefined;
733
+ }
734
+ const port = deps.layers().daemon.port ?? DEFAULT_PORT;
735
+ const health = await deps.health(port);
736
+ if (!health.ok) return `the restarted daemon did not answer /healthz on :${port}`;
737
+ let parsed: unknown;
738
+ try {
739
+ parsed = JSON.parse(health.body ?? "");
740
+ } catch {
741
+ return `the restarted daemon's /healthz on :${port} is not JSON — the live session cannot be attested`;
742
+ }
743
+ const projects = projectsFromHealth(parsed);
744
+ if (projects === undefined) {
745
+ return `the restarted daemon's /healthz on :${port} carried no projects — the live session cannot be attested`;
746
+ }
747
+ const names: string[] = [];
748
+ for (const entry of projects) {
749
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry) && "project" in entry) {
750
+ if (typeof entry.project === "string") names.push(entry.project);
751
+ }
752
+ }
753
+ if (names.length === 0) {
754
+ return `the restarted daemon's /healthz on :${port} named no project — the live session cannot be attested`;
755
+ }
756
+ const pane = await deps.probePaneOmp(deps.herdrSession());
757
+ for (const project of names) {
758
+ const problem = sessionReloadProblem({
759
+ facts: orchestratorFactsFromHealth(health.body, project),
760
+ targetVersion,
761
+ reloadAfterMs,
762
+ herdrRestarted: herdrRestarted && modeFromHealth(parsed, project) === "external",
763
+ pane,
764
+ });
765
+ if (problem !== undefined) return problem;
766
+ }
767
+ return undefined;
768
+ }
769
+
770
+ /**
771
+ * The `projects` array of a daemon /healthz payload, narrowed without an
772
+ * unchecked cast: `undefined` when the payload is not an object with a
773
+ * `projects` array — the caller decides what an unreadable answer means.
774
+ */
775
+ function projectsFromHealth(parsed: unknown): unknown[] | undefined {
776
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
777
+ if (!("projects" in parsed)) return undefined;
778
+ return Array.isArray(parsed.projects) ? parsed.projects : undefined;
779
+ }
780
+
781
+ /**
782
+ * One project's attested orchestrator mode off a parsed /healthz payload.
783
+ * `undefined` when the entry or its mode cannot be read.
784
+ */
785
+ function modeFromHealth(parsed: unknown, project: string): string | undefined {
786
+ const projects = projectsFromHealth(parsed);
787
+ if (projects === undefined) return undefined;
788
+ for (const entry of projects) {
789
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
790
+ if (!("project" in entry) || entry.project !== project) continue;
791
+ if (!("orchestrator" in entry)) return undefined;
792
+ const orchestrator = entry.orchestrator;
793
+ if (orchestrator === null || typeof orchestrator !== "object" || Array.isArray(orchestrator)) {
794
+ return undefined;
795
+ }
796
+ if (!("mode" in orchestrator) || typeof orchestrator.mode !== "string") return undefined;
797
+ return orchestrator.mode;
798
+ }
799
+ return undefined;
800
+ }
801
+
802
+ /**
803
+ * The verify leg after the reloads (#832): the upgrade only reports success
804
+ * once the live orchestrator process has proven it executes the newly
805
+ * installed extension. Polls the attestation within the same recovery window
806
+ * as the other post-reload checks — the restarted daemon needs seconds to
807
+ * boot its orchestrator child and answer — then throws into the transaction's
808
+ * rollback path, so a failed session reload is a durable, actionable failure
809
+ * and never a reported success.
810
+ */
811
+ async function waitForSessionVerified(
812
+ deps: UpgradeDeps,
813
+ targetVersion: string,
814
+ reloadAfterMs: number,
815
+ initial: FleetLayers,
816
+ ): Promise<void> {
817
+ let problem = "the live orchestrator session did not attest a reload";
818
+ for (let attempt = 0; attempt < RECOVERY_ATTEMPTS; attempt += 1) {
819
+ problem = (await sessionVerifyProblem(deps, targetVersion, reloadAfterMs, initial)) ?? "";
820
+ if (problem === "") return;
821
+ await deps.sleep(RECOVERY_POLL_MS);
822
+ }
823
+ throw new Error(`upgrade verification failed: ${problem}`);
824
+ }
825
+
671
826
  /** One project's brief, as the transaction found it. */
672
827
  interface ScopedBrief {
673
828
  /** The selector to address the project-aware deps and the CLI with. */
@@ -1047,6 +1202,9 @@ export async function upgradeConductor(
1047
1202
  let briefChanged = false;
1048
1203
  let herdrReloadStarted = false;
1049
1204
  let daemonReloadStarted = false;
1205
+ // The instant the transaction began replacing surfaces: the deadline the
1206
+ // live orchestrator session must prove it restarted after (#832).
1207
+ const reloadStarted = Date.now();
1050
1208
  try {
1051
1209
  if (installNeeded) {
1052
1210
  installTouched = true;
@@ -1129,6 +1287,22 @@ export async function upgradeConductor(
1129
1287
  await deps.sleep(1_000);
1130
1288
  await waitForRecovery(deps, initial, scope);
1131
1289
 
1290
+ // verify 3/3 (#832): package identities are on disk, but the orchestrator
1291
+ // session process executes the module it LOADED at start — a session that
1292
+ // survived the restarts keeps running the pre-upgrade extension no matter
1293
+ // what the packages say. The transaction only reports success once the
1294
+ // live session (daemon-attested for the embedded orchestrator, pane start
1295
+ // times for the external one) proves it reloaded the installed release. A
1296
+ // fleet that restarted nothing — dispatch stopped AND herdr inactive —
1297
+ // has no live orchestrator to prove, so there is nothing to attest.
1298
+ if (initial.dispatch !== "stopped" || initial.herdr === "active") {
1299
+ deps.log("verify 3/3: the live orchestrator session loaded the installed extension");
1300
+ await waitForSessionVerified(deps, release.version, reloadStarted, initial);
1301
+ journal({ kind: "phase", phase: "verify", surface: "session", ok: true, version: release.version });
1302
+ } else {
1303
+ deps.log("verify 3/3: no live orchestrator session to attest (dispatch stopped, herdr inactive)");
1304
+ }
1305
+
1132
1306
  if (!initial.paused) deps.setPaused(false, scope.pauseKey);
1133
1307
  const restoredDispatch = deps.layers(scope.pauseKey).dispatch;
1134
1308
  if (restoredDispatch !== initial.dispatch) {
@@ -371,6 +371,45 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
371
371
  roleRefusalText: (role) =>
372
372
  `review authority is the orchestrator's; this is a ${role} session, and a worker never returns its own PR for revision.`,
373
373
  },
374
+ conductor_pr_recover: {
375
+ name: "conductor_pr_recover",
376
+ mutating: true,
377
+ allowedRoles: ["orchestrator"],
378
+ description:
379
+ "Recover the missing pull request for a terminal run whose branch was preserved with no PR (#806): " +
380
+ "the daemon proves the run has no live worker, that no other PR already represents the issue, and that the " +
381
+ "routed branch still exists at the exact 40-hex head the run recorded, then opens the missing PR from that " +
382
+ "branch to the repo's configured default branch and records it on the run so review/merge settle normally. " +
383
+ "Automatically adopts and returns an existing PR at that exact head (idempotent). Refused for a live run, a " +
384
+ "closed or merged issue/PR, a replaced or force-pushed head, an unrelated open closer, a recorded PR with no " +
385
+ "readable state, or a repo the project no longer routes.",
386
+ args: {
387
+ issueUrl: {
388
+ type: "string",
389
+ required: true,
390
+ description:
391
+ "Full issue URL of the run to recover. Must be in this project's tracker repository; the run resolved is the newest attempt for it.",
392
+ },
393
+ branch: {
394
+ type: "string",
395
+ required: false,
396
+ description: "Optional assertion: must equal the run's recorded branch.",
397
+ },
398
+ base: {
399
+ type: "string",
400
+ required: false,
401
+ description: "Optional assertion: must equal the repo's configured defaultBranch.",
402
+ },
403
+ headSha: {
404
+ type: "string",
405
+ required: false,
406
+ description: "Optional assertion: must equal the run's recorded exact head.",
407
+ },
408
+ rationale: RATIONALE_ARG,
409
+ },
410
+ roleRefusalText: (role) =>
411
+ `pull-request recovery is the orchestrator's; this is a ${role} session, and a worker's PR is its own to push and leave.`,
412
+ },
374
413
  conductor_pr_status: {
375
414
  name: "conductor_pr_status",
376
415
  mutating: false,