omp-conductor 0.8.0 → 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 +45 -21
- 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/cli.ts +17 -5
- package/src/config.ts +13 -0
- package/src/daemon.ts +238 -123
- package/src/diff-flags.ts +35 -6
- package/src/fleet.ts +28 -2
- package/src/label-projection.ts +93 -0
- package/src/routing.ts +20 -0
- package/src/store.ts +254 -2
- package/src/tracker/github.ts +266 -18
- package/src/types.ts +75 -3
- package/src/unblock.ts +70 -19
- package/src/upgrade.ts +28 -4
package/README.md
CHANGED
|
@@ -524,6 +524,18 @@ Four control planes used to answer "stop" differently. The package verbs:
|
|
|
524
524
|
|
|
525
525
|
`status` prints a layered header (`dispatch` / `ticks` / next tick time / `pane` / `recovery` / `herdr` / `telegram` / `daemon`) so a paused fleet cannot hide an armed orchestrator still spending turns. The Telegram line calls the official `getMe` endpoint to prove the token and API are usable without sending a message, then separately reports whether the inbound bridge is configured.
|
|
526
526
|
|
|
527
|
+
When the tracker is behind what the store decided, `status` adds a
|
|
528
|
+
`labels projection N pending (oldest …)` row: those are label
|
|
529
|
+
changes the dispatcher committed to and the [projector](#the-tick) has not been
|
|
530
|
+
able to apply yet (a 403, a rate limit). The intended label state is durable, so
|
|
531
|
+
nothing is lost and safety is preserved: for an issue the queue read already
|
|
532
|
+
returns, a pending *state-label removal* is overlaid as applied, so a stale
|
|
533
|
+
label cannot block redispatch. A pending *queue-label add* is different — the
|
|
534
|
+
queue read asks GitHub for issues carrying the queue label, so that issue stays
|
|
535
|
+
invisible to dispatch until the add actually lands. The row is the lag to
|
|
536
|
+
watch: it should drain, and while it does not, work waiting on a queue-label
|
|
537
|
+
add waits with it.
|
|
538
|
+
|
|
527
539
|
`omp-conductor board [--project NAME]` opens the same facts as a live terminal
|
|
528
540
|
kanban instead of a scrolling wall of status text. Its columns are Queue,
|
|
529
541
|
Claimed, Running, Green, Blocked, Failed, Orphaned, Merged, and History.
|
|
@@ -649,14 +661,17 @@ Per tick, for the daemon's project:
|
|
|
649
661
|
|
|
650
662
|
Then, per admitted issue:
|
|
651
663
|
|
|
652
|
-
1. **
|
|
653
|
-
This ordering is the whole crash-safety story: the label,
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
664
|
+
1. **Create the run row (`claimed`) — before any worktree or session exists.**
|
|
665
|
+
This ordering is the whole crash-safety story: the *row*, not a label, is the
|
|
666
|
+
guard against dispatching the same issue twice. It is local and written before
|
|
667
|
+
anything that can fail; if the process dies at any later point, the startup
|
|
668
|
+
orphan sweep marks the left-behind row `orphaned` and the orchestrator's drain
|
|
669
|
+
duty triages it (see below). The `agent:in-progress` label is a write-behind
|
|
670
|
+
projection of that row — enqueued in the same breath and applied to the tracker
|
|
671
|
+
by the projector with retry — so even a tracker that refuses the write cannot
|
|
672
|
+
recreate a duplicate PR while the guard is unavailable.
|
|
673
|
+
2. Run the tick's post-admission projection pass, which applies freshly enqueued
|
|
674
|
+
label ops on the healthy path.
|
|
660
675
|
3. Clear any stale tree for this issue, then add a fresh worktree at
|
|
661
676
|
`<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
|
|
662
677
|
on the run's branch off the repo's default branch.
|
|
@@ -679,9 +694,17 @@ Then, per admitted issue:
|
|
|
679
694
|
settles gives up its `agent:in-progress` label. See
|
|
680
695
|
[what settles a green PR](#what-settles-a-green-pr).
|
|
681
696
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
697
|
+
Every label write the dispatcher makes goes through the label projection
|
|
698
|
+
outbox and is applied to the tracker by the projector with retry, strictly in
|
|
699
|
+
the order it was enqueued per issue — a later op for one issue never lands
|
|
700
|
+
before an earlier one that is still owed. A state-label swap on a dispatch
|
|
701
|
+
outcome enqueues the new label ahead of the old one's removal, so the issue
|
|
702
|
+
is never briefly bare (the shape eligibility reads as fresh work), while a
|
|
703
|
+
requeue (`swapToQueue`, `unblock`) enqueues its removals ahead of the queue
|
|
704
|
+
add for the same reason in reverse: the issue must not look claimable before
|
|
705
|
+
its stale state label is gone. A refused write is deferred with backoff
|
|
706
|
+
instead of dropped, and `status` shows any un-applied lag on a
|
|
707
|
+
`labels projection` row.
|
|
685
708
|
|
|
686
709
|
**Every continuable end salvages the tree first.** A turns-cap kill, a
|
|
687
710
|
wall-clock kill, a crash and a graceful block all leave a tree the next
|
|
@@ -793,15 +816,16 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
|
|
|
793
816
|
attempt; a merge does not spend failure or continuation budget. A settled row also
|
|
794
817
|
loses `agent:in-progress` from its issue: the row transition and the label are one
|
|
795
818
|
fact, and a terminal answer about the PR proves no worker process owns the issue,
|
|
796
|
-
so the duplicate-dispatch guard it exists for is spent. The
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
so
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
still
|
|
819
|
+
so the duplicate-dispatch guard it exists for is spent. The removal is *enqueued
|
|
820
|
+
on the label projection outbox in the same breath as the terminal write* — a
|
|
821
|
+
durable local write that cannot fail on the tracker — so the row settles at once
|
|
822
|
+
and the projector applies the label with retry. A tracker that refuses the
|
|
823
|
+
removal can no longer strand it: the op stays owed, eligibility overlays the
|
|
824
|
+
pending removal so the issue is not held back by a label that is already decided
|
|
825
|
+
gone, and `status` shows the lag. Anything beyond that one release — a re-queue,
|
|
826
|
+
a `blocked` marker — is still the orchestrator's drain-duty judgement. One
|
|
827
|
+
unreachable PR costs its own row and nothing else; the rest of the sweep still
|
|
828
|
+
settles.
|
|
805
829
|
|
|
806
830
|
Until this existed, nothing ever revisited a `pushed-green` row: the startup
|
|
807
831
|
reconciler only settles rows that held a process, and `merged` went unwritten. On
|
|
@@ -1955,7 +1979,7 @@ omp-conductor help
|
|
|
1955
1979
|
| `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
|
|
1956
1980
|
| `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
1957
1981
|
| `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
|
|
1958
|
-
| `unblock <issue> [--force] [--no-requeue]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again, and restore the project queue label by default so the dispatcher actually sees it. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label (and the queue label stays off until that run settles), and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. `--no-requeue` clears the state labels only, leaving the queue label untouched — the case where you are about to close the issue. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1982
|
+
| `unblock <issue> [--force] [--no-requeue]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again, and restore the project queue label by default so the dispatcher actually sees it. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label (and the queue label stays off until that run settles), and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. The label changes go through the [label projection outbox](#the-tick): they are applied inline before the command returns, but **a tracker that refuses them (403, rate limit) no longer fails the verb** — it exits `0`, the intended label state is durable and the daemon retries it, and the output says `label sync queued (N pending) — the daemon retries` instead of claiming the labels were restored. Safety is preserved, but the issue is only claimable once the queue label itself lands: the queue read asks GitHub for issues carrying that label, so a refused queue-label add keeps the issue out of dispatch until projection succeeds. `--no-requeue` clears the state labels only, leaving the queue label untouched — the case where you are about to close the issue. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1959
1983
|
| `verb <conductor_*> [--arg k=v ...]` | Run one [mediated verb](#the-mediated-verbs-126) as the orchestrator, from the CLI — the external-orchestrator half of the verb surface. Every argument goes in as a `--arg k=v` string; an orchestrator can merge (`conductor_pr_merge`), label (`conductor_label`), release (`conductor_release`), update a branch (`conductor_pr_update_branch`) or title/body (`conductor_pr_update`), or read PR state (`conductor_pr_status`). The daemon applies the same checks and writes the same ledger rows a session's call would; a missing `--arg` is refused exactly as a missing tool argument is, worker-only verbs (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed`, and a refusal exits `3`. An unknown verb exits `2`. |
|
|
1960
1984
|
| `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
|
|
1961
1985
|
| `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/backups.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable file backups under conductor state.
|
|
3
|
+
*
|
|
4
|
+
* Both the brief writer and the config writer keep a copy of every previous
|
|
5
|
+
* version of the file they replace, so a bad write — or a process dying
|
|
6
|
+
* mid-upgrade — never destroys the only copy of operator-owned bytes.
|
|
7
|
+
* Retention is intentionally unbounded, matching the brief backups.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
constants,
|
|
13
|
+
copyFileSync,
|
|
14
|
+
linkSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** A timestamp stem safe to embed in a file name. */
|
|
21
|
+
export function backupTimestamp(): string {
|
|
22
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Publishes `source` under `backupRoot` as `stem`, suffixing on collision. */
|
|
26
|
+
export function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
|
|
27
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
28
|
+
const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
|
|
29
|
+
copyFileSync(source, temporary, constants.COPYFILE_EXCL);
|
|
30
|
+
try {
|
|
31
|
+
for (let suffix = 0; ; suffix += 1) {
|
|
32
|
+
const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
|
|
33
|
+
try {
|
|
34
|
+
// Linking a complete temp file publishes the backup atomically without
|
|
35
|
+
// overwriting a backup created concurrently at the same millisecond.
|
|
36
|
+
linkSync(temporary, destination);
|
|
37
|
+
return destination;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
unlinkSync(temporary);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/board.ts
CHANGED
|
@@ -20,13 +20,21 @@ import type {
|
|
|
20
20
|
AdmissionHoldReason,
|
|
21
21
|
PrVerification,
|
|
22
22
|
ProjectConfig,
|
|
23
|
+
ReadyIssue,
|
|
23
24
|
RunRecord,
|
|
24
25
|
RunState,
|
|
25
26
|
Store,
|
|
27
|
+
Tracker,
|
|
26
28
|
} from "./types.ts";
|
|
27
29
|
|
|
28
30
|
const REFRESH_MS = 1_000;
|
|
29
31
|
const HEALTH_REFRESH_MS = 10_000;
|
|
32
|
+
/** Base cadence for the tracker label read, and the ceiling the idle backoff
|
|
33
|
+
* in {@link nextLabelDelayMs} doubles toward. Separate from the health gate
|
|
34
|
+
* above because only this one leaves the machine: daemon state and the plan
|
|
35
|
+
* allowance are local reads and stay fresh at the base cadence regardless. */
|
|
36
|
+
const LABEL_REFRESH_MS = 10_000;
|
|
37
|
+
const LABEL_REFRESH_MAX_MS = 60_000;
|
|
30
38
|
/** Cadence for re-verifying pushed rows against GitHub — deliberately slower
|
|
31
39
|
* than the board's health/label probe: it is one `gh` call per pushed row, and
|
|
32
40
|
* pull-request state does not move at 1 Hz. #173. */
|
|
@@ -176,6 +184,10 @@ export interface BoardSnapshot {
|
|
|
176
184
|
* the PR looks like now. Rendered as a `now:` suffix on the card. */
|
|
177
185
|
pr: ReadonlyMap<string, { status: PrVerification["status"]; reason: string }>;
|
|
178
186
|
runs: RunRecord[];
|
|
187
|
+
/** What this board itself has spent on the tracker in the last minute, and
|
|
188
|
+
* how much of that was free 304s (#210). Absent in one-shot contexts like
|
|
189
|
+
* `board --json`, which have no rate to report. */
|
|
190
|
+
trackerCost?: { calls: number; free: number };
|
|
179
191
|
now: number;
|
|
180
192
|
}
|
|
181
193
|
|
|
@@ -399,6 +411,83 @@ export function boardLanes(snapshot: BoardSnapshot): Map<BoardLane, number[]> {
|
|
|
399
411
|
return lanes;
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The four lifecycle label sets of one open-issue snapshot.
|
|
416
|
+
*
|
|
417
|
+
* Label compare is case-insensitive: GitHub label names are unique
|
|
418
|
+
* case-insensitively, and the server-side filter this replaces matched them
|
|
419
|
+
* that way. An issue carrying more than one of the four lands in each matching
|
|
420
|
+
* set, exactly as the four separate label queries did — `laneOf` already
|
|
421
|
+
* resolves that precedence.
|
|
422
|
+
*/
|
|
423
|
+
export function boardLabelSets(
|
|
424
|
+
issues: readonly ReadyIssue[],
|
|
425
|
+
project: ProjectConfig,
|
|
426
|
+
): { queued: Set<number>; inProgress: Set<number>; blocked: Set<number>; failed: Set<number> } {
|
|
427
|
+
const queued = new Set<number>();
|
|
428
|
+
const inProgress = new Set<number>();
|
|
429
|
+
const blocked = new Set<number>();
|
|
430
|
+
const failed = new Set<number>();
|
|
431
|
+
const names: Record<"queued" | "inProgress" | "blocked" | "failed", string> = {
|
|
432
|
+
queued: project.queueLabel.toLowerCase(),
|
|
433
|
+
inProgress: project.stateLabels.inProgress.toLowerCase(),
|
|
434
|
+
blocked: project.stateLabels.blocked.toLowerCase(),
|
|
435
|
+
failed: project.stateLabels.failed.toLowerCase(),
|
|
436
|
+
};
|
|
437
|
+
for (const issue of issues) {
|
|
438
|
+
for (const label of issue.labels) {
|
|
439
|
+
const lower = label.toLowerCase();
|
|
440
|
+
if (lower === names.queued) queued.add(issue.number);
|
|
441
|
+
else if (lower === names.inProgress) inProgress.add(issue.number);
|
|
442
|
+
else if (lower === names.blocked) blocked.add(issue.number);
|
|
443
|
+
else if (lower === names.failed) failed.add(issue.number);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { queued, inProgress, blocked, failed };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function sameIssueSet(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
|
|
450
|
+
if (a.size !== b.size) return false;
|
|
451
|
+
for (const issue of a) if (!b.has(issue)) return false;
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Whether two label reads say the same thing.
|
|
457
|
+
*
|
|
458
|
+
* Read outcome counts as well as membership, but only whether a read *failed*
|
|
459
|
+
* — never the message. GitHub's failures carry a fresh request id every time
|
|
460
|
+
* (`... request ID E05A:12929D:...`), so comparing text would score two
|
|
461
|
+
* identical failures as a change and pin the fast cadence on a tracker that is
|
|
462
|
+
* broken, which is exactly backwards.
|
|
463
|
+
*/
|
|
464
|
+
export function boardLabelsEqual(a: BoardLabels, b: BoardLabels): boolean {
|
|
465
|
+
return (
|
|
466
|
+
sameIssueSet(a.queued, b.queued) &&
|
|
467
|
+
sameIssueSet(a.inProgress, b.inProgress) &&
|
|
468
|
+
sameIssueSet(a.blocked, b.blocked) &&
|
|
469
|
+
sameIssueSet(a.failed, b.failed) &&
|
|
470
|
+
sameIssueSet(a.open, b.open) &&
|
|
471
|
+
(a.error === undefined) === (b.error === undefined)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* How long to wait before the next tracker label read.
|
|
477
|
+
*
|
|
478
|
+
* #203 made that read a single conditional request that answers 304 while
|
|
479
|
+
* nothing changes, so an idle board no longer spends rate-limit budget — but a
|
|
480
|
+
* request nobody needs is still a request, and a board left open in a pane all
|
|
481
|
+
* day makes thousands of them. Each pass that finds nothing changed doubles the
|
|
482
|
+
* wait up to {@link LABEL_REFRESH_MAX_MS}; any change, and any operator
|
|
483
|
+
* keypress, drops it straight back to {@link LABEL_REFRESH_MS}. So the board is
|
|
484
|
+
* never more than a minute behind, and is back to base cadence the moment
|
|
485
|
+
* either the work or the operator moves.
|
|
486
|
+
*/
|
|
487
|
+
export function nextLabelDelayMs(current: number, unchanged: boolean): number {
|
|
488
|
+
return unchanged ? Math.min(current * 2, LABEL_REFRESH_MAX_MS) : LABEL_REFRESH_MS;
|
|
489
|
+
}
|
|
490
|
+
|
|
402
491
|
function cardIssue(card: BoardCard): number {
|
|
403
492
|
return card.kind === "issue" ? card.issue : card.run.issue;
|
|
404
493
|
}
|
|
@@ -562,15 +651,41 @@ function healthLine(snapshot: BoardSnapshot): string {
|
|
|
562
651
|
].join(" · ");
|
|
563
652
|
}
|
|
564
653
|
|
|
565
|
-
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
654
|
+
/**
|
|
655
|
+
* Sliding one-minute count of the board's own tracker requests, pruning what
|
|
656
|
+
* has aged out of the window.
|
|
657
|
+
*
|
|
658
|
+
* #210 asks for the board's request cost to be visible to a human: a board
|
|
659
|
+
* left open in a pane spent 95% of the fleet's GitHub budget for three and a
|
|
660
|
+
* half hours and nothing on screen said so. Mutates the two windows in place —
|
|
661
|
+
* they are the caller's ring buffers, kept small by this prune.
|
|
662
|
+
*/
|
|
663
|
+
export function trackerCostWindow(
|
|
664
|
+
calls: number[],
|
|
665
|
+
free: number[],
|
|
666
|
+
now: number,
|
|
667
|
+
windowMs = 60_000,
|
|
668
|
+
): { calls: number; free: number } {
|
|
669
|
+
const cutoff = now - windowMs;
|
|
670
|
+
while (calls.length > 0 && calls[0]! < cutoff) calls.shift();
|
|
671
|
+
while (free.length > 0 && free[0]! < cutoff) free.shift();
|
|
672
|
+
return { calls: calls.length, free: free.length };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** How current the label sets behind the lanes are, and what this board is
|
|
676
|
+
* spending to keep them current. A lane rule is only as authoritative as its
|
|
677
|
+
* last successful read, so a stale one says so instead of letting an empty
|
|
678
|
+
* QUEUE read as an empty queue. */
|
|
568
679
|
function labelFact(snapshot: BoardSnapshot): string {
|
|
569
680
|
const { readAt, error } = snapshot.labels;
|
|
570
|
-
|
|
681
|
+
const cost =
|
|
682
|
+
snapshot.trackerCost === undefined
|
|
683
|
+
? ""
|
|
684
|
+
: ` · ${snapshot.trackerCost.calls}/min${snapshot.trackerCost.free === 0 ? "" : ` (${snapshot.trackerCost.free} free)`}`;
|
|
685
|
+
if (error === undefined) return `tracker ok${cost}`;
|
|
571
686
|
const detail = error.replace(/\s+/g, " ").slice(0, 80);
|
|
572
|
-
if (readAt === 0) return `tracker unread — ${detail}`;
|
|
573
|
-
return `tracker stale ${humanDuration(snapshot.now - readAt)} — ${detail}`;
|
|
687
|
+
if (readAt === 0) return `tracker unread${cost} — ${detail}`;
|
|
688
|
+
return `tracker stale ${humanDuration(snapshot.now - readAt)}${cost} — ${detail}`;
|
|
574
689
|
}
|
|
575
690
|
|
|
576
691
|
function integrationLine(snapshot: BoardSnapshot): string {
|
|
@@ -799,36 +914,31 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
799
914
|
* under a run row — are invisible to it, and a board built from run rows alone
|
|
800
915
|
* reports last-run history as current work.
|
|
801
916
|
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* the
|
|
808
|
-
* runs beside.
|
|
917
|
+
* One open-issue snapshot per pass, never one list call per label (#203): the
|
|
918
|
+
* four lifecycle sets are derived in memory from it by {@link boardLabelSets},
|
|
919
|
+
* and PARKED openness is read off the same snapshot instead of a per-candidate
|
|
920
|
+
* `issueState` fan-out. The single read rides the conditional-request cache, so
|
|
921
|
+
* an idle board costs no primary rate-limit budget at all. The cadence stays
|
|
922
|
+
* the ten-second health refresh, never the one-second redraw path.
|
|
809
923
|
*
|
|
810
924
|
* Never throws. A failed read keeps the previous sets on screen and records
|
|
811
925
|
* why, because blanking QUEUE and FAILED is a louder lie than showing them
|
|
812
926
|
* stale — {@link labelFact} puts the staleness in the header.
|
|
813
927
|
*/
|
|
814
|
-
async function probeBoardLabels(
|
|
928
|
+
export async function probeBoardLabels(
|
|
815
929
|
project: ProjectConfig,
|
|
816
930
|
store: Store,
|
|
817
931
|
previous?: BoardLabels,
|
|
932
|
+
probeTracker?: Tracker,
|
|
818
933
|
): Promise<BoardLabels> {
|
|
819
|
-
const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
|
|
820
|
-
new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
|
|
821
934
|
try {
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
openIssuesLabelled(project.stateLabels.blocked),
|
|
826
|
-
openIssuesLabelled(project.stateLabels.failed),
|
|
827
|
-
]);
|
|
935
|
+
const tracker = probeTracker ?? makeTracker(project);
|
|
936
|
+
const open = await tracker.listOpenIssues();
|
|
937
|
+
const { queued, inProgress, blocked, failed } = boardLabelSets(open, project);
|
|
828
938
|
// PARKED candidates: the newest run per issue, terminal, non-merged, and
|
|
829
939
|
// wearing no state or queue label. Exactly the rows {@link laneOf} would
|
|
830
|
-
// otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit
|
|
831
|
-
//
|
|
940
|
+
// otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit
|
|
941
|
+
// and cached: a failed read keeps the last set.
|
|
832
942
|
const labelled = new Set([...queued, ...inProgress, ...blocked, ...failed]);
|
|
833
943
|
const newestRun = new Map<number, RunRecord>();
|
|
834
944
|
for (const run of store.recentRuns(project.name, Date.now() - MERGED_HISTORY_MS)) {
|
|
@@ -837,14 +947,11 @@ async function probeBoardLabels(
|
|
|
837
947
|
const parkedCandidates = [...newestRun.values()].filter(
|
|
838
948
|
(run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
|
|
839
949
|
);
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}),
|
|
846
|
-
);
|
|
847
|
-
return { queued, inProgress, blocked, failed, open, readAt: Date.now() };
|
|
950
|
+
// The snapshot IS the set of open issues, so openness needs no per-issue
|
|
951
|
+
// lookup: a candidate whose issue appears in it is open.
|
|
952
|
+
const openNumbers = new Set(open.map((issue) => issue.number));
|
|
953
|
+
const openSet = new Set(parkedCandidates.map((run) => run.issue).filter((n) => openNumbers.has(n)));
|
|
954
|
+
return { queued, inProgress, blocked, failed, open: openSet, readAt: Date.now() };
|
|
848
955
|
} catch (err) {
|
|
849
956
|
return {
|
|
850
957
|
...(previous ?? UNREAD_LABELS),
|
|
@@ -905,6 +1012,81 @@ async function waitForInput(queue: KeyInput[], setWake: (wake?: () => void) => v
|
|
|
905
1012
|
return queue.shift() ?? { name: "refresh" };
|
|
906
1013
|
}
|
|
907
1014
|
|
|
1015
|
+
/**
|
|
1016
|
+
* One-shot snapshot for the headless board (#200): the same setup and one round
|
|
1017
|
+
* of probes `runBoard` performs on entry, with the per-row PR re-verification
|
|
1018
|
+
* probe deliberately skipped — a one-shot is a point-in-time read, so there is
|
|
1019
|
+
* no live verdict map to keep warm.
|
|
1020
|
+
*/
|
|
1021
|
+
export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnapshot> {
|
|
1022
|
+
const cfg = loadConfig();
|
|
1023
|
+
const project = findProject(cfg, projectName);
|
|
1024
|
+
const caps = resolveCaps(project, cfg.defaults);
|
|
1025
|
+
const store: Store = openStore(dbPath());
|
|
1026
|
+
try {
|
|
1027
|
+
const now = Date.now();
|
|
1028
|
+
const [health, labels, planUsage] = await Promise.all([
|
|
1029
|
+
probeBoardHealth(project),
|
|
1030
|
+
probeBoardLabels(project, store),
|
|
1031
|
+
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
1032
|
+
]);
|
|
1033
|
+
return {
|
|
1034
|
+
project,
|
|
1035
|
+
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
1036
|
+
health,
|
|
1037
|
+
labels,
|
|
1038
|
+
pr: new Map(),
|
|
1039
|
+
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
1040
|
+
now,
|
|
1041
|
+
};
|
|
1042
|
+
} finally {
|
|
1043
|
+
store.close();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
interface BoardJsonRun {
|
|
1048
|
+
issue: number;
|
|
1049
|
+
state: string;
|
|
1050
|
+
attempt?: number;
|
|
1051
|
+
prUrl?: string;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The headless board's JSON view (#200): one entry per issue per lane, fields
|
|
1056
|
+
* read from the lane's newest run row. Issues the store has never run (queue
|
|
1057
|
+
* members placed by label or hold) carry `"state": "queued"` instead. Every
|
|
1058
|
+
* COLUMN_DEFS lane key is emitted, empty ones included, so consumers never
|
|
1059
|
+
* key-check; keys and entries keep the board's stable render order.
|
|
1060
|
+
*/
|
|
1061
|
+
export function boardJson(snapshot: BoardSnapshot): string {
|
|
1062
|
+
// `recentRuns` yields the newest attempt per issue first; indexing keeps one
|
|
1063
|
+
// entry per issue even when a caller hands over a wider run list.
|
|
1064
|
+
const newest = new Map<number, RunRecord>();
|
|
1065
|
+
for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run);
|
|
1066
|
+
|
|
1067
|
+
const lanesByKey = boardLanes(snapshot);
|
|
1068
|
+
const lanes: Record<string, BoardJsonRun[]> = {};
|
|
1069
|
+
for (const { key } of COLUMN_DEFS) {
|
|
1070
|
+
lanes[key] = (lanesByKey.get(key) ?? []).map((issue) => {
|
|
1071
|
+
const run = newest.get(issue);
|
|
1072
|
+
if (run === undefined) {
|
|
1073
|
+
// Label- or hold-placed issues the store never ran. The queue lane's
|
|
1074
|
+
// state is its own word ("queued"); the other label-placed lanes keep
|
|
1075
|
+
// their key as the state, which is exactly what they are.
|
|
1076
|
+
return { issue, state: key === "queue" ? "queued" : key };
|
|
1077
|
+
}
|
|
1078
|
+
const entry: BoardJsonRun = { issue, state: run.state, attempt: run.attempt };
|
|
1079
|
+
if (run.prUrl !== undefined) entry.prUrl = run.prUrl;
|
|
1080
|
+
return entry;
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
return JSON.stringify(
|
|
1084
|
+
{ project: snapshot.project.name, generatedAt: new Date(snapshot.now).toISOString(), lanes },
|
|
1085
|
+
null,
|
|
1086
|
+
2,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
908
1090
|
export async function runBoard(projectName?: string): Promise<void> {
|
|
909
1091
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
910
1092
|
throw new Error("board needs an interactive terminal (TTY)");
|
|
@@ -920,13 +1102,28 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
920
1102
|
let stopping = false;
|
|
921
1103
|
let help = false;
|
|
922
1104
|
let notice = "";
|
|
1105
|
+
// One tracker for the whole board, bound to its own call counters (#210):
|
|
1106
|
+
// every request this UI makes is counted where a human can see it, instead
|
|
1107
|
+
// of only being visible to an external shim on `gh`.
|
|
1108
|
+
const trackerCalls: number[] = [];
|
|
1109
|
+
const trackerFree: number[] = [];
|
|
1110
|
+
const boardTracker = makeTracker(project, undefined, {
|
|
1111
|
+
onCall: () => trackerCalls.push(Date.now()),
|
|
1112
|
+
onNotModified: () => trackerFree.push(Date.now()),
|
|
1113
|
+
});
|
|
923
1114
|
let [health, labels, planUsage] = await Promise.all([
|
|
924
1115
|
probeBoardHealth(project),
|
|
925
|
-
probeBoardLabels(project, store),
|
|
1116
|
+
probeBoardLabels(project, store, undefined, boardTracker),
|
|
926
1117
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
927
1118
|
]);
|
|
928
1119
|
let healthAt = Date.now();
|
|
929
1120
|
let healthRefresh: Promise<void> | undefined;
|
|
1121
|
+
// The tracker read is gated separately from health and the plan allowance:
|
|
1122
|
+
// those two are local reads that cost no API traffic, so they stay on the
|
|
1123
|
+
// base cadence while this one backs off across unchanged passes.
|
|
1124
|
+
let labelAt = Date.now();
|
|
1125
|
+
let labelDelay = LABEL_REFRESH_MS;
|
|
1126
|
+
let labelRefresh: Promise<void> | undefined;
|
|
930
1127
|
// Live re-verification of pushed rows (#173): cached per run id, refreshed on
|
|
931
1128
|
// the slow PR cadence so a `gh` call per pushed row does not ride the 1 Hz
|
|
932
1129
|
// repaint or the 10 s health gate.
|
|
@@ -956,14 +1153,12 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
956
1153
|
healthAt = now;
|
|
957
1154
|
healthRefresh = Promise.all([
|
|
958
1155
|
probeBoardHealth(project),
|
|
959
|
-
probeBoardLabels(project, store, labels),
|
|
960
1156
|
// On the health cadence, not the 1s repaint: the provider read is a
|
|
961
1157
|
// subprocess, and an allowance does not move at 1 Hz.
|
|
962
1158
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
963
1159
|
])
|
|
964
|
-
.then(([nextHealth,
|
|
1160
|
+
.then(([nextHealth, nextPlanUsage]) => {
|
|
965
1161
|
health = nextHealth;
|
|
966
|
-
labels = nextLabels;
|
|
967
1162
|
planUsage = nextPlanUsage;
|
|
968
1163
|
enqueue(queue, { name: "refresh" }, wake);
|
|
969
1164
|
})
|
|
@@ -974,6 +1169,21 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
974
1169
|
healthRefresh = undefined;
|
|
975
1170
|
});
|
|
976
1171
|
}
|
|
1172
|
+
if (now - labelAt >= labelDelay && labelRefresh === undefined) {
|
|
1173
|
+
labelAt = now;
|
|
1174
|
+
labelRefresh = probeBoardLabels(project, store, labels, boardTracker)
|
|
1175
|
+
.then((nextLabels) => {
|
|
1176
|
+
labelDelay = nextLabelDelayMs(labelDelay, boardLabelsEqual(labels, nextLabels));
|
|
1177
|
+
labels = nextLabels;
|
|
1178
|
+
enqueue(queue, { name: "refresh" }, wake);
|
|
1179
|
+
})
|
|
1180
|
+
.catch((err: unknown) => {
|
|
1181
|
+
notice = `label refresh failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1182
|
+
})
|
|
1183
|
+
.finally(() => {
|
|
1184
|
+
labelRefresh = undefined;
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
977
1187
|
// #173: re-verify pushed rows against the tracker on their own slow
|
|
978
1188
|
// cadence, so red-shown-as-green and a moved head surface without paying
|
|
979
1189
|
// for the read every second. A failed probe leaves the last verdicts in
|
|
@@ -984,10 +1194,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
984
1194
|
(run) => (run.state === "pushed-green" || run.state === "pushed-pending") && run.prUrl !== undefined && run.headSha !== undefined,
|
|
985
1195
|
);
|
|
986
1196
|
prProbeRun = (async () => {
|
|
987
|
-
const tracker = makeTracker(project);
|
|
988
1197
|
await Promise.all(
|
|
989
1198
|
rows.map(async (run) => {
|
|
990
|
-
const verified = await
|
|
1199
|
+
const verified = await boardTracker.verifyPr(run.prUrl!, run.headSha!);
|
|
991
1200
|
if (verified !== undefined) prProbe.set(run.id, { status: verified.status, reason: verified.reason });
|
|
992
1201
|
}),
|
|
993
1202
|
);
|
|
@@ -1006,6 +1215,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1006
1215
|
labels,
|
|
1007
1216
|
pr: prProbe,
|
|
1008
1217
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
1218
|
+
trackerCost: trackerCostWindow(trackerCalls, trackerFree, now),
|
|
1009
1219
|
now,
|
|
1010
1220
|
};
|
|
1011
1221
|
normalizeCursor(snapshot, cursor);
|
|
@@ -1017,6 +1227,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1017
1227
|
});
|
|
1018
1228
|
const name = key.name ?? key.sequence;
|
|
1019
1229
|
if (name === "refresh" || name === "resize") continue;
|
|
1230
|
+
// Past this point the key came from a person, not the refresh timer:
|
|
1231
|
+
// someone watching the board gets the base cadence back immediately.
|
|
1232
|
+
labelDelay = LABEL_REFRESH_MS;
|
|
1020
1233
|
if (name === "?" || key.sequence === "?") {
|
|
1021
1234
|
help = !help;
|
|
1022
1235
|
continue;
|
|
@@ -1037,6 +1250,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1037
1250
|
}
|
|
1038
1251
|
if (name === "r") {
|
|
1039
1252
|
healthAt = 0;
|
|
1253
|
+
labelAt = 0;
|
|
1040
1254
|
notice = "refresh requested";
|
|
1041
1255
|
continue;
|
|
1042
1256
|
}
|
|
@@ -1110,7 +1324,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1110
1324
|
notice = "unblock is available for blocked, failed or orphaned issues";
|
|
1111
1325
|
} else {
|
|
1112
1326
|
notice = await unblock(project, cardIssue(card));
|
|
1327
|
+
// An unblock just changed labels: re-read both gates now.
|
|
1113
1328
|
healthAt = 0;
|
|
1329
|
+
labelAt = 0;
|
|
1114
1330
|
}
|
|
1115
1331
|
}
|
|
1116
1332
|
}
|
package/src/brief-upgrade.ts
CHANGED
|
@@ -10,12 +10,8 @@
|
|
|
10
10
|
* banner can `retrofit` one at a classified cut before migrating.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { randomUUID } from "node:crypto";
|
|
14
13
|
import {
|
|
15
|
-
constants,
|
|
16
|
-
copyFileSync,
|
|
17
14
|
existsSync,
|
|
18
|
-
linkSync,
|
|
19
15
|
mkdirSync,
|
|
20
16
|
readFileSync,
|
|
21
17
|
readdirSync,
|
|
@@ -23,6 +19,7 @@ import {
|
|
|
23
19
|
writeFileSync,
|
|
24
20
|
} from "node:fs";
|
|
25
21
|
import { basename, dirname, join } from "node:path";
|
|
22
|
+
import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
|
|
26
23
|
import { stateDir } from "./config.ts";
|
|
27
24
|
|
|
28
25
|
/**
|
|
@@ -315,32 +312,6 @@ export function briefBackupDir(): string {
|
|
|
315
312
|
return join(stateDir(), "backups", "briefs");
|
|
316
313
|
}
|
|
317
314
|
|
|
318
|
-
function backupTimestamp(): string {
|
|
319
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
|
|
323
|
-
mkdirSync(backupRoot, { recursive: true });
|
|
324
|
-
const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
|
|
325
|
-
copyFileSync(source, temporary, constants.COPYFILE_EXCL);
|
|
326
|
-
try {
|
|
327
|
-
for (let suffix = 0; ; suffix += 1) {
|
|
328
|
-
const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
|
|
329
|
-
try {
|
|
330
|
-
// Linking a complete temp file publishes the backup atomically without
|
|
331
|
-
// overwriting a backup created concurrently at the same millisecond.
|
|
332
|
-
linkSync(temporary, destination);
|
|
333
|
-
return destination;
|
|
334
|
-
} catch (err) {
|
|
335
|
-
if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
336
|
-
throw err;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
} finally {
|
|
340
|
-
unlinkSync(temporary);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
315
|
/**
|
|
345
316
|
* Moves only conductor's timestamp-shaped legacy sidecar backups into state.
|
|
346
317
|
* Unknown `.bak` files remain operator-owned. Copy-before-unlink also works
|