omp-conductor 0.3.21 → 0.3.23
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 +67 -27
- package/package.json +1 -1
- package/src/board.ts +292 -55
- package/src/briefs/orchestrator.md +24 -2
- package/src/daemon.ts +101 -15
- package/src/fleet.ts +65 -1
- package/src/orchestrator-tick.ts +93 -5
- package/src/tracker/github.ts +19 -6
- package/src/types.ts +25 -3
- package/src/unblock.ts +55 -17
package/src/board.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
|
14
14
|
import { healthCheck, livingDaemon } from "./lifecycle.ts";
|
|
15
15
|
import { dbPath, openStore } from "./store.ts";
|
|
16
16
|
import { formatTranscriptLine } from "./transcript.ts";
|
|
17
|
+
import { makeTracker } from "./tracker/github.ts";
|
|
17
18
|
import type { AdmissionHoldReason, ProjectConfig, RunRecord, RunState, Store } from "./types.ts";
|
|
18
19
|
|
|
19
20
|
const REFRESH_MS = 1_000;
|
|
@@ -35,23 +36,52 @@ const YELLOW = `${CSI}33m`;
|
|
|
35
36
|
const RED = `${CSI}31m`;
|
|
36
37
|
const MAGENTA = `${CSI}35m`;
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The board's lanes, in operator reading order.
|
|
41
|
+
*
|
|
42
|
+
* Each entry used to carry the `RunState[]` that filled it, which is the defect
|
|
43
|
+
* #109 reports: a lane filled from the newest run row answers "what happened
|
|
44
|
+
* last" under a heading an operator reads as "what needs action". Membership is
|
|
45
|
+
* now decided once, by {@link laneOf}, from state that is true right now.
|
|
46
|
+
*/
|
|
38
47
|
const COLUMN_DEFS = [
|
|
39
|
-
{ key: "queue", title: "QUEUE"
|
|
40
|
-
{ key: "claimed", title: "CLAIMED"
|
|
41
|
-
{ key: "running", title: "RUNNING"
|
|
42
|
-
{ key: "green", title: "GREEN"
|
|
43
|
-
{ key: "blocked", title: "BLOCKED"
|
|
44
|
-
{ key: "failed", title: "FAILED"
|
|
45
|
-
{ key: "
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
{ key: "queue", title: "QUEUE" },
|
|
49
|
+
{ key: "claimed", title: "CLAIMED" },
|
|
50
|
+
{ key: "running", title: "RUNNING" },
|
|
51
|
+
{ key: "green", title: "GREEN" },
|
|
52
|
+
{ key: "blocked", title: "BLOCKED" },
|
|
53
|
+
{ key: "failed", title: "FAILED" },
|
|
54
|
+
{ key: "orphaned", title: "ORPHANED" },
|
|
55
|
+
{ key: "merged", title: "MERGED" },
|
|
56
|
+
{ key: "history", title: "HISTORY" },
|
|
57
|
+
] as const satisfies readonly { key: string; title: string }[];
|
|
58
|
+
|
|
59
|
+
export type BoardLane = (typeof COLUMN_DEFS)[number]["key"];
|
|
60
|
+
|
|
61
|
+
/** Run states that mean a worker process or an open pull request exists *now*,
|
|
62
|
+
* and the lane each is drawn in. Being in this table is why the store outranks
|
|
63
|
+
* every label in {@link laneOf}: no label is more current than a live run. */
|
|
64
|
+
const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
|
|
65
|
+
claimed: "claimed",
|
|
66
|
+
running: "running",
|
|
67
|
+
"pushed-pending": "green",
|
|
68
|
+
"pushed-green": "green",
|
|
69
|
+
};
|
|
70
|
+
|
|
49
71
|
type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
|
|
50
72
|
|
|
51
|
-
|
|
52
|
-
|
|
73
|
+
/**
|
|
74
|
+
* A card the board can only describe from tracker state, because the store has
|
|
75
|
+
* no run row for the issue: it was placed by a label it currently carries, or
|
|
76
|
+
* by an admission hold recorded in the latest tick.
|
|
77
|
+
*/
|
|
78
|
+
interface IssueCard {
|
|
79
|
+
kind: "issue";
|
|
53
80
|
issue: number;
|
|
54
|
-
|
|
81
|
+
/** Why it is in this lane: the hold keeping it out of dispatch, or the label. */
|
|
82
|
+
reason: string;
|
|
83
|
+
/** Where that fact came from, in one short line. */
|
|
84
|
+
note: string;
|
|
55
85
|
}
|
|
56
86
|
|
|
57
87
|
interface RunCard {
|
|
@@ -59,7 +89,7 @@ interface RunCard {
|
|
|
59
89
|
run: RunRecord;
|
|
60
90
|
}
|
|
61
91
|
|
|
62
|
-
type BoardCard =
|
|
92
|
+
type BoardCard = IssueCard | RunCard;
|
|
63
93
|
|
|
64
94
|
export interface BoardHealth {
|
|
65
95
|
layers: FleetLayers;
|
|
@@ -68,10 +98,44 @@ export interface BoardHealth {
|
|
|
68
98
|
codeGraph?: CodeGraphHealth;
|
|
69
99
|
}
|
|
70
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Which issues currently carry each label the board reasons about, read from
|
|
103
|
+
* the tracker rather than inferred from run rows.
|
|
104
|
+
*
|
|
105
|
+
* The store cannot answer this. It only knows what it did itself, so a label a
|
|
106
|
+
* human added or removed, and an issue closed out from under a run row, are
|
|
107
|
+
* both invisible to it — which is how eleven historical terminal runs were
|
|
108
|
+
* drawn as failed work on 2026-08-08 (#109).
|
|
109
|
+
*/
|
|
110
|
+
export interface BoardLabels {
|
|
111
|
+
/** Open issues carrying `ProjectConfig.queueLabel` — what dispatches next. */
|
|
112
|
+
queued: ReadonlySet<number>;
|
|
113
|
+
inProgress: ReadonlySet<number>;
|
|
114
|
+
blocked: ReadonlySet<number>;
|
|
115
|
+
failed: ReadonlySet<number>;
|
|
116
|
+
/** Epoch ms of the last read that succeeded; 0 when none has. */
|
|
117
|
+
readAt: number;
|
|
118
|
+
/** Message from the most recent failed read. The sets above are then the last
|
|
119
|
+
* good ones: blanking QUEUE and FAILED is a louder lie than showing them
|
|
120
|
+
* stale, so the failure is reported in the header instead. */
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The sets before the first read lands, and the fallback when a read has never
|
|
125
|
+
* succeeded. `readAt: 0` is what {@link labelFact} reads as "unread". */
|
|
126
|
+
const UNREAD_LABELS: BoardLabels = {
|
|
127
|
+
queued: new Set<number>(),
|
|
128
|
+
inProgress: new Set<number>(),
|
|
129
|
+
blocked: new Set<number>(),
|
|
130
|
+
failed: new Set<number>(),
|
|
131
|
+
readAt: 0,
|
|
132
|
+
};
|
|
133
|
+
|
|
71
134
|
export interface BoardSnapshot {
|
|
72
135
|
project: ProjectConfig;
|
|
73
136
|
status: StatusSnapshot;
|
|
74
137
|
health: BoardHealth;
|
|
138
|
+
labels: BoardLabels;
|
|
75
139
|
runs: RunRecord[];
|
|
76
140
|
now: number;
|
|
77
141
|
}
|
|
@@ -91,7 +155,7 @@ interface KeyInput {
|
|
|
91
155
|
ctrl?: boolean;
|
|
92
156
|
}
|
|
93
157
|
|
|
94
|
-
function ansiColor(key:
|
|
158
|
+
function ansiColor(key: BoardLane): string {
|
|
95
159
|
switch (key) {
|
|
96
160
|
case "running":
|
|
97
161
|
return CYAN;
|
|
@@ -100,9 +164,12 @@ function ansiColor(key: ColumnKey): string {
|
|
|
100
164
|
return GREEN;
|
|
101
165
|
case "claimed":
|
|
102
166
|
case "blocked":
|
|
167
|
+
case "orphaned":
|
|
103
168
|
return YELLOW;
|
|
104
169
|
case "failed":
|
|
105
170
|
return RED;
|
|
171
|
+
case "history":
|
|
172
|
+
return DIM;
|
|
106
173
|
default:
|
|
107
174
|
return MAGENTA;
|
|
108
175
|
}
|
|
@@ -147,36 +214,145 @@ function humanDuration(ms: number): string {
|
|
|
147
214
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
148
215
|
}
|
|
149
216
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The board's single lane rule.
|
|
219
|
+
*
|
|
220
|
+
* `omp-conductor board` answers one operator question — "what needs action, and
|
|
221
|
+
* what will dispatch next?" — so every lane has to be a statement about state
|
|
222
|
+
* *now*. What an issue's newest run row last did is a different question, and
|
|
223
|
+
* answering it under an operational heading is the 2026-08-08 dogfood failure
|
|
224
|
+
* in #109: the board read `FAILED 11` on conductor 0.3.19 while the tracker
|
|
225
|
+
* carried four `agent:failed` issues (#82, #140, #297, #307). The other seven
|
|
226
|
+
* were two requeued issues also drawn in QUEUE (#43, #321), a deliberately
|
|
227
|
+
* protected dirty orphan (#319), two closed issues (#135, #310) and two open
|
|
228
|
+
* parent issues a human had already cleared (#81, #86) — every one of them a
|
|
229
|
+
* historical terminal run, not one of them failed work.
|
|
230
|
+
*
|
|
231
|
+
* So: the store decides the live lanes, because a running worker is the most
|
|
232
|
+
* current fact there is; the tracker's current labels decide every stopped
|
|
233
|
+
* lane; each issue matches exactly once, first branch wins; and last-run
|
|
234
|
+
* history is answered in HISTORY, under its own name.
|
|
235
|
+
*/
|
|
236
|
+
function laneOf(
|
|
237
|
+
snapshot: BoardSnapshot,
|
|
238
|
+
issue: number,
|
|
239
|
+
run: RunRecord | undefined,
|
|
240
|
+
queued: ReadonlySet<number>,
|
|
241
|
+
): BoardLane | undefined {
|
|
242
|
+
const live = run === undefined ? undefined : LIVE_LANES[run.state];
|
|
243
|
+
if (live !== undefined) return live;
|
|
244
|
+
|
|
245
|
+
const { labels } = snapshot;
|
|
246
|
+
// State labels outrank the queue label, because that is the order
|
|
247
|
+
// `routing.isEligible` reads them in: it requires the queue label AND the
|
|
248
|
+
// absence of all three state labels, so an issue carrying both will never be
|
|
249
|
+
// dispatched no matter how queued it looks. Drawing it under QUEUE — "waiting
|
|
250
|
+
// for dispatch" — is the same class of lie as #109 itself, just inverted, and
|
|
251
|
+
// it is not hypothetical: veltro#319 on 2026-08-09 carried `ready-for-agent`
|
|
252
|
+
// and `agent:in-progress` together, so a queue-first rule puts the protected
|
|
253
|
+
// dirty orphan back in the queue lane this issue moved it out of.
|
|
254
|
+
if (labels.failed.has(issue)) return "failed";
|
|
255
|
+
if (labels.blocked.has(issue)) return "blocked";
|
|
256
|
+
// The tracker says a worker owns this issue and the store says none is
|
|
257
|
+
// running. Nothing will touch it until a human does, and its worktree is kept
|
|
258
|
+
// because it may hold uncommitted work: that is a protected orphan, and
|
|
259
|
+
// calling it a failure is what put #319 under FAILED.
|
|
260
|
+
if (labels.inProgress.has(issue)) return "orphaned";
|
|
261
|
+
// What is left carrying the queue label really is next up, held or not.
|
|
262
|
+
// Requeued work is queued work: #43 and #321 carried `ready-for-agent` and a
|
|
263
|
+
// terminal newest run at the same time, and with no state label on them the
|
|
264
|
+
// label is the whole of what happens next.
|
|
265
|
+
if (queued.has(issue)) return "queue";
|
|
266
|
+
if (run?.state === "merged") return "merged";
|
|
267
|
+
if (run === undefined) return undefined;
|
|
268
|
+
// What is left is a terminal run row with no current claim on it — a closed
|
|
269
|
+
// issue, or one a human already cleared. Recent rows stay readable under
|
|
270
|
+
// HISTORY for the same 24 hours MERGED uses; older ones are not news.
|
|
271
|
+
return (run.endedAt ?? run.startedAt) >= snapshot.now - MERGED_HISTORY_MS ? "history" : undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Description lines for an issue the store has no run row for. Only the lanes
|
|
275
|
+
* a label or a hold can place on their own reach this: queue, failed, blocked
|
|
276
|
+
* and orphaned. */
|
|
277
|
+
function issueCard(
|
|
278
|
+
snapshot: BoardSnapshot,
|
|
279
|
+
issue: number,
|
|
280
|
+
lane: BoardLane,
|
|
281
|
+
holds: ReadonlyMap<number, AdmissionHoldReason>,
|
|
282
|
+
): IssueCard {
|
|
283
|
+
const hold = holds.get(issue);
|
|
284
|
+
if (lane === "queue") {
|
|
285
|
+
return hold === undefined
|
|
286
|
+
? { kind: "issue", issue, reason: "queued", note: "waiting for dispatch" }
|
|
287
|
+
: { kind: "issue", issue, reason: hold.replaceAll("-", " "), note: "held in latest tick" };
|
|
288
|
+
}
|
|
289
|
+
const { blocked, failed, inProgress } = snapshot.project.stateLabels;
|
|
290
|
+
const label = lane === "failed" ? failed : lane === "blocked" ? blocked : inProgress;
|
|
291
|
+
return { kind: "issue", issue, reason: label, note: "no run recorded" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The whole board, partitioned. Rebuilt per call rather than cached: it is
|
|
295
|
+
* tens of issues wide and redraws once a second, so a cache would buy nothing
|
|
296
|
+
* and would go stale the moment a caller mutated a snapshot in place. */
|
|
297
|
+
function laneCards(snapshot: BoardSnapshot): Map<BoardLane, BoardCard[]> {
|
|
298
|
+
const lanes = new Map<BoardLane, BoardCard[]>();
|
|
299
|
+
for (const column of COLUMN_DEFS) lanes.set(column.key, []);
|
|
300
|
+
|
|
301
|
+
// `recentRuns` already yields the newest attempt per issue; indexing it keeps
|
|
302
|
+
// one card per issue even if a caller hands over a wider run list.
|
|
303
|
+
const newest = new Map<number, RunRecord>();
|
|
304
|
+
for (const run of snapshot.runs) if (!newest.has(run.issue)) newest.set(run.issue, run);
|
|
305
|
+
|
|
306
|
+
// Holds name issues the latest dispatch pass looked at and kept back, so they
|
|
307
|
+
// are queue members by definition. They join the label set rather than
|
|
308
|
+
// replacing it: the label read is authoritative and uncapped, while the hold
|
|
309
|
+
// sample is bounded to five per reason but survives a tracker read that just
|
|
310
|
+
// failed.
|
|
311
|
+
const queued = new Set<number>(snapshot.labels.queued);
|
|
312
|
+
const holds = new Map<number, AdmissionHoldReason>();
|
|
153
313
|
for (const hold of snapshot.status.dispatch?.holds ?? []) {
|
|
154
314
|
for (const issue of hold.issues) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
cards.push({ kind: "queue", issue, reason: hold.reason });
|
|
315
|
+
queued.add(issue);
|
|
316
|
+
if (!holds.has(issue)) holds.set(issue, hold.reason);
|
|
158
317
|
}
|
|
159
318
|
}
|
|
160
|
-
|
|
319
|
+
|
|
320
|
+
const place = (issue: number, run: RunRecord | undefined): void => {
|
|
321
|
+
const lane = laneOf(snapshot, issue, run, queued);
|
|
322
|
+
if (lane === undefined) return;
|
|
323
|
+
lanes.get(lane)!.push(run === undefined ? issueCard(snapshot, issue, lane, holds) : { kind: "run", run });
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
for (const [issue, run] of newest) place(issue, run);
|
|
327
|
+
// Issues the tracker knows about that this store never ran, or whose run rows
|
|
328
|
+
// have aged out. FAILED has to count them: the operator is asking how many
|
|
329
|
+
// issues are failed, not how many failures this database still remembers.
|
|
330
|
+
const labelled = [...queued, ...snapshot.labels.failed, ...snapshot.labels.blocked, ...snapshot.labels.inProgress];
|
|
331
|
+
for (const issue of [...new Set(labelled)].sort((a, b) => a - b)) {
|
|
332
|
+
if (!newest.has(issue)) place(issue, undefined);
|
|
333
|
+
}
|
|
334
|
+
return lanes;
|
|
161
335
|
}
|
|
162
336
|
|
|
163
|
-
function cardsFor(snapshot: BoardSnapshot, key:
|
|
164
|
-
|
|
165
|
-
const states: readonly RunState[] = COLUMN_DEFS.find((column) => column.key === key)?.states ?? [];
|
|
166
|
-
return snapshot.runs.filter((run) => states.includes(run.state)).map((run) => ({ kind: "run", run }));
|
|
337
|
+
function cardsFor(snapshot: BoardSnapshot, key: BoardLane): BoardCard[] {
|
|
338
|
+
return laneCards(snapshot).get(key) ?? [];
|
|
167
339
|
}
|
|
168
340
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
341
|
+
/** Issue numbers per lane, in render order. Exported so the lane rule can be
|
|
342
|
+
* asserted directly — including that no issue is in two lanes, the defect this
|
|
343
|
+
* model exists to make impossible. */
|
|
344
|
+
export function boardLanes(snapshot: BoardSnapshot): Map<BoardLane, number[]> {
|
|
345
|
+
const lanes = new Map<BoardLane, number[]>();
|
|
346
|
+
for (const [lane, cards] of laneCards(snapshot)) lanes.set(lane, cards.map(cardIssue));
|
|
347
|
+
return lanes;
|
|
172
348
|
}
|
|
173
349
|
|
|
174
350
|
function cardIssue(card: BoardCard): number {
|
|
175
|
-
return card.kind === "
|
|
351
|
+
return card.kind === "issue" ? card.issue : card.run.issue;
|
|
176
352
|
}
|
|
177
353
|
|
|
178
354
|
function cardKey(card: BoardCard): string {
|
|
179
|
-
return card.kind === "
|
|
355
|
+
return card.kind === "issue" ? `issue:${card.issue}` : `run:${card.run.id}`;
|
|
180
356
|
}
|
|
181
357
|
|
|
182
358
|
function focusedCard(snapshot: BoardSnapshot, cursor: BoardCursor): BoardCard | undefined {
|
|
@@ -249,13 +425,9 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
|
249
425
|
return lines;
|
|
250
426
|
}
|
|
251
427
|
|
|
252
|
-
function queueCardLines(card: QueueCard): string[] {
|
|
253
|
-
return [`#${card.issue}`, card.reason.replaceAll("-", " "), "held in latest tick"];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
428
|
function renderColumn(
|
|
257
429
|
snapshot: BoardSnapshot,
|
|
258
|
-
key:
|
|
430
|
+
key: BoardLane,
|
|
259
431
|
title: string,
|
|
260
432
|
selectedColumn: boolean,
|
|
261
433
|
selectedCard: number,
|
|
@@ -263,7 +435,6 @@ function renderColumn(
|
|
|
263
435
|
height: number,
|
|
264
436
|
): string[] {
|
|
265
437
|
const cards = cardsFor(snapshot, key);
|
|
266
|
-
const count = columnCount(snapshot, key);
|
|
267
438
|
const color = ansiColor(key);
|
|
268
439
|
const slots = Math.max(1, Math.floor(Math.max(0, height - 2) / 5));
|
|
269
440
|
const start = selectedColumn
|
|
@@ -271,16 +442,15 @@ function renderColumn(
|
|
|
271
442
|
: 0;
|
|
272
443
|
const shown = cards.slice(start, start + slots);
|
|
273
444
|
const lines = [
|
|
274
|
-
styledCell(` ${title} ${
|
|
445
|
+
styledCell(` ${title} ${cards.length}`, width, `${BOLD}${color}`),
|
|
275
446
|
styledCell(start === 0 ? "─".repeat(width) : ` ↑ ${start} earlier`, width, DIM),
|
|
276
447
|
];
|
|
277
448
|
|
|
278
449
|
if (cards.length === 0) {
|
|
279
|
-
|
|
280
|
-
lines.push(styledCell(` ${empty}`, width, DIM));
|
|
450
|
+
lines.push(styledCell(" (empty)", width, DIM));
|
|
281
451
|
} else {
|
|
282
452
|
for (const [offset, card] of shown.entries()) {
|
|
283
|
-
const raw = card.kind === "run" ? runCardLines(card.run, snapshot) :
|
|
453
|
+
const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : [`#${card.issue}`, card.reason, card.note];
|
|
284
454
|
while (raw.length < 4) raw.push("");
|
|
285
455
|
const selected = selectedColumn && start + offset === selectedCard;
|
|
286
456
|
for (const [lineIndex, value] of raw.entries()) {
|
|
@@ -315,13 +485,24 @@ function healthLine(snapshot: BoardSnapshot): string {
|
|
|
315
485
|
].join(" · ");
|
|
316
486
|
}
|
|
317
487
|
|
|
488
|
+
/** How current the label sets behind the lanes are. A lane rule is only as
|
|
489
|
+
* authoritative as its last successful read, so a stale one says so instead of
|
|
490
|
+
* letting an empty QUEUE read as an empty queue. */
|
|
491
|
+
function labelFact(snapshot: BoardSnapshot): string {
|
|
492
|
+
const { readAt, error } = snapshot.labels;
|
|
493
|
+
if (error === undefined) return "tracker ok";
|
|
494
|
+
const detail = error.replace(/\s+/g, " ").slice(0, 80);
|
|
495
|
+
if (readAt === 0) return `tracker unread — ${detail}`;
|
|
496
|
+
return `tracker stale ${humanDuration(snapshot.now - readAt)} — ${detail}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
318
499
|
function integrationLine(snapshot: BoardSnapshot): string {
|
|
319
500
|
const { telegram, codeGraph } = snapshot.health;
|
|
320
501
|
const graph = codeGraph?.configured
|
|
321
502
|
? `${codeGraph.status} ${codeGraph.repos.filter((repo) => repo.index === "present").length}/${codeGraph.repos.length} indexed`
|
|
322
503
|
: "off";
|
|
323
504
|
const telegramDetail = telegram.detail?.replace(/\s+/g, " ").slice(0, 80);
|
|
324
|
-
return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`}`;
|
|
505
|
+
return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`} · ${labelFact(snapshot)}`;
|
|
325
506
|
}
|
|
326
507
|
|
|
327
508
|
function admissionLine(snapshot: BoardSnapshot): string {
|
|
@@ -408,12 +589,12 @@ function wrap(value: string, width: number): string[] {
|
|
|
408
589
|
function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] {
|
|
409
590
|
const card = focusedCard(snapshot, cursor);
|
|
410
591
|
if (card === undefined) return [styledCell("No card selected.", width, DIM)];
|
|
411
|
-
if (card.kind === "
|
|
592
|
+
if (card.kind === "issue") {
|
|
412
593
|
return [
|
|
413
|
-
styledCell(`
|
|
414
|
-
styledCell(
|
|
594
|
+
styledCell(` ISSUE #${card.issue} `, width, `${BOLD}${REVERSE}`),
|
|
595
|
+
styledCell(`${card.reason} — ${card.note}`, width),
|
|
415
596
|
"",
|
|
416
|
-
styledCell("
|
|
597
|
+
styledCell("The store has no run row for this issue, so there is no transcript to inspect.", width, DIM),
|
|
417
598
|
];
|
|
418
599
|
}
|
|
419
600
|
|
|
@@ -441,7 +622,7 @@ function renderHelp(width: number, height: number): string[] {
|
|
|
441
622
|
"←/→ or h/l select column",
|
|
442
623
|
"↑/↓ or k/j select card",
|
|
443
624
|
"Enter inspect/follow transcript",
|
|
444
|
-
"u unblock selected failed or
|
|
625
|
+
"u unblock selected blocked, failed or orphaned issue",
|
|
445
626
|
"i open selected issue",
|
|
446
627
|
"p open selected pull request",
|
|
447
628
|
"r refresh health now",
|
|
@@ -520,6 +701,45 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
520
701
|
return { layers, telegram, daemon, codeGraph: cachedGraph ?? (await probeCodeGraph(project)) };
|
|
521
702
|
}
|
|
522
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The label sets behind every stopped lane.
|
|
706
|
+
*
|
|
707
|
+
* Reading them is the whole of the #109 fix: the store only knows what it did
|
|
708
|
+
* itself, so a label a human added or removed — and an issue closed out from
|
|
709
|
+
* under a run row — are invisible to it, and a board built from run rows alone
|
|
710
|
+
* reports last-run history as current work.
|
|
711
|
+
*
|
|
712
|
+
* "Open issues carrying label L" is a read the tracker port spells exactly
|
|
713
|
+
* once, as `listReady`, taking the label from `queueLabel`. Substituting the
|
|
714
|
+
* label reuses that one read for the state labels rather than adding a twelfth
|
|
715
|
+
* method every future adapter would have to implement for a read-only surface.
|
|
716
|
+
* Four `gh` calls in parallel, on the ten-second health cadence and never on
|
|
717
|
+
* the one-second redraw path: the same order of cost as the health probe it
|
|
718
|
+
* runs beside.
|
|
719
|
+
*
|
|
720
|
+
* Never throws. A failed read keeps the previous sets on screen and records
|
|
721
|
+
* why, because blanking QUEUE and FAILED is a louder lie than showing them
|
|
722
|
+
* stale — {@link labelFact} puts the staleness in the header.
|
|
723
|
+
*/
|
|
724
|
+
async function probeBoardLabels(project: ProjectConfig, previous?: BoardLabels): Promise<BoardLabels> {
|
|
725
|
+
const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
|
|
726
|
+
new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
|
|
727
|
+
try {
|
|
728
|
+
const [queued, inProgress, blocked, failed] = await Promise.all([
|
|
729
|
+
openIssuesLabelled(project.queueLabel),
|
|
730
|
+
openIssuesLabelled(project.stateLabels.inProgress),
|
|
731
|
+
openIssuesLabelled(project.stateLabels.blocked),
|
|
732
|
+
openIssuesLabelled(project.stateLabels.failed),
|
|
733
|
+
]);
|
|
734
|
+
return { queued, inProgress, blocked, failed, readAt: Date.now() };
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return {
|
|
737
|
+
...(previous ?? UNREAD_LABELS),
|
|
738
|
+
error: err instanceof Error ? err.message : String(err),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
523
743
|
function issueUrl(project: ProjectConfig, issue: number): string {
|
|
524
744
|
return `https://github.com/${project.tracker.repo}/issues/${issue}`;
|
|
525
745
|
}
|
|
@@ -587,7 +807,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
587
807
|
let stopping = false;
|
|
588
808
|
let help = false;
|
|
589
809
|
let notice = "";
|
|
590
|
-
let health = await probeBoardHealth(project);
|
|
810
|
+
let [health, labels] = await Promise.all([probeBoardHealth(project), probeBoardLabels(project)]);
|
|
591
811
|
let healthAt = Date.now();
|
|
592
812
|
let healthRefresh: Promise<void> | undefined;
|
|
593
813
|
|
|
@@ -611,9 +831,10 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
611
831
|
const now = Date.now();
|
|
612
832
|
if (now - healthAt >= HEALTH_REFRESH_MS && healthRefresh === undefined) {
|
|
613
833
|
healthAt = now;
|
|
614
|
-
healthRefresh = probeBoardHealth(project)
|
|
615
|
-
.then((
|
|
616
|
-
health =
|
|
834
|
+
healthRefresh = Promise.all([probeBoardHealth(project), probeBoardLabels(project, labels)])
|
|
835
|
+
.then(([nextHealth, nextLabels]) => {
|
|
836
|
+
health = nextHealth;
|
|
837
|
+
labels = nextLabels;
|
|
617
838
|
enqueue(queue, { name: "refresh" }, wake);
|
|
618
839
|
})
|
|
619
840
|
.catch((err: unknown) => {
|
|
@@ -627,6 +848,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
627
848
|
project,
|
|
628
849
|
status: statusSnapshotFromStore(project, caps, store),
|
|
629
850
|
health,
|
|
851
|
+
labels,
|
|
630
852
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
631
853
|
now,
|
|
632
854
|
};
|
|
@@ -712,11 +934,26 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
712
934
|
continue;
|
|
713
935
|
}
|
|
714
936
|
if (name === "u") {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
937
|
+
// Lane, not run state. `unblock` clears the blocked and failed labels
|
|
938
|
+
// unconditionally and the in-progress label once the newest run row is
|
|
939
|
+
// terminal (#18), so it is meaningful for exactly the three lanes those
|
|
940
|
+
// labels define — including an issue with no run row at all, which the
|
|
941
|
+
// old newest-run check could not reach.
|
|
942
|
+
//
|
|
943
|
+
// On an ORPHANED card backed by an `orphaned` row this is the release
|
|
944
|
+
// path: that row is terminal, so the in-progress label finally comes
|
|
945
|
+
// off. On an ORPHANED card with no run row it is not, and `unblock`
|
|
946
|
+
// says so itself rather than being talked out of it here — a missing
|
|
947
|
+
// row cannot be told apart from a claim that raced the store write, so
|
|
948
|
+
// it leaves the label in place by design and prints that line. Being
|
|
949
|
+
// wrong the other way is two workers on one issue; the exit for a card
|
|
950
|
+
// stuck like that is escalation, not a wider gate here. Both outcomes
|
|
951
|
+
// are worth offering; neither is promised.
|
|
952
|
+
const lane = COLUMN_DEFS[cursor.column]?.key;
|
|
953
|
+
if (card === undefined || (lane !== "blocked" && lane !== "failed" && lane !== "orphaned")) {
|
|
954
|
+
notice = "unblock is available for blocked, failed or orphaned issues";
|
|
718
955
|
} else {
|
|
719
|
-
notice = await unblock(project, card
|
|
956
|
+
notice = await unblock(project, cardIssue(card));
|
|
720
957
|
healthAt = 0;
|
|
721
958
|
}
|
|
722
959
|
}
|
|
@@ -87,7 +87,8 @@ checked in this order:
|
|
|
87
87
|
`merged`. Never release the label: the open-PR guard only objects to an *open*
|
|
88
88
|
PR, so a re-claim would re-implement a PR that is already on the base branch.
|
|
89
89
|
Close the issue if the merge satisfied it (a merge with no closing keyword
|
|
90
|
-
leaves it open)
|
|
90
|
+
leaves it open) and take the queue label off. The in-progress label is not
|
|
91
|
+
yours to strip: the conductor releases it when the run row settles.
|
|
91
92
|
- **A PR closed without merging.** A human read the work and said no; the row says
|
|
92
93
|
`failed`. Read the rejection before you touch anything — most of the time a
|
|
93
94
|
review comment is a spec change. Fold what it says into the issue, then release
|
|
@@ -114,6 +115,16 @@ checked in this order:
|
|
|
114
115
|
- **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
|
|
115
116
|
the next tick re-claim it clean.
|
|
116
117
|
|
|
118
|
+
Releasing a label, in any of these cases, is `omp-conductor unblock <n>` — never
|
|
119
|
+
a hand edit, for the reason above: the verb writes through the same tracker the
|
|
120
|
+
dispatcher does. It takes the blocked and failed labels off unconditionally, and
|
|
121
|
+
`agent:in-progress` too once the newest run row is terminal — which includes the
|
|
122
|
+
`orphaned` row a daemon restart writes for a worker that died with it, so that is
|
|
123
|
+
the usual answer here. Two situations leave in-progress in place, and it says
|
|
124
|
+
which in its output: a run that is genuinely still live, where declining is the
|
|
125
|
+
correct answer, and no run row at all to prove the worker is gone. In that second
|
|
126
|
+
case, escalate the stuck issue rather than editing its labels.
|
|
127
|
+
|
|
117
128
|
Never leave an orphan holding a slot "to be safe": a label nobody is working under
|
|
118
129
|
is not safety, it is a deadlocked fleet that looks busy.
|
|
119
130
|
|
|
@@ -151,6 +162,13 @@ actually asked, in one message, from evidence you already hold or go and fetch.
|
|
|
151
162
|
Then stop. Do not continue loop narration in the same reply, and do not restate
|
|
152
163
|
in-progress work unless they asked for it. The loop resumes on the next tick.
|
|
153
164
|
|
|
165
|
+
A message may also reach you **mid-tick** (delivery is steering: it arrives
|
|
166
|
+
between two of your tool calls). Treat it as an interrupt, not a new tick:
|
|
167
|
+
answer it immediately with `telegram_send` in one message, then return to the
|
|
168
|
+
duty you were in the middle of and finish it. Never abandon or restart the tick
|
|
169
|
+
because a message arrived, and never batch the answer "for the report" — the
|
|
170
|
+
person is waiting now.
|
|
171
|
+
|
|
154
172
|
## Escalation tiers
|
|
155
173
|
|
|
156
174
|
| Tier | Meaning | Handled by |
|
|
@@ -234,7 +252,11 @@ The protocol, in order:
|
|
|
234
252
|
text is what you *apply* on a yes — it is not what you send.
|
|
235
253
|
2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
|
|
236
254
|
call `telegram_ask`; never use the generic `ask` UI. Confirm that the tool
|
|
237
|
-
delivered the question to the configured Telegram chat.
|
|
255
|
+
delivered the question to the configured Telegram chat. It is mounted on a
|
|
256
|
+
locally injected tick only once your operator has configured the bridge's
|
|
257
|
+
notify destination, and a tick that lacks it says so in its own text. When it
|
|
258
|
+
does, send the same single yes/no question with `telegram_send` and treat
|
|
259
|
+
your operator's later reply as the answer — never assume one. Telegram renders
|
|
238
260
|
none of your markdown, so asterisks and backticks arrive as literal characters:
|
|
239
261
|
- Lead with one plain sentence: what changes, and why, in your own words.
|
|
240
262
|
- Then show only the lines that actually change, compact, under two short
|