omp-conductor 0.20.1 → 0.20.3

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.
@@ -94,6 +94,12 @@ interface GhCheck {
94
94
  status?: string;
95
95
  conclusion?: string;
96
96
  state?: string;
97
+ /** When the run started (`startedAt` on a GraphQL CheckRun, `started_at`
98
+ * over REST); a StatusContext stamps `createdAt` instead. Undefined when
99
+ * the payload spelled none — unordered entries resolve pessimistically
100
+ * (#1066), never invented. */
101
+ startedAt?: string;
102
+ createdAt?: string;
97
103
  detailsUrl?: string;
98
104
  }
99
105
 
@@ -419,6 +425,75 @@ function checkVerdict(check: GhCheck): CheckVerdict {
419
425
  return "failed";
420
426
  }
421
427
 
428
+ /** One check normalized onto the verdict vocabulary, carrying whatever run
429
+ * timestamp its payload spelled: undefined means unordered, which
430
+ * {@link resolveNewestPerName} treats pessimistically. */
431
+ interface NamedVerdict {
432
+ readonly name: string;
433
+ readonly verdict: CheckVerdict;
434
+ readonly state: string;
435
+ readonly startedAt: string | undefined;
436
+ }
437
+
438
+ /** Epoch ms of an ISO stamp, or undefined when absent or unreadable — both
439
+ * leave the entry unordered rather than inventing an age for it. */
440
+ function startedAtMs(startedAt: string | undefined): number | undefined {
441
+ if (startedAt === undefined) return undefined;
442
+ const ms = Date.parse(startedAt);
443
+ return Number.isFinite(ms) ? ms : undefined;
444
+ }
445
+
446
+ /** Worse beats better when a name's deciding entries disagree. */
447
+ const VERDICT_RANK: Readonly<Record<CheckVerdict, number>> = { failed: 0, pending: 1, green: 2 };
448
+
449
+ /**
450
+ * Decide every check name by its newest run, never by payload order (#1066).
451
+ *
452
+ * One head can carry several check-runs of one name — a re-run creates a new
453
+ * run record without retiring the old one — and folding them first-match lets
454
+ * a stale `skipped` mask a newer `failure`: exactly how veltro#717 nearly
455
+ * merged green over its only upgrade-path check, whose failing run started
456
+ * five minutes behind the skipped row of the same name. Entries are grouped
457
+ * by name and each group is decided by its newest tier, GitHub's own checks-UI
458
+ * rule:
459
+ *
460
+ * - timed entries at the maximum `started_at` decide; older runs of the name
461
+ * cannot mask them, so a re-run that went green supersedes its red
462
+ * predecessor;
463
+ * - ties at one instant resolve pessimistically — a failure anywhere in the
464
+ * deciding tier fails the name;
465
+ * - an entry without a readable timestamp cannot be proven older than
466
+ * anything, so it belongs to every deciding tier (pessimistic again).
467
+ *
468
+ * The representative kept per name is the deciding entry itself, so the
469
+ * folds' refusal lines render the decided verdict's own spelling.
470
+ */
471
+ function resolveNewestPerName(entries: readonly NamedVerdict[]): NamedVerdict[] {
472
+ const groups = new Map<string, { entry: NamedVerdict; startedMs: number | undefined }[]>();
473
+ for (const entry of entries) {
474
+ const stamped = { entry, startedMs: startedAtMs(entry.startedAt) };
475
+ const group = groups.get(entry.name);
476
+ if (group === undefined) groups.set(entry.name, [stamped]);
477
+ else group.push(stamped);
478
+ }
479
+ const resolved: NamedVerdict[] = [];
480
+ for (const group of groups.values()) {
481
+ let newest = -Infinity;
482
+ for (const { startedMs } of group) {
483
+ if (startedMs !== undefined && startedMs > newest) newest = startedMs;
484
+ }
485
+ let decided: NamedVerdict | undefined;
486
+ for (const { entry, startedMs } of group) {
487
+ if (startedMs !== undefined && startedMs !== newest) continue;
488
+ if (decided === undefined || VERDICT_RANK[entry.verdict] < VERDICT_RANK[decided.verdict]) {
489
+ decided = entry;
490
+ }
491
+ }
492
+ if (decided !== undefined) resolved.push(decided);
493
+ }
494
+ return resolved;
495
+ }
496
+
422
497
  /**
423
498
  * Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
424
499
  * payload, optionally against a caller-observed head. A missing rollup is
@@ -430,6 +505,10 @@ function checkVerdict(check: GhCheck): CheckVerdict {
430
505
  * a different question: *what is this PR's state*, and a merged or closed PR
431
506
  * is reported as a fact rather than a failure — the merge gate paths never
432
507
  * pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
508
+ *
509
+ * Same-name entries are decided by recency, not payload order (#1066): only
510
+ * the newest run of a name answers for it, ties and unreadable stamps resolve
511
+ * pessimistically, and the green count names de-duplicated checks.
433
512
  */
434
513
  export function prVerificationFrom(
435
514
  raw: string,
@@ -467,23 +546,34 @@ export function prVerificationFrom(
467
546
  if (checks.length === 0) {
468
547
  return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
469
548
  }
470
- const failed = checks.filter((check) => checkVerdict(check) === "failed");
549
+ // Same-name runs decide by recency, not payload order (#1066): the rollup
550
+ // can carry an older run of a name beside a newer one, and first-match
551
+ // folding would let the older verdict answer for the name.
552
+ const resolved = resolveNewestPerName(
553
+ checks.map((check): NamedVerdict => ({
554
+ name: checkName(check),
555
+ verdict: checkVerdict(check),
556
+ state: check.conclusion ?? check.state ?? "unknown",
557
+ startedAt: check.startedAt ?? check.createdAt,
558
+ })),
559
+ );
560
+ const failed = resolved.filter((check) => check.verdict === "failed");
471
561
  if (failed.length > 0) {
472
562
  return {
473
563
  status: "failed",
474
- reason: `Checks failed: ${failed.map((check) => `${checkName(check)} (${check.conclusion ?? check.state ?? "unknown"})`).join(", ")}`,
564
+ reason: `Checks failed: ${failed.map((check) => `${check.name} (${check.state})`).join(", ")}`,
475
565
  headSha,
476
566
  };
477
567
  }
478
- const pending = checks.filter((check) => checkVerdict(check) === "pending");
568
+ const pending = resolved.filter((check) => check.verdict === "pending");
479
569
  if (pending.length > 0) {
480
570
  return {
481
571
  status: "pending",
482
- reason: `Checks pending: ${pending.map(checkName).join(", ")}`,
572
+ reason: `Checks pending: ${pending.map((check) => check.name).join(", ")}`,
483
573
  headSha,
484
574
  };
485
575
  }
486
- return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
576
+ return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha };
487
577
  }
488
578
 
489
579
  /**
@@ -538,6 +628,9 @@ interface RestCheckConclusion {
538
628
  * GitHub spelled it — surfaced in refusals exactly like the GraphQL one. */
539
629
  state: string;
540
630
  verdict: CheckVerdict;
631
+ /** The run's `started_at` (or a commit status's `updated_at`), undefined
632
+ * when the page spelled none — unordered, never invented (#1066). */
633
+ startedAt: string | undefined;
541
634
  }
542
635
 
543
636
  /** One REST check plane, parsed strictly. */
@@ -579,6 +672,7 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
579
672
  const name = row["name"];
580
673
  const status = row["status"];
581
674
  const conclusion = row["conclusion"];
675
+ const startedAt = row["started_at"];
582
676
  if (typeof name !== "string" || name === "") return undefined;
583
677
  if (typeof status !== "string") return undefined;
584
678
  if (conclusion !== undefined && conclusion !== null && typeof conclusion !== "string") return undefined;
@@ -591,7 +685,12 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
591
685
  : conclusion === "success" || conclusion === "skipped" || conclusion === "neutral"
592
686
  ? "green"
593
687
  : "failed";
594
- return { name, state: typeof conclusion === "string" ? conclusion : status, verdict };
688
+ return {
689
+ name,
690
+ state: typeof conclusion === "string" ? conclusion : status,
691
+ verdict,
692
+ startedAt: typeof startedAt === "string" ? startedAt : undefined,
693
+ };
595
694
  }
596
695
 
597
696
  /** One REST commit-status member onto the rollup vocabulary, or undefined when
@@ -599,12 +698,14 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
599
698
  function restStatusConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
600
699
  const context = row["context"];
601
700
  const state = row["state"];
701
+ const updatedAt = typeof row["updated_at"] === "string" ? row["updated_at"] : undefined;
702
+ const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : undefined;
602
703
  if (typeof context !== "string" || context === "") return undefined;
603
704
  if (typeof state !== "string") return undefined;
604
705
  // StatusContext semantics: `success` is green, `pending` pending, anything
605
706
  // else (`failure`/`error`) is failed.
606
707
  const verdict: CheckVerdict = state === "success" ? "green" : state === "pending" ? "pending" : "failed";
607
- return { name: context, state, verdict };
708
+ return { name: context, state, verdict, startedAt: updatedAt ?? createdAt };
608
709
  }
609
710
 
610
711
  /**
@@ -706,6 +807,10 @@ function restProjectionVerdict(
706
807
  * Throws on a payload that cannot prove the head or either check plane, so the
707
808
  * caller maps it to `head-unresolvable` exactly as it does for the GraphQL
708
809
  * parser.
810
+ *
811
+ * Same-name runs are decided by recency, not page order (#1066): the newest
812
+ * attempt of a name answers for it, so a stale `skipped` beside a newer
813
+ * `failure` fails the verdict whatever order the pages arrive in.
709
814
  */
710
815
  export function restVerificationFrom(
711
816
  prRaw: string,
@@ -732,7 +837,11 @@ export function restVerificationFrom(
732
837
  if (checks.length === 0) {
733
838
  return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
734
839
  }
735
- const failed = checks.filter((c) => c.verdict === "failed");
840
+ // Same-name runs decide by recency, not page order (#1066): the flattened
841
+ // planes carry every attempt of a name, and first-match folding would let
842
+ // the oldest attempt answer for it.
843
+ const resolved = resolveNewestPerName(checks);
844
+ const failed = resolved.filter((c) => c.verdict === "failed");
736
845
  if (failed.length > 0) {
737
846
  return {
738
847
  status: "failed",
@@ -740,7 +849,7 @@ export function restVerificationFrom(
740
849
  headSha,
741
850
  };
742
851
  }
743
- const pending = checks.filter((c) => c.verdict === "pending");
852
+ const pending = resolved.filter((c) => c.verdict === "pending");
744
853
  if (pending.length > 0) {
745
854
  return {
746
855
  status: "pending",
@@ -748,7 +857,7 @@ export function restVerificationFrom(
748
857
  headSha,
749
858
  };
750
859
  }
751
- return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
860
+ return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha };
752
861
  }
753
862
 
754
863
  /**
@@ -2074,6 +2183,19 @@ export function makeTracker(
2074
2183
  const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
2075
2184
  return rest ?? verification;
2076
2185
  }
2186
+ if (verification.status === "green") {
2187
+ // A rollup context is one NAME, not one RUN (#1066): veltro#717
2188
+ // read eighteen green contexts while the same SHA carried a newer
2189
+ // `failure` run of a name whose rollup row still said `skipped`.
2190
+ // Recency ordering helps only when both rows arrive together; a
2191
+ // rollup that omits the newer run entirely cannot be ordered back
2192
+ // to correctness, so green is re-proven from the flattened REST
2193
+ // planes — the same evidence the empty-rollup path above trusts.
2194
+ // REST that cannot answer leaves this verdict standing, exactly as
2195
+ // a degraded plane leaves the #999 pending standing.
2196
+ const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
2197
+ return rest ?? verification;
2198
+ }
2077
2199
  if (verification.status !== "failed") return verification;
2078
2200
 
2079
2201
  const detailsUrl = failedCheck(raw)?.detailsUrl;
package/src/types.ts CHANGED
@@ -90,6 +90,12 @@ export interface Caps {
90
90
  /** Wall-clock ceiling for one worker (90 min): a session that is merely
91
91
  * stuck spends no turns, so turns alone cannot detect it. */
92
92
  workerWallClockMs: number;
93
+ /** How long a live worker's transcript may stay unwritten before the
94
+ * daemon settles it as a progress stall (#1086). `null` derives it from the
95
+ * wall-clock ceiling — one third, so a hung run is caught long before its
96
+ * clock runs out. A single turn can legitimately run for minutes on a slow
97
+ * provider, so this reads *transcript writes*, never the turn counter. */
98
+ workerStallSilenceMs: number | null;
93
99
  /** Failed implementation/CI attempts allowed before escalation. Operational
94
100
  * continuations do not consume this budget. */
95
101
  maxAttemptsPerIssue: number;
@@ -1831,6 +1837,13 @@ export const FAILURE_CLASSES = [
1831
1837
  * 2026-08-23 failures were this, at 100/180 and 54/180 turns, while a third
1832
1838
  * run on the same model finished green in the same window. */
1833
1839
  "model-empty-stop",
1840
+ /** A live session stopped writing its transcript for longer than the
1841
+ * configured silence window, and the daemon settled it (#1086). Not a size
1842
+ * verdict: the work did not run out of budget, the session simply never
1843
+ * came back — the evidence names how long it was silent and at which turn.
1844
+ * When the attempt had already pushed a green PR, the settle path lands the
1845
+ * row `pushed-green` instead, so landed work is never charged anything. */
1846
+ "progress-stall",
1834
1847
  "unknown",
1835
1848
  ] as const;
1836
1849
 
@@ -2131,6 +2144,14 @@ export interface RunRecord {
2131
2144
  autoCompactionCount?: number;
2132
2145
  /** omp session transcript, so a human can read what the worker actually did. */
2133
2146
  sessionFile?: string;
2147
+ /**
2148
+ * When the run's transcript was last observed to grow, as epoch ms (#1086).
2149
+ * Recorded by the daemon's progress watch from the file's own mtime — the
2150
+ * write instant, not the observation instant — and read by `status` to show
2151
+ * a silence interval while the row is live. Absent means the pass has not
2152
+ * looked at this run (or it has no transcript yet), never "no progress".
2153
+ */
2154
+ lastProgressAt?: number;
2134
2155
  /**
2135
2156
  * The exact `session-host` pid this run's Herdr representation was reported
2136
2157
  * for, and the pane Herdr knows it as (#842).
@@ -3372,6 +3393,11 @@ export interface Store {
3372
3393
  * predicate pick the window. Read-only.
3373
3394
  */
3374
3395
  recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[];
3396
+ /** The newest runs carrying a usable per-turn latency (#1063): terminal
3397
+ * rows with turns and elapsed time recorded, newest first. Bounded by
3398
+ * `limit` rows like `recentSpendSamples` — the caller over-samples and lets
3399
+ * its own model-attribution filter pick the window. Read-only. */
3400
+ recentLatencySamples(project: string, limit: number): RunRecord[];
3375
3401
  /** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */
3376
3402
  ghCallsBetween(sinceDay: string, untilDay: string): number;
3377
3403
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
@@ -4034,6 +4060,11 @@ export const DEFAULT_CAPS: Caps = {
4034
4060
  workerMaxTurns: 120,
4035
4061
  workerMaxTurnsCeiling: 240,
4036
4062
  workerWallClockMs: 90 * 60 * 1000,
4063
+ // Derived as a third of the wall-clock ceiling unless an operator names the
4064
+ // window: a hung run is caught with most of its budget unspent, while a
4065
+ // slow-but-writing session (measured fleet latency has reached
4066
+ // 1.5 min/turn) never trips it (#1086).
4067
+ workerStallSilenceMs: null,
4037
4068
  maxAttemptsPerIssue: 2,
4038
4069
  maxContinuationsPerIssue: 2,
4039
4070
  };
package/src/upgrade.ts CHANGED
@@ -1696,7 +1696,8 @@ export async function upgradeConductor(
1696
1696
  journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
1697
1697
  const failure = err instanceof Error ? err.message : String(err);
1698
1698
  throw new Error(
1699
- `upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
1699
+ `upgrade failed while draining workers: ${failure}; no installation started; ` +
1700
+ `dispatch remains paused — resume dispatch with \`omp-conductor resume --all\``,
1700
1701
  );
1701
1702
  }
1702
1703
  journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
@@ -1916,7 +1917,8 @@ export async function upgradeConductor(
1916
1917
  `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1917
1918
  `herdr=${surfaces.herdrSource ?? "missing"}`,
1918
1919
  ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
1919
- "Dispatch stays paused until the first tick after the restart records the rollback verified.",
1920
+ "Dispatch stays paused until the first tick after the restart records the rollback verified; " +
1921
+ "if it is still paused, resume it with `omp-conductor resume --all`.",
1920
1922
  ],
1921
1923
  `upgrade:${release.version}:rolled-back`,
1922
1924
  );
@@ -1931,16 +1933,21 @@ export async function upgradeConductor(
1931
1933
  `Failure: ${failure}`,
1932
1934
  installAttribution,
1933
1935
  `Rollback failure: ${rollback}`,
1934
- "The fleet may be at mixed versions; do not resume dispatch.",
1936
+ "The fleet may be at mixed versions; do not resume dispatch until versions are verified.",
1937
+ "Once one version is confirmed everywhere, `omp-conductor resume --all` lifts the pause.",
1935
1938
  "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
1936
1939
  ],
1937
1940
  `upgrade:${release.version}:rollback-failed`,
1938
1941
  );
1939
1942
  throw new Error(
1940
- `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
1943
+ `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused — ` +
1944
+ `\`omp-conductor resume --all\` lifts the pause once mixed versions are ruled out`,
1941
1945
  );
1942
1946
  }
1943
- throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
1947
+ throw new Error(
1948
+ `upgrade failed: ${failure}; previous installation restored; dispatch remains paused — ` +
1949
+ `resume dispatch with \`omp-conductor resume --all\``,
1950
+ );
1944
1951
  }
1945
1952
 
1946
1953
  // The install landed; name any host-runtime destination this version no
@@ -426,7 +426,8 @@ export function releaseRequirementRefusal(
426
426
  const drain =
427
427
  ". Settle the fleet with a bounded drain: " +
428
428
  `omp-conductor drain start${facts.project === undefined ? "" : ` --project ${facts.project}`} --until <deadline>` +
429
- " — claiming pauses while settlement runs, and admission resumes at the deadline on its own.";
429
+ " — claiming pauses until the deadline even once the fleet goes quiet (an emptied fleet opens" +
430
+ " the window rather than ending the drain early), and admission resumes at the deadline on its own.";
430
431
  return (
431
432
  `policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
432
433
  (parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
@@ -2292,22 +2293,25 @@ async function prUpdateVerb(
2292
2293
  */
2293
2294
 
2294
2295
  /**
2295
- * The run states a review revision may start from (#795).
2296
+ * The run states a review revision may start from (#795, #1101).
2296
2297
  *
2297
2298
  * A revision round resumes the exact run whose row owns the PR, so the
2298
- * revisable states are exactly the terminal runs that pushed one: a settled
2299
- * `pushed-green` row, or a `failed` / `killed` row — a run that capped or
2300
- * failed *after* pushing a green PR. The PR is the durable artefact, the
2301
- * exact-head green verification in the verb is the gate on "green at the
2302
- * reviewed SHA", and a terminal row proves no worker is in flight, so the
2303
- * findings are returned without the close-PR unblock continuation dance.
2299
+ * revisable states are exactly the terminal runs that own the named PR: a
2300
+ * settled `pushed-green` row, a `failed` / `killed` row — a run that capped or
2301
+ * failed *after* pushing a green PR and, since #1101, a `stopped` row that
2302
+ * had already pushed when the operator stopped it. Ownership is proved by the
2303
+ * selection itself (`runsForProjectPr` matches rows that recorded the PR), so
2304
+ * a stopped run that never pushed can never reach this gate. Without the
2305
+ * stopped leg a stopped-with-green-PR run was both unreviewable (this verb)
2306
+ * and unreplaceable (the open-PR dispatch guard) at once — closing the PR by
2307
+ * hand was the only exit, which is exactly what #1096 hit.
2304
2308
  *
2305
2309
  * Closed on purpose: a live row (`running` / `claimed`) is already doing its
2306
2310
  * own work, a `pushed-pending` PR is not green yet, and a `blocked` /
2307
- * `orphaned` / `stopped` / `merged` row is not work returned for revision.
2308
- * The set itself is the shared `REVISABLE_RUN_STATES` in `decisions.ts` —
2309
- * the one definition the review verb and the `pr-review-ready` watch gate on,
2310
- * so the two can never drift (#844).
2311
+ * `orphaned` / `merged` row is not work returned for revision. The set itself
2312
+ * is the shared `REVISABLE_RUN_STATES` in `decisions.ts` — the one definition
2313
+ * the review verb and the `pr-review-ready` watch gate on, so the two can
2314
+ * never drift (#844).
2311
2315
  */
2312
2316
 
2313
2317
  async function prReviewVerb(