omp-conductor 0.15.11 → 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 +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- 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 +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/types.ts
CHANGED
|
@@ -336,6 +336,16 @@ export const SESSION_ROLES = ["worker", "orchestrator"] as const;
|
|
|
336
336
|
|
|
337
337
|
export type SessionRole = (typeof SESSION_ROLES)[number];
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Env var the daemon sets on a spawned session so the reporting CLI can tell a
|
|
341
|
+
* worker session from the operator's shell. The daemon stamps it on the
|
|
342
|
+
* session-host child (and thus on every tool process it spawns); a direct CLI
|
|
343
|
+
* run — the operator's shell or a systemd unit — has it absent and is treated
|
|
344
|
+
* as the orchestrator, preserving the historical surface. {@link SESSION_ROLES} is
|
|
345
|
+
* the closed vocabulary: a value outside it must be refused, never assumed.
|
|
346
|
+
*/
|
|
347
|
+
export const SESSION_ROLE_ENV = "OMP_CONDUCTOR_SESSION_ROLE";
|
|
348
|
+
|
|
339
349
|
/**
|
|
340
350
|
* Who holds an authority the daemon itself never exercises. Declared as data
|
|
341
351
|
* for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and
|
|
@@ -632,6 +642,21 @@ export interface ProjectConfig {
|
|
|
632
642
|
* answered the question wants.
|
|
633
643
|
*/
|
|
634
644
|
workerModel?: string;
|
|
645
|
+
/**
|
|
646
|
+
* Ordered fallback models for this project, tried after {@link workerModel}
|
|
647
|
+
* once a run chain has suffered enough consecutive provider-class failures
|
|
648
|
+
* (stream stalls and credit refusals). Each new issue still starts on the
|
|
649
|
+
* primary; recovery is sticky to the chain, never global (#286). Empty or
|
|
650
|
+
* absent preserves the pre-failover dispatch exactly: every attempt stays on
|
|
651
|
+
* the primary until the existing escalation path takes over.
|
|
652
|
+
*/
|
|
653
|
+
modelFallbacks?: string[];
|
|
654
|
+
/**
|
|
655
|
+
* Consecutive provider-class failures on one run chain after which the next
|
|
656
|
+
* dispatch moves to the next model in {@link modelFallbacks}. Defaults to 2
|
|
657
|
+
* when absent or unusable.
|
|
658
|
+
*/
|
|
659
|
+
modelFallbackThreshold?: number;
|
|
635
660
|
/**
|
|
636
661
|
* How a stuck run reaches a human, what to do when it cannot, and who runs
|
|
637
662
|
* the session that triages it. See {@link ORCHESTRATOR_MODES}.
|
|
@@ -688,6 +713,20 @@ export interface ProjectConfig {
|
|
|
688
713
|
workspaceRoot: string;
|
|
689
714
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
690
715
|
mirrorRoot: string;
|
|
716
|
+
/**
|
|
717
|
+
* Git treeish (commit SHAs or refs) on the base branch that every preserved
|
|
718
|
+
* continuation branch must contain before the dispatcher may reattach it
|
|
719
|
+
* (#428). A base safety fix protects only branches forked after it landed; a
|
|
720
|
+
* continuation forked before it still carries the dangerous code and, on a
|
|
721
|
+
* shared host, re-running the lifecycle suite it retains is what SIGTERMed
|
|
722
|
+
* the production daemon. With a marker configured, the dispatcher refuses to
|
|
723
|
+
* reattach any preserved branch missing it, holds the issue as `stale-base`
|
|
724
|
+
* (distinct from capacity or dependency holds), and never silently merges
|
|
725
|
+
* base into the user's work. Absent or empty, ordinary stale continuations
|
|
726
|
+
* keep today's behaviour. Optional and hand-edited, like
|
|
727
|
+
* {@link recoveryMerges}.
|
|
728
|
+
*/
|
|
729
|
+
criticalBase?: string[];
|
|
691
730
|
}
|
|
692
731
|
|
|
693
732
|
/**
|
|
@@ -728,6 +767,16 @@ export interface ReadyIssue {
|
|
|
728
767
|
updatedAt: string;
|
|
729
768
|
}
|
|
730
769
|
|
|
770
|
+
/**
|
|
771
|
+
* One issue comment as the worker brief renders it: author login and verbatim
|
|
772
|
+
* body, in the tracker's own order (oldest first), so a later correction
|
|
773
|
+
* visibly supersedes an earlier note.
|
|
774
|
+
*/
|
|
775
|
+
export interface IssueComment {
|
|
776
|
+
author: string;
|
|
777
|
+
body: string;
|
|
778
|
+
}
|
|
779
|
+
|
|
731
780
|
/**
|
|
732
781
|
* How a pull request ended, in tracker-agnostic terms. Lowercase because the
|
|
733
782
|
* loop's vocabulary is lowercase; mapping GitHub's `MERGED`/`CLOSED`/`OPEN` onto
|
|
@@ -879,6 +928,16 @@ export interface Tracker {
|
|
|
879
928
|
* Complete — follows pagination to the end.
|
|
880
929
|
*/
|
|
881
930
|
listOpenIssues(): Promise<ReadyIssue[]>;
|
|
931
|
+
/**
|
|
932
|
+
* The issue's comments, oldest first. The dispatcher renders them into the
|
|
933
|
+
* worker brief, so grooming the orchestrator posted as a comment reaches the
|
|
934
|
+
* worker's opening prompt without any runtime read.
|
|
935
|
+
*
|
|
936
|
+
* Throws when the tracker could not be read: the caller must never mistake
|
|
937
|
+
* "could not read" for "no comments", which is exactly the failure mode this
|
|
938
|
+
* read exists to prevent (#517).
|
|
939
|
+
*/
|
|
940
|
+
listComments(issue: number): Promise<IssueComment[]>;
|
|
882
941
|
addLabel(issue: number, label: string): Promise<void>;
|
|
883
942
|
removeLabel(issue: number, label: string): Promise<void>;
|
|
884
943
|
comment(issue: number, body: string): Promise<void>;
|
|
@@ -1154,6 +1213,16 @@ export interface RunRecord {
|
|
|
1154
1213
|
* copy of real work, so the tree was kept and the issue is held out of
|
|
1155
1214
|
* dispatch until an operator acknowledges it. */
|
|
1156
1215
|
salvageError?: string;
|
|
1216
|
+
/**
|
|
1217
|
+
* The model this attempt actually dispatched on, written at dispatch when the
|
|
1218
|
+
* project configures a failover chain (`modelFallbacks`). Absent means the
|
|
1219
|
+
* run predates the column or the project has no chain — never "the harness
|
|
1220
|
+
* downgraded"; the harness's own downgrade is carried as
|
|
1221
|
+
* `modelFallbackMessage` on the worker result instead, because that is
|
|
1222
|
+
* evidence about this run while this column is attribution for the chain
|
|
1223
|
+
* (#286).
|
|
1224
|
+
*/
|
|
1225
|
+
model?: string;
|
|
1157
1226
|
/** When an operator accepted the loss or recovered the tree by hand
|
|
1158
1227
|
* (`unblock --force`). Clears the hold without erasing what happened. */
|
|
1159
1228
|
salvageAckAt?: number;
|
|
@@ -1218,6 +1287,7 @@ export type AdmissionHoldReason =
|
|
|
1218
1287
|
| "daily-spend-cap"
|
|
1219
1288
|
| "plan-usage-cap"
|
|
1220
1289
|
| "shutting-down"
|
|
1290
|
+
| "stale-base"
|
|
1221
1291
|
| "unroutable:no-repo-label"
|
|
1222
1292
|
| "unroutable:multiple-repo-labels"
|
|
1223
1293
|
| "unroutable:unknown-repo";
|
|
@@ -1258,6 +1328,7 @@ export type FrictionAdmissionReason =
|
|
|
1258
1328
|
| "parent-lookup-error"
|
|
1259
1329
|
| "issue-state-lookup-error"
|
|
1260
1330
|
| "open-pr-lookup-error"
|
|
1331
|
+
| "stale-base"
|
|
1261
1332
|
| "unroutable:no-repo-label"
|
|
1262
1333
|
| "unroutable:multiple-repo-labels"
|
|
1263
1334
|
| "unroutable:unknown-repo";
|
|
@@ -1665,6 +1736,25 @@ export interface Store {
|
|
|
1665
1736
|
/** Start the cooldown only after a tick carrying these signals was sent. */
|
|
1666
1737
|
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
|
|
1667
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;
|
|
1668
1758
|
/** Record one observed GitHub rate-limit refusal (the tracker's hook). Rows
|
|
1669
1759
|
* older than 24h are pruned in the same write (#198). */
|
|
1670
1760
|
recordGhRefusal?(at: number): void;
|
|
@@ -1881,6 +1971,98 @@ export interface HeldNoticeDraft {
|
|
|
1881
1971
|
urgent?: true;
|
|
1882
1972
|
}
|
|
1883
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
|
+
|
|
1884
2066
|
/**
|
|
1885
2067
|
* Baseline limits used when a project omits `caps`. Data, not behaviour: kept
|
|
1886
2068
|
* beside the type so the defaults cannot drift out of shape with it.
|
package/src/upgrade.ts
CHANGED
|
@@ -311,13 +311,39 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
|
|
|
311
311
|
* exactly this transaction's pause while leaving any per-project operator hold
|
|
312
312
|
* standing.
|
|
313
313
|
*/
|
|
314
|
-
interface UpgradeScope {
|
|
314
|
+
export interface UpgradeScope {
|
|
315
315
|
/** Project selectors to address the project-aware deps with, never empty. */
|
|
316
316
|
selectors: readonly (string | undefined)[];
|
|
317
317
|
/** The sentinel this transaction pauses and resumes. */
|
|
318
318
|
pauseKey: string | undefined;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The subset of {@link UpgradeDeps} the pause/drain fence needs. Factored out
|
|
323
|
+
* so a caller that only wants the fence — `setup host` — can provide just
|
|
324
|
+
* these members instead of a full install-and-verify deps object. Every
|
|
325
|
+
* {@link UpgradeDeps} is a `DrainDeps`.
|
|
326
|
+
*/
|
|
327
|
+
export interface DrainDeps {
|
|
328
|
+
/** Live worker count per project selector; `undefined` = the one configured project. */
|
|
329
|
+
snapshot(project?: string): { liveWorkers: number };
|
|
330
|
+
/** The fleet's current dispatch/daemon layers for one project selector. */
|
|
331
|
+
layers(project?: string): FleetLayers;
|
|
332
|
+
/** Every configured project name, in config order (#389). */
|
|
333
|
+
projectNames(): readonly string[];
|
|
334
|
+
/**
|
|
335
|
+
* The daemon the transaction targets: running or not, its project when it
|
|
336
|
+
* recorded one, and a `generation` identifying the exact instance (#377).
|
|
337
|
+
*/
|
|
338
|
+
daemonIdentity(): { running: boolean; project?: string; generation?: string };
|
|
339
|
+
/** The active pause sentinel as an instance, when one is readable (#377). */
|
|
340
|
+
pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
|
|
341
|
+
/** Write or clear the durable pause sentinel for the scope's project. */
|
|
342
|
+
setPaused(value: boolean, project?: string): void;
|
|
343
|
+
sleep(ms: number): Promise<void>;
|
|
344
|
+
log(message: string): void;
|
|
345
|
+
}
|
|
346
|
+
|
|
321
347
|
/**
|
|
322
348
|
* Decides which projects a transaction covers, and refuses the requests that
|
|
323
349
|
* cannot be honoured truthfully.
|
|
@@ -336,7 +362,11 @@ interface UpgradeScope {
|
|
|
336
362
|
* A single-configured-project host keeps the historical bare selector, so its
|
|
337
363
|
* behaviour is unchanged whether or not the daemon recorded its name.
|
|
338
364
|
*/
|
|
339
|
-
function resolveScope(
|
|
365
|
+
export function resolveScope(
|
|
366
|
+
deps: DrainDeps,
|
|
367
|
+
verb: DrainVerb,
|
|
368
|
+
project?: string,
|
|
369
|
+
): UpgradeScope {
|
|
340
370
|
let configured: readonly string[] = [];
|
|
341
371
|
try {
|
|
342
372
|
configured = deps.projectNames();
|
|
@@ -372,8 +402,8 @@ function resolveScope(deps: UpgradeDeps, verb: "upgrade" | "restart", project?:
|
|
|
372
402
|
return { selectors: [undefined], pauseKey: undefined };
|
|
373
403
|
}
|
|
374
404
|
|
|
375
|
-
async function waitForDrain(
|
|
376
|
-
deps:
|
|
405
|
+
export async function waitForDrain(
|
|
406
|
+
deps: DrainDeps,
|
|
377
407
|
scope: UpgradeScope,
|
|
378
408
|
deadlineAt?: number,
|
|
379
409
|
stale?: () => string | undefined,
|
|
@@ -427,7 +457,7 @@ interface RestartBegun {
|
|
|
427
457
|
* stopped by it.
|
|
428
458
|
*/
|
|
429
459
|
function restartFenceProblem(
|
|
430
|
-
deps:
|
|
460
|
+
deps: DrainDeps,
|
|
431
461
|
scope: UpgradeScope,
|
|
432
462
|
begun: RestartBegun,
|
|
433
463
|
): string | undefined {
|
|
@@ -450,6 +480,79 @@ function restartFenceProblem(
|
|
|
450
480
|
return undefined;
|
|
451
481
|
}
|
|
452
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
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Pause claims and drain live workers to idle — the destructive half of the
|
|
495
|
+
* trusted restart transaction, minus the restart. Exported so `setup host`
|
|
496
|
+
* runs the same fence `upgrade` and `restart` do, then performs its own unit
|
|
497
|
+
* installs instead of {@link drainAndRestart}'s daemon restart.
|
|
498
|
+
*
|
|
499
|
+
* Generation-scoped (#377) exactly as the full transaction is: the caller
|
|
500
|
+
* captures the restart-owned pause and the daemon generation, and the drain
|
|
501
|
+
* aborts if a `resume` lifts the pause, replaces the sentinel, or a newer
|
|
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.
|
|
510
|
+
*/
|
|
511
|
+
export async function pauseAndDrain(
|
|
512
|
+
deps: DrainDeps,
|
|
513
|
+
verb: DrainVerb,
|
|
514
|
+
o: { project?: string; timeoutMs?: number },
|
|
515
|
+
): Promise<{ scope: UpgradeScope; initialPaused: boolean }> {
|
|
516
|
+
// Host-wide by default (#389): the daemon this restarts serves every
|
|
517
|
+
// configured project, so the drain counts every project's workers and the
|
|
518
|
+
// pause covers all of them.
|
|
519
|
+
const scope = resolveScope(deps, verb, o.project);
|
|
520
|
+
const initial = deps.layers(scope.pauseKey);
|
|
521
|
+
if (!initial.paused) deps.setPaused(true, scope.pauseKey);
|
|
522
|
+
// The lock must be provable NOW. A pause that cannot be read as an instance
|
|
523
|
+
// — sentinel malformed or unreadable — would make the whole fence fail open
|
|
524
|
+
// (nothing to compare against), so the transaction refuses before waiting
|
|
525
|
+
// on anything it cannot act upon either way.
|
|
526
|
+
const pauseToken = deps.pauseState(scope.pauseKey);
|
|
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);
|
|
534
|
+
throw new Error(
|
|
535
|
+
`${verb} cancelled: cannot prove the ${verb}-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const begun: RestartBegun = {
|
|
539
|
+
pauseToken,
|
|
540
|
+
daemon: deps.daemonIdentity(),
|
|
541
|
+
};
|
|
542
|
+
const stale = () => restartFenceProblem(deps, scope, begun);
|
|
543
|
+
await waitForDrain(
|
|
544
|
+
deps,
|
|
545
|
+
scope,
|
|
546
|
+
o.timeoutMs === undefined ? undefined : Date.now() + o.timeoutMs,
|
|
547
|
+
stale,
|
|
548
|
+
);
|
|
549
|
+
// The drain completed; the world may have moved on while it did. Re-prove
|
|
550
|
+
// the pause and the generation an instant before the destructive call.
|
|
551
|
+
const cancelled = stale();
|
|
552
|
+
if (cancelled !== undefined) throw new Error(cancelled);
|
|
553
|
+
return { scope, initialPaused: initial.paused };
|
|
554
|
+
}
|
|
555
|
+
|
|
453
556
|
/**
|
|
454
557
|
* Pause, drain, restart, restore — the trusted restart transaction, minus the
|
|
455
558
|
* install/verify steps of {@link upgradeConductor}.
|
|
@@ -477,34 +580,9 @@ export async function drainAndRestart(
|
|
|
477
580
|
deps: UpgradeDeps,
|
|
478
581
|
o: { project?: string; timeoutMs: number },
|
|
479
582
|
): Promise<void> {
|
|
480
|
-
|
|
481
|
-
// configured project, so the drain counts every project's workers and the
|
|
482
|
-
// pause covers all of them.
|
|
483
|
-
const scope = resolveScope(deps, "restart", o.project);
|
|
484
|
-
const initial = deps.layers(scope.pauseKey);
|
|
485
|
-
if (!initial.paused) deps.setPaused(true, scope.pauseKey);
|
|
486
|
-
// The lock must be provable NOW. A pause that cannot be read as an instance
|
|
487
|
-
// — sentinel malformed or unreadable — would make the whole fence fail open
|
|
488
|
-
// (nothing to compare against), so the transaction refuses before waiting
|
|
489
|
-
// on anything it cannot act upon either way.
|
|
490
|
-
const pauseToken = deps.pauseState(scope.pauseKey);
|
|
491
|
-
if (pauseToken === undefined) {
|
|
492
|
-
throw new Error(
|
|
493
|
-
"restart cancelled: cannot prove the restart-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted",
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
const begun: RestartBegun = {
|
|
497
|
-
pauseToken,
|
|
498
|
-
daemon: deps.daemonIdentity(),
|
|
499
|
-
};
|
|
500
|
-
const stale = () => restartFenceProblem(deps, scope, begun);
|
|
501
|
-
await waitForDrain(deps, scope, Date.now() + o.timeoutMs, stale);
|
|
502
|
-
// The drain completed; the world may have moved on while it did. Re-prove
|
|
503
|
-
// the pause and the generation an instant before the destructive call.
|
|
504
|
-
const cancelled = stale();
|
|
505
|
-
if (cancelled !== undefined) throw new Error(cancelled);
|
|
583
|
+
const { scope, initialPaused } = await pauseAndDrain(deps, "restart", o);
|
|
506
584
|
await deps.restartDaemon();
|
|
507
|
-
if (!
|
|
585
|
+
if (!initialPaused) deps.setPaused(false, scope.pauseKey);
|
|
508
586
|
}
|
|
509
587
|
|
|
510
588
|
function recoveryProblem(
|
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(
|