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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. 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(deps: UpgradeDeps, verb: "upgrade" | "restart", project?: string): UpgradeScope {
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: UpgradeDeps,
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: UpgradeDeps,
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
- // Host-wide by default (#389): the daemon this restarts serves every
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 (!initial.paused) deps.setPaused(false, scope.pauseKey);
585
+ if (!initialPaused) deps.setPaused(false, scope.pauseKey);
508
586
  }
509
587
 
510
588
  function recoveryProblem(
@@ -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: "Full pull request URL, belonging to a run in this project.",
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`
@@ -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
- return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
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(