omp-conductor 0.7.1 → 0.9.0
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 +60 -30
- package/package.json +1 -1
- package/src/backups.ts +46 -0
- package/src/board.ts +254 -38
- package/src/brief-upgrade.ts +1 -30
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +90 -29
- package/src/config.ts +24 -0
- package/src/daemon.ts +311 -131
- package/src/decisions.ts +67 -7
- package/src/diff-flags.ts +35 -6
- package/src/fleet.ts +65 -6
- package/src/label-projection.ts +93 -0
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/routing.ts +20 -0
- package/src/setup.ts +1 -1
- package/src/store.ts +254 -2
- package/src/tracker/github.ts +489 -80
- package/src/types.ts +85 -3
- package/src/unblock.ts +156 -20
- package/src/upgrade.ts +57 -7
- package/src/worktree.ts +23 -4
package/src/decisions.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
|
|
|
17
17
|
/**
|
|
18
18
|
* A precondition whose truth this package can check on its own.
|
|
19
19
|
*
|
|
20
|
-
* Exactly
|
|
20
|
+
* Exactly six kinds, deliberately. Each one is a question the tracker or npm
|
|
21
21
|
* already answers, so the row moves from "parked" to "act on this" without a
|
|
22
22
|
* human re-reading it. Anything richer — a label appearing, a workflow going
|
|
23
23
|
* green — is a follow-on issue rather than a grammar nobody validated.
|
|
@@ -25,11 +25,22 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
|
|
|
25
25
|
export type DecisionCondition =
|
|
26
26
|
| { kind: "pr-merged"; url: string }
|
|
27
27
|
| { kind: "issue-closed"; issue: number }
|
|
28
|
-
| { kind: "npm-version"; spec: string }
|
|
28
|
+
| { kind: "npm-version"; spec: string }
|
|
29
|
+
| { kind: "pr-checks-green"; url: string }
|
|
30
|
+
| { kind: "pr-mergeable"; url: string }
|
|
31
|
+
| { kind: "rate-limit-reset" };
|
|
29
32
|
|
|
30
33
|
/** `pkg@version`, including a scoped package (`@scope/pkg@1.2.3`). */
|
|
31
34
|
const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Check states that count as a green verdict for `pr-checks-green`, copied from
|
|
38
|
+
* the daemon's failure classifier (`failure-class.ts` `SUCCESS_CHECK_STATES`):
|
|
39
|
+
* both spellings are terminally successful, and anything else — a failure, a
|
|
40
|
+
* still-running/pending check, a cancelled or skipped runner — is not.
|
|
41
|
+
*/
|
|
42
|
+
const GREEN_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* Parse a raw condition, or `undefined` when it is not one of the three forms.
|
|
35
46
|
*
|
|
@@ -46,8 +57,8 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
|
|
|
46
57
|
const rest = text.slice(at + 1).trim();
|
|
47
58
|
if (rest.length === 0) return undefined;
|
|
48
59
|
|
|
49
|
-
if (kind === "pr-merged") {
|
|
50
|
-
return rest.startsWith("https://") ? { kind
|
|
60
|
+
if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-mergeable") {
|
|
61
|
+
return rest.startsWith("https://") ? { kind, url: rest } : undefined;
|
|
51
62
|
}
|
|
52
63
|
if (kind === "issue-closed") {
|
|
53
64
|
if (!/^\d+$/.test(rest)) return undefined;
|
|
@@ -57,14 +68,22 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
|
|
|
57
68
|
if (kind === "npm-version") {
|
|
58
69
|
return NPM_SPEC.test(rest) ? { kind: "npm-version", spec: rest } : undefined;
|
|
59
70
|
}
|
|
71
|
+
if (kind === "rate-limit-reset") {
|
|
72
|
+
// Spelled `rate-limit-reset:github`: the grammar requires a non-empty rest,
|
|
73
|
+
// and `github` names the one GraphQL provider the fleet is on.
|
|
74
|
+
return rest === "github" ? { kind: "rate-limit-reset" } : undefined;
|
|
75
|
+
}
|
|
60
76
|
return undefined;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
/** The
|
|
79
|
+
/** The six accepted forms, for a refusal that can be acted on in one turn. */
|
|
64
80
|
export const CONDITION_FORMS = [
|
|
65
81
|
"pr-merged:https://github.com/owner/repo/pull/123",
|
|
66
82
|
"issue-closed:123",
|
|
67
83
|
"npm-version:omp-conductor@0.4.3",
|
|
84
|
+
"pr-checks-green:https://github.com/owner/repo/pull/123",
|
|
85
|
+
"pr-mergeable:https://github.com/owner/repo/pull/123",
|
|
86
|
+
"rate-limit-reset:github",
|
|
68
87
|
] as const;
|
|
69
88
|
|
|
70
89
|
/** Probe for `npm-version`, injectable so tests never reach the network. */
|
|
@@ -90,6 +109,33 @@ export const probeNpmVersion: NpmProbe = async (spec) => {
|
|
|
90
109
|
}
|
|
91
110
|
};
|
|
92
111
|
|
|
112
|
+
/** Probe for `rate-limit-reset`, injectable so tests never reach the network. */
|
|
113
|
+
export type RateLimitProbe = () => Promise<boolean>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `gh api rate_limit` — any remaining GraphQL quota means the limit has reset
|
|
117
|
+
* (or never bound). Bounded at 10 s like {@link probeNpmVersion}, for the same
|
|
118
|
+
* reason: this runs inside a tick, and a hung `gh` must cost one unevaluated
|
|
119
|
+
* decision, not the tick.
|
|
120
|
+
*/
|
|
121
|
+
export const probeRateLimitReset: RateLimitProbe = async () => {
|
|
122
|
+
const proc = Bun.spawn(
|
|
123
|
+
["gh", "api", "rate_limit", "--jq", ".resources.graphql.remaining"],
|
|
124
|
+
{ stdout: "pipe", stderr: "ignore" },
|
|
125
|
+
);
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
proc.kill();
|
|
128
|
+
}, 10_000);
|
|
129
|
+
try {
|
|
130
|
+
const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
131
|
+
return code === 0 && Number.parseInt(text.trim(), 10) > 0;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
93
139
|
/**
|
|
94
140
|
* Check every open decision that carries a condition and has not met it yet.
|
|
95
141
|
*
|
|
@@ -105,7 +151,7 @@ export async function evaluateDecisionConditions(
|
|
|
105
151
|
store: Store,
|
|
106
152
|
project: string,
|
|
107
153
|
tracker: Tracker,
|
|
108
|
-
|
|
154
|
+
probes: { npm: NpmProbe; rateLimit: RateLimitProbe },
|
|
109
155
|
now: () => number,
|
|
110
156
|
): Promise<DecisionRecord[]> {
|
|
111
157
|
const met: DecisionRecord[] = [];
|
|
@@ -119,8 +165,22 @@ export async function evaluateDecisionConditions(
|
|
|
119
165
|
satisfied = (await tracker.prState(condition.url)) === "merged";
|
|
120
166
|
} else if (condition.kind === "issue-closed") {
|
|
121
167
|
satisfied = (await tracker.issueState(condition.issue)) === "closed";
|
|
168
|
+
} else if (condition.kind === "npm-version") {
|
|
169
|
+
satisfied = await probes.npm(condition.spec);
|
|
170
|
+
} else if (condition.kind === "pr-checks-green") {
|
|
171
|
+
// The same conclusion values the daemon's failure classifier treats as
|
|
172
|
+
// a green verdict (`success` / `neutral`, lowercased): a non-empty list
|
|
173
|
+
// in which every check is terminally successful and none is failing or
|
|
174
|
+
// pending (#189).
|
|
175
|
+
const checks = await tracker.checkConclusions(condition.url);
|
|
176
|
+
satisfied =
|
|
177
|
+
checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
|
|
178
|
+
} else if (condition.kind === "pr-mergeable") {
|
|
179
|
+
// `clean` is the tracker's "this PR can merge" literal; `unknown` and a
|
|
180
|
+
// conflict are both unsatisfied (#189).
|
|
181
|
+
satisfied = (await tracker.mergeable(condition.url)) === "clean";
|
|
122
182
|
} else {
|
|
123
|
-
satisfied = await
|
|
183
|
+
satisfied = await probes.rateLimit();
|
|
124
184
|
}
|
|
125
185
|
} catch {
|
|
126
186
|
continue;
|
package/src/diff-flags.ts
CHANGED
|
@@ -435,6 +435,14 @@ function evidence(text: string): string {
|
|
|
435
435
|
export interface SettlementAudit {
|
|
436
436
|
/** The worker's final report, verbatim. */
|
|
437
437
|
report: string;
|
|
438
|
+
/** Earlier attempts' reports for the same issue, oldest first. Their
|
|
439
|
+
* `changed:` disclosures are pooled as coverage so a file disclosed in a
|
|
440
|
+
* prior attempt is never flagged as undisclosed in the final one, while the
|
|
441
|
+
* reverse (unmatched-claim) direction reads only the current report — a file
|
|
442
|
+
* claimed in attempt 1 and reverted by attempt 3 must not come back as a new
|
|
443
|
+
* finding (#199). Absent means this is the only attempt, or older rows never
|
|
444
|
+
* stored a report. */
|
|
445
|
+
priorReports?: readonly string[];
|
|
438
446
|
/** The dispatching issue's title and body — the attribution source. */
|
|
439
447
|
issueText: string;
|
|
440
448
|
diff: PrDiff;
|
|
@@ -457,8 +465,13 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
|
|
|
457
465
|
function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
458
466
|
const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
|
|
459
467
|
const claims = claimedPaths(audit.report);
|
|
468
|
+
const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
|
|
469
|
+
// Coverage pools the current report with every prior attempt's disclosures:
|
|
470
|
+
// a file the final report no longer names was disclosed while the work was
|
|
471
|
+
// still in flight, so it must not be flagged undisclosed (#199).
|
|
472
|
+
const coverage = [...new Set([...claims, ...priorClaims])];
|
|
460
473
|
|
|
461
|
-
if (claims.length === 0) {
|
|
474
|
+
if (claims.length === 0 && priorClaims.length === 0) {
|
|
462
475
|
// The degenerate case of the same check: with no usable `changed:` line
|
|
463
476
|
// every file is undisclosed, and saying so once beats saying it per file.
|
|
464
477
|
// Reporting it at all is what stops the check being defeated by writing
|
|
@@ -476,8 +489,8 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
476
489
|
}
|
|
477
490
|
|
|
478
491
|
for (const file of touched) {
|
|
479
|
-
if (
|
|
480
|
-
if (file.previousPath !== undefined &&
|
|
492
|
+
if (coverage.some((claim) => covers(claim, file.path))) continue;
|
|
493
|
+
if (file.previousPath !== undefined && coverage.some((claim) => covers(claim, file.previousPath ?? ""))) {
|
|
481
494
|
continue;
|
|
482
495
|
}
|
|
483
496
|
flags.push({
|
|
@@ -491,6 +504,10 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
491
504
|
// file it edited and then reverted, or one it renamed away from. Worth
|
|
492
505
|
// surfacing because a `changed:` line that describes a different PR is the
|
|
493
506
|
// signature of a report written from memory rather than from `git diff`.
|
|
507
|
+
//
|
|
508
|
+
// It iterates only the CURRENT report's claims (#199). A file claimed in
|
|
509
|
+
// attempt 1 and reverted by attempt 3 was true then and false now; pooling
|
|
510
|
+
// priors into this loop would turn every honest revert into a fresh finding.
|
|
494
511
|
for (const claim of claims) {
|
|
495
512
|
if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
|
|
496
513
|
continue;
|
|
@@ -661,18 +678,30 @@ const HEADING = "settlement audit";
|
|
|
661
678
|
* that could not be read in full — then the absence of flags is not evidence of
|
|
662
679
|
* anything, and saying so is the honest report.
|
|
663
680
|
*/
|
|
681
|
+
/** Appended to the heading when a multi-attempt audit pooled prior disclosures,
|
|
682
|
+
* so a reader is told the coverage is wider than this one report (#199). */
|
|
683
|
+
function pooledHeading(attempts: number | undefined): string {
|
|
684
|
+
return attempts !== undefined && attempts > 1
|
|
685
|
+
? ` — disclosures pooled across ${attempts} attempt(s)`
|
|
686
|
+
: "";
|
|
687
|
+
}
|
|
688
|
+
|
|
664
689
|
export function formatSettlementFlags(
|
|
665
690
|
flags: readonly SettlementFlag[],
|
|
666
|
-
diff: { truncated: boolean } = { truncated: false },
|
|
691
|
+
diff: { truncated: boolean; attempts?: number } = { truncated: false },
|
|
667
692
|
): string[] {
|
|
668
693
|
if (flags.length === 0) {
|
|
669
694
|
return diff.truncated
|
|
670
|
-
? [
|
|
695
|
+
? [
|
|
696
|
+
`${HEADING}: no flags, but the PR diff was too large to read in full` +
|
|
697
|
+
`${pooledHeading(diff.attempts)} — this is not a clean bill`,
|
|
698
|
+
]
|
|
671
699
|
: [];
|
|
672
700
|
}
|
|
673
701
|
const lines = [
|
|
674
702
|
`${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
|
|
675
|
-
(diff.truncated ? ", and the PR diff was too large to read in full" : "")
|
|
703
|
+
(diff.truncated ? ", and the PR diff was too large to read in full" : "") +
|
|
704
|
+
pooledHeading(diff.attempts),
|
|
676
705
|
];
|
|
677
706
|
for (const flag of flags.slice(0, RENDERED_FLAGS)) {
|
|
678
707
|
lines.push(
|
package/src/fleet.ts
CHANGED
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
formatReleaseGrants,
|
|
43
43
|
formatSalvagedRuns,
|
|
44
44
|
isPaused,
|
|
45
|
+
pauseProvenance,
|
|
46
|
+
pausedAt,
|
|
45
47
|
setPaused,
|
|
46
48
|
statusSnapshot,
|
|
47
49
|
type StatusSnapshot,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
SYSTEMD_UNIT,
|
|
56
58
|
} from "./lifecycle.ts";
|
|
57
59
|
import { formatRss, rssBytesFromHealthz } from "./host.ts";
|
|
60
|
+
import { fetchRateLimit } from "./tracker/github.ts";
|
|
58
61
|
import {
|
|
59
62
|
readTickConfig,
|
|
60
63
|
readTickRuntimeStatus,
|
|
@@ -252,9 +255,9 @@ export interface HaltWithPaneResult extends HaltResult {
|
|
|
252
255
|
pane: PaneStopResult;
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
export function hold(projectName?: string): HoldResult {
|
|
258
|
+
export function hold(projectName?: string, source: string = "hold"): HoldResult {
|
|
256
259
|
const wasPaused = isPaused();
|
|
257
|
-
setPaused(true);
|
|
260
|
+
setPaused(true, { source });
|
|
258
261
|
return { wasPaused, disarmed: disarmTicks(projectName) };
|
|
259
262
|
}
|
|
260
263
|
|
|
@@ -263,7 +266,7 @@ export function releaseHold(): void {
|
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
export async function halt(projectName?: string): Promise<HaltResult> {
|
|
266
|
-
const held = hold(projectName);
|
|
269
|
+
const held = hold(projectName, "halt");
|
|
267
270
|
const stop = await stopDaemon();
|
|
268
271
|
return { hold: held, stop };
|
|
269
272
|
}
|
|
@@ -1019,8 +1022,27 @@ export function formatFleetStatus(
|
|
|
1019
1022
|
|
|
1020
1023
|
const graphBlock = formatCodeGraphHealth(codeGraph, now);
|
|
1021
1024
|
|
|
1025
|
+
// Pause provenance (`pause --reason`, an integrity/spend-cap/upgrade pause,
|
|
1026
|
+
// a halt) answers "who stopped the fleet" without opening a file (#185). An
|
|
1027
|
+
// unparseable sentinel — paused but with no datable line 1 — is itself news:
|
|
1028
|
+
// it means a run admitted before an *unknown* pause cannot prove innocence
|
|
1029
|
+
// (#174), so every mutation is refused.
|
|
1030
|
+
const dispatchLine =
|
|
1031
|
+
layers.dispatch === "paused"
|
|
1032
|
+
? (() => {
|
|
1033
|
+
const prov = pauseProvenance();
|
|
1034
|
+
if (prov !== undefined) {
|
|
1035
|
+
const reason = prov.reason === undefined ? "" : ` — "${prov.reason}"`;
|
|
1036
|
+
return `dispatch paused (source: ${prov.source}${reason})`;
|
|
1037
|
+
}
|
|
1038
|
+
return isPaused() && pausedAt() === undefined
|
|
1039
|
+
? "dispatch paused (unparseable sentinel — all mutations refused)"
|
|
1040
|
+
: "dispatch paused";
|
|
1041
|
+
})()
|
|
1042
|
+
: `dispatch ${layers.dispatch}`;
|
|
1043
|
+
|
|
1022
1044
|
return [
|
|
1023
|
-
|
|
1045
|
+
dispatchLine,
|
|
1024
1046
|
tickLine,
|
|
1025
1047
|
...(nextTickLine === undefined ? [] : [nextTickLine]),
|
|
1026
1048
|
paneLine,
|
|
@@ -1053,6 +1075,39 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1053
1075
|
// independent controls and an operator has to see which one stopped the
|
|
1054
1076
|
// fleet (#110).
|
|
1055
1077
|
` plan usage ${planUsageLine(s.planUsage)}`,
|
|
1078
|
+
// The tracker's API budget, when the renderer could read it. Absent on a
|
|
1079
|
+
// broken `gh`: one missing row, never a broken report (#188). Next to it,
|
|
1080
|
+
// the refusals the tracker actually observed, so a budget that looks
|
|
1081
|
+
// healthy next to every call being refused is still visible (#198).
|
|
1082
|
+
...(s.github === undefined
|
|
1083
|
+
? []
|
|
1084
|
+
: [
|
|
1085
|
+
` github graphql ${s.github.graphql.remaining}/${s.github.graphql.limit}, core ${s.github.core.remaining}/${s.github.core.limit} (graphql resets ${Math.max(0, Math.round((s.github.graphql.reset * 1000 - Date.now()) / 60_000))}m)` +
|
|
1086
|
+
(s.ghRefusals === undefined || s.ghRefusals.count === 0
|
|
1087
|
+
? ""
|
|
1088
|
+
: ` — ${s.ghRefusals.count} refusal(s) in last 5m (last ${new Date(s.ghRefusals.latestAt ?? Date.now()).toISOString().slice(11, 19)}Z)`),
|
|
1089
|
+
]),
|
|
1090
|
+
// The daemon's own observed github traffic today (#198): a separate row
|
|
1091
|
+
// from the polled budget above, so one missing `status` read never hides
|
|
1092
|
+
// the other. `onCall` counts every spawn including ones that end 304, so
|
|
1093
|
+
// `daemon` is spawns and `daemon-304` is the unbilled subset — billed ≈
|
|
1094
|
+
// difference, and the polled budget row stays the authority (#203).
|
|
1095
|
+
` github calls daemon ${s.ghCallsToday?.find((c) => c.source === "daemon")?.calls ?? 0} today` +
|
|
1096
|
+
((s.ghCallsToday?.find((c) => c.source === "daemon-304")?.calls ?? 0) === 0
|
|
1097
|
+
? ""
|
|
1098
|
+
: ` (${s.ghCallsToday!.find((c) => c.source === "daemon-304")!.calls} free 304s)`),
|
|
1099
|
+
// Label-projection ops the tracker has not applied yet (#201): GitHub is
|
|
1100
|
+
// behind what the store decided, and the operator can see the lag instead
|
|
1101
|
+
// of discovering it as a stale label or a missing one.
|
|
1102
|
+
...(s.labelOps === undefined
|
|
1103
|
+
? []
|
|
1104
|
+
: [
|
|
1105
|
+
` labels projection ${s.labelOps.pending} pending (oldest ${
|
|
1106
|
+
s.labelOps.oldestAgeMs >= 60_000
|
|
1107
|
+
? `${Math.round(s.labelOps.oldestAgeMs / 60_000)}m`
|
|
1108
|
+
: `${Math.max(1, Math.round(s.labelOps.oldestAgeMs / 1000))}s`
|
|
1109
|
+
})`,
|
|
1110
|
+
]),
|
|
1056
1111
|
` new worker turns ${s.caps.workerMaxTurns}`,
|
|
1057
1112
|
` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
|
|
1058
1113
|
` failed attempts ${s.caps.maxAttemptsPerIssue}`,
|
|
@@ -1097,18 +1152,22 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1097
1152
|
const layers = fleetLayers(projectName);
|
|
1098
1153
|
const project = findProject(loadConfig(), projectName);
|
|
1099
1154
|
const rec = livingDaemon();
|
|
1100
|
-
const [health, telegram, planUsage] = await Promise.all([
|
|
1155
|
+
const [health, telegram, planUsage, github] = await Promise.all([
|
|
1101
1156
|
rec === undefined ? undefined : healthCheck(rec.port),
|
|
1102
1157
|
probeTelegramHealth(projectName),
|
|
1103
1158
|
// Read here rather than in `statusSnapshot`, which is synchronous and used
|
|
1104
1159
|
// by callers that must not shell out. An unmetered project never spawns
|
|
1105
1160
|
// the provider at all.
|
|
1106
1161
|
readPlanUsage(s.caps.planUsage, sharedUsageSource()),
|
|
1162
|
+
// Same reasoning as `planUsage`: the snapshot is synchronous, this read is
|
|
1163
|
+
// a shell-out, and undefined on any failure — one missing row, never a
|
|
1164
|
+
// broken report (#188).
|
|
1165
|
+
fetchRateLimit(),
|
|
1107
1166
|
]);
|
|
1108
1167
|
const cached = codeGraphFromHealthz(health?.body, project.name);
|
|
1109
1168
|
const codeGraph = cached ?? (await probeCodeGraph(project));
|
|
1110
1169
|
return formatFleetStatus(
|
|
1111
|
-
{ ...s, planUsage },
|
|
1170
|
+
{ ...s, planUsage, github },
|
|
1112
1171
|
layers,
|
|
1113
1172
|
health,
|
|
1114
1173
|
telegram,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The label projection outbox (#201).
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher used to write GitHub labels directly, which made the label —
|
|
5
|
+
* not the store — the crash guard against double dispatch, and let one refused
|
|
6
|
+
* label write strand an issue (`#184`, `#198`). Dispatch state is now
|
|
7
|
+
* store-authoritative; every decided label change is an outbox row that this
|
|
8
|
+
* module projects onto the tracker with retry.
|
|
9
|
+
*
|
|
10
|
+
* Both the daemon tick and the `unblock` CLI path drain through the same
|
|
11
|
+
* `projectLabels` so a 403 no longer takes either down: the op defers with
|
|
12
|
+
* backoff and the daemon retries it. Per-issue ops apply in id order, which is
|
|
13
|
+
* what makes a swap atomic — `add` lands before the paired `remove` exactly as
|
|
14
|
+
* enqueued.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { GhRateLimitError, isRateLimitRefusal } from "./tracker/github.ts";
|
|
18
|
+
import type { ProjectConfig, Store, Tracker } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Retry backoff for one failed label op: 30s doubling, capped at 15m (#201). */
|
|
21
|
+
export function labelOpBackoffMs(attempts: number): number {
|
|
22
|
+
return Math.min(30_000 * 2 ** attempts, 15 * 60_000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function errText(e: unknown): string {
|
|
26
|
+
return e instanceof Error ? e.message : String(e);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drain as many due label ops as the tracker will take, in id order.
|
|
31
|
+
*
|
|
32
|
+
* A failed op defers with backoff and holds the rest of its issue for the next
|
|
33
|
+
* pass: a later op for the same issue must not land while an earlier one could
|
|
34
|
+
* not (that is the atomicity `#184` needed — add-queue-label must not beat
|
|
35
|
+
* remove-blocked when they were enqueued in that order). Other issues continue.
|
|
36
|
+
*
|
|
37
|
+
* Returns what happened so `unblock` can say "queued, the daemon retries"
|
|
38
|
+
* instead of pretending a deferred op was applied.
|
|
39
|
+
*/
|
|
40
|
+
export async function projectLabels(
|
|
41
|
+
store: Store,
|
|
42
|
+
tracker: Tracker,
|
|
43
|
+
project: ProjectConfig,
|
|
44
|
+
): Promise<{ applied: number; deferred: number }> {
|
|
45
|
+
let applied = 0;
|
|
46
|
+
let deferred = 0;
|
|
47
|
+
// `pendingLabelOps` returns at most the oldest owed row per issue (it
|
|
48
|
+
// excludes any row that still has an earlier pending sibling), so a swap's
|
|
49
|
+
// second op can never land before its first — per-issue atomicity enforced
|
|
50
|
+
// across calls and processes, not just one pass. Re-querying lets a healthy
|
|
51
|
+
// chain drain fully in this one call (unblock clears every state label in
|
|
52
|
+
// one go), while a failing op defers its issue and parks the rest until it
|
|
53
|
+
// settles.
|
|
54
|
+
for (;;) {
|
|
55
|
+
const due = store.pendingLabelOps(project.name, Date.now());
|
|
56
|
+
if (due.length === 0) break;
|
|
57
|
+
let progressed = false;
|
|
58
|
+
for (const op of due) {
|
|
59
|
+
try {
|
|
60
|
+
if (op.op === "add") await tracker.addLabel(op.issue, op.label);
|
|
61
|
+
else await tracker.removeLabel(op.issue, op.label);
|
|
62
|
+
store.settleLabelOp(op.id);
|
|
63
|
+
applied += 1;
|
|
64
|
+
progressed = true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// A rate-limit refusal defers WITHOUT counting an attempt: a shared
|
|
67
|
+
// outage (all of GitHub refusing) is not this op's fault, so it must
|
|
68
|
+
// not burn the op's backoff escalation (#208). The circuit breaker's
|
|
69
|
+
// fast-fail (`GhRateLimitError`) is the most common shape — its stderr
|
|
70
|
+
// says "circuit breaker open", which `isRateLimitRefusal`'s body regex
|
|
71
|
+
// does not match — so classify it by type first and honour its
|
|
72
|
+
// `retryAtMs`. Every other failure counts and doubles the next wait.
|
|
73
|
+
if (err instanceof GhRateLimitError) {
|
|
74
|
+
store.deferLabelOp(op.id, errText(err), err.retryAtMs, false);
|
|
75
|
+
} else {
|
|
76
|
+
const rateLimited = isRateLimitRefusal(err);
|
|
77
|
+
store.deferLabelOp(
|
|
78
|
+
op.id,
|
|
79
|
+
errText(err),
|
|
80
|
+
Date.now() + labelOpBackoffMs(op.attempts + 1),
|
|
81
|
+
!rateLimited,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
deferred += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Every remaining op in this pass was deferred (parked behind its backoff),
|
|
88
|
+
// so re-querying would only re-select the same deferred rows; nothing more
|
|
89
|
+
// is applicable until a backoff elapses.
|
|
90
|
+
if (!progressed) break;
|
|
91
|
+
}
|
|
92
|
+
return { applied, deferred };
|
|
93
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -292,6 +292,13 @@ export type StopResult =
|
|
|
292
292
|
| { kind: "stopped"; pid: number; via: "systemctl" | "signal" }
|
|
293
293
|
| { kind: "not-running" };
|
|
294
294
|
|
|
295
|
+
/** What {@link restartDaemon} returns: the pre-restart record, the new one, and the path taken. */
|
|
296
|
+
export interface RestartResult {
|
|
297
|
+
previous: DaemonRecord | undefined;
|
|
298
|
+
record: DaemonRecord;
|
|
299
|
+
via: "systemctl" | "cli";
|
|
300
|
+
}
|
|
301
|
+
|
|
295
302
|
/**
|
|
296
303
|
* Stops the daemon.
|
|
297
304
|
*
|
|
@@ -365,7 +372,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
365
372
|
*/
|
|
366
373
|
export async function restartDaemon(
|
|
367
374
|
o: { port?: number; project?: string; timeoutMs?: number } = {},
|
|
368
|
-
): Promise<
|
|
375
|
+
): Promise<RestartResult> {
|
|
369
376
|
const previous = livingDaemon();
|
|
370
377
|
const ownership = probeUnit();
|
|
371
378
|
if (ownership.kind === "unknown") {
|