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/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: "upgrade" | "restart" | "setup host",
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. On ANY throw after the pause the fleet stays
493
- * paused (no resume in a catch), so a drain that outlives `timeoutMs` cannot
494
- * silently resume dispatch over a wedged daemon. `timeoutMs` absent waits
495
- * indefinitely, which is the `upgrade` posture.
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: "upgrade" | "restart" | "setup host",
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
  );
@@ -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(