omp-conductor 0.9.1 → 0.12.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 +57 -12
- package/package.json +1 -1
- package/src/board.ts +203 -63
- package/src/briefs/orchestrator.md +45 -1
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +331 -20
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +72 -0
- package/src/fleet.ts +2 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +111 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +201 -16
package/src/board.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "./fleet.ts";
|
|
13
13
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
14
14
|
import { healthCheck, livingDaemon } from "./lifecycle.ts";
|
|
15
|
+
import type { WorkerPausePhase } from "./worker.ts";
|
|
15
16
|
import { dbPath, openStore } from "./store.ts";
|
|
16
17
|
import { formatTranscriptLine } from "./transcript.ts";
|
|
17
18
|
import { makeTracker } from "./tracker/github.ts";
|
|
@@ -72,9 +73,9 @@ const COLUMN_DEFS = [
|
|
|
72
73
|
{ key: "blocked", title: "BLOCKED" },
|
|
73
74
|
{ key: "failed", title: "FAILED" },
|
|
74
75
|
{ key: "orphaned", title: "ORPHANED" },
|
|
75
|
-
{ key: "parked", title: "PARKED" },
|
|
76
76
|
{ key: "merged", title: "MERGED" },
|
|
77
|
-
{ key: "
|
|
77
|
+
{ key: "settled", title: "SETTLED" },
|
|
78
|
+
{ key: "parked", title: "PARKED" },
|
|
78
79
|
] as const satisfies readonly { key: string; title: string }[];
|
|
79
80
|
|
|
80
81
|
export type BoardLane = (typeof COLUMN_DEFS)[number]["key"];
|
|
@@ -136,6 +137,11 @@ export interface BoardHealth {
|
|
|
136
137
|
codeGraph?: CodeGraphHealth;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
interface BoardHealthProbe {
|
|
141
|
+
health: BoardHealth;
|
|
142
|
+
pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
/**
|
|
140
146
|
* Which issues currently carry each label the board reasons about, read from
|
|
141
147
|
* the tracker rather than inferred from run rows.
|
|
@@ -151,11 +157,15 @@ export interface BoardLabels {
|
|
|
151
157
|
inProgress: ReadonlySet<number>;
|
|
152
158
|
blocked: ReadonlySet<number>;
|
|
153
159
|
failed: ReadonlySet<number>;
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Issues the tracker reports CLOSED, derived only from a *successful* label
|
|
162
|
+
* snapshot on the same 10s cadence as the other sets. This is the one fact
|
|
163
|
+
* that separates SETTLED (finished work, issue closed) from PARKED (a human
|
|
164
|
+
* still has to look): absence from this set means open **or** the probe could
|
|
165
|
+
* not tell, and either way the run parks rather than falsely asserting
|
|
166
|
+
* closure. On a failed read the last good set survives behind {@link error}.
|
|
167
|
+
*/
|
|
168
|
+
closed: ReadonlySet<number>;
|
|
159
169
|
/** Epoch ms of the last read that succeeded; 0 when none has. */
|
|
160
170
|
readAt: number;
|
|
161
171
|
/** Message from the most recent failed read. The sets above are then the last
|
|
@@ -171,7 +181,7 @@ const UNREAD_LABELS: BoardLabels = {
|
|
|
171
181
|
inProgress: new Set<number>(),
|
|
172
182
|
blocked: new Set<number>(),
|
|
173
183
|
failed: new Set<number>(),
|
|
174
|
-
|
|
184
|
+
closed: new Set<number>(),
|
|
175
185
|
readAt: 0,
|
|
176
186
|
};
|
|
177
187
|
|
|
@@ -179,6 +189,7 @@ export interface BoardSnapshot {
|
|
|
179
189
|
project: ProjectConfig;
|
|
180
190
|
status: StatusSnapshot;
|
|
181
191
|
health: BoardHealth;
|
|
192
|
+
pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
|
|
182
193
|
labels: BoardLabels;
|
|
183
194
|
/** Live re-verification of pushed rows (#173): run id → what the tracker says
|
|
184
195
|
* the PR looks like now. Rendered as a `now:` suffix on the card. */
|
|
@@ -225,7 +236,7 @@ function ansiColor(key: BoardLane): string {
|
|
|
225
236
|
return YELLOW;
|
|
226
237
|
case "failed":
|
|
227
238
|
return RED;
|
|
228
|
-
case "
|
|
239
|
+
case "settled":
|
|
229
240
|
return DIM;
|
|
230
241
|
default:
|
|
231
242
|
return MAGENTA;
|
|
@@ -285,10 +296,11 @@ function humanDuration(ms: number): string {
|
|
|
285
296
|
* parent issues a human had already cleared (#81, #86) — every one of them a
|
|
286
297
|
* historical terminal run, not one of them failed work.
|
|
287
298
|
*
|
|
288
|
-
*
|
|
299
|
+
* so: the store decides the live lanes, because a running worker is the most
|
|
289
300
|
* current fact there is; the tracker's current labels decide every stopped
|
|
290
301
|
* lane; each issue matches exactly once, first branch wins; and last-run
|
|
291
|
-
* history is answered in
|
|
302
|
+
* history is answered in SETTLED (issue closed) and PARKED (issue still open,
|
|
303
|
+
* labels cleared), under their own names.
|
|
292
304
|
*/
|
|
293
305
|
function laneOf(
|
|
294
306
|
snapshot: BoardSnapshot,
|
|
@@ -322,17 +334,19 @@ function laneOf(
|
|
|
322
334
|
if (queued.has(issue)) return "queue";
|
|
323
335
|
if (run?.state === "merged") return "merged";
|
|
324
336
|
if (run === undefined) return undefined;
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// being the issue's last word after 24 hours.
|
|
331
|
-
if (PARKED_STATES.has(run.state) && labels.
|
|
332
|
-
// What is left is a
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
337
|
+
// SETTLED vs PARKED, decided by the one fact the board can prove: whether the
|
|
338
|
+
// tracker confirmed the issue closed. Absence from `closed` means open OR the
|
|
339
|
+
// probe could not tell, and either way the run parks — a failed label read
|
|
340
|
+
// must render the issue as "a human has to look", never as finished work
|
|
341
|
+
// (#218). PARKED is deliberately not subject to MERGED_HISTORY_MS: parked work
|
|
342
|
+
// does not stop being the issue's last word after 24 hours.
|
|
343
|
+
if (PARKED_STATES.has(run.state) && !labels.closed.has(issue)) return "parked";
|
|
344
|
+
// What is left is a confirmed-closed issue with a terminal run row. Recent
|
|
345
|
+
// rows stay readable under SETTLED for the same 24 hours MERGED uses; older
|
|
346
|
+
// ones are not news. Named for what it means — the run settled and the issue
|
|
347
|
+
// is closed — not for the vague archive HISTORY read as, which is how one lane
|
|
348
|
+
// came to look like two different things (#218).
|
|
349
|
+
return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "settled" : undefined;
|
|
336
350
|
}
|
|
337
351
|
|
|
338
352
|
/** Description lines for an issue the store has no run row for. Only the lanes
|
|
@@ -467,7 +481,7 @@ export function boardLabelsEqual(a: BoardLabels, b: BoardLabels): boolean {
|
|
|
467
481
|
sameIssueSet(a.inProgress, b.inProgress) &&
|
|
468
482
|
sameIssueSet(a.blocked, b.blocked) &&
|
|
469
483
|
sameIssueSet(a.failed, b.failed) &&
|
|
470
|
-
sameIssueSet(a.
|
|
484
|
+
sameIssueSet(a.closed, b.closed) &&
|
|
471
485
|
(a.error === undefined) === (b.error === undefined)
|
|
472
486
|
);
|
|
473
487
|
}
|
|
@@ -551,43 +565,54 @@ function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
|
|
|
551
565
|
cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset);
|
|
552
566
|
}
|
|
553
567
|
|
|
554
|
-
function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
568
|
+
function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] {
|
|
555
569
|
const endedAt = run.endedAt ?? snapshot.now;
|
|
556
570
|
const duration = humanDuration(endedAt - run.startedAt);
|
|
571
|
+
const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue) : undefined;
|
|
572
|
+
const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : "";
|
|
557
573
|
const lines = [
|
|
558
574
|
// The class, when the sweep has attached one and nothing has recovered it
|
|
559
575
|
// yet (#132): a row state says "failed", which four completed issues on this
|
|
560
576
|
// fleet also said. The class says which of the two this is.
|
|
561
577
|
run.failureClass === undefined || run.recoveredAt !== undefined
|
|
562
|
-
?
|
|
563
|
-
:
|
|
578
|
+
? `${pausePrefix}#${run.issue} · ${run.repo}`
|
|
579
|
+
: `${pausePrefix}#${run.issue} · ${run.repo} [${run.failureClass}]`,
|
|
564
580
|
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
565
581
|
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
566
582
|
];
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
// #173: a terminal blocked/failed run whose state label is gone reads as the
|
|
580
|
-
// lane it sits in — which the label being absent made PARKED, not the truth.
|
|
581
|
-
if (isLastRun(run, snapshot.labels)) lines.push(`last run: ${run.state}`);
|
|
582
|
-
// Ordered by what needs a human first. An unsalvaged tree outranks even a
|
|
583
|
-
// last error: the error describes a run that is over, the tree is work that
|
|
584
|
-
// is still at risk and an issue that will not dispatch (#118).
|
|
583
|
+
// Exactly one fourth line, so a card stays within the four content lines that
|
|
584
|
+
// renderColumn's slot arithmetic (`slots = floor((height - 2) / 5)`) allocates
|
|
585
|
+
// for it. Every fact a card can carry folds into this single priority:
|
|
586
|
+
// an unsalvaged tree outranks even a live verdict (#118) — the error describes
|
|
587
|
+
// a run that is over, the tree is work still at risk — then the live probe for
|
|
588
|
+
// a pushed row, then the word the lane name cannot carry, then the run's own
|
|
589
|
+
// evidence.
|
|
590
|
+
//
|
|
591
|
+
// `isLastRun` is deliberately not a card line: it belongs in the detail header
|
|
592
|
+
// (renderDetail's `stateShown`), where a full width can spell out `last run:
|
|
593
|
+
// blocked`. Putting it here too would blow the budget and repeat the #218
|
|
594
|
+
// conflation on the two-line boundary.
|
|
585
595
|
if (run.salvageError !== undefined && run.salvageAckAt === undefined) lines.push("UNSALVAGED WIP");
|
|
586
|
-
else
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
596
|
+
else {
|
|
597
|
+
// #173: a live re-verification of a pushed row, when the 60s probe has one.
|
|
598
|
+
// Guarded on the pushed states: a stale entry must not follow a row that
|
|
599
|
+
// settled into another lane and keep claiming "now: green" about a PR that
|
|
600
|
+
// already merged or failed.
|
|
601
|
+
const live =
|
|
602
|
+
run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined;
|
|
603
|
+
if (live !== undefined) {
|
|
604
|
+
const reason = live.reason.split(/\s+/, 1)[0] ?? "";
|
|
605
|
+
lines.push(
|
|
606
|
+
live.status === "failed" ? `now: red — ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`,
|
|
607
|
+
);
|
|
608
|
+
} else if (lane === "settled") lines.push("issue closed");
|
|
609
|
+
else if (lane === "parked") lines.push("needs a label");
|
|
610
|
+
else if (run.state === "pushed-pending") lines.push("checks pending");
|
|
611
|
+
else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`);
|
|
612
|
+
else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
|
|
613
|
+
else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
|
|
614
|
+
else lines.push(run.branch);
|
|
615
|
+
}
|
|
591
616
|
return lines;
|
|
592
617
|
}
|
|
593
618
|
|
|
@@ -616,7 +641,7 @@ function renderColumn(
|
|
|
616
641
|
lines.push(styledCell(" (empty)", width, DIM));
|
|
617
642
|
} else {
|
|
618
643
|
for (const [offset, card] of shown.entries()) {
|
|
619
|
-
const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : [`#${card.issue}`, card.reason, card.note];
|
|
644
|
+
const raw = card.kind === "run" ? runCardLines(card.run, snapshot, key) : [`#${card.issue}`, card.reason, card.note];
|
|
620
645
|
while (raw.length < 4) raw.push("");
|
|
621
646
|
const selected = selectedColumn && start + offset === selectedCard;
|
|
622
647
|
for (const [lineIndex, value] of raw.entries()) {
|
|
@@ -703,7 +728,7 @@ function admissionLine(snapshot: BoardSnapshot): string {
|
|
|
703
728
|
const queue =
|
|
704
729
|
dispatch === undefined
|
|
705
730
|
? "dispatch not recorded"
|
|
706
|
-
: `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.
|
|
731
|
+
: `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`;
|
|
707
732
|
const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", ");
|
|
708
733
|
const holds = holdText === undefined || holdText === "" ? "none" : holdText;
|
|
709
734
|
return (
|
|
@@ -804,8 +829,20 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
|
|
|
804
829
|
// #173: a blocked/failed run whose label is gone is the reason this card is
|
|
805
830
|
// parked; the header says so rather than presenting the state as current.
|
|
806
831
|
const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
|
|
832
|
+
const pausePhase = snapshot.pausedPhases.get(run.issue);
|
|
807
833
|
const metadata = [
|
|
808
834
|
styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
|
|
835
|
+
...(pausePhase === undefined
|
|
836
|
+
? []
|
|
837
|
+
: [
|
|
838
|
+
styledCell(
|
|
839
|
+
pausePhase === "paused"
|
|
840
|
+
? "worker paused · wall clock frozen"
|
|
841
|
+
: "worker pausing · draining to harness idle",
|
|
842
|
+
width,
|
|
843
|
+
YELLOW,
|
|
844
|
+
),
|
|
845
|
+
]),
|
|
809
846
|
styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
|
|
810
847
|
styledCell(`branch ${run.branch}`, width, DIM),
|
|
811
848
|
styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
|
|
@@ -828,12 +865,15 @@ function renderHelp(width: number, height: number): string[] {
|
|
|
828
865
|
"↑/↓ or k/j select card",
|
|
829
866
|
"Enter inspect/follow transcript",
|
|
830
867
|
"u unblock selected blocked, failed or orphaned issue",
|
|
868
|
+
"space pause/resume selected worker",
|
|
831
869
|
"i open selected issue",
|
|
832
870
|
"p open selected pull request",
|
|
833
871
|
"r refresh health now",
|
|
834
872
|
"? close help",
|
|
835
873
|
"Esc back, then quit",
|
|
836
874
|
"q / Ctrl-C back, then quit",
|
|
875
|
+
"",
|
|
876
|
+
"PARKED = issue open, no label — nothing dispatches it until you label it",
|
|
837
877
|
];
|
|
838
878
|
const top = Math.max(0, Math.floor((height - lines.length) / 2));
|
|
839
879
|
return [
|
|
@@ -882,7 +922,37 @@ export function renderBoard(
|
|
|
882
922
|
].join("\n");
|
|
883
923
|
}
|
|
884
924
|
|
|
885
|
-
|
|
925
|
+
export function workerPhasesFromHealthz(
|
|
926
|
+
body: string | undefined,
|
|
927
|
+
project: string,
|
|
928
|
+
): ReadonlyMap<number, WorkerPausePhase> {
|
|
929
|
+
const phases = new Map<number, WorkerPausePhase>();
|
|
930
|
+
if (body === undefined) return phases;
|
|
931
|
+
try {
|
|
932
|
+
const payload = JSON.parse(body) as unknown;
|
|
933
|
+
if (payload === null || typeof payload !== "object") return phases;
|
|
934
|
+
if (Reflect.get(payload, "project") !== project) return phases;
|
|
935
|
+
const workers = Reflect.get(payload, "workers");
|
|
936
|
+
if (!Array.isArray(workers)) return phases;
|
|
937
|
+
for (const worker of workers) {
|
|
938
|
+
if (worker === null || typeof worker !== "object") continue;
|
|
939
|
+
const issue = Reflect.get(worker, "issue");
|
|
940
|
+
const phase = Reflect.get(worker, "phase");
|
|
941
|
+
if (
|
|
942
|
+
Number.isSafeInteger(issue) &&
|
|
943
|
+
(issue as number) > 0 &&
|
|
944
|
+
(phase === "pausing" || phase === "paused")
|
|
945
|
+
) {
|
|
946
|
+
phases.set(issue as number, phase);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
} catch {
|
|
950
|
+
// An unreadable health body means no trustworthy pause phase.
|
|
951
|
+
}
|
|
952
|
+
return phases;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
|
|
886
956
|
const layers = fleetLayers(project.name);
|
|
887
957
|
const record = livingDaemon();
|
|
888
958
|
const wrongRecord = record?.project !== undefined && record.project !== project.name;
|
|
@@ -903,7 +973,18 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
903
973
|
}
|
|
904
974
|
}
|
|
905
975
|
const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
|
|
906
|
-
return {
|
|
976
|
+
return {
|
|
977
|
+
health: {
|
|
978
|
+
layers,
|
|
979
|
+
telegram,
|
|
980
|
+
daemon,
|
|
981
|
+
codeGraph: cachedGraph ?? (await probeCodeGraph(project)),
|
|
982
|
+
},
|
|
983
|
+
pausedPhases:
|
|
984
|
+
daemon === "ok"
|
|
985
|
+
? workerPhasesFromHealthz(health?.body, project.name)
|
|
986
|
+
: new Map<number, WorkerPausePhase>(),
|
|
987
|
+
};
|
|
907
988
|
}
|
|
908
989
|
|
|
909
990
|
/**
|
|
@@ -916,7 +997,7 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
916
997
|
*
|
|
917
998
|
* One open-issue snapshot per pass, never one list call per label (#203): the
|
|
918
999
|
* four lifecycle sets are derived in memory from it by {@link boardLabelSets},
|
|
919
|
-
* and
|
|
1000
|
+
* and SETTLED closure is read off the same snapshot instead of a per-candidate
|
|
920
1001
|
* `issueState` fan-out. The single read rides the conditional-request cache, so
|
|
921
1002
|
* an idle board costs no primary rate-limit budget at all. The cadence stays
|
|
922
1003
|
* the ten-second health refresh, never the one-second redraw path.
|
|
@@ -947,11 +1028,19 @@ export async function probeBoardLabels(
|
|
|
947
1028
|
const parkedCandidates = [...newestRun.values()].filter(
|
|
948
1029
|
(run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
|
|
949
1030
|
);
|
|
950
|
-
// The snapshot IS the set of open issues, so
|
|
951
|
-
// lookup: a candidate whose issue
|
|
1031
|
+
// The snapshot IS the set of open issues, so closure needs no per-issue
|
|
1032
|
+
// lookup: a candidate whose issue does NOT appear in it is closed, *because
|
|
1033
|
+
// this read succeeded*. That last clause is the whole distinction on the
|
|
1034
|
+
// failed path — `closed` is only ever derived here, inside the try, so an
|
|
1035
|
+
// unreadable probe survives as the previous set (or empty) and an issue the
|
|
1036
|
+
// board could not confirm closed keeps parking rather than falsely reading
|
|
1037
|
+
// as SETTLED (#218). Absence from `closed` is therefore open OR unknown,
|
|
1038
|
+
// and both mean PARKED.
|
|
952
1039
|
const openNumbers = new Set(open.map((issue) => issue.number));
|
|
953
|
-
const
|
|
954
|
-
|
|
1040
|
+
const closed = new Set(
|
|
1041
|
+
parkedCandidates.map((run) => run.issue).filter((n) => !openNumbers.has(n)),
|
|
1042
|
+
);
|
|
1043
|
+
return { queued, inProgress, blocked, failed, closed, readAt: Date.now() };
|
|
955
1044
|
} catch (err) {
|
|
956
1045
|
return {
|
|
957
1046
|
...(previous ?? UNREAD_LABELS),
|
|
@@ -994,6 +1083,41 @@ async function unblock(project: ProjectConfig, issue: number): Promise<string> {
|
|
|
994
1083
|
if (code !== 0) return (stderr || stdout).trim().replace(/\s+/g, " ") || `#${issue}: unblock failed`;
|
|
995
1084
|
return summarizeUnblockOutput(issue, stdout);
|
|
996
1085
|
}
|
|
1086
|
+
|
|
1087
|
+
async function toggleWorkerPause(
|
|
1088
|
+
project: ProjectConfig,
|
|
1089
|
+
issue: number,
|
|
1090
|
+
phase: WorkerPausePhase | undefined,
|
|
1091
|
+
): Promise<string> {
|
|
1092
|
+
if (phase === "pausing") return "still pausing — wait";
|
|
1093
|
+
const daemon = livingDaemon();
|
|
1094
|
+
if (daemon === undefined) return "daemon is not running";
|
|
1095
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
1096
|
+
return `daemon serves project "${daemon.project}", not requested project "${project.name}"`;
|
|
1097
|
+
}
|
|
1098
|
+
const action = phase === "paused" ? "resume" : "pause";
|
|
1099
|
+
try {
|
|
1100
|
+
const response = await fetch(
|
|
1101
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/${action}`,
|
|
1102
|
+
{
|
|
1103
|
+
method: "PUT",
|
|
1104
|
+
headers: { "content-type": "application/json" },
|
|
1105
|
+
body: JSON.stringify({ project: project.name }),
|
|
1106
|
+
},
|
|
1107
|
+
);
|
|
1108
|
+
const payload = (await response.json()) as { error?: unknown; phase?: unknown };
|
|
1109
|
+
if (!response.ok) {
|
|
1110
|
+
return typeof payload.error === "string"
|
|
1111
|
+
? payload.error
|
|
1112
|
+
: `daemon returned HTTP ${response.status}`;
|
|
1113
|
+
}
|
|
1114
|
+
return typeof payload.phase === "string"
|
|
1115
|
+
? `#${issue} worker ${payload.phase}`
|
|
1116
|
+
: "daemon returned an invalid worker-control response";
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
return err instanceof Error ? err.message : String(err);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
997
1121
|
function enqueue(queue: KeyInput[], key: KeyInput, wake: (() => void) | undefined): void {
|
|
998
1122
|
queue.push(key);
|
|
999
1123
|
wake?.();
|
|
@@ -1025,7 +1149,7 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
|
|
|
1025
1149
|
const store: Store = openStore(dbPath());
|
|
1026
1150
|
try {
|
|
1027
1151
|
const now = Date.now();
|
|
1028
|
-
const [
|
|
1152
|
+
const [healthProbe, labels, planUsage] = await Promise.all([
|
|
1029
1153
|
probeBoardHealth(project),
|
|
1030
1154
|
probeBoardLabels(project, store),
|
|
1031
1155
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
@@ -1033,7 +1157,8 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
|
|
|
1033
1157
|
return {
|
|
1034
1158
|
project,
|
|
1035
1159
|
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
1036
|
-
health,
|
|
1160
|
+
health: healthProbe.health,
|
|
1161
|
+
pausedPhases: healthProbe.pausedPhases,
|
|
1037
1162
|
labels,
|
|
1038
1163
|
pr: new Map(),
|
|
1039
1164
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
@@ -1111,11 +1236,13 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1111
1236
|
onCall: () => trackerCalls.push(Date.now()),
|
|
1112
1237
|
onNotModified: () => trackerFree.push(Date.now()),
|
|
1113
1238
|
});
|
|
1114
|
-
let [
|
|
1239
|
+
let [healthProbe, labels, planUsage] = await Promise.all([
|
|
1115
1240
|
probeBoardHealth(project),
|
|
1116
1241
|
probeBoardLabels(project, store, undefined, boardTracker),
|
|
1117
1242
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
1118
1243
|
]);
|
|
1244
|
+
let health = healthProbe.health;
|
|
1245
|
+
let pausedPhases = healthProbe.pausedPhases;
|
|
1119
1246
|
let healthAt = Date.now();
|
|
1120
1247
|
let healthRefresh: Promise<void> | undefined;
|
|
1121
1248
|
// The tracker read is gated separately from health and the plan allowance:
|
|
@@ -1157,8 +1284,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1157
1284
|
// subprocess, and an allowance does not move at 1 Hz.
|
|
1158
1285
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
1159
1286
|
])
|
|
1160
|
-
.then(([
|
|
1161
|
-
health =
|
|
1287
|
+
.then(([nextProbe, nextPlanUsage]) => {
|
|
1288
|
+
health = nextProbe.health;
|
|
1289
|
+
pausedPhases = nextProbe.pausedPhases;
|
|
1162
1290
|
planUsage = nextPlanUsage;
|
|
1163
1291
|
enqueue(queue, { name: "refresh" }, wake);
|
|
1164
1292
|
})
|
|
@@ -1212,6 +1340,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1212
1340
|
project,
|
|
1213
1341
|
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
1214
1342
|
health,
|
|
1343
|
+
pausedPhases,
|
|
1215
1344
|
labels,
|
|
1216
1345
|
pr: prProbe,
|
|
1217
1346
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
@@ -1303,6 +1432,17 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1303
1432
|
else notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`;
|
|
1304
1433
|
continue;
|
|
1305
1434
|
}
|
|
1435
|
+
if (name === "space") {
|
|
1436
|
+
const lane = COLUMN_DEFS[cursor.column]?.key;
|
|
1437
|
+
if (card === undefined || card.kind !== "run" || lane !== "running") {
|
|
1438
|
+
notice = "pause/resume is available for RUNNING cards";
|
|
1439
|
+
} else {
|
|
1440
|
+
const issue = cardIssue(card);
|
|
1441
|
+
notice = await toggleWorkerPause(project, issue, pausedPhases.get(issue));
|
|
1442
|
+
healthAt = 0;
|
|
1443
|
+
}
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1306
1446
|
if (name === "u") {
|
|
1307
1447
|
// Lane, not run state. `unblock` clears the blocked and failed labels
|
|
1308
1448
|
// unconditionally and the in-progress label once the newest run row is
|
|
@@ -191,6 +191,39 @@ Keep the queue worth draining.
|
|
|
191
191
|
every attempt.
|
|
192
192
|
- An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
|
|
193
193
|
split it, or hand it back to a human.
|
|
194
|
+
- **Grooming is throughput-bound, so delegate the finding.** N workers drain the
|
|
195
|
+
queue in parallel while you refill it alone: on a three-worker fleet grooming
|
|
196
|
+
has to produce roughly three well-specced issues in the time one worker takes
|
|
197
|
+
to finish one. Auditing candidates serially cannot keep up, and the board then
|
|
198
|
+
reads "0 ready / workers idle" while you are doing exactly what this duty asks.
|
|
199
|
+
When the queue is below the grooming trigger, fan out read-only `scout`
|
|
200
|
+
subagents over backlog clusters **in one batch** rather than auditing one issue
|
|
201
|
+
at a time. Scouts do the finding; you still do the deciding and you still write
|
|
202
|
+
the brief. Name the authoritative source in every scout brief and forbid
|
|
203
|
+
unnamed fallbacks. The quality bar above does not move.
|
|
204
|
+
- **Give every scout the same return contract**, or it comes back with prose
|
|
205
|
+
nobody can act on:
|
|
206
|
+
- verdict — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
|
|
207
|
+
`NEEDS PRODUCT DECISION`
|
|
208
|
+
- routing — exactly one repo, or `MULTI` with the split
|
|
209
|
+
- evidence — the file or symbol proving `ALREADY DONE`, never a title match
|
|
210
|
+
- entry points — the 3-6 files to change or read first
|
|
211
|
+
- existing tests covering the behaviour, by path
|
|
212
|
+
- the one thing most likely to be silently faked
|
|
213
|
+
- source — where the code was read: the clone/ref and how fresh it is. A
|
|
214
|
+
scout that cannot reach a source it trusts returns `BLOCKED` and says so;
|
|
215
|
+
silent fallback to an unnamed source is the failure mode of delegated
|
|
216
|
+
research — stale evidence reads exactly like good evidence.
|
|
217
|
+
- **Disqualifying an issue is a successful grooming outcome.** Measured on this
|
|
218
|
+
package's own fleet: four scouts over sixteen backlog issues promoted four and
|
|
219
|
+
*disqualified six* that looked promotable from their titles — four written
|
|
220
|
+
against a tenancy model a later epic retired, two blocked behind open
|
|
221
|
+
prerequisites named in their own bodies. Each would have burned a worker's whole
|
|
222
|
+
attempt to discover. On anything old, check first whether a later epic has
|
|
223
|
+
invalidated the issue's premise; that check is mechanical, read-only, and
|
|
224
|
+
exactly what a scout is cheap at and you are expensive at.
|
|
225
|
+
- Examine widely, promote narrowly. Sixteen examined and four promoted is the
|
|
226
|
+
shape to aim for — the cap is on what you **promote**, never on what you look at.
|
|
194
227
|
|
|
195
228
|
## Duty 3 — report
|
|
196
229
|
|
|
@@ -200,12 +233,23 @@ decides whether this tick ends in a message or in silence.
|
|
|
200
233
|
*How* a report is delivered is not yours, and is not negotiable: run
|
|
201
234
|
`omp-conductor report --text "<the whole report>"` (add `--kind digest` for the
|
|
202
235
|
daily digest). It persists the text before anything is sent and prints a report
|
|
203
|
-
id; the daemon retries until it lands and `omp-conductor status` lists whatever
|
|
236
|
+
report id; the daemon retries until it lands and `omp-conductor status` lists whatever
|
|
204
237
|
has not. Writing a report as end-of-turn text on a tick reaches nobody — that is
|
|
205
238
|
how a suite release and two tier-2 escalations went missing on 2026-08-06 — and
|
|
206
239
|
`telegram_send` reaches somebody but leaves no record that it did, so a report
|
|
207
240
|
sent that way is undetectable when it does not arrive.
|
|
208
241
|
|
|
242
|
+
A report is an update. It never contains a request: no "needs you" header, no
|
|
243
|
+
"let me know", no embedded options. Anything needing a decision, approval or
|
|
244
|
+
answer leaves as its own ask (`telegram_ask`) at the moment it is known — the
|
|
245
|
+
question in one sentence, your recommendation, and the options with their
|
|
246
|
+
consequences, the recommended one marked. Batch several questions into one
|
|
247
|
+
ask (the surface takes up to five); never one call per question, and never a
|
|
248
|
+
numbered menu typed into a plain message. Still open a `decision` row for
|
|
249
|
+
anything you ask: the ask is how it reaches a human, the row is what stops it
|
|
250
|
+
being forgotten. In both directions the delivery contract is explicit: a
|
|
251
|
+
message you did not explicitly send is a message that did not arrive.
|
|
252
|
+
|
|
209
253
|
## Human messages
|
|
210
254
|
|
|
211
255
|
A human writing to you between ticks is not a tick. Answer with a **single
|
package/src/briefs/policy.md
CHANGED
|
@@ -104,9 +104,12 @@ two mistakes. `omp-conductor status` lists anything still undelivered.
|
|
|
104
104
|
an answer to their message, or a question of your own. It is not a report: it
|
|
105
105
|
leaves no record that anything went out. And a `cancelled` or errored
|
|
106
106
|
`telegram_ask` is a delivery failure, not an answer: re-deliver the question with
|
|
107
|
-
`telegram_send`, or report the channel as broken. It is never "asked once, no
|
|
107
|
+
with `telegram_send`, or report the channel as broken. It is never "asked once, no
|
|
108
108
|
reply, dropped".
|
|
109
109
|
|
|
110
|
+
Reports never carry questions: anything needing an answer goes out as its own
|
|
111
|
+
ask, with a recommendation and options.
|
|
112
|
+
|
|
110
113
|
No scope licenses narration. No progress updates, no "checking the queue
|
|
111
114
|
now", no restating this brief back. Evidence, or silence.
|
|
112
115
|
|