omp-conductor 0.19.5 → 0.19.7
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/REFERENCE.md +17 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +18 -8
- package/src/command-manifest.ts +22 -5
- package/src/commands/context.ts +1 -0
- package/src/commands/report.ts +63 -0
- package/src/commands/restore-db.ts +1 -1
- package/src/commands/snapshot-db.ts +67 -0
- package/src/commands/upgrade-install.ts +1 -1
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/verb.ts +1 -0
- package/src/commands/watch.ts +14 -2
- package/src/config.ts +17 -0
- package/src/daemon.ts +146 -35
- package/src/doctor.ts +53 -15
- package/src/escalate.ts +39 -21
- package/src/fleet.ts +979 -143
- package/src/orchestrator-tick.ts +14 -3
- package/src/setup-host.ts +10 -10
- package/src/setup-wizard.ts +1 -1
- package/src/status-render.ts +10 -0
- package/src/store.ts +338 -22
- package/src/types.ts +51 -3
- package/src/upgrade-journal.ts +35 -1
- package/src/upgrade-verify.ts +27 -6
- package/src/upgrade.ts +301 -22
- package/src/verbs/actions.ts +6 -1
- package/src/verbs/server.ts +146 -31
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
package/src/daemon.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
loadConfig,
|
|
18
18
|
resolveCaps,
|
|
19
19
|
resolveReleaseGrants,
|
|
20
|
+
resolveSharedInstallAuthority,
|
|
20
21
|
resolveReview,
|
|
21
22
|
stateDir,
|
|
22
23
|
} from "./config.ts";
|
|
@@ -63,6 +64,7 @@ import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
|
|
|
63
64
|
import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
|
|
64
65
|
import { processStartTimeMs } from "./upgrade-verify.ts";
|
|
65
66
|
import {
|
|
67
|
+
closeWorkerPane,
|
|
66
68
|
fleetLayers,
|
|
67
69
|
herdrPaneOmpStarts,
|
|
68
70
|
openWorkerPane,
|
|
@@ -72,6 +74,9 @@ import {
|
|
|
72
74
|
releaseWorkerPane,
|
|
73
75
|
reportWorkerPaneState,
|
|
74
76
|
resolveHerdrSession,
|
|
77
|
+
retireWorkerPane,
|
|
78
|
+
type HerdrRun,
|
|
79
|
+
type WorkspaceOwnership,
|
|
75
80
|
type WorkerPaneOutcome,
|
|
76
81
|
} from "./fleet.ts";
|
|
77
82
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
@@ -171,6 +176,7 @@ import type {
|
|
|
171
176
|
DigestBacklog,
|
|
172
177
|
Escalation,
|
|
173
178
|
InterruptCategory,
|
|
179
|
+
HandoffWithdrawal,
|
|
174
180
|
IssueComment,
|
|
175
181
|
IssueSnapshot,
|
|
176
182
|
PrDiff,
|
|
@@ -338,6 +344,12 @@ interface Deps {
|
|
|
338
344
|
workerDeps?: RunWorkerDeps;
|
|
339
345
|
/** Harness per-process logs used to classify terminal worker routes. */
|
|
340
346
|
harnessLogDir?: string;
|
|
347
|
+
/**
|
|
348
|
+
* The Herdr runner for the worker-representation surface (#1035). Production
|
|
349
|
+
* leaves it absent — the real `herdr` CLI answers; lifecycle tests inject a
|
|
350
|
+
* fake so convergence is proven without a terminal.
|
|
351
|
+
*/
|
|
352
|
+
herdrRun?: HerdrRun;
|
|
341
353
|
integrity: IntegrityGate;
|
|
342
354
|
stall: StallGate;
|
|
343
355
|
/**
|
|
@@ -451,6 +463,7 @@ interface Deps {
|
|
|
451
463
|
export function verbDeps(d: Pick<Deps, "project" | "store" | "tracker" | "verbActions">): VerbDeps {
|
|
452
464
|
return {
|
|
453
465
|
project: () => findProject(loadConfig(), d.project.name),
|
|
466
|
+
projects: () => loadConfig().projects,
|
|
454
467
|
store: d.store,
|
|
455
468
|
tracker: d.tracker,
|
|
456
469
|
actions: d.verbActions ?? githubVerbActions(d.project),
|
|
@@ -1909,6 +1922,17 @@ export async function handleIssue(
|
|
|
1909
1922
|
// for a worker that has already blocked.
|
|
1910
1923
|
let paneSeq = 0;
|
|
1911
1924
|
const nextPaneSeq = (): number => (paneSeq += 1);
|
|
1925
|
+
/**
|
|
1926
|
+
* The injected Herdr runner, when this daemon carries one (#1035); every
|
|
1927
|
+
* representation call below shares it so tests see one argv stream. The
|
|
1928
|
+
* ownership adapter is always present in production — the store-backed
|
|
1929
|
+
* half of workspace discovery, so what a Herdr restart forgets, the
|
|
1930
|
+
* durable row remembers (#1035 review).
|
|
1931
|
+
*/
|
|
1932
|
+
const herdr = {
|
|
1933
|
+
...(d.herdrRun === undefined ? {} : { run: d.herdrRun }),
|
|
1934
|
+
ownership: workspaceOwnership(d.store),
|
|
1935
|
+
};
|
|
1912
1936
|
/**
|
|
1913
1937
|
* Project one authoritative transition onto the pane (#842).
|
|
1914
1938
|
*
|
|
@@ -1919,6 +1943,7 @@ export async function handleIssue(
|
|
|
1919
1943
|
const projectPaneState = (state: "working" | "idle" | "blocked" | "unknown", message?: string): void => {
|
|
1920
1944
|
if (workerPane === undefined) return;
|
|
1921
1945
|
const reported = reportWorkerPaneState(workerPane.paneId, workerPane.label, state, {
|
|
1946
|
+
...herdr,
|
|
1922
1947
|
seq: nextPaneSeq(),
|
|
1923
1948
|
...(message === undefined ? {} : { message }),
|
|
1924
1949
|
});
|
|
@@ -2526,6 +2551,7 @@ export async function handleIssue(
|
|
|
2526
2551
|
workerPane = { kind: "tracked", paneId: recorded.paneId, label: recorded.paneLabel, pid };
|
|
2527
2552
|
store.updateRun(runId, { workerPid: pid });
|
|
2528
2553
|
const state = reportWorkerPaneState(recorded.paneId, recorded.paneLabel, "working", {
|
|
2554
|
+
...herdr,
|
|
2529
2555
|
seq: nextPaneSeq(),
|
|
2530
2556
|
});
|
|
2531
2557
|
log(
|
|
@@ -2535,14 +2561,17 @@ export async function handleIssue(
|
|
|
2535
2561
|
);
|
|
2536
2562
|
return;
|
|
2537
2563
|
}
|
|
2538
|
-
const outcome = openWorkerPane(
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2564
|
+
const outcome = openWorkerPane(
|
|
2565
|
+
{
|
|
2566
|
+
project: project.name,
|
|
2567
|
+
issue,
|
|
2568
|
+
attempt,
|
|
2569
|
+
runId,
|
|
2570
|
+
pid,
|
|
2571
|
+
...(recorded?.sessionFile === undefined ? {} : { sessionFile: recorded.sessionFile }),
|
|
2572
|
+
},
|
|
2573
|
+
herdr,
|
|
2574
|
+
);
|
|
2546
2575
|
if (outcome.kind === "tracked") {
|
|
2547
2576
|
workerPane = outcome;
|
|
2548
2577
|
// Durable before it is announced: a pane the store does not know
|
|
@@ -2566,8 +2595,14 @@ export async function handleIssue(
|
|
|
2566
2595
|
},
|
|
2567
2596
|
release: () => {
|
|
2568
2597
|
if (workerPane === undefined) return;
|
|
2569
|
-
|
|
2570
|
-
|
|
2598
|
+
// Retirement, not release alone (#1035): a settled run keeps no
|
|
2599
|
+
// representation. The close reaches only the follower; the
|
|
2600
|
+
// authoritative child was never in the pane to be signalled.
|
|
2601
|
+
const retired = retireWorkerPane(workerPane.paneId, workerPane.label, {
|
|
2602
|
+
...herdr,
|
|
2603
|
+
seq: nextPaneSeq(),
|
|
2604
|
+
});
|
|
2605
|
+
if (!retired.ok) log(`#${issue} herdr pane retirement failed: ${retired.reason}`);
|
|
2571
2606
|
workerPane = undefined;
|
|
2572
2607
|
},
|
|
2573
2608
|
},
|
|
@@ -3062,17 +3097,19 @@ export async function handleIssue(
|
|
|
3062
3097
|
* losing a dispatch pass over a version string would be absurd.
|
|
3063
3098
|
*/
|
|
3064
3099
|
/**
|
|
3065
|
-
* Make the
|
|
3100
|
+
* Make the worker workspaces agree with the live run set, once per pass
|
|
3101
|
+
* (#841, reworked by #1035).
|
|
3066
3102
|
*
|
|
3067
3103
|
* Runs on every dispatch pass rather than only at startup, because the thing it
|
|
3068
3104
|
* repairs — a Herdr restart — is not a conductor event and announces itself
|
|
3069
3105
|
* nowhere. It is idempotent by construction: a reconciled fleet reports `intact`
|
|
3070
|
-
* for every live worker and finds
|
|
3071
|
-
* instead of accumulating panes or churning them.
|
|
3106
|
+
* for every live worker and finds nothing left to close, so repeated passes
|
|
3107
|
+
* converge instead of accumulating panes or churning them. This pass owns this
|
|
3108
|
+
* project's worker workspace even when no run is live — that is how a settled-out
|
|
3109
|
+
* project's stale shells converge to zero and its empty workspace is removed.
|
|
3072
3110
|
*
|
|
3073
|
-
* Nothing here can stop a worker
|
|
3074
|
-
*
|
|
3075
|
-
* signalled, and no pane is ever closed.
|
|
3111
|
+
* Nothing here can stop a worker: panes hold followers only; the authoritative
|
|
3112
|
+
* child lives in this daemon's process tree and is never signalled.
|
|
3076
3113
|
*/
|
|
3077
3114
|
/**
|
|
3078
3115
|
* Pane re-establishment attempts spent per run, for this daemon process only
|
|
@@ -3086,7 +3123,25 @@ export async function handleIssue(
|
|
|
3086
3123
|
*/
|
|
3087
3124
|
const paneAttemptsSpent = new Map<string, number>();
|
|
3088
3125
|
|
|
3089
|
-
|
|
3126
|
+
/** Exported for the lifecycle tests (#1035): the pass is pure over the store
|
|
3127
|
+
* and the injected Herdr runner, so a test drives it exactly as the tick does. */
|
|
3128
|
+
/** The store-backed {@link WorkspaceOwnership} adapter (#1035 review): the
|
|
3129
|
+
* conductor store is the durable leg of workspace discovery, surviving both
|
|
3130
|
+
* daemon and Herdr restarts. One shape for every caller so they cannot
|
|
3131
|
+
* disagree about what ownership reads. */
|
|
3132
|
+
function workspaceOwnership(store: Deps["store"]): WorkspaceOwnership {
|
|
3133
|
+
return {
|
|
3134
|
+
recordedWorkspaces: (project) => store.workerWorkspaceIds(project),
|
|
3135
|
+
rememberWorkspace: (project, workspaceId) => store.rememberWorkerWorkspace(project, workspaceId),
|
|
3136
|
+
forgetWorkspace: (project, workspaceId) => store.forgetWorkerWorkspace(project, workspaceId),
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
export function reconcilePanes(
|
|
3141
|
+
d: Pick<Deps, "store" | "herdrRun">,
|
|
3142
|
+
project: string,
|
|
3143
|
+
log: (message: string) => void,
|
|
3144
|
+
): void {
|
|
3090
3145
|
const live = d.store.liveRuns(project);
|
|
3091
3146
|
const liveKeys = new Set(live.map((run) => `${project}\u0000${run.id}`));
|
|
3092
3147
|
for (const key of [...paneAttemptsSpent.keys()]) {
|
|
@@ -3106,8 +3161,10 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3106
3161
|
...(run.paneLabel === undefined ? {} : { paneLabel: run.paneLabel }),
|
|
3107
3162
|
...(run.sessionFile === undefined ? {} : { sessionFile: run.sessionFile }),
|
|
3108
3163
|
})),
|
|
3109
|
-
{},
|
|
3164
|
+
{ ...(d.herdrRun === undefined ? {} : { run: d.herdrRun }), ownership: workspaceOwnership(d.store) },
|
|
3110
3165
|
attempts,
|
|
3166
|
+
PANE_REATTEMPT_MAX,
|
|
3167
|
+
[project],
|
|
3111
3168
|
);
|
|
3112
3169
|
if (!result.ok) {
|
|
3113
3170
|
// An unreadable workspace is not evidence that anything is stale, so nothing
|
|
@@ -3120,6 +3177,14 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3120
3177
|
switch (outcome.kind) {
|
|
3121
3178
|
case "intact":
|
|
3122
3179
|
break;
|
|
3180
|
+
case "visual-unreadable":
|
|
3181
|
+
// The pane keeps standing: an unreadable follower table is no evidence
|
|
3182
|
+
// of death, and a live worker's representation is never destroyed on a
|
|
3183
|
+
// guess (#1035 review). Said once per pass, not acted on.
|
|
3184
|
+
log(
|
|
3185
|
+
`#${runIssue(live, outcome.runId)} herdr pane ${outcome.paneId} visual unreadable, left standing: ${outcome.reason}`,
|
|
3186
|
+
);
|
|
3187
|
+
break;
|
|
3123
3188
|
case "reassociated":
|
|
3124
3189
|
d.store.updateRun(outcome.runId, {
|
|
3125
3190
|
paneId: outcome.paneId,
|
|
@@ -3136,11 +3201,11 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3136
3201
|
break;
|
|
3137
3202
|
}
|
|
3138
3203
|
// #998: a terminal refusing to split will refuse the next one too, and
|
|
3139
|
-
// each retry made the pile worse — spend the whole budget at once.
|
|
3204
|
+
// each retry made the pile worse — spend the whole budget at once. The
|
|
3205
|
+
// phase is structured now (#1035 review): the old substring sniff died
|
|
3206
|
+
// when the reason strings changed and never fired again.
|
|
3140
3207
|
const key = `${project}\u0000${outcome.runId}`;
|
|
3141
|
-
const spent = outcome.
|
|
3142
|
-
? PANE_REATTEMPT_MAX
|
|
3143
|
-
: (paneAttemptsSpent.get(key) ?? 0) + 1;
|
|
3208
|
+
const spent = outcome.phase === "split" ? PANE_REATTEMPT_MAX : (paneAttemptsSpent.get(key) ?? 0) + 1;
|
|
3144
3209
|
paneAttemptsSpent.set(key, spent);
|
|
3145
3210
|
// Once per run, at the moment the budget runs out — not once per pass.
|
|
3146
3211
|
// Four identical lines a minute for #986 is what made the real cause
|
|
@@ -3156,11 +3221,33 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3156
3221
|
// Already reported when the budget ran out; the run keeps working with
|
|
3157
3222
|
// no pane, and a later restart gets a fresh budget.
|
|
3158
3223
|
break;
|
|
3159
|
-
case "stale-released":
|
|
3160
|
-
|
|
3224
|
+
case "stale-released": {
|
|
3225
|
+
const what =
|
|
3226
|
+
outcome.cause === "duplicate"
|
|
3227
|
+
? "closed duplicate herdr pane"
|
|
3228
|
+
: outcome.cause === "unidentified"
|
|
3229
|
+
? "closed unidentified herdr pane"
|
|
3230
|
+
: "released settled herdr pane";
|
|
3231
|
+
log(`${what} ${outcome.paneId} (run ${outcome.runId || "unidentified"}${outcome.cause === "settled" ? " is not live" : ""})`);
|
|
3161
3232
|
break;
|
|
3162
|
-
|
|
3163
|
-
|
|
3233
|
+
}
|
|
3234
|
+
case "stale-release-failed": {
|
|
3235
|
+
const what =
|
|
3236
|
+
outcome.cause === "duplicate"
|
|
3237
|
+
? "duplicate herdr pane"
|
|
3238
|
+
: outcome.cause === "unidentified"
|
|
3239
|
+
? "unidentified herdr pane"
|
|
3240
|
+
: "settled herdr pane";
|
|
3241
|
+
log(`${what} ${outcome.paneId} could not be closed: ${outcome.reason}`);
|
|
3242
|
+
break;
|
|
3243
|
+
}
|
|
3244
|
+
case "workspace-removed":
|
|
3245
|
+
log(`removed empty herdr worker workspace ${outcome.workspaceId} for ${outcome.project}`);
|
|
3246
|
+
break;
|
|
3247
|
+
case "workspace-remove-failed":
|
|
3248
|
+
log(
|
|
3249
|
+
`herdr worker workspace ${outcome.workspaceId} for ${outcome.project} could not be removed: ${outcome.reason}`,
|
|
3250
|
+
);
|
|
3164
3251
|
break;
|
|
3165
3252
|
}
|
|
3166
3253
|
}
|
|
@@ -6605,6 +6692,8 @@ export interface StatusSnapshot {
|
|
|
6605
6692
|
* unknown outcome, or written off. An empty list is the only honest way to
|
|
6606
6693
|
* say "everything authored this cycle actually went out" (#123). */
|
|
6607
6694
|
openReports: ReportRecord[];
|
|
6695
|
+
/** Recent successful report/notice withdrawals, newest first. */
|
|
6696
|
+
handoffWithdrawals?: HandoffWithdrawal[];
|
|
6608
6697
|
/** Ordinary outcomes and deferred escalations not yet associated with an
|
|
6609
6698
|
* accepted digest report. */
|
|
6610
6699
|
digestBacklog: DigestBacklog;
|
|
@@ -6791,6 +6880,7 @@ export function statusSnapshotFromStore(
|
|
|
6791
6880
|
quarantinedRuns: store.quarantinedRuns(p.name),
|
|
6792
6881
|
turnOverrides: store.listTurnOverrides(p.name),
|
|
6793
6882
|
openReports: store.openReports(p.name),
|
|
6883
|
+
handoffWithdrawals: store.handoffWithdrawals(p.name),
|
|
6794
6884
|
digestBacklog: store.digestBacklog(p.name),
|
|
6795
6885
|
verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
|
|
6796
6886
|
liveWorkers: live.length,
|
|
@@ -7199,11 +7289,18 @@ interface ProjectRuntime {
|
|
|
7199
7289
|
reportPass?: Promise<void>;
|
|
7200
7290
|
}
|
|
7201
7291
|
|
|
7202
|
-
function orchestratorStandingOrders(
|
|
7292
|
+
export function orchestratorStandingOrders(
|
|
7293
|
+
project: ProjectConfig,
|
|
7294
|
+
projects: readonly ProjectConfig[],
|
|
7295
|
+
): {
|
|
7203
7296
|
brief: string;
|
|
7204
7297
|
releaseGrants: ResolvedGrants;
|
|
7205
7298
|
} {
|
|
7206
|
-
const
|
|
7299
|
+
const installAuthority = resolveSharedInstallAuthority(projects);
|
|
7300
|
+
const releaseGrants = {
|
|
7301
|
+
...resolveReleaseGrants(project),
|
|
7302
|
+
install: installAuthority.holder ?? "human",
|
|
7303
|
+
};
|
|
7207
7304
|
const grantedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] === "orchestrator");
|
|
7208
7305
|
const deniedShapes = RELEASE_SHAPES.filter((shape) => releaseGrants[shape] !== "orchestrator");
|
|
7209
7306
|
return {
|
|
@@ -7230,6 +7327,13 @@ function orchestratorStandingOrders(project: ProjectConfig): {
|
|
|
7230
7327
|
? "every release and deploy shape is mechanically blocked for you"
|
|
7231
7328
|
: `you may invoke ${grantedShapes.join(", ")} — and only by the procedure in your POLICY.md`
|
|
7232
7329
|
}.` + (deniedShapes.length === 0 ? "" : ` Blocked: ${deniedShapes.join(", ")}.`),
|
|
7330
|
+
...(installAuthority.holder === undefined
|
|
7331
|
+
? [
|
|
7332
|
+
`Host-global install authority conflicts: ${installAuthority.entries
|
|
7333
|
+
.map((entry) => `${entry.project}=${entry.holder}`)
|
|
7334
|
+
.join(", ")}. Install is blocked.`,
|
|
7335
|
+
]
|
|
7336
|
+
: []),
|
|
7233
7337
|
"Handle each escalation below before the next one.",
|
|
7234
7338
|
].join("\n"),
|
|
7235
7339
|
};
|
|
@@ -7410,15 +7514,22 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
7410
7514
|
// The workspace outlives the process, so a restart inherits panes whose
|
|
7411
7515
|
// workers are gone (#842). A session-host child dies with the daemon that
|
|
7412
7516
|
// owned its socket, so an orphaned row's pane is authoritatively dead
|
|
7413
|
-
// whatever its recorded pid says
|
|
7414
|
-
//
|
|
7415
|
-
//
|
|
7416
|
-
//
|
|
7417
|
-
|
|
7517
|
+
// whatever its recorded pid says. Ownership is proven before the close
|
|
7518
|
+
// (#1035 review): pane ids are reused after a Herdr restart, so only a
|
|
7519
|
+
// pane inside a workspace conductor owns — token-marked or store-recorded,
|
|
7520
|
+
// which is what survives Herdr dropping tokens on restore — whose
|
|
7521
|
+
// reported identity still agrees with this row is retired. An unowned
|
|
7522
|
+
// hit closes nothing and says so.
|
|
7523
|
+
const orphanPane = releaseOrphanedWorkerPane(
|
|
7524
|
+
{ paneId: run.paneId, paneLabel: run.paneLabel, runId: run.id, project: run.project },
|
|
7525
|
+
{ ownership: workspaceOwnership(store) },
|
|
7526
|
+
);
|
|
7418
7527
|
if (orphanPane.kind === "released") {
|
|
7419
7528
|
projectLog(
|
|
7420
|
-
`#${run.issue} herdr pane ${orphanPane.paneId}
|
|
7529
|
+
`#${run.issue} herdr pane ${orphanPane.paneId} retired: its worker died with the previous daemon`,
|
|
7421
7530
|
);
|
|
7531
|
+
} else if (orphanPane.kind === "unowned") {
|
|
7532
|
+
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
|
|
7422
7533
|
} else if (orphanPane.kind === "failed") {
|
|
7423
7534
|
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
|
|
7424
7535
|
}
|
|
@@ -7432,7 +7543,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
7432
7543
|
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
7433
7544
|
}
|
|
7434
7545
|
|
|
7435
|
-
const { brief, releaseGrants } = orchestratorStandingOrders(project);
|
|
7546
|
+
const { brief, releaseGrants } = orchestratorStandingOrders(project, cfg.projects);
|
|
7436
7547
|
let orchestrator: OrchestratorHandle | undefined;
|
|
7437
7548
|
let orchestratorVerbs: VerbListener | undefined;
|
|
7438
7549
|
/** First start-failure cause, surfaced by the orchestrator-down incident (#288). */
|
package/src/doctor.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
claimedTelegramTopics,
|
|
69
69
|
lockPidAlive,
|
|
70
70
|
pidAlive,
|
|
71
|
+
killOf,
|
|
71
72
|
readTelegramChannel,
|
|
72
73
|
readTelegramDmOwner,
|
|
73
74
|
readTelegramPollState,
|
|
@@ -85,6 +86,7 @@ import {
|
|
|
85
86
|
import {
|
|
86
87
|
herdrConductorPluginConfigDir,
|
|
87
88
|
planHostRuntime,
|
|
89
|
+
RECOVER_SERVICE_NAME,
|
|
88
90
|
STAGED_SERVICE_NAME,
|
|
89
91
|
SYSTEMD_UNIT_DIR,
|
|
90
92
|
tickCwdForProject,
|
|
@@ -229,6 +231,8 @@ export interface DoctorDeps {
|
|
|
229
231
|
hasSystemd?: () => boolean;
|
|
230
232
|
/** The installed unit text at an absolute path, or undefined when absent. */
|
|
231
233
|
readUnit?: (path: string) => string | undefined;
|
|
234
|
+
/** Whether one installed systemd unit is currently failed; undefined when unreadable. */
|
|
235
|
+
unitFailed?: (name: string) => boolean | undefined;
|
|
232
236
|
/** stat one path; undefined when it does not exist. */
|
|
233
237
|
stat?: (path: string) => Stats | undefined;
|
|
234
238
|
/** uid of a username (`id -u`), or undefined when it cannot be resolved. */
|
|
@@ -308,12 +312,11 @@ export interface DoctorDeps {
|
|
|
308
312
|
* claim-only verdict checks only. */
|
|
309
313
|
armScanDirs?: (project: ProjectConfig) => readonly string[];
|
|
310
314
|
/** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
|
|
311
|
-
* topics.ts semantics (EPERM is dead).
|
|
315
|
+
* topics.ts semantics (EPERM is dead). The one claim-liveness fact in this
|
|
316
|
+
* package: topic-pin's dead partition, its exact-pin check, project
|
|
317
|
+
* resolution and the telegram-plumbing verdict all derive from it, so two
|
|
318
|
+
* rows cannot answer one claim differently (#987). */
|
|
312
319
|
pidAlive?: (pid: number) => boolean;
|
|
313
|
-
/** Whether one claimed topic is live — the single fact `topic-pin` and
|
|
314
|
-
* `telegram-plumbing` both read, so they cannot disagree about a claim
|
|
315
|
-
* (#987). Defaults to {@link claimIsLive}. */
|
|
316
|
-
claimIsLive?: (claim: { pid?: number }) => boolean;
|
|
317
320
|
/** The provider allowance window nearest its ceiling, from `omp usage --json`
|
|
318
321
|
* — what binds a subscription-billed fleet instead of a dollar cap (#984).
|
|
319
322
|
* `undefined` when the provider reports no comparable window. */
|
|
@@ -397,6 +400,18 @@ function defaultReadUnit(path: string): string | undefined {
|
|
|
397
400
|
}
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
export function systemdStateFailed(state: string): boolean | undefined {
|
|
404
|
+
const normalized = state.trim();
|
|
405
|
+
if (normalized === "") return undefined;
|
|
406
|
+
return normalized === "failed";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function defaultUnitFailed(name: string): boolean | undefined {
|
|
410
|
+
const ran = spawnSync("systemctl", ["is-failed", name], { encoding: "utf8" });
|
|
411
|
+
if (ran.error !== undefined) return undefined;
|
|
412
|
+
return systemdStateFailed(ran.stdout ?? "");
|
|
413
|
+
}
|
|
414
|
+
|
|
400
415
|
function defaultStat(path: string): Stats | undefined {
|
|
401
416
|
try {
|
|
402
417
|
return statSync(path);
|
|
@@ -660,7 +675,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
|
|
|
660
675
|
return warnFinding(
|
|
661
676
|
"db-backup",
|
|
662
677
|
"conductor.db has no snapshot — losing it loses the audit trail with no recovery path",
|
|
663
|
-
"
|
|
678
|
+
"run `omp-conductor snapshot-db` (the daemon cadence uses the same SQLite snapshot primitive)",
|
|
664
679
|
);
|
|
665
680
|
}
|
|
666
681
|
const newestMtime = snapshots
|
|
@@ -670,7 +685,7 @@ function dbBackupProbe(probes: Probes, cfg: ConductorConfig | undefined): Findin
|
|
|
670
685
|
return warnFinding(
|
|
671
686
|
"db-backup",
|
|
672
687
|
`conductor.db is newer than every snapshot (${snapshots.length} file(s)) — the newest writes were never snapshot`,
|
|
673
|
-
"
|
|
688
|
+
"run `omp-conductor snapshot-db` so restore-db has the current state",
|
|
674
689
|
);
|
|
675
690
|
}
|
|
676
691
|
return passFinding("db-backup", `conductor.db snapshot is fresh (${snapshots.length} file(s))`);
|
|
@@ -934,6 +949,22 @@ function recoveryProbe(probes: Probes): Finding {
|
|
|
934
949
|
);
|
|
935
950
|
}
|
|
936
951
|
|
|
952
|
+
function recoveryStateProbe(probes: Probes): Finding {
|
|
953
|
+
const id = "systemd-recovery-state";
|
|
954
|
+
if (!probes.hasSystemd()) return passFinding(id, "no systemd directory on this host — nothing to check");
|
|
955
|
+
if (probes.readUnit(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME)) === undefined) {
|
|
956
|
+
return passFinding(id, "recovery unit is not installed — systemd-recovery owns that finding");
|
|
957
|
+
}
|
|
958
|
+
const failed = probes.unitFailed(RECOVER_SERVICE_NAME);
|
|
959
|
+
if (failed === false) return passFinding(id, `${RECOVER_SERVICE_NAME} is not failed`);
|
|
960
|
+
const fix =
|
|
961
|
+
`inspect \`systemctl status ${RECOVER_SERVICE_NAME}\` and \`journalctl -u ${RECOVER_SERVICE_NAME}\`; ` +
|
|
962
|
+
`fix the root cause, then run \`systemctl reset-failed ${RECOVER_SERVICE_NAME}\` and rerun doctor`;
|
|
963
|
+
return failed === true
|
|
964
|
+
? warnFinding(id, `${RECOVER_SERVICE_NAME} is in failed state — its last recovery did not complete`, fix)
|
|
965
|
+
: warnFinding(id, `${RECOVER_SERVICE_NAME} state could not be read`, fix);
|
|
966
|
+
}
|
|
967
|
+
|
|
937
968
|
function installedUnitUser(probes: Probes): string | undefined {
|
|
938
969
|
const unit = probes.readUnit(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME));
|
|
939
970
|
if (unit === undefined) return undefined;
|
|
@@ -1370,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1370
1401
|
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
|
|
1371
1402
|
);
|
|
1372
1403
|
}
|
|
1373
|
-
// One liveness fact
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1404
|
+
// One liveness fact for everything this probe decides — the dead partition,
|
|
1405
|
+
// the exact-pin check below and identity resolution all read
|
|
1406
|
+
// `probes.pidAlive` through the same helper, so diverging injections cannot
|
|
1407
|
+
// split the rows' answer again (#987). Before #987 every registry row was
|
|
1408
|
+
// treated as live here, so a pin aimed at a corpse read PASS while the
|
|
1409
|
+
// plumbing row called the same claim dead.
|
|
1410
|
+
const isLive = (claim: { pid?: number }): boolean => claimIsLive(claim, killOf(probes.pidAlive));
|
|
1376
1411
|
const all = result.claims;
|
|
1377
|
-
const claims = all.filter(
|
|
1378
|
-
const dead = all.filter((claim) => !
|
|
1412
|
+
const claims = all.filter(isLive);
|
|
1413
|
+
const dead = all.filter((claim) => !isLive(claim));
|
|
1379
1414
|
// Named, never dropped: the row is the only index to the remote topic, so
|
|
1380
1415
|
// deleting it strands the topic instead of cleaning it up.
|
|
1381
1416
|
const deadNote =
|
|
@@ -1397,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1397
1432
|
`[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
|
|
1398
1433
|
);
|
|
1399
1434
|
}
|
|
1435
|
+
// `claims` is already the live partition, so a dead pinned row cannot win
|
|
1436
|
+
// this check (#987).
|
|
1400
1437
|
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1401
1438
|
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
|
|
1402
1439
|
}
|
|
1403
|
-
const match = resolveProjectClaim(claims, p.name);
|
|
1440
|
+
const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
|
|
1404
1441
|
if (match.kind === "match") {
|
|
1405
1442
|
return passFinding(
|
|
1406
1443
|
"topic-pin",
|
|
@@ -1713,7 +1750,7 @@ function parseEnvKey(text: string, key: string): string | undefined {
|
|
|
1713
1750
|
|
|
1714
1751
|
/**
|
|
1715
1752
|
* Install-surface parity (#904). omp-conductor installs onto three surfaces —
|
|
1716
|
-
* the
|
|
1753
|
+
* the discovered CLI package tree, the omp plugin, and the herdr recovery
|
|
1717
1754
|
* plugin — and only a live `upgrade` invocation ever compared them. On a
|
|
1718
1755
|
* fleet whose installs are manual they diverge silently: measured on this
|
|
1719
1756
|
* host on 2026-08-22, the omp plugin sat on the withdrawn 0.18.1 release for
|
|
@@ -2002,6 +2039,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
2002
2039
|
// the first one stands in for the rendering seam.
|
|
2003
2040
|
findings.push(unitProbe(probes, projects[0], cfg));
|
|
2004
2041
|
findings.push(recoveryProbe(probes));
|
|
2042
|
+
findings.push(recoveryStateProbe(probes));
|
|
2005
2043
|
findings.push(ownershipProbe(probes));
|
|
2006
2044
|
// #541 seam checks, host-global: the live herdr config and the plugin's
|
|
2007
2045
|
// config.env are single files on the host, not per-project facts.
|
|
@@ -2106,6 +2144,7 @@ export function defaultProbes(): Probes {
|
|
|
2106
2144
|
repoLabels: defaultRepoLabels,
|
|
2107
2145
|
hasSystemd: () => existsSync(SYSTEMD_UNIT_DIR),
|
|
2108
2146
|
readUnit: defaultReadUnit,
|
|
2147
|
+
unitFailed: defaultUnitFailed,
|
|
2109
2148
|
stat: defaultStat,
|
|
2110
2149
|
uidOf: defaultUidOf,
|
|
2111
2150
|
dbIntegrity: defaultDbIntegrity,
|
|
@@ -2159,7 +2198,6 @@ export function defaultProbes(): Probes {
|
|
|
2159
2198
|
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
2160
2199
|
},
|
|
2161
2200
|
pidAlive,
|
|
2162
|
-
claimIsLive: (claim) => claimIsLive(claim),
|
|
2163
2201
|
allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
|
|
2164
2202
|
lockPidAlive,
|
|
2165
2203
|
lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
|
package/src/escalate.ts
CHANGED
|
@@ -467,11 +467,13 @@ export interface ClaimedTopic {
|
|
|
467
467
|
* own maintenance, and every topic-addressed send silently degrades to the main
|
|
468
468
|
* chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
|
|
469
469
|
*
|
|
470
|
-
* The operator's pin still wins whenever it is live. Only a pin that
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
470
|
+
* The operator's pin still wins whenever it is live. Only a pin that no *live*
|
|
471
|
+
* claim carries is replaced, and only by the claim this project can be
|
|
472
|
+
* identified with — so a deliberately separate alerts topic is never hijacked
|
|
473
|
+
* by the pane's own thread. A dead row wearing the pin id does not keep it in
|
|
474
|
+
* place: the row is the bridge's past, not a destination (#987). Unavailable
|
|
475
|
+
* bridge state changes nothing, and #318's stale-topic retry remains the last
|
|
476
|
+
* line of defence.
|
|
475
477
|
*
|
|
476
478
|
* Identity is read from the herdr space first, and only then from the claim's
|
|
477
479
|
* title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
|
|
@@ -483,14 +485,19 @@ export interface ClaimedTopic {
|
|
|
483
485
|
* The substitution is logged, naming the project and which identity answered.
|
|
484
486
|
* Never an id: a log line is a place these leak from.
|
|
485
487
|
*/
|
|
486
|
-
export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
|
|
488
|
+
export function resolveProjectTopicId(project: ProjectConfig, alive?: (pid: number) => boolean): number | undefined {
|
|
487
489
|
const pinned = project.escalation.telegramTopicId;
|
|
488
490
|
if (pinned === undefined) return undefined;
|
|
489
491
|
const result = claimedTelegramTopics();
|
|
490
492
|
if (result.kind !== "ok" || result.claims.length === 0) return pinned;
|
|
491
493
|
const claims = result.claims;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
+
// The pin wins only while a *live* claim carries it; a dead pinned row falls
|
|
495
|
+
// through to the live-claim substitution below instead of winning the
|
|
496
|
+
// exact-pin check (#987).
|
|
497
|
+
if (claims.some((claim) => claim.threadId === pinned && claimIsLive(claim, alive === undefined ? undefined : killOf(alive)))) {
|
|
498
|
+
return pinned;
|
|
499
|
+
}
|
|
500
|
+
const match = claimForProject(claims, project.name, alive);
|
|
494
501
|
if (match === undefined) return pinned;
|
|
495
502
|
warn(
|
|
496
503
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
@@ -519,25 +526,32 @@ export type ProjectClaim =
|
|
|
519
526
|
* How the live claims answer to a project, identity spellings in order:
|
|
520
527
|
* unique herdr space, then unique title.
|
|
521
528
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
+
* Only live claims take part ({@link claimIsLive}): a dead row neither
|
|
530
|
+
* answers on its own nor turns this project's one live pane into an
|
|
531
|
+
* ambiguity (#987). Ambiguity is not a coin toss: paging the wrong project's
|
|
532
|
+
* topic is worse than the flat-chat degrade #318 already handles, and
|
|
533
|
+
* scanning the wrong pane's session misses the reply entirely. The title is
|
|
534
|
+
* a fallback for bridges that never captured a space, so it runs only when
|
|
535
|
+
* *no* live claim carries the project's space — several live claims wearing
|
|
536
|
+
* the space is an ambiguity the title cannot resolve, because the titled
|
|
537
|
+
* claim may be a sibling pane, not this project (#626).
|
|
529
538
|
*/
|
|
530
539
|
export function resolveProjectClaim(
|
|
531
540
|
claims: readonly ClaimedTopic[],
|
|
532
541
|
projectName: string,
|
|
542
|
+
alive?: (pid: number) => boolean,
|
|
533
543
|
): ProjectClaim {
|
|
534
|
-
|
|
544
|
+
// Dead claims do not vote: a closed pane's row must not turn this project's
|
|
545
|
+
// one live claim into an ambiguity, and must not answer by title on its own
|
|
546
|
+
// (#987). Ambiguity among genuinely live claims is unchanged (#626).
|
|
547
|
+
const live = claims.filter((claim) => claimIsLive(claim, alive === undefined ? undefined : killOf(alive)));
|
|
548
|
+
const bySpace = live.filter((claim) => claim.workspaceLabel === projectName);
|
|
535
549
|
if (bySpace.length > 0) {
|
|
536
550
|
return bySpace.length === 1
|
|
537
551
|
? { kind: "match", claim: bySpace[0]! }
|
|
538
552
|
: { kind: "ambiguous", claimants: bySpace };
|
|
539
553
|
}
|
|
540
|
-
const byTitle =
|
|
554
|
+
const byTitle = live.filter((claim) => claim.name === projectName);
|
|
541
555
|
if (byTitle.length === 0) return { kind: "none" };
|
|
542
556
|
return byTitle.length === 1
|
|
543
557
|
? { kind: "match", claim: byTitle[0]! }
|
|
@@ -556,8 +570,9 @@ export function resolveProjectClaim(
|
|
|
556
570
|
export function claimForProject(
|
|
557
571
|
claims: readonly ClaimedTopic[],
|
|
558
572
|
projectName: string,
|
|
573
|
+
alive?: (pid: number) => boolean,
|
|
559
574
|
): ClaimedTopic | undefined {
|
|
560
|
-
const match = resolveProjectClaim(claims, projectName);
|
|
575
|
+
const match = resolveProjectClaim(claims, projectName, alive);
|
|
561
576
|
return match.kind === "match" ? match.claim : undefined;
|
|
562
577
|
}
|
|
563
578
|
|
|
@@ -574,10 +589,13 @@ export function claimForProject(
|
|
|
574
589
|
* the claim names no file — the caller then falls back to the cwd-derived
|
|
575
590
|
* directory, which is the honest answer for a host that predates claims.
|
|
576
591
|
*/
|
|
577
|
-
export function resolveClaimedSessionFile(
|
|
592
|
+
export function resolveClaimedSessionFile(
|
|
593
|
+
project: ProjectConfig,
|
|
594
|
+
alive?: (pid: number) => boolean,
|
|
595
|
+
): string | undefined {
|
|
578
596
|
const result = claimedTelegramTopics();
|
|
579
597
|
if (result.kind !== "ok" || result.claims.length === 0) return undefined;
|
|
580
|
-
return claimForProject(result.claims, project.name)?.sessionFile;
|
|
598
|
+
return claimForProject(result.claims, project.name, alive)?.sessionFile;
|
|
581
599
|
}
|
|
582
600
|
|
|
583
601
|
/**
|
|
@@ -1111,7 +1129,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
|
1111
1129
|
* Throwing is how `pidAlive` spells "dead", which is the contract it applies to
|
|
1112
1130
|
* whatever this returns.
|
|
1113
1131
|
*/
|
|
1114
|
-
function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1132
|
+
export function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1115
1133
|
return (target) => {
|
|
1116
1134
|
if (!alive(target)) throw new Error("not alive");
|
|
1117
1135
|
};
|