@quickchatai/quickreview 1.3.1 → 1.3.2

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 (46) hide show
  1. package/README.md +10 -11
  2. package/config.example.toml +5 -1
  3. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  4. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  5. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  6. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  7. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  8. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  9. package/dist/src/cli.js +2 -0
  10. package/dist/src/cli.js.map +1 -1
  11. package/dist/src/daemon.js +7 -4
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/ledgercheck.js +39 -10
  14. package/dist/src/ledgercheck.js.map +1 -1
  15. package/dist/src/session-command-supervisor.js +38 -9
  16. package/dist/src/session-command-supervisor.js.map +1 -1
  17. package/dist/src/state.js +73 -9
  18. package/dist/src/state.js.map +1 -1
  19. package/dist/src/tui/alerts.js +4 -1
  20. package/dist/src/tui/alerts.js.map +1 -1
  21. package/dist/src/tui/board.js +1 -1
  22. package/dist/src/tui/board.js.map +1 -1
  23. package/dist/src/verdicts.js +12 -0
  24. package/dist/src/verdicts.js.map +1 -1
  25. package/dist/src/wire.js +3 -2
  26. package/dist/src/wire.js.map +1 -1
  27. package/dist/src/workerbreaker.js +32 -5
  28. package/dist/src/workerbreaker.js.map +1 -1
  29. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  30. package/extensions/vendor/openviking-pi/README.md +39 -5
  31. package/extensions/vendor/openviking-pi/index.ts +4 -0
  32. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  33. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  34. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  35. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  36. package/package.json +2 -2
  37. package/src/cli.ts +2 -0
  38. package/src/daemon.ts +8 -4
  39. package/src/ledgercheck.ts +45 -9
  40. package/src/session-command-supervisor.ts +41 -8
  41. package/src/state.ts +101 -11
  42. package/src/tui/alerts.ts +9 -2
  43. package/src/tui/board.ts +1 -1
  44. package/src/verdicts.ts +13 -0
  45. package/src/wire.ts +3 -2
  46. package/src/workerbreaker.ts +41 -5
package/src/state.ts CHANGED
@@ -2,7 +2,10 @@
2
2
  // Core tables: pr_state, rounds, bot_comments (registry), deliveries
3
3
  // (webhook dedupe), events (append log).
4
4
  import { findingRowFields } from "@quickchatai/pi-quickreview/src/finding-row.js";
5
- import { roundStageDeadlines } from "@quickchatai/pi-quickreview/src/round-stage-deadlines.js";
5
+ import {
6
+ discoveryRowsOf,
7
+ roundStageDeadlines,
8
+ } from "@quickchatai/pi-quickreview/src/round-stage-deadlines.js";
6
9
  import Database from "better-sqlite3";
7
10
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
8
11
  import { dirname, join } from "node:path";
@@ -550,8 +553,9 @@ export type DrainState =
550
553
  /** When the hold lapses; null = until lifted (operator only). */
551
554
  expires_at: string | null;
552
555
  /** Why a SYSTEM engaged it (`worker_failures`, the crash-loop breaker); null
553
- * for a hand-set drain. A drain with a reason is lifted only by an explicit
554
- * `drain off`, never by a boot's first reconcile pass. */
556
+ * for a hand-set drain. A drain with a reason is never lifted by a boot's
557
+ * first reconcile pass: the breaker's lapses at its `expires_at`, or an
558
+ * explicit `drain off` lifts it sooner. */
555
559
  reason: string | null;
556
560
  /** The operator's no-auto-lift HOLD (`qcr drain on --hold`): no
557
561
  * boot's first reconcile lifts it, only an explicit operator `drain off`.
@@ -1050,7 +1054,8 @@ CREATE TABLE IF NOT EXISTS drain_state (
1050
1054
  -- with parked_at set gets no worker until the operator's rerun-round or
1051
1055
  -- restart-session clears the row. worker_breaker_global.epoch ticks at every
1052
1056
  -- worker success (and at the lift of the breaker's drain); a failure counts
1053
- -- toward the global drain only while it carries the current epoch.
1057
+ -- toward the global drain only while it carries the current epoch. trips
1058
+ -- counts the breaker's drains since then: each waits twice as long to lapse.
1054
1059
  CREATE TABLE IF NOT EXISTS worker_breaker (
1055
1060
  pr INTEGER PRIMARY KEY,
1056
1061
  failures INTEGER NOT NULL DEFAULT 0,
@@ -1064,6 +1069,7 @@ CREATE TABLE IF NOT EXISTS worker_breaker (
1064
1069
  CREATE TABLE IF NOT EXISTS worker_breaker_global (
1065
1070
  id INTEGER PRIMARY KEY CHECK (id = 1),
1066
1071
  epoch INTEGER NOT NULL DEFAULT 0,
1072
+ trips INTEGER NOT NULL DEFAULT 0,
1067
1073
  updated_at TEXT NOT NULL
1068
1074
  );
1069
1075
  -- The host lease: who holds a PR's session while the operator works
@@ -1828,6 +1834,7 @@ export class StateStore {
1828
1834
  this.db.exec("CREATE INDEX IF NOT EXISTS bot_comments_id ON bot_comments(comment_id)");
1829
1835
  this.migrateRecheckReceipts();
1830
1836
  this.migrateDrainHold();
1837
+ this.migrateBreakerHeal();
1831
1838
  this.migrateSessionCommandCancelledStatus();
1832
1839
  this.runDataMigrations();
1833
1840
  }
@@ -1932,6 +1939,37 @@ export class StateStore {
1932
1939
  })();
1933
1940
  }
1934
1941
 
1942
+ /**
1943
+ * The breaker's drain lapses on its own (src/workerbreaker.ts). One an older
1944
+ * daemon set stands with no expiry, and only `drain off` would lift it: the
1945
+ * boot that adds the `trips` column gives it an expiry of now, so the first
1946
+ * sweep lifts it and the queue retries, with the failures that tripped it
1947
+ * spent. Only the breaker's drain is carried
1948
+ * (its reason, no hold); a hand-set drain stands as it was set.
1949
+ */
1950
+ private migrateBreakerHeal(): void {
1951
+ this.db.transaction(() => {
1952
+ if (
1953
+ !this.addColumn(
1954
+ "ALTER TABLE worker_breaker_global ADD COLUMN trips INTEGER NOT NULL DEFAULT 0",
1955
+ )
1956
+ )
1957
+ return;
1958
+ const info = this.db
1959
+ .prepare(
1960
+ `UPDATE drain_state SET expires_at = ?
1961
+ WHERE id = 1 AND draining = 1 AND reason = ? AND expires_at IS NULL AND hold = 0`,
1962
+ )
1963
+ .run(this.ts(), WORKER_FAILURES_DRAIN_REASON);
1964
+ if (info.changes > 0) {
1965
+ // The failures that tripped it are spent, as at a new trip: a retry
1966
+ // failing once more must not re-drain with them.
1967
+ this.resetWorkerFailureWindow();
1968
+ this.logEvent("breaker_drain_heal_carried", null, {});
1969
+ }
1970
+ })();
1971
+ }
1972
+
1935
1973
  /** Existing databases carry a CHECK constraint that predates operator
1936
1974
  * cancellation. Rebuild this one table in-place; every column and index is
1937
1975
  * copied, and the transaction makes the old or new shape visible atomically. */
@@ -6696,18 +6734,31 @@ export class StateStore {
6696
6734
  * One more consecutive worker failure for `pr`. The PR is parked when the count
6697
6735
  * reaches `parkAfter`; `parked` says whether it stands parked now and
6698
6736
  * `newlyParked` whether THIS failure parked it (the caller raises the anomaly
6699
- * once).
6737
+ * once). With `drain: false` it does not count toward the global drain: its
6738
+ * epoch is one no window ever carries, so `failedWorkerPrsSince` never lists
6739
+ * it. With `park: false` it counts but does not park the PR by itself.
6700
6740
  */
6701
6741
  recordWorkerFailure(
6702
6742
  pr: number,
6703
- o: { commandId: string; error: string; at: string; parkAfter: number },
6743
+ o: {
6744
+ commandId: string;
6745
+ error: string;
6746
+ at: string;
6747
+ parkAfter: number;
6748
+ drain?: boolean;
6749
+ park?: boolean;
6750
+ },
6704
6751
  ): { failures: number; parked: boolean; newlyParked: boolean } {
6705
6752
  return this.db
6706
6753
  .transaction(() => {
6707
6754
  const row = this.workerBreaker(pr);
6708
6755
  const failures = (row?.failures ?? 0) + 1;
6709
6756
  const wasParked = row?.parked_at != null;
6710
- const parkedAt = wasParked ? row.parked_at : failures >= o.parkAfter ? o.at : null;
6757
+ const parkedAt = wasParked
6758
+ ? row.parked_at
6759
+ : o.park !== false && failures >= o.parkAfter
6760
+ ? o.at
6761
+ : null;
6711
6762
  this.db
6712
6763
  .prepare(
6713
6764
  `INSERT INTO worker_breaker (pr, failures, parked_at, last_error, last_failure_at, last_command_id, failure_epoch, updated_at)
@@ -6722,7 +6773,7 @@ export class StateStore {
6722
6773
  error: o.error,
6723
6774
  at: o.at,
6724
6775
  command: o.commandId,
6725
- epoch: this.workerEpoch(),
6776
+ epoch: o.drain === false ? -1 : this.workerEpoch(),
6726
6777
  });
6727
6778
  return {
6728
6779
  failures,
@@ -6770,12 +6821,31 @@ export class StateStore {
6770
6821
  return row?.epoch ?? 0;
6771
6822
  }
6772
6823
 
6773
- /** Start the global failure window afresh: earlier failures stop counting. */
6824
+ /** Start the global failure window afresh: earlier failures stop counting,
6825
+ * and the breaker's next drain waits the shortest time again. */
6774
6826
  resetWorkerFailureWindow(): void {
6775
6827
  this.db
6776
6828
  .prepare(
6777
- `INSERT INTO worker_breaker_global (id, epoch, updated_at) VALUES (1, 1, @ts)
6778
- ON CONFLICT(id) DO UPDATE SET epoch = epoch + 1, updated_at = @ts`,
6829
+ `INSERT INTO worker_breaker_global (id, epoch, trips, updated_at) VALUES (1, 1, 0, @ts)
6830
+ ON CONFLICT(id) DO UPDATE SET epoch = epoch + 1, trips = 0, updated_at = @ts`,
6831
+ )
6832
+ .run({ ts: this.ts() });
6833
+ }
6834
+
6835
+ /** The breaker's drains since the last worker success or operator lift. */
6836
+ workerBreakerTrips(): number {
6837
+ const row = this.db.prepare("SELECT trips FROM worker_breaker_global WHERE id = 1").get() as
6838
+ { trips: number } | undefined;
6839
+ return row?.trips ?? 0;
6840
+ }
6841
+
6842
+ /** The breaker drained once more: the failures that tripped it are spent (a
6843
+ * new epoch), and its next drain waits longer. */
6844
+ recordWorkerBreakerTrip(): void {
6845
+ this.db
6846
+ .prepare(
6847
+ `INSERT INTO worker_breaker_global (id, epoch, trips, updated_at) VALUES (1, 1, 1, @ts)
6848
+ ON CONFLICT(id) DO UPDATE SET epoch = epoch + 1, trips = trips + 1, updated_at = @ts`,
6779
6849
  )
6780
6850
  .run({ ts: this.ts() });
6781
6851
  }
@@ -7005,6 +7075,25 @@ export class StateStore {
7005
7075
  return someMarkHasKey(rows, key);
7006
7076
  }
7007
7077
 
7078
+ /** Has a check after this (pr, key)'s spent nudge already been recorded? */
7079
+ ledgerRepeatSeen(pr: number, key: string): boolean {
7080
+ const rows = this.db
7081
+ .prepare("SELECT payload FROM events WHERE kind = 'ledger_stale_repeat' AND pr = ?")
7082
+ .all(pr) as Array<{ payload: string | null }>;
7083
+ return someMarkHasKey(rows, key);
7084
+ }
7085
+
7086
+ /** The heads a recheck upheld on `pr` at or after `since` (every one when null). */
7087
+ recheckUpheldHeads(pr: number, since: string | null): string[] {
7088
+ const rows = this.db
7089
+ .prepare("SELECT payload FROM events WHERE kind = 'recheck_upheld' AND pr = ? AND ts >= ?")
7090
+ .all(pr, since ?? "") as Array<{ payload: string | null }>;
7091
+ // An unreadable mark names no head.
7092
+ return rows
7093
+ .map((r) => readStringField(r.payload, "head_sha"))
7094
+ .filter((head): head is string => head !== undefined && head !== "");
7095
+ }
7096
+
7008
7097
  /**
7009
7098
  * What the event log says about the migration-collision COMMENT with this
7010
7099
  * fingerprint on this PR (the collision comment's dedupe). Scans every such event for the
@@ -8178,6 +8267,7 @@ export function buildStatusSnapshot(
8178
8267
  started,
8179
8268
  roundStageBounds,
8180
8269
  round.sweep_owed === 1,
8270
+ discoveryRowsOf(round),
8181
8271
  );
8182
8272
  return {
8183
8273
  ...identity,
package/src/tui/alerts.ts CHANGED
@@ -30,6 +30,7 @@ import { zoneDateString } from "../eod.js";
30
30
  import { curationNeedsAttention } from "../learner/status.js";
31
31
  import { memoryStatusLine, type MemoryStatus } from "../memory/health.js";
32
32
  import type { StatusSnapshot } from "../state.js";
33
+ import { DEFAULT_BREAKER_LIMITS } from "../workerbreaker.js";
33
34
 
34
35
  export type AlertTier = "notice" | "alert";
35
36
 
@@ -616,7 +617,11 @@ export interface WorkerCrashAlertMemory {
616
617
  export function maybeWriteWorkerCrashAlerts(
617
618
  alertsDir: string,
618
619
  snapshot:
619
- Pick<StatusSnapshot, "prs" | "draining" | "draining_reason" | "draining_since"> | undefined,
620
+ | Pick<
621
+ StatusSnapshot,
622
+ "prs" | "draining" | "draining_reason" | "draining_since" | "draining_until"
623
+ >
624
+ | undefined,
620
625
  fresh: boolean,
621
626
  mem: WorkerCrashAlertMemory,
622
627
  now: () => Date = () => new Date(),
@@ -660,7 +665,9 @@ export function maybeWriteWorkerCrashAlerts(
660
665
  "worker-failures-drain",
661
666
  null,
662
667
  "workers failing on several PRs: the bot is draining",
663
- "Workers failed on three PRs within 15 minutes with no success, so the bot drains: no round, answer or debug worker starts. Fix the cause, then run drain off.",
668
+ snapshot.draining_until
669
+ ? `Workers failed on three PRs within 15 minutes with no success, so the bot drains: no round, answer or debug worker starts. It lifts itself at ${snapshot.draining_until} and the queue retries; if workers fail again it drains again, each time waiting twice as long (up to ${DEFAULT_BREAKER_LIMITS.healMaxMs / 3_600_000} h). Run drain off to retry now.`
670
+ : "Workers failed on three PRs within 15 minutes with no success, so the bot drains: no round, answer or debug worker starts. Fix the cause, then run drain off.",
664
671
  );
665
672
  return names;
666
673
  }
package/src/tui/board.ts CHANGED
@@ -534,7 +534,7 @@ function barSegments(view: BoardView, alerts: boolean): string[] {
534
534
  // guessed at. The operator's HOLD says that no boot lifts it; an
535
535
  // operator drain from a daemon that reports no hold marker says "hold ?".
536
536
  const drainSeg = s?.draining
537
- ? `${FG.anomaly}⛔ DRAINING — ${s.running_rounds} round${s.running_rounds === 1 ? "" : "s"} finishing · ${s.drain_failed_loud ?? 0} failed loud · ${s.drained_deliveries ?? 0} dropped · ${leverHolderLabel(s.draining_by, s.draining_until)}${drainHoldLabel(s)}${s.draining_reason === "worker_failures" ? " · workers keep failing, drain off lifts it" : ""}${RESET}`
537
+ ? `${FG.anomaly}⛔ DRAINING — ${s.running_rounds} round${s.running_rounds === 1 ? "" : "s"} finishing · ${s.drain_failed_loud ?? 0} failed loud · ${s.drained_deliveries ?? 0} dropped · ${leverHolderLabel(s.draining_by, s.draining_until)}${drainHoldLabel(s)}${s.draining_reason === "worker_failures" ? (s.draining_until ? " · workers kept failing, it retries when it lapses; drain off lifts it now" : " · workers keep failing, drain off lifts it") : ""}${RESET}`
538
538
  : "";
539
539
  const td = s?.thread_decisions;
540
540
  const breakerSeg =
package/src/verdicts.ts CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  import { estimateUsd, type ModelPricing } from "./usage.js";
81
81
  import { TranscriptIndex } from "./transcriptindex.js";
82
82
  import { withinBudget } from "@quickchatai/pi-quickreview/src/budget.js";
83
+ import { VALIDATE_PASS } from "@quickchatai/pi-quickreview/src/include.js";
83
84
  import type { GateResult } from "@quickchatai/pi-quickreview/src/gate.js";
84
85
  import { applyEvent, restoreStandingLifecycle } from "./statemachine.js";
85
86
  import type { VerdictAnnouncer } from "./slack/announce.js";
@@ -505,6 +506,12 @@ function plannedTierMismatches(passes: VerdictPassEntry[], rows: PlanRow[]): str
505
506
  return wrong;
506
507
  }
507
508
 
509
+ /** Every lens of the stage reported its candidate count, and every count is zero. */
510
+ function nothingToValidate(passes: VerdictPassEntry[]): boolean {
511
+ const lenses = passes.filter((p) => p.name !== VALIDATE_PASS);
512
+ return lenses.length > 0 && lenses.every((p) => p.findings === 0);
513
+ }
514
+
508
515
  function comparePlan(
509
516
  passes: VerdictPassEntry[],
510
517
  plan: PlanRow[] | null,
@@ -3003,6 +3010,12 @@ export class VerdictWatcher {
3003
3010
  const deltaPasses = followUp ? v.passes.filter((p) => p.phase !== "sweep") : v.passes;
3004
3011
  const sweepPasses = followUp ? v.passes.filter((p) => p.phase === "sweep") : [];
3005
3012
  const main = comparePlan(deltaPasses, plan, addable, legacyPlanned, true);
3013
+ // A follow-up's delta stage dispatches no validator when its lenses
3014
+ // found nothing (prompts/round-follow-up.md, "Nothing to validate ⇒ no
3015
+ // validator"): that absence keeps the rule. Only a count every lens
3016
+ // reported as zero says so; an unreported one still owes the validator.
3017
+ if (followUp && nothingToValidate(deltaPasses))
3018
+ main.missing = main.missing.filter((name) => name !== VALIDATE_PASS);
3006
3019
  let sweep: PlanComparison | null = null;
3007
3020
  if (sweepPasses.length > 0 || sweepStatus === "ran") {
3008
3021
  const sweepPlan = round.sweep_plan ? (JSON.parse(round.sweep_plan) as PlanRow[]) : [];
package/src/wire.ts CHANGED
@@ -1836,8 +1836,9 @@ export function createProductionDaemon(parts: ProductionParts = buildParts()): D
1836
1836
  // pump starts nothing, recheck/ping-pong/EOD hold — in-flight rounds
1837
1837
  // finish naturally. Off is the manual lift; the automatic one is the
1838
1838
  // next boot's first healthy reconcile pass (daemon.ts) — except for
1839
- // the operator's HOLD, which only an explicit off lifts. Never touches
1840
- // GitHub, never goes near the gate.
1839
+ // the operator's HOLD, which only an explicit off lifts, and the worker
1840
+ // breaker's drain, which lapses on its own (src/workerbreaker.ts).
1841
+ // Never touches GitHub, never goes near the gate.
1841
1842
  drain(on, issuer, forMin, deliveryKey, hold) {
1842
1843
  return drainLever(
1843
1844
  { ...leverDeps, deliveryKey },
@@ -4,14 +4,22 @@
4
4
  //
5
5
  // - PER PR. A worker's failure is a non-zero exit, a `failed` event over the IPC,
6
6
  // or a deadline kill (wall clock, stage or silence). After `parkAfter`
7
- // consecutive ones the PR is PARKED: no further spawn, a `worker_crash_loop`
7
+ // consecutive ones the PR is PARKED (a provider network drop counts but
8
+ // never parks by itself: it says nothing about the PR): no further spawn, a `worker_crash_loop`
8
9
  // anomaly (to the companion inbox), the last error in status.json and a TUI
9
10
  // alert. A worker that served its round resets the PR's count. The operator
10
11
  // unparks with `rerun-round` or `restart-session` (src/operator-recovery.ts).
11
12
  // - GLOBAL. `drainAfterPrs` PRs failing within `windowMs`, with no worker success
12
13
  // in between, set the existing drain valve with the reason `worker_failures`.
13
- // Only an explicit `drain off` lifts it (a boot's first healthy reconcile pass
14
- // leaves a reasoned drain standing).
14
+ // The drain heals itself: it lapses after `healAfterMs`, the queue retries,
15
+ // and if workers fail again it drains again, each time waiting twice as long
16
+ // up to `healMaxMs`. A served round (or the operator's `drain off`, which
17
+ // lifts it at once) starts the wait from `healAfterMs` again. A boot's first
18
+ // healthy reconcile pass does not lift it early, and an operator's own
19
+ // `drain on` over it stands until the operator lifts it. A PR's FIRST
20
+ // failure that is the provider's network dropping (`fetch failed`, a reset
21
+ // socket) does not feed the drain: one blip fails every concurrent worker at
22
+ // the same instant, and each of them is retried. A retry that fails again counts.
15
23
  // - EVERY HOST. `gate` is the one question each host asks before it starts a
16
24
  // round worker, an answer worker or a debug round: the bot
17
25
  // is drained, or the PR is parked, and the answer is no. A takeover
@@ -29,12 +37,18 @@ export interface BreakerLimits {
29
37
  drainAfterPrs: number;
30
38
  /** The window those PRs' latest failures must fall in. */
31
39
  windowMs: number;
40
+ /** How long the breaker's first drain stands before it lapses and the queue retries. */
41
+ healAfterMs: number;
42
+ /** The longest a drain waits, however often it has tripped. */
43
+ healMaxMs: number;
32
44
  }
33
45
 
34
46
  export const DEFAULT_BREAKER_LIMITS: BreakerLimits = {
35
47
  parkAfter: 2,
36
48
  drainAfterPrs: 3,
37
49
  windowMs: 15 * 60_000,
50
+ healAfterMs: 15 * 60_000,
51
+ healMaxMs: 4 * 60 * 60_000,
38
52
  };
39
53
 
40
54
  export type SpawnVerdict = { ok: true } | { ok: false; why: "draining" | "parked"; reason: string };
@@ -48,6 +62,15 @@ export interface WorkerFailure {
48
62
 
49
63
  const ERROR_CHARS = 300;
50
64
 
65
+ /** The provider's transport dropped the turn (src/botworker.ts words it), as
66
+ * opposed to the provider answering with an error or refusing the login. */
67
+ const PROVIDER_TRANSPORT =
68
+ /the provider failed the turn: .*\b(fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up)\b/i;
69
+
70
+ function isProviderTransportFailure(error: string): boolean {
71
+ return PROVIDER_TRANSPORT.test(error);
72
+ }
73
+
51
74
  export class WorkerBreaker {
52
75
  readonly limits: BreakerLimits;
53
76
  private readonly now: () => number;
@@ -96,11 +119,15 @@ export class WorkerBreaker {
96
119
  failure(pr: number, commandId: string, error: string): WorkerFailure {
97
120
  const at = this.iso();
98
121
  const last = error.replace(/\s+/g, " ").trim().slice(0, ERROR_CHARS) || "no reason given";
122
+ const firstFailure = (this.store.workerBreaker(pr)?.failures ?? 0) === 0;
123
+ const transport = isProviderTransportFailure(last);
99
124
  const res = this.store.recordWorkerFailure(pr, {
100
125
  commandId,
101
126
  error: last,
102
127
  at,
103
128
  parkAfter: this.limits.parkAfter,
129
+ drain: !(firstFailure && transport),
130
+ park: !transport,
104
131
  });
105
132
  if (res.newlyParked) {
106
133
  const detail = `PR #${pr} is parked after ${res.failures} consecutive worker failures; last: ${last}`;
@@ -126,12 +153,20 @@ export class WorkerBreaker {
126
153
  const since = new Date(this.now() - this.limits.windowMs).toISOString();
127
154
  const prs = this.store.failedWorkerPrsSince(since);
128
155
  if (prs.length < this.limits.drainAfterPrs) return false;
156
+ // A standing drain, the breaker's or a hand-set one, is never extended here.
157
+ if (this.store.drainState().draining) return false;
158
+ const waitMs = Math.min(
159
+ this.limits.healAfterMs * 2 ** this.store.workerBreakerTrips(),
160
+ this.limits.healMaxMs,
161
+ );
162
+ const lapses = new Date(this.now() + waitMs).toISOString();
129
163
  const res = this.store.engageDrain(
130
- { issuer: "operator", expires_at: null },
164
+ { issuer: "operator", expires_at: lapses },
131
165
  WORKER_FAILURES_DRAIN_REASON,
132
166
  );
133
167
  if (res.outcome === "unchanged") return false;
134
- const detail = `workers failed for ${prs.length} PRs (${prs.map((n) => `#${n}`).join(", ")}) within ${Math.round(this.limits.windowMs / 60_000)} min with no success; the bot is draining (${WORKER_FAILURES_DRAIN_REASON}) until an operator runs drain off`;
168
+ this.store.recordWorkerBreakerTrip();
169
+ const detail = `workers failed for ${prs.length} PRs (${prs.map((n) => `#${n}`).join(", ")}) within ${Math.round(this.limits.windowMs / 60_000)} min with no success; the bot is draining (${WORKER_FAILURES_DRAIN_REASON}) and retries at ${lapses} (in ${Math.round(waitMs / 60_000)} min); drain off lifts it now`;
135
170
  this.log(`worker_failures drain — ${detail}`);
136
171
  this.store.logEvent("drain_on", null, {
137
172
  by: "system",
@@ -139,6 +174,7 @@ export class WorkerBreaker {
139
174
  outcome: res.outcome,
140
175
  prs,
141
176
  at,
177
+ expires_at: lapses,
142
178
  });
143
179
  this.store.logAnomaly(
144
180
  "worker_crash_loop",