omp-conductor 0.4.2 → 0.4.4
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +391 -9
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +230 -4
- package/src/tracker/github.ts +188 -5
- package/src/types.ts +183 -13
- package/src/upgrade.ts +27 -3
package/src/tracker/github.ts
CHANGED
|
@@ -113,18 +113,29 @@ interface GhPrVerification {
|
|
|
113
113
|
|
|
114
114
|
/** Carries the captured stderr so callers can classify a failure without
|
|
115
115
|
* re-running the command or parsing the message text of a plain Error. */
|
|
116
|
-
class GhError extends Error {
|
|
116
|
+
export class GhError extends Error {
|
|
117
117
|
readonly argv: string[];
|
|
118
118
|
readonly code: number;
|
|
119
119
|
readonly stderr: string;
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Whatever the command printed before it failed.
|
|
122
|
+
*
|
|
123
|
+
* Carried because a nonzero exit is not always a failure to *read*: `gh pr
|
|
124
|
+
* checks` exits 1 when a check failed and 8 when one is pending, having
|
|
125
|
+
* already written the complete JSON payload asked for. Discarding stdout here
|
|
126
|
+
* made both CI failure classes unreachable on exactly the pull requests they
|
|
127
|
+
* exist to classify — a red PR answered "no checks" (#132 follow-up).
|
|
128
|
+
*/
|
|
129
|
+
readonly stdout: string;
|
|
130
|
+
|
|
131
|
+
constructor(argv: string[], code: number, stderr: string, stdout = "") {
|
|
122
132
|
const detail = stderr.trim() || "(no stderr)";
|
|
123
133
|
super(`\`gh ${argv.join(" ")}\` exited ${code}: ${detail}`);
|
|
124
134
|
this.name = "GhError";
|
|
125
135
|
this.argv = argv;
|
|
126
136
|
this.code = code;
|
|
127
137
|
this.stderr = stderr;
|
|
138
|
+
this.stdout = stdout;
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
|
|
@@ -155,7 +166,7 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
|
155
166
|
|
|
156
167
|
const signal = proc.signalCode;
|
|
157
168
|
if (code !== 0 || signal) {
|
|
158
|
-
throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr);
|
|
169
|
+
throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr, stdout);
|
|
159
170
|
}
|
|
160
171
|
return stdout;
|
|
161
172
|
}
|
|
@@ -381,10 +392,104 @@ export function parentNumberFrom(raw: string): number | undefined {
|
|
|
381
392
|
return n;
|
|
382
393
|
}
|
|
383
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Parse `gh pr checks --json name,state,link`.
|
|
397
|
+
*
|
|
398
|
+
* Tolerant on purpose: a payload shape this build does not recognise yields an
|
|
399
|
+
* empty list, which the classifier reads as "could not tell" — the same posture
|
|
400
|
+
* every other reader in this adapter takes.
|
|
401
|
+
*/
|
|
402
|
+
export function checkConclusionsFrom(raw: string): { name: string; state: string; link?: string }[] {
|
|
403
|
+
let parsed: unknown;
|
|
404
|
+
try {
|
|
405
|
+
parsed = JSON.parse(raw) as unknown;
|
|
406
|
+
} catch {
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
if (!Array.isArray(parsed)) return [];
|
|
410
|
+
const checks: { name: string; state: string; link?: string }[] = [];
|
|
411
|
+
for (const entry of parsed) {
|
|
412
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
413
|
+
const row = entry as { readonly [key: string]: unknown };
|
|
414
|
+
const name = row["name"];
|
|
415
|
+
const state = row["state"];
|
|
416
|
+
if (typeof name !== "string" || typeof state !== "string") continue;
|
|
417
|
+
const link = row["link"];
|
|
418
|
+
checks.push({
|
|
419
|
+
name,
|
|
420
|
+
state,
|
|
421
|
+
...(typeof link === "string" && link.length > 0 ? { link } : {}),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return checks;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** GitHub's mergeability spelling, fail-open to `unknown` on anything else. */
|
|
428
|
+
export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown" {
|
|
429
|
+
switch (raw.trim().replaceAll('"', "")) {
|
|
430
|
+
case "CONFLICTING":
|
|
431
|
+
return "conflicting";
|
|
432
|
+
case "MERGEABLE":
|
|
433
|
+
return "clean";
|
|
434
|
+
default:
|
|
435
|
+
// `UNKNOWN` is GitHub still computing the merge, and it is common on a PR
|
|
436
|
+
// pushed seconds ago. Reading it as clean would let a conflict recovery
|
|
437
|
+
// fire on a PR nobody has assessed yet.
|
|
438
|
+
return "unknown";
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Distinct workflow-run ids behind the non-success checks of a PR.
|
|
444
|
+
*
|
|
445
|
+
* Extracted from each check's `link` because that is the only run identifier
|
|
446
|
+
* `gh pr checks` reports. Deduplicated: one workflow run usually backs several
|
|
447
|
+
* checks, and re-running it once is the whole point.
|
|
448
|
+
*/
|
|
449
|
+
export function failedRunIds(
|
|
450
|
+
checks: readonly { name: string; state: string; link?: string }[],
|
|
451
|
+
): string[] {
|
|
452
|
+
const ids: string[] = [];
|
|
453
|
+
for (const check of checks) {
|
|
454
|
+
const state = check.state.trim().toLowerCase();
|
|
455
|
+
if (state === "success" || state === "neutral") continue;
|
|
456
|
+
const match = /\/actions\/runs\/(\d+)\//.exec(check.link ?? "");
|
|
457
|
+
const id = match?.[1];
|
|
458
|
+
if (id !== undefined && !ids.includes(id)) ids.push(id);
|
|
459
|
+
}
|
|
460
|
+
return ids;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Parse `[{number, state}]` from `gh issue list` or the sub-issues API. */
|
|
464
|
+
export function labeledIssuesFrom(raw: string): { number: number; state: IssueState }[] {
|
|
465
|
+
let parsed: unknown;
|
|
466
|
+
try {
|
|
467
|
+
parsed = JSON.parse(raw) as unknown;
|
|
468
|
+
} catch {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
if (!Array.isArray(parsed)) return [];
|
|
472
|
+
const issues: { number: number; state: IssueState }[] = [];
|
|
473
|
+
for (const entry of parsed) {
|
|
474
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
475
|
+
const row = entry as { readonly [key: string]: unknown };
|
|
476
|
+
const number = row["number"];
|
|
477
|
+
const rawState = row["state"];
|
|
478
|
+
if (typeof number !== "number" || !Number.isInteger(number) || typeof rawState !== "string") continue;
|
|
479
|
+
// The REST API answers lowercase, the CLI uppercase. Both are the same fact.
|
|
480
|
+
const state = issueStateFrom(rawState.toUpperCase());
|
|
481
|
+
if (state === undefined) continue;
|
|
482
|
+
issues.push({ number, state });
|
|
483
|
+
}
|
|
484
|
+
return issues;
|
|
485
|
+
}
|
|
486
|
+
|
|
384
487
|
export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
385
488
|
const repo = p.tracker.repo;
|
|
386
489
|
|
|
387
|
-
|
|
490
|
+
// Named rather than returned inline, so `rerunFailedChecks` can reuse
|
|
491
|
+
// `checkConclusions` instead of re-implementing the same `gh` call.
|
|
492
|
+
const tracker: Tracker = {
|
|
388
493
|
async listReady(): Promise<ReadyIssue[]> {
|
|
389
494
|
const raw = await runGh([
|
|
390
495
|
"issue",
|
|
@@ -573,5 +678,83 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
573
678
|
return undefined;
|
|
574
679
|
}
|
|
575
680
|
},
|
|
681
|
+
async checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]> {
|
|
682
|
+
if (!PR_URL.test(prUrl)) return [];
|
|
683
|
+
try {
|
|
684
|
+
return checkConclusionsFrom(await runGh(["pr", "checks", prUrl, "--json", "name,state,link"]));
|
|
685
|
+
} catch (err) {
|
|
686
|
+
// A nonzero exit is the *documented* answer for a PR with a failing or
|
|
687
|
+
// pending check — `gh pr checks` exits 1 and 8 respectively, having
|
|
688
|
+
// already printed the whole payload. Reading only the throw is what made
|
|
689
|
+
// `ci-infra` and `ci-deterministic` unreachable on live data: every red
|
|
690
|
+
// PR answered "no checks", so it classified `unknown` and escalated.
|
|
691
|
+
//
|
|
692
|
+
// So the payload wins when there is one, and only a genuinely unreadable
|
|
693
|
+
// answer falls through to "could not tell".
|
|
694
|
+
const stdout = err instanceof GhError ? err.stdout : "";
|
|
695
|
+
return checkConclusionsFrom(stdout);
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
|
|
700
|
+
if (!PR_URL.test(prUrl)) return "unknown";
|
|
701
|
+
try {
|
|
702
|
+
return mergeableFrom(await runGh(["pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"]));
|
|
703
|
+
} catch {
|
|
704
|
+
return "unknown";
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async rerunFailedChecks(prUrl: string): Promise<void> {
|
|
709
|
+
const runIds = failedRunIds(await tracker.checkConclusions(prUrl));
|
|
710
|
+
for (const id of runIds) {
|
|
711
|
+
try {
|
|
712
|
+
await runGh(["run", "rerun", id, "--failed"]);
|
|
713
|
+
} catch {
|
|
714
|
+
// Per run id: a workflow run too old to re-run, or one already
|
|
715
|
+
// re-running, must not stop the others. The sweep re-verifies the PR
|
|
716
|
+
// against its head on a later tick either way.
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
async listLabeled(label: string, limit = 50): Promise<{ number: number; state: IssueState }[]> {
|
|
722
|
+
try {
|
|
723
|
+
return labeledIssuesFrom(
|
|
724
|
+
await runGh([
|
|
725
|
+
"issue",
|
|
726
|
+
"list",
|
|
727
|
+
"--repo",
|
|
728
|
+
repo,
|
|
729
|
+
"--label",
|
|
730
|
+
label,
|
|
731
|
+
"--state",
|
|
732
|
+
"all",
|
|
733
|
+
"--json",
|
|
734
|
+
"number,state",
|
|
735
|
+
"--limit",
|
|
736
|
+
String(limit),
|
|
737
|
+
]),
|
|
738
|
+
);
|
|
739
|
+
} catch {
|
|
740
|
+
// A reconcile that cannot list must remove no labels: an empty answer is
|
|
741
|
+
// read as "no evidence", never as "nothing carries this label".
|
|
742
|
+
return [];
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> {
|
|
747
|
+
try {
|
|
748
|
+
return labeledIssuesFrom(
|
|
749
|
+
await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
|
|
750
|
+
);
|
|
751
|
+
} catch {
|
|
752
|
+
// Also the answer on a repo without the sub-issues API, or an issue that
|
|
753
|
+
// was never decomposed. All three mean the same thing to the caller.
|
|
754
|
+
return [];
|
|
755
|
+
}
|
|
756
|
+
},
|
|
576
757
|
};
|
|
758
|
+
|
|
759
|
+
return tracker;
|
|
577
760
|
}
|
package/src/types.ts
CHANGED
|
@@ -114,10 +114,17 @@ export interface RepoTarget {
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
116
|
* How much the orchestrator says out loud without being asked. Declared as data
|
|
117
|
-
* so the validator, the wizard and the brief all enumerate the same
|
|
118
|
-
* a
|
|
117
|
+
* so the validator, the wizard and the brief all enumerate the same three
|
|
118
|
+
* values: a fourth scope cannot be added while one of them still knows only
|
|
119
|
+
* three.
|
|
120
|
+
*
|
|
121
|
+
* `escalations` interrupts only for a tier-2 decision. `material` reports every
|
|
122
|
+
* material event as it happens. `decisions` sits between them: a decision the
|
|
123
|
+
* session needs, or a condition that stops the fleet, interrupts immediately;
|
|
124
|
+
* every other material event accumulates and ships with the next tick report —
|
|
125
|
+
* one message on a schedule instead of a ping per merge (#138).
|
|
119
126
|
*/
|
|
120
|
-
export const REPORT_SCOPES = ["escalations", "material"] as const;
|
|
127
|
+
export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const;
|
|
121
128
|
|
|
122
129
|
export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
123
130
|
|
|
@@ -608,16 +615,6 @@ export interface ProjectConfig {
|
|
|
608
615
|
workspaceRoot: string;
|
|
609
616
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
610
617
|
mirrorRoot: string;
|
|
611
|
-
/**
|
|
612
|
-
* Extra roots an orchestrator session may *read*, on top of the state
|
|
613
|
-
* directory and its own briefs. Absolute (or `~`-expandable) paths only,
|
|
614
|
-
* rejected at load rather than resolved against a cwd nobody can name.
|
|
615
|
-
*
|
|
616
|
-
* It cannot widen the gate past the roots #127 exists to close: the denied
|
|
617
|
-
* list — every worker checkout, the mirror cache, this package's own
|
|
618
|
-
* install — outranks anything named here.
|
|
619
|
-
*/
|
|
620
|
-
orchestratorReadPaths?: string[];
|
|
621
618
|
}
|
|
622
619
|
|
|
623
620
|
/**
|
|
@@ -836,8 +833,84 @@ export interface Tracker {
|
|
|
836
833
|
* better answer on a later tick.
|
|
837
834
|
*/
|
|
838
835
|
prDiff(url: string): Promise<PrDiff | undefined>;
|
|
836
|
+
/**
|
|
837
|
+
* Every check on a PR with the state the tracker reports for it (#132).
|
|
838
|
+
*
|
|
839
|
+
* Deliberately separate from {@link Tracker.verifyPr}, which answers "may this
|
|
840
|
+
* merge" and collapses every non-success into one verdict. Classification asks
|
|
841
|
+
* a different question — *why* is it not green — and the answer is the
|
|
842
|
+
* difference between re-running a cancelled runner for free and charging an
|
|
843
|
+
* implementation attempt for a real test failure.
|
|
844
|
+
*
|
|
845
|
+
* An empty array means "could not tell", the same posture as the rest of this
|
|
846
|
+
* port: nothing downstream may read it as "no checks failed".
|
|
847
|
+
*/
|
|
848
|
+
checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]>;
|
|
849
|
+
/** Whether the PR can merge into its base. `unknown` on any doubt, so a
|
|
850
|
+
* mergeability nobody could read never becomes a conflict recovery. */
|
|
851
|
+
mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown">;
|
|
852
|
+
/** Re-run the failed jobs behind a PR's non-success checks. Best effort per
|
|
853
|
+
* workflow run: one un-rerunnable run must not stop the others. */
|
|
854
|
+
rerunFailedChecks(prUrl: string): Promise<void>;
|
|
855
|
+
/** Issues carrying `label`, open or closed, bounded. Empty on any failure —
|
|
856
|
+
* a reconcile that cannot list must remove no labels. */
|
|
857
|
+
listLabeled(label: string, limit?: number): Promise<{ number: number; state: IssueState }[]>;
|
|
858
|
+
/** Sub-issues of `issue`, with their states. Empty when there are none *or*
|
|
859
|
+
* when the lookup failed: both mean "no evidence this was decomposed", and
|
|
860
|
+
* the reconcile below only ever acts on positive evidence. */
|
|
861
|
+
childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]>;
|
|
839
862
|
}
|
|
840
863
|
|
|
864
|
+
/**
|
|
865
|
+
* Why a terminal run ended badly (#132).
|
|
866
|
+
*
|
|
867
|
+
* Declared as data here for the same reason `REPORT_SCOPES` is: the classifier,
|
|
868
|
+
* the store's budget counters, `status` and the board all enumerate these, so a
|
|
869
|
+
* new class must fail to compile in each of those places rather than silently
|
|
870
|
+
* resolve to "unclassified" in one of them. The decision table that maps signals
|
|
871
|
+
* onto them lives in `failure-class.ts`.
|
|
872
|
+
*
|
|
873
|
+
* The issue's `question-answerable` / `question-decision` split is deliberately
|
|
874
|
+
* one `question`: telling them apart needs semantic judgement about the worker's
|
|
875
|
+
* question, which is the orchestrator's job rather than a decision table's. The
|
|
876
|
+
* escalation carries the worker's own blocking report, so the orchestrator
|
|
877
|
+
* answers instead of re-investigating.
|
|
878
|
+
*/
|
|
879
|
+
export const FAILURE_CLASSES = [
|
|
880
|
+
"turn-cap-progress",
|
|
881
|
+
"turn-cap-spinning",
|
|
882
|
+
"admin-kill",
|
|
883
|
+
"ci-infra",
|
|
884
|
+
"ci-deterministic",
|
|
885
|
+
"merge-conflict",
|
|
886
|
+
"question",
|
|
887
|
+
"orphan-clean",
|
|
888
|
+
"orphan-dirty",
|
|
889
|
+
"settlement-stuck",
|
|
890
|
+
"unknown",
|
|
891
|
+
] as const;
|
|
892
|
+
|
|
893
|
+
export type FailureClass = (typeof FAILURE_CLASSES)[number];
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* What the daemon does about a class.
|
|
897
|
+
*
|
|
898
|
+
* `none` completes the vocabulary rather than naming an outcome: an
|
|
899
|
+
* unclassifiable run escalates and never silently retries, so nothing in the
|
|
900
|
+
* decision table maps to it.
|
|
901
|
+
*/
|
|
902
|
+
export const RECOVERY_ACTIONS = [
|
|
903
|
+
"requeue",
|
|
904
|
+
"continue",
|
|
905
|
+
"rerun-checks",
|
|
906
|
+
"settle",
|
|
907
|
+
"escalate",
|
|
908
|
+
"hold",
|
|
909
|
+
"none",
|
|
910
|
+
] as const;
|
|
911
|
+
|
|
912
|
+
export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number];
|
|
913
|
+
|
|
841
914
|
/**
|
|
842
915
|
* Execution state is separate from the tracker's own labels on purpose: labels
|
|
843
916
|
* are coarse and human-editable, while the loop needs to distinguish "pushed
|
|
@@ -902,6 +975,17 @@ export interface RunRecord {
|
|
|
902
975
|
* exactly as an unflagged one does, and the flags are evidence for whoever
|
|
903
976
|
* reviews the PR. Absent means the audit found nothing, or never ran. */
|
|
904
977
|
settlementFlags?: SettlementFlag[];
|
|
978
|
+
/**
|
|
979
|
+
* Why this run ended badly, and what the daemon did about it (#132). Absent
|
|
980
|
+
* means the sweep has not looked at the row yet — never "nothing was wrong":
|
|
981
|
+
* every budget counter treats an unclassified terminal row exactly as it did
|
|
982
|
+
* before classification existed.
|
|
983
|
+
*/
|
|
984
|
+
failureClass?: FailureClass;
|
|
985
|
+
recoveryAction?: RecoveryAction;
|
|
986
|
+
/** When the recovery for that class ran. Absent means still unrecovered, which
|
|
987
|
+
* is what `status` counts and what the board's card suffix reports. */
|
|
988
|
+
recoveredAt?: number;
|
|
905
989
|
}
|
|
906
990
|
|
|
907
991
|
export type AdmissionHoldReason =
|
|
@@ -1073,6 +1157,60 @@ export interface ReportEnqueue {
|
|
|
1073
1157
|
deduped: boolean;
|
|
1074
1158
|
}
|
|
1075
1159
|
|
|
1160
|
+
/**
|
|
1161
|
+
* Where one operator decision stands (#136).
|
|
1162
|
+
*
|
|
1163
|
+
* `open` is the only state that owes anybody anything, which is why the tick
|
|
1164
|
+
* digest reads exactly that set. The three terminal states are distinct because
|
|
1165
|
+
* they describe different histories: `answered` carries what the operator
|
|
1166
|
+
* decided, `withdrawn` records that the session stopped needing it (and why),
|
|
1167
|
+
* and `expired` is the ledger closing a question nobody answered inside
|
|
1168
|
+
* {@link DECISION_TTL_MS} rather than letting it accumulate forever.
|
|
1169
|
+
*/
|
|
1170
|
+
export const DECISION_STATES = ["open", "answered", "withdrawn", "expired"] as const;
|
|
1171
|
+
|
|
1172
|
+
export type DecisionState = (typeof DECISION_STATES)[number];
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* How long an unanswered question stays open — the seven days the floor's
|
|
1176
|
+
* parked-amendment protocol already promised, now enforced instead of
|
|
1177
|
+
* remembered.
|
|
1178
|
+
*/
|
|
1179
|
+
export const DECISION_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
1180
|
+
|
|
1181
|
+
/** One question put to the operator, and its answer if it has one. */
|
|
1182
|
+
export interface DecisionRecord {
|
|
1183
|
+
id: string;
|
|
1184
|
+
project: string;
|
|
1185
|
+
/** The question verbatim, as it was sent. Re-asking must not reword it. */
|
|
1186
|
+
question: string;
|
|
1187
|
+
/** What is waiting on the answer — an issue, a release, a PR. Free text,
|
|
1188
|
+
* because the point is that a human reads it in a digest. */
|
|
1189
|
+
blocks?: string;
|
|
1190
|
+
askedAt: number;
|
|
1191
|
+
expiresAt: number;
|
|
1192
|
+
/** Raw machine-checkable precondition, e.g. `pr-merged:<url>`. Stored as
|
|
1193
|
+
* written and parsed on read, so a grammar this build does not know is a row
|
|
1194
|
+
* that still lists rather than a row that fails to load. */
|
|
1195
|
+
condition?: string;
|
|
1196
|
+
/** When that precondition was first observed true. Set once; the digest
|
|
1197
|
+
* promotes the row from "parked" to "act on this now". */
|
|
1198
|
+
conditionMetAt?: number;
|
|
1199
|
+
state: DecisionState;
|
|
1200
|
+
resolvedAt?: number;
|
|
1201
|
+
/** The answer, the withdrawal reason, or the expiry note. */
|
|
1202
|
+
resolution?: string;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** What a caller hands over. The store owns the id, the state and the expiry. */
|
|
1206
|
+
export interface DecisionDraft {
|
|
1207
|
+
project: string;
|
|
1208
|
+
question: string;
|
|
1209
|
+
blocks?: string;
|
|
1210
|
+
condition?: string;
|
|
1211
|
+
at: number;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1076
1214
|
/**
|
|
1077
1215
|
* Bookkeeping only — GitHub labels remain the source of truth. The store
|
|
1078
1216
|
* exists to answer cap questions cheaply and to survive a restart; if it is
|
|
@@ -1160,6 +1298,38 @@ export interface Store {
|
|
|
1160
1298
|
recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[];
|
|
1161
1299
|
/** Everything an operator still has to care about: pending, sending, failed. */
|
|
1162
1300
|
openReports(project: string): ReportRecord[];
|
|
1301
|
+
/**
|
|
1302
|
+
* Terminal rows the classification sweep still owes something to, newest first
|
|
1303
|
+
* and bounded (#132): never classified, or classified with a retryable recovery
|
|
1304
|
+
* that has not run. The second half is what makes "retrying next tick" true
|
|
1305
|
+
* when a tracker write fails mid-recovery.
|
|
1306
|
+
*
|
|
1307
|
+
* Bounded because each row costs tracker calls to gather facts for: a fleet
|
|
1308
|
+
* with a long unclassified history works through it over several ticks rather
|
|
1309
|
+
* than spending one tick on all of it.
|
|
1310
|
+
*/
|
|
1311
|
+
runsNeedingClassification(project: string, limit?: number): RunRecord[];
|
|
1312
|
+
/** Unrecovered rows per class, for `status`. Empty when nothing is carrying one. */
|
|
1313
|
+
failureClassCounts(project: string): { cls: FailureClass; n: number }[];
|
|
1314
|
+
/** Rows whose recovery ran at or after `since`, newest first — the tick's
|
|
1315
|
+
* "auto-recovered since last tick" line, and its count. */
|
|
1316
|
+
recoveredSince(project: string, since: number): RunRecord[];
|
|
1317
|
+
/**
|
|
1318
|
+
* Record a question put to the operator (#136). Expiry is the store's, not the
|
|
1319
|
+
* caller's: a session that computed its own deadline would be back to
|
|
1320
|
+
* remembering things.
|
|
1321
|
+
*/
|
|
1322
|
+
createDecision(draft: DecisionDraft): DecisionRecord;
|
|
1323
|
+
/** Everything still owed an answer, oldest first — what a tick digest reads. */
|
|
1324
|
+
openDecisions(project: string): DecisionRecord[];
|
|
1325
|
+
/** Answer or withdraw one. `false` when the id is unknown or already closed,
|
|
1326
|
+
* so a double-resolve cannot overwrite the first answer. */
|
|
1327
|
+
resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean;
|
|
1328
|
+
/** First observation that a row's condition came true. Idempotent. */
|
|
1329
|
+
markDecisionConditionMet(id: string, at: number): boolean;
|
|
1330
|
+
/** Close every open row past its deadline and return them, so the caller can
|
|
1331
|
+
* say what it just closed rather than reporting a count. */
|
|
1332
|
+
expireDueDecisions(project: string, now: number): DecisionRecord[];
|
|
1163
1333
|
/**
|
|
1164
1334
|
* Append one decided verb call to the run-scoped action ledger (#126).
|
|
1165
1335
|
* Written for refusals as well as approvals, and written *before* the
|
package/src/upgrade.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
|
|
|
3
3
|
import { setPaused, statusSnapshot } from "./daemon.ts";
|
|
4
4
|
import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
|
|
5
5
|
import { livingDaemon, restartDaemon } from "./lifecycle.ts";
|
|
6
|
-
import { configPath, findProject, loadConfig, resolveCaps,
|
|
6
|
+
import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
|
|
7
7
|
import { renderBriefForProject } from "./setup.ts";
|
|
8
8
|
import {
|
|
9
9
|
STAGED_SERVICE_NAME,
|
|
@@ -276,6 +276,21 @@ async function waitForRecovery(
|
|
|
276
276
|
throw new Error(`upgrade verification failed: ${problem}`);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Brings the brief up with the freshly installed package.
|
|
281
|
+
*
|
|
282
|
+
* `brief-upgrade --migrate --apply` is the cross-version ABI: this code runs
|
|
283
|
+
* from the *old* CLI while the new one is already installed, so the verb has to
|
|
284
|
+
* keep working across the boundary. When it does not — a flag renamed, the verb
|
|
285
|
+
* gone — the tolerable outcome depends entirely on the layout:
|
|
286
|
+
*
|
|
287
|
+
* - `overlay` fleets need nothing. The floor is recomposed from the installed
|
|
288
|
+
* package on every tick, so a failed migrate costs nothing and stopping the
|
|
289
|
+
* upgrade over it would be the worse trade.
|
|
290
|
+
* - a legacy layout needs it. Skipping the migration silently would leave the
|
|
291
|
+
* fleet on a single-file brief whose floor no longer tracks the package —
|
|
292
|
+
* the exact drift this verb exists to end — so it fails loudly and rolls back.
|
|
293
|
+
*/
|
|
279
294
|
async function upgradeBrief(
|
|
280
295
|
deps: UpgradeDeps,
|
|
281
296
|
kind: BriefLayout["kind"],
|
|
@@ -283,9 +298,16 @@ async function upgradeBrief(
|
|
|
283
298
|
): Promise<void> {
|
|
284
299
|
const selected = project === undefined ? [] : ["--project", project];
|
|
285
300
|
if (kind === "legacy-handwritten") {
|
|
301
|
+
// Ungated on purpose: a hand-written brief cannot be migrated without it.
|
|
286
302
|
await mustRun(deps, "omp-conductor", ["brief-upgrade", "--retrofit", "--apply", ...selected]);
|
|
287
303
|
}
|
|
288
|
-
|
|
304
|
+
try {
|
|
305
|
+
await mustRun(deps, "omp-conductor", ["brief-upgrade", "--migrate", "--apply", ...selected]);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (kind !== "overlay") throw err;
|
|
308
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
309
|
+
deps.log(`brief: brief-upgrade unavailable in the target CLI (${msg}) — overlay recomposes each tick, continuing`);
|
|
310
|
+
}
|
|
289
311
|
}
|
|
290
312
|
|
|
291
313
|
async function rollbackUpgrade(
|
|
@@ -314,7 +336,9 @@ async function rollbackUpgrade(
|
|
|
314
336
|
const path = configPath();
|
|
315
337
|
if (readFileSync(path, "utf8") !== configBefore) {
|
|
316
338
|
deps.log("rollback: conductor config.json");
|
|
317
|
-
writeConfigFile(JSON.parse(
|
|
339
|
+
// Exact bytes. `writeConfigFile(JSON.parse(...))` returns canonically
|
|
340
|
+
// formatted output, which restores the keys but not the file.
|
|
341
|
+
writeConfigRaw(configBefore);
|
|
318
342
|
}
|
|
319
343
|
} catch (err) {
|
|
320
344
|
failures.push(`could not restore config.json: ${err instanceof Error ? err.message : String(err)}`);
|