omp-conductor 0.15.12 → 0.15.13
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 +9 -4
- package/package.json +1 -1
- package/src/commands/restart.ts +81 -54
- package/src/commands/stop.ts +45 -22
- package/src/daemon.ts +253 -54
- package/src/doctor.ts +241 -4
- package/src/escalate.ts +8 -0
- package/src/fleet.ts +49 -2
- package/src/lifecycle.ts +113 -2
- package/src/omp.ts +24 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +7 -0
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +278 -113
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +181 -0
- package/src/types.ts +111 -0
- package/src/upgrade.ts +26 -6
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +21 -0
package/src/store.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
DecisionState,
|
|
25
25
|
DigestBacklog,
|
|
26
26
|
DispatchSummary,
|
|
27
|
+
DaemonStop,
|
|
28
|
+
DaemonStopDraft,
|
|
27
29
|
FrictionAdmissionReason,
|
|
28
30
|
FrictionKind,
|
|
29
31
|
FrictionObservation,
|
|
@@ -38,6 +40,9 @@ import type {
|
|
|
38
40
|
MaterialEventDraft,
|
|
39
41
|
LabelOp,
|
|
40
42
|
MergeLock,
|
|
43
|
+
OrchestratorDownMode,
|
|
44
|
+
OrchestratorIncident,
|
|
45
|
+
OrchestratorIncidentDraft,
|
|
41
46
|
ReportDeliveryState,
|
|
42
47
|
ReportDraft,
|
|
43
48
|
ReportEnqueue,
|
|
@@ -327,6 +332,65 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
|
|
|
327
332
|
return item;
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
/** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
|
|
336
|
+
interface OrchestratorIncidentRow {
|
|
337
|
+
project: string;
|
|
338
|
+
mode: string;
|
|
339
|
+
cause: string | null;
|
|
340
|
+
since: number;
|
|
341
|
+
diverted: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** NULL columns become absent properties, matching the other row converters. */
|
|
345
|
+
function toOrchestratorIncident(row: OrchestratorIncidentRow): OrchestratorIncident {
|
|
346
|
+
return {
|
|
347
|
+
project: row.project,
|
|
348
|
+
mode: row.mode as OrchestratorDownMode,
|
|
349
|
+
...(row.cause === null ? {} : { cause: row.cause }),
|
|
350
|
+
since: row.since,
|
|
351
|
+
diverted: row.diverted,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The `daemon_stops` table exactly as SQLite hands it back (#378). */
|
|
356
|
+
interface DaemonStopRow {
|
|
357
|
+
id: string;
|
|
358
|
+
at: number;
|
|
359
|
+
controlPath: string;
|
|
360
|
+
callerPid: number | null;
|
|
361
|
+
callerUid: number | null;
|
|
362
|
+
role: string | null;
|
|
363
|
+
scope: string;
|
|
364
|
+
project: string | null;
|
|
365
|
+
daemonPid: number | null;
|
|
366
|
+
runtimeDir: string | null;
|
|
367
|
+
affected: string;
|
|
368
|
+
reason: string;
|
|
369
|
+
unattributed: number;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* NULL columns become absent properties, matching the other row converters:
|
|
374
|
+
* a record read back out of the store deep-equals the one that went in.
|
|
375
|
+
*/
|
|
376
|
+
function toDaemonStop(row: DaemonStopRow): DaemonStop {
|
|
377
|
+
return {
|
|
378
|
+
id: row.id,
|
|
379
|
+
at: row.at,
|
|
380
|
+
controlPath: row.controlPath,
|
|
381
|
+
...(row.callerPid === null ? {} : { callerPid: row.callerPid }),
|
|
382
|
+
...(row.callerUid === null ? {} : { callerUid: row.callerUid }),
|
|
383
|
+
...(row.role === null ? {} : { role: row.role as SessionRole }),
|
|
384
|
+
scope: row.scope as "global" | "project",
|
|
385
|
+
...(row.project === null ? {} : { project: row.project }),
|
|
386
|
+
...(row.daemonPid === null ? {} : { daemonPid: row.daemonPid }),
|
|
387
|
+
...(row.runtimeDir === null ? {} : { runtimeDir: row.runtimeDir }),
|
|
388
|
+
affected: JSON.parse(row.affected) as { project: string; live: number }[],
|
|
389
|
+
reason: row.reason,
|
|
390
|
+
unattributed: row.unattributed === 1,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
330
394
|
const SCHEMA = `
|
|
331
395
|
CREATE TABLE IF NOT EXISTS runs (
|
|
332
396
|
id TEXT PRIMARY KEY,
|
|
@@ -628,6 +692,44 @@ CREATE TABLE IF NOT EXISTS intake_items (
|
|
|
628
692
|
);
|
|
629
693
|
CREATE INDEX IF NOT EXISTS intake_items_project_state
|
|
630
694
|
ON intake_items (project, state, createdAt);
|
|
695
|
+
-- The embedded orchestrator's lost-liveness incident (#288): durable so a
|
|
696
|
+
-- daemon restarted while its orchestrator is still down rediscovers the open
|
|
697
|
+
-- incident instead of forgetting it, and the diverted counter accumulates
|
|
698
|
+
-- across the outage and across restarts. One row per project; removed on
|
|
699
|
+
-- recovery. since is the dedupe anchor for both the down page and its
|
|
700
|
+
-- closing recovery page. The mode CHECK keeps the two causes distinct.
|
|
701
|
+
CREATE TABLE IF NOT EXISTS orchestrator_incidents (
|
|
702
|
+
project TEXT PRIMARY KEY,
|
|
703
|
+
mode TEXT NOT NULL CHECK (mode IN ('start-failed', 'crashed')),
|
|
704
|
+
cause TEXT,
|
|
705
|
+
since INTEGER NOT NULL,
|
|
706
|
+
diverted INTEGER NOT NULL DEFAULT 0
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
-- Every stop/restart of the shared daemon, and who asked for it and why
|
|
710
|
+
-- (#378). Deliberately NOT partitioned by project: the daemon serves every
|
|
711
|
+
-- configured project, so a record written by one project's CLI must be
|
|
712
|
+
-- readable from another project's status the moment the daemon is down —
|
|
713
|
+
-- that cross-project read is the whole point. Written before signalling on
|
|
714
|
+
-- the mediated paths (cli stop/restart) and as an honest unattributed
|
|
715
|
+
-- fallback when the daemon receives a signal with no mediated request.
|
|
716
|
+
-- Explicit fields only: never raw argv, environment values or credentials.
|
|
717
|
+
CREATE TABLE IF NOT EXISTS daemon_stops (
|
|
718
|
+
id TEXT PRIMARY KEY,
|
|
719
|
+
at INTEGER NOT NULL,
|
|
720
|
+
controlPath TEXT NOT NULL,
|
|
721
|
+
callerPid INTEGER,
|
|
722
|
+
callerUid INTEGER,
|
|
723
|
+
role TEXT,
|
|
724
|
+
scope TEXT NOT NULL,
|
|
725
|
+
project TEXT,
|
|
726
|
+
daemonPid INTEGER,
|
|
727
|
+
runtimeDir TEXT,
|
|
728
|
+
affected TEXT NOT NULL,
|
|
729
|
+
reason TEXT NOT NULL,
|
|
730
|
+
unattributed INTEGER NOT NULL
|
|
731
|
+
);
|
|
732
|
+
CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
|
|
631
733
|
`;
|
|
632
734
|
|
|
633
735
|
/**
|
|
@@ -1365,6 +1467,23 @@ export function openStore(dbPath: string): Store {
|
|
|
1365
1467
|
const insertNotified = db.query<unknown, [string, number]>(
|
|
1366
1468
|
`INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
|
|
1367
1469
|
);
|
|
1470
|
+
// Orchestrator-down incident (#288). One row per project; INSERT OR IGNORE is
|
|
1471
|
+
// the dedupe so a flapping orchestrator opens (and pages) once per incident.
|
|
1472
|
+
const selectOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
|
|
1473
|
+
`SELECT project, mode, cause, since, diverted FROM orchestrator_incidents WHERE project = ?`,
|
|
1474
|
+
);
|
|
1475
|
+
const insertOrchestratorIncident = db.query<unknown, [string, string, string | null, number]>(
|
|
1476
|
+
`INSERT OR IGNORE INTO orchestrator_incidents (project, mode, cause, since)
|
|
1477
|
+
VALUES (?, ?, ?, ?)`,
|
|
1478
|
+
);
|
|
1479
|
+
const bumpOrchestratorIncident = db.query<unknown, [number, string]>(
|
|
1480
|
+
`UPDATE orchestrator_incidents SET diverted = diverted + ? WHERE project = ?`,
|
|
1481
|
+
);
|
|
1482
|
+
const deleteOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
|
|
1483
|
+
`DELETE FROM orchestrator_incidents
|
|
1484
|
+
WHERE project = ?
|
|
1485
|
+
RETURNING project, mode, cause, since, diverted`,
|
|
1486
|
+
);
|
|
1368
1487
|
const upsertDispatch = db.query<unknown, [string, string]>(
|
|
1369
1488
|
`INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
|
|
1370
1489
|
ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
|
|
@@ -1889,6 +2008,16 @@ export function openStore(dbPath: string): Store {
|
|
|
1889
2008
|
ORDER BY at DESC, rowid DESC
|
|
1890
2009
|
LIMIT ?`,
|
|
1891
2010
|
);
|
|
2011
|
+
const insertDaemonStop = db.query<unknown, SqlValue[]>(
|
|
2012
|
+
`INSERT INTO daemon_stops
|
|
2013
|
+
(id, at, controlPath, callerPid, callerUid, role, scope, project, daemonPid, runtimeDir, affected, reason, unattributed)
|
|
2014
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2015
|
+
);
|
|
2016
|
+
// `rowid` breaks the tie when two requests land in the same millisecond, so
|
|
2017
|
+
// "newest first" never silently hides one stop behind another.
|
|
2018
|
+
const selectLatestDaemonStop = db.query<DaemonStopRow, []>(
|
|
2019
|
+
`SELECT * FROM daemon_stops ORDER BY at DESC, rowid DESC LIMIT 1`,
|
|
2020
|
+
);
|
|
1892
2021
|
// Breaking a stale claim and taking it must be one transaction, or two
|
|
1893
2022
|
// daemons both see the stale row, both delete it, and both insert.
|
|
1894
2023
|
const breakStaleMergeLock = db.query<unknown, [string, number]>(
|
|
@@ -2223,6 +2352,58 @@ export function openStore(dbPath: string): Store {
|
|
|
2223
2352
|
insertNotified.run(key, Date.now());
|
|
2224
2353
|
},
|
|
2225
2354
|
|
|
2355
|
+
orchestratorIncident(project: string): OrchestratorIncident | undefined {
|
|
2356
|
+
const row = selectOrchestratorIncident.get(project);
|
|
2357
|
+
return row === null ? undefined : toOrchestratorIncident(row);
|
|
2358
|
+
},
|
|
2359
|
+
|
|
2360
|
+
openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean {
|
|
2361
|
+
return (
|
|
2362
|
+
insertOrchestratorIncident.run(
|
|
2363
|
+
draft.project,
|
|
2364
|
+
draft.mode,
|
|
2365
|
+
draft.cause ?? null,
|
|
2366
|
+
draft.since,
|
|
2367
|
+
).changes === 1
|
|
2368
|
+
);
|
|
2369
|
+
},
|
|
2370
|
+
|
|
2371
|
+
bumpOrchestratorDiverted(project: string, by = 1): void {
|
|
2372
|
+
// Only meaningful while an incident is open: a healthy or external
|
|
2373
|
+
// orchestrator has no row, and the UPDATE touches nothing.
|
|
2374
|
+
bumpOrchestratorIncident.run(by, project);
|
|
2375
|
+
},
|
|
2376
|
+
|
|
2377
|
+
closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
|
|
2378
|
+
const row = deleteOrchestratorIncident.get(project);
|
|
2379
|
+
return row === null ? undefined : toOrchestratorIncident(row);
|
|
2380
|
+
},
|
|
2381
|
+
|
|
2382
|
+
recordDaemonStop(draft: DaemonStopDraft): DaemonStop {
|
|
2383
|
+
const entry: DaemonStop = { ...draft, id: crypto.randomUUID(), at: Date.now() };
|
|
2384
|
+
insertDaemonStop.run(
|
|
2385
|
+
entry.id,
|
|
2386
|
+
entry.at,
|
|
2387
|
+
entry.controlPath,
|
|
2388
|
+
toSql(entry.callerPid),
|
|
2389
|
+
toSql(entry.callerUid),
|
|
2390
|
+
toSql(entry.role),
|
|
2391
|
+
entry.scope,
|
|
2392
|
+
toSql(entry.project),
|
|
2393
|
+
toSql(entry.daemonPid),
|
|
2394
|
+
toSql(entry.runtimeDir),
|
|
2395
|
+
JSON.stringify(entry.affected),
|
|
2396
|
+
entry.reason,
|
|
2397
|
+
entry.unattributed ? 1 : 0,
|
|
2398
|
+
);
|
|
2399
|
+
return entry;
|
|
2400
|
+
},
|
|
2401
|
+
|
|
2402
|
+
latestDaemonStop(): DaemonStop | undefined {
|
|
2403
|
+
const row = selectLatestDaemonStop.get();
|
|
2404
|
+
return row === null ? undefined : toDaemonStop(row);
|
|
2405
|
+
},
|
|
2406
|
+
|
|
2226
2407
|
recordDispatch(project: string, summary: DispatchSummary): void {
|
|
2227
2408
|
const previous = selectDispatch.get(project);
|
|
2228
2409
|
upsertDispatch.run(project, JSON.stringify(summary));
|
package/src/types.ts
CHANGED
|
@@ -1736,6 +1736,25 @@ export interface Store {
|
|
|
1736
1736
|
/** Start the cooldown only after a tick carrying these signals was sent. */
|
|
1737
1737
|
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
|
|
1738
1738
|
markNotified(key: string): void;
|
|
1739
|
+
/** The live embedded-orchestrator-down incident for a project, if any. */
|
|
1740
|
+
orchestratorIncident(project: string): OrchestratorIncident | undefined;
|
|
1741
|
+
/** Record a new incident. `false` when one is already open for the project —
|
|
1742
|
+
* dedupe so a flapping orchestrator opens (and pages) once per incident. */
|
|
1743
|
+
openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean;
|
|
1744
|
+
/** Count tier-1 escalations diverted to an issue comment. No-op when no
|
|
1745
|
+
* incident is open, so a healthy or external orchestrator never accumulates. */
|
|
1746
|
+
bumpOrchestratorDiverted(project: string, by?: number): void;
|
|
1747
|
+
/** Close the open incident and hand back what it accumulated, for the
|
|
1748
|
+
* recovery page's downtime and diverted count. `undefined` when none. */
|
|
1749
|
+
closeOrchestratorIncident(project: string, at: number): OrchestratorIncident | undefined;
|
|
1750
|
+
/** Append one daemon stop/restart provenance line (#378). Host-wide — never
|
|
1751
|
+
* partitioned by project, so any project's status reads the same records. */
|
|
1752
|
+
recordDaemonStop(draft: DaemonStopDraft): DaemonStop;
|
|
1753
|
+
/** The newest stop/restart provenance line, or `undefined` when none was
|
|
1754
|
+
* ever recorded. Read by `status` while the daemon is down and after the
|
|
1755
|
+
* next start — the debrief line an operator gets instead of a bare
|
|
1756
|
+
* "daemon not running". */
|
|
1757
|
+
latestDaemonStop(): DaemonStop | undefined;
|
|
1739
1758
|
/** Record one observed GitHub rate-limit refusal (the tracker's hook). Rows
|
|
1740
1759
|
* older than 24h are pruned in the same write (#198). */
|
|
1741
1760
|
recordGhRefusal?(at: number): void;
|
|
@@ -1952,6 +1971,98 @@ export interface HeldNoticeDraft {
|
|
|
1952
1971
|
urgent?: true;
|
|
1953
1972
|
}
|
|
1954
1973
|
|
|
1974
|
+
/**
|
|
1975
|
+
* Why the embedded orchestrator is down. `start-failed` covers a session that
|
|
1976
|
+
* never came up (the daemon's `startOrchestrator` threw); `crashed` covers a
|
|
1977
|
+
* session that died after a healthy start (the terminal event reached the
|
|
1978
|
+
* handle and it reported not alive).
|
|
1979
|
+
*/
|
|
1980
|
+
export type OrchestratorDownMode = "start-failed" | "crashed";
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* The durable orchestrator-down incident, one per project, re-derived across
|
|
1984
|
+
* daemon restarts so a restart while still down cannot forget it.
|
|
1985
|
+
*
|
|
1986
|
+
* Rows persist while the incident is open and are removed on recovery. The
|
|
1987
|
+
* escalate path writes it from the store and the reconcile closes it against
|
|
1988
|
+
* the same store, so a daemon killed mid-incident leaves the row for the next
|
|
1989
|
+
* one — the records ledger advances only on an finished outcome, never on a
|
|
1990
|
+
* process that is gone.
|
|
1991
|
+
*/
|
|
1992
|
+
export interface OrchestratorIncident {
|
|
1993
|
+
project: string;
|
|
1994
|
+
mode: OrchestratorDownMode;
|
|
1995
|
+
/** Bounded, human-readable cause. Normally the start error or a "session
|
|
1996
|
+
* child exited N" note. */
|
|
1997
|
+
cause?: string;
|
|
1998
|
+
/** Epoch ms when this incident began. Stable for the life of the row, and
|
|
1999
|
+
* the dedupe anchor: the down page and its recovery page both key on it. */
|
|
2000
|
+
since: number;
|
|
2001
|
+
/** Tier-1 escalations diverted to an issue comment while it was down. */
|
|
2002
|
+
diverted: number;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/** What {@link Store.openOrchestratorIncident} is handed. */
|
|
2006
|
+
export interface OrchestratorIncidentDraft {
|
|
2007
|
+
project: string;
|
|
2008
|
+
mode: OrchestratorDownMode;
|
|
2009
|
+
cause?: string;
|
|
2010
|
+
since: number;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* One durable stop/restart provenance line for the shared daemon (#378).
|
|
2015
|
+
*
|
|
2016
|
+
* Host-wide on purpose: the daemon serves every configured project, so this
|
|
2017
|
+
* table is NOT partitioned by project — a record written by one project's CLI
|
|
2018
|
+
* must be readable from another project's `status` the moment the daemon is
|
|
2019
|
+
* down, exactly the situation the incidents in #378 left unattributable.
|
|
2020
|
+
*
|
|
2021
|
+
* Explicit fields only, and never credentials or environment values: the audit
|
|
2022
|
+
* incident was a stop nobody could attribute, and a "just serialise the
|
|
2023
|
+
* command line" implementation would leak tokens. Caller pid/uid and session
|
|
2024
|
+
* role are captured when a conductor process carried the request; the
|
|
2025
|
+
* external-signal fallback records `unattributed` because Linux exposes no
|
|
2026
|
+
* sender identity for a signal, and saying which daemon and which live runs
|
|
2027
|
+
* were affected is the honest capture of what the receiving process knew.
|
|
2028
|
+
*/
|
|
2029
|
+
export interface DaemonStop {
|
|
2030
|
+
id: string;
|
|
2031
|
+
/** Epoch ms the stop/restart was requested (mediated) or observed (fallback). */
|
|
2032
|
+
at: number;
|
|
2033
|
+
/**
|
|
2034
|
+
* The operator-visible control path: "cli stop", "cli restart",
|
|
2035
|
+
* "cli stop via systemctl" (the mediated request, as actually delivered),
|
|
2036
|
+
* or "external signal" (the unattributed fallback).
|
|
2037
|
+
*/
|
|
2038
|
+
controlPath: string;
|
|
2039
|
+
/** Caller pid, when a conductor process carried the request. */
|
|
2040
|
+
callerPid?: number;
|
|
2041
|
+
/** Caller uid, when knowable. */
|
|
2042
|
+
callerUid?: number;
|
|
2043
|
+
/** The requesting session's role, when knowable (see {@link SESSION_ROLE_ENV}). */
|
|
2044
|
+
role?: SessionRole;
|
|
2045
|
+
/** "project" when the request originated from one project; "global" otherwise. */
|
|
2046
|
+
scope: "global" | "project";
|
|
2047
|
+
/** The originating project, when project-scoped. */
|
|
2048
|
+
project?: string;
|
|
2049
|
+
/** The daemon pid the stop/restart acted on, when one was known. */
|
|
2050
|
+
daemonPid?: number;
|
|
2051
|
+
/** The daemon runtime directory, when the record is the receiving daemon's
|
|
2052
|
+
* own fallback. Host paths only — the field never carries secrets. */
|
|
2053
|
+
runtimeDir?: string;
|
|
2054
|
+
/** Every configured project with its live-run count at request time. A
|
|
2055
|
+
* project-scoped stop of the shared daemon names the siblings here. */
|
|
2056
|
+
affected: { project: string; live: number }[];
|
|
2057
|
+
/** Non-secret reason; never credentials or environment values. */
|
|
2058
|
+
reason: string;
|
|
2059
|
+
/** True only when no mediated request existed — the external-signal fallback. */
|
|
2060
|
+
unattributed: boolean;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/** What a caller hands {@link Store.recordDaemonStop}. The store owns `id` and `at`. */
|
|
2064
|
+
export type DaemonStopDraft = Omit<DaemonStop, "id" | "at">;
|
|
2065
|
+
|
|
1955
2066
|
/**
|
|
1956
2067
|
* Baseline limits used when a project omits `caps`. Data, not behaviour: kept
|
|
1957
2068
|
* beside the type so the defaults cannot drift out of shape with it.
|
package/src/upgrade.ts
CHANGED
|
@@ -364,7 +364,7 @@ export interface DrainDeps {
|
|
|
364
364
|
*/
|
|
365
365
|
export function resolveScope(
|
|
366
366
|
deps: DrainDeps,
|
|
367
|
-
verb:
|
|
367
|
+
verb: DrainVerb,
|
|
368
368
|
project?: string,
|
|
369
369
|
): UpgradeScope {
|
|
370
370
|
let configured: readonly string[] = [];
|
|
@@ -480,6 +480,16 @@ function restartFenceProblem(
|
|
|
480
480
|
return undefined;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Every verb {@link pauseAndDrain} accepts, declared once and used for both the
|
|
485
|
+
* `verb` parameter's type and the sentinel round-trip test — so a fourth verb
|
|
486
|
+
* that cannot be encoded as a single `source=` token (see
|
|
487
|
+
* {@link pauseSourceToken}; a verb with a space) fails the suite instead of
|
|
488
|
+
* shipping an unprovable pause (#552).
|
|
489
|
+
*/
|
|
490
|
+
export const DRAIN_VERBS = ["upgrade", "restart", "setup host"] as const;
|
|
491
|
+
export type DrainVerb = (typeof DRAIN_VERBS)[number];
|
|
492
|
+
|
|
483
493
|
/**
|
|
484
494
|
* Pause claims and drain live workers to idle — the destructive half of the
|
|
485
495
|
* trusted restart transaction, minus the restart. Exported so `setup host`
|
|
@@ -489,14 +499,18 @@ function restartFenceProblem(
|
|
|
489
499
|
* Generation-scoped (#377) exactly as the full transaction is: the caller
|
|
490
500
|
* captures the restart-owned pause and the daemon generation, and the drain
|
|
491
501
|
* aborts if a `resume` lifts the pause, replaces the sentinel, or a newer
|
|
492
|
-
* daemon generation appears.
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
502
|
+
* daemon generation appears. The fence failure path (the pause it just
|
|
503
|
+
* created cannot be read back as an instance) fails closed WITHOUT leaving
|
|
504
|
+
* that pause behind: a pause the transaction cannot prove it is still using
|
|
505
|
+
* is released, restoring the entry state, so the refusal never stops dispatch
|
|
506
|
+
* on its own (#552). A drain that outlives `timeoutMs` stays paused (no
|
|
507
|
+
* resume in a catch) — a drain cannot silently resume dispatch over a wedged
|
|
508
|
+
* daemon. `timeoutMs` absent waits indefinitely, which is the `upgrade`
|
|
509
|
+
* posture.
|
|
496
510
|
*/
|
|
497
511
|
export async function pauseAndDrain(
|
|
498
512
|
deps: DrainDeps,
|
|
499
|
-
verb:
|
|
513
|
+
verb: DrainVerb,
|
|
500
514
|
o: { project?: string; timeoutMs?: number },
|
|
501
515
|
): Promise<{ scope: UpgradeScope; initialPaused: boolean }> {
|
|
502
516
|
// Host-wide by default (#389): the daemon this restarts serves every
|
|
@@ -511,6 +525,12 @@ export async function pauseAndDrain(
|
|
|
511
525
|
// on anything it cannot act upon either way.
|
|
512
526
|
const pauseToken = deps.pauseState(scope.pauseKey);
|
|
513
527
|
if (pauseToken === undefined) {
|
|
528
|
+
// Fail closed — but do not abandon our own pause. It is only correct to
|
|
529
|
+
// hold a pause the transaction is still using, and a sentinel we cannot
|
|
530
|
+
// prove is unusable: restore the entry state exactly, so a verb that
|
|
531
|
+
// paused the fleet releases it (a fleet already paused stays paused)
|
|
532
|
+
// before the refusal propagates (#552).
|
|
533
|
+
if (!initial.paused) deps.setPaused(false, scope.pauseKey);
|
|
514
534
|
throw new Error(
|
|
515
535
|
`${verb} cancelled: cannot prove the ${verb}-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted`,
|
|
516
536
|
);
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -180,18 +180,21 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
180
180
|
allowedRoles: ["orchestrator"],
|
|
181
181
|
description:
|
|
182
182
|
"Merge one open pull request. The daemon re-reads the live head immediately before merging " +
|
|
183
|
-
"and refuses on any mismatch with headSha, and one merge is in flight per project at a time."
|
|
183
|
+
"and refuses on any mismatch with headSha, and one merge is in flight per project at a time. " +
|
|
184
|
+
"A PR a run of this project opened merges on the holder's authority; a PR no run opened " +
|
|
185
|
+
"merges only as the orchestrator's own work (author=orchestrator), and only in a routed repo.",
|
|
184
186
|
args: {
|
|
185
187
|
prUrl: {
|
|
186
188
|
type: "string",
|
|
187
189
|
required: true,
|
|
188
|
-
description:
|
|
190
|
+
description:
|
|
191
|
+
"Full pull request URL — a run's in this project, or the orchestrator's own (author=orchestrator).",
|
|
189
192
|
},
|
|
190
193
|
headSha: {
|
|
191
194
|
type: "string",
|
|
192
195
|
required: true,
|
|
193
196
|
description:
|
|
194
|
-
"The head you believe you are merging. Re-read live before the merge; a stale one is refused.",
|
|
197
|
+
"The exact head you believe you are merging. Re-read live before the merge; a stale one is refused.",
|
|
195
198
|
},
|
|
196
199
|
reason: {
|
|
197
200
|
type: "string",
|
|
@@ -199,6 +202,16 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
199
202
|
description: `Why this merge, from the closed set: ${MERGE_REASONS.join(", ")}.`,
|
|
200
203
|
oneOf: MERGE_REASONS,
|
|
201
204
|
},
|
|
205
|
+
author: {
|
|
206
|
+
type: "string",
|
|
207
|
+
required: false,
|
|
208
|
+
description:
|
|
209
|
+
"Optional authorship claim; only 'orchestrator'. States that no run opened this PR — it is " +
|
|
210
|
+
"the orchestrator's own work. Required for a PR no run in this project opened; it never " +
|
|
211
|
+
"unlocks a run's PR, and never bypasses the configured holder, the routed-repo scope, the " +
|
|
212
|
+
"live-head recheck, the green-checks gate, the pause or the ledger.",
|
|
213
|
+
oneOf: ["orchestrator"],
|
|
214
|
+
},
|
|
202
215
|
rationale: RATIONALE_ARG,
|
|
203
216
|
},
|
|
204
217
|
// The exact sentence #126 asks a refused worker to be given. `authority`
|
package/src/verbs/server.ts
CHANGED
|
@@ -812,6 +812,12 @@ async function prMergeVerb(
|
|
|
812
812
|
const prUrl = String(args["prUrl"]);
|
|
813
813
|
const headSha = String(args["headSha"]);
|
|
814
814
|
const reason = String(args["reason"]);
|
|
815
|
+
// An explicit authorship claim: `<author> = "orchestrator"` states that no
|
|
816
|
+
// run opened this PR — it is the caller's own work. The claim is the only
|
|
817
|
+
// thing that can make a run-less PR eligible, and it unlocks nothing else:
|
|
818
|
+
// the holder gate above, the routed-repo scope, the pause, the live-head
|
|
819
|
+
// recheck, the green-checks gate and the ledger all still apply below.
|
|
820
|
+
const authoredByOrchestrator = args["author"] === "orchestrator";
|
|
815
821
|
const target = runForPr(deps, project.name, prUrl);
|
|
816
822
|
const recovery =
|
|
817
823
|
target === undefined
|
|
@@ -836,8 +842,28 @@ async function prMergeVerb(
|
|
|
836
842
|
: "Ask the operator which session is meant to hold it."),
|
|
837
843
|
);
|
|
838
844
|
}
|
|
845
|
+
if (authoredByOrchestrator && target !== undefined) {
|
|
846
|
+
return refuse(
|
|
847
|
+
"pr-not-this-run",
|
|
848
|
+
`refused: ${prUrl} is a run-opened PR in ${project.name}; the orchestrator-authors claim is only for ` +
|
|
849
|
+
"pull requests no run ever opened, and a run-owned PR is not claimable as the orchestrator's own work.",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
839
852
|
if (target === undefined && recovery === undefined) {
|
|
840
|
-
|
|
853
|
+
if (!authoredByOrchestrator) {
|
|
854
|
+
return refuse(
|
|
855
|
+
"pr-not-this-run",
|
|
856
|
+
`refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
|
|
857
|
+
"If the orchestrator authored it (no run opened it), re-call with --arg author=orchestrator; " +
|
|
858
|
+
"a PR a run opened stays that run's.",
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (routedRepo === undefined) {
|
|
862
|
+
return refuse(
|
|
863
|
+
"pr-not-this-run",
|
|
864
|
+
`refused: ${prUrl} is not in ${project.name}'s routed repositories; the orchestrator-authors path cannot widen project scope.`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
841
867
|
}
|
|
842
868
|
if (target === undefined && routedRepo === undefined) {
|
|
843
869
|
return refuse(
|