@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.
- package/README.md +10 -11
- package/config.example.toml +5 -1
- package/dist/extensions/vendor/openviking-pi/index.js +4 -0
- package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
- package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
- package/dist/src/cli.js +2 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.js +7 -4
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/ledgercheck.js +39 -10
- package/dist/src/ledgercheck.js.map +1 -1
- package/dist/src/session-command-supervisor.js +38 -9
- package/dist/src/session-command-supervisor.js.map +1 -1
- package/dist/src/state.js +73 -9
- package/dist/src/state.js.map +1 -1
- package/dist/src/tui/alerts.js +4 -1
- package/dist/src/tui/alerts.js.map +1 -1
- package/dist/src/tui/board.js +1 -1
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/verdicts.js +12 -0
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/wire.js +3 -2
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerbreaker.js +32 -5
- package/dist/src/workerbreaker.js.map +1 -1
- package/extensions/vendor/openviking-pi/NOTICE +5 -0
- package/extensions/vendor/openviking-pi/README.md +39 -5
- package/extensions/vendor/openviking-pi/index.ts +4 -0
- package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
- package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
- package/extensions/vendor/openviking-pi/sync.ts +80 -0
- package/extensions/vendor/openviking-pi/upstream.json +2 -1
- package/package.json +2 -2
- package/src/cli.ts +2 -0
- package/src/daemon.ts +8 -4
- package/src/ledgercheck.ts +45 -9
- package/src/session-command-supervisor.ts +41 -8
- package/src/state.ts +101 -11
- package/src/tui/alerts.ts +9 -2
- package/src/tui/board.ts +1 -1
- package/src/verdicts.ts +13 -0
- package/src/wire.ts +3 -2
- 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 {
|
|
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
|
|
554
|
-
*
|
|
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: {
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
1840
|
-
//
|
|
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 },
|
package/src/workerbreaker.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
14
|
-
//
|
|
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:
|
|
164
|
+
{ issuer: "operator", expires_at: lapses },
|
|
131
165
|
WORKER_FAILURES_DRAIN_REASON,
|
|
132
166
|
);
|
|
133
167
|
if (res.outcome === "unchanged") return false;
|
|
134
|
-
|
|
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",
|