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.
- package/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/upgrade-verify.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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) {
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -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,
|