omp-conductor 0.19.2 → 0.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/REFERENCE.md +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +13 -1
- package/src/arm-challenge.ts +92 -16
- package/src/board.ts +50 -9
- package/src/cli.ts +2 -0
- package/src/command-manifest.ts +10 -0
- package/src/commands/arm.ts +30 -2
- package/src/commands/companion.ts +103 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/worker.ts +1 -0
- package/src/companion-view.ts +81 -0
- package/src/config-schema.ts +7 -1
- package/src/config.ts +6 -3
- package/src/daemon.ts +288 -15
- package/src/dashboard/controls.ts +1 -1
- package/src/doctor.ts +126 -7
- package/src/escalate.ts +47 -1
- package/src/failure-class.fixture.json +546 -0
- package/src/failure-class.ts +192 -0
- package/src/fleet.ts +286 -32
- package/src/orchestrator-tick.ts +115 -34
- package/src/settlement.ts +181 -0
- package/src/setup.ts +6 -4
- package/src/spend-telemetry.ts +74 -2
- package/src/status-render.ts +25 -6
- package/src/store.ts +39 -3
- package/src/types.ts +39 -2
- package/src/usage.ts +25 -0
package/README.md
CHANGED
|
@@ -386,9 +386,10 @@ message on stderr) on every uncertainty:
|
|
|
386
386
|
- the tick config does not parse — the agent name would be a guess
|
|
387
387
|
- `herdr agent list` is unreachable, prints nothing, or prints output with no
|
|
388
388
|
explicit `agents` array; only a real `agents: []` means "no agents"
|
|
389
|
-
- an agent row is unreadable — a missing `
|
|
390
|
-
present with a non-string value. An
|
|
391
|
-
|
|
389
|
+
- an agent row is unreadable — a missing `pane_id`, a non-string non-null
|
|
390
|
+
`name`, or an `agent` field present with a non-string value. An absent or
|
|
391
|
+
`null` `name` is an unnamed pane; an absent or `null` `agent` is the sticky
|
|
392
|
+
claim herdr reports after the agent exits, and both stay normal answers
|
|
392
393
|
- the configured agent name is not unique, or the claimed pane runs some other agent
|
|
393
394
|
- `pane process-info` fails, or the claim is live `omp` but names no recognizable
|
|
394
395
|
omp foreground PID — "cannot see it" is never reported as "it is stopped"
|
package/REFERENCE.md
CHANGED
|
@@ -2225,7 +2225,7 @@ Field notes:
|
|
|
2225
2225
|
| `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
|
|
2226
2226
|
| `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
|
|
2227
2227
|
| `release.versionFile` | Optional, per repo: a repo-relative JSON file with a top-level string `version`, such as `omp/package.json`. Declares that tags must match the version already landed on the live default branch. A delegated `git-tag` for such a repo requires delegated `version-bump-pr` too; otherwise config loading fails with the missing preparation path instead of granting an impossible release. Absolute paths and `..` are refused. |
|
|
2228
|
-
| `groomBelow` | Optional; default `4`. Routable candidates below this count make the orchestrator's tick prompt say the queue is running low and to groom it (Duty 2). An integer ≥ 1
|
|
2228
|
+
| `groomBelow` | Optional; default `4`. Routable candidates below this count make the orchestrator's tick prompt say the queue is running low and to groom it (Duty 2). An integer ≥ 1, or the literal `"always"` (#988) — groom on demand every tick while any ungroomed, unrefused, unparked candidate exists, whatever the queue volume. Anything else fails the config load. |
|
|
2229
2229
|
| `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
|
|
2230
2230
|
| `routing.labelPrefix` | Optional; defaults to `repo:`. |
|
|
2231
2231
|
| `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
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.",
|
|
@@ -171,7 +171,19 @@
|
|
|
171
171
|
"type": "string",
|
|
172
172
|
"minLength": 1
|
|
173
173
|
},
|
|
174
|
-
"groomBelow": {
|
|
174
|
+
"groomBelow": {
|
|
175
|
+
"anyOf": [
|
|
176
|
+
{
|
|
177
|
+
"type": "integer",
|
|
178
|
+
"minimum": 1,
|
|
179
|
+
"maximum": 9007199254740991
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"type": "string",
|
|
183
|
+
"const": "always"
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
},
|
|
175
187
|
"stateLabels": {
|
|
176
188
|
"type": "object",
|
|
177
189
|
"properties": {
|
package/src/arm-challenge.ts
CHANGED
|
@@ -156,6 +156,30 @@ function challengeHash(code: string): string {
|
|
|
156
156
|
return createHash("sha256").update(code).digest("hex");
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The state key of a whole-fleet ceremony (#991): one challenge that arms every
|
|
161
|
+
* configured project on a single matching reply.
|
|
162
|
+
*
|
|
163
|
+
* A NUL byte, so it can never collide with a project name — `encodeProjectKey`
|
|
164
|
+
* escapes it to hex, and no configured project can produce the same file. The
|
|
165
|
+
* empty string was unavailable: that is already the key of a legacy unstamped
|
|
166
|
+
* config, and a fleet record must not shadow one.
|
|
167
|
+
*/
|
|
168
|
+
export const FLEET_ARM_KEY = "\u0000fleet";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether a token looks like a challenge code at all.
|
|
172
|
+
*
|
|
173
|
+
* This is what makes a reply answerable without knowing any code: the codes are
|
|
174
|
+
* `FLEET-` plus hex (see `makeChallengeCode`), so a token of that shape is an
|
|
175
|
+
* arming attempt whether or not it matches, while ordinary chat is left alone.
|
|
176
|
+
* Only the shape is inspected — the token itself is never hashed against
|
|
177
|
+
* anything but a pending record, never logged, and never echoed.
|
|
178
|
+
*/
|
|
179
|
+
export function looksLikeChallengeCode(token: string): boolean {
|
|
180
|
+
return /^FLEET-[0-9A-Fa-f]{4,32}$/.test(token);
|
|
181
|
+
}
|
|
182
|
+
|
|
159
183
|
/**
|
|
160
184
|
* Acknowledgement files outliving their transaction — a crash between the
|
|
161
185
|
* adapter's write and the host's settle — are inert by construction: no
|
|
@@ -225,26 +249,78 @@ export function recordArmChallenge(
|
|
|
225
249
|
* host settle all fail closed without touching anyone else's handshake, and
|
|
226
250
|
* two projects acknowledging concurrently cannot clobber each other.
|
|
227
251
|
*/
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
252
|
+
/**
|
|
253
|
+
* What an inbound reply was, so the adapter can answer it (#991).
|
|
254
|
+
*
|
|
255
|
+
* Before this, a non-matching or expired code was simply ignored: the waiter
|
|
256
|
+
* kept waiting and the operator had no idea they had been heard. The fastest
|
|
257
|
+
* way to be sure you held the current code was to scroll the chat, which is
|
|
258
|
+
* exactly the work conductor-owned verification was supposed to end.
|
|
259
|
+
*
|
|
260
|
+
* - `matched` — an active proof; the acknowledgement is written.
|
|
261
|
+
* - `expired` — a code that hashes to a pending record whose window has closed.
|
|
262
|
+
* - `unknown` — a challenge-shaped token that matches nothing readable here.
|
|
263
|
+
* - `none` — no challenge-shaped token at all: ordinary chat, answer nothing.
|
|
264
|
+
*/
|
|
265
|
+
export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Classify one inbound reply against this session's own records, writing the
|
|
269
|
+
* acknowledgement on a match.
|
|
270
|
+
*
|
|
271
|
+
* Two records are consulted and no others: this project's, and the fleet-wide
|
|
272
|
+
* one (#991). That bound is the non-disclosure property — a reply that matches
|
|
273
|
+
* nothing here can never reveal that a *different* project has a live
|
|
274
|
+
* challenge, because no other project's record is ever read.
|
|
275
|
+
*
|
|
276
|
+
* Fail-closed ordering is unchanged: the project's own active challenge wins,
|
|
277
|
+
* then the fleet ceremony, and an expired record is never a proof.
|
|
278
|
+
*/
|
|
279
|
+
export function classifyArmReply(
|
|
280
|
+
project: string | undefined,
|
|
281
|
+
replyText: string,
|
|
282
|
+
now: number,
|
|
283
|
+
): ArmReplyVerdict {
|
|
232
284
|
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
233
285
|
// splits one; empty replies simply yield no token.
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
286
|
+
const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0);
|
|
287
|
+
if (tokens.length === 0) return "none";
|
|
288
|
+
const candidates = [readPendingFor(projectKey(project)), readPendingFor(FLEET_ARM_KEY)].filter(
|
|
289
|
+
(pending): pending is PendingChallenge => pending !== undefined,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
let expired = false;
|
|
293
|
+
for (const token of tokens) {
|
|
294
|
+
const hash = challengeHash(token);
|
|
295
|
+
for (const pending of candidates) {
|
|
296
|
+
if (pending.hash !== hash) continue;
|
|
297
|
+
if (now >= pending.expiresAt) {
|
|
298
|
+
// Keep looking: a fresh record for the same code is a proof, and only
|
|
299
|
+
// an exhausted search may conclude "expired".
|
|
300
|
+
expired = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Keyed by the challenge id, so replays overwrite the one record the
|
|
304
|
+
// single live waiter consumes; a stale id's file can never be created
|
|
305
|
+
// here.
|
|
306
|
+
const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
|
|
307
|
+
writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
|
|
308
|
+
return "matched";
|
|
240
309
|
}
|
|
241
310
|
}
|
|
242
|
-
if (
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
311
|
+
if (expired) return "expired";
|
|
312
|
+
// Shape only, and only to decide whether the operator gets an answer. A token
|
|
313
|
+
// that is not challenge-shaped is ordinary chat.
|
|
314
|
+
return tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The inbound adapter's acknowledgement (conductor #614), kept as the boolean
|
|
319
|
+
* the availability gate already reads. {@link classifyArmReply} is the same
|
|
320
|
+
* pass with the non-matching cases named.
|
|
321
|
+
*/
|
|
322
|
+
export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
|
|
323
|
+
return classifyArmReply(project, replyText, now) === "matched";
|
|
248
324
|
}
|
|
249
325
|
|
|
250
326
|
/** The acknowledgement record for one exact challenge id, or undefined. */
|
package/src/board.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { FleetLayers, TelegramHealth } from "./status-render.ts";
|
|
|
14
14
|
import { probeCodeGraph, DEFAULT_DEPS, type CodeGraphHealth } from "./graph-health.ts";
|
|
15
15
|
import { healthCheck, livingDaemon } from "./lifecycle.ts";
|
|
16
16
|
import type { WorkerPausePhase } from "./worker.ts";
|
|
17
|
+
import type { WorkerPauseView } from "./fleet.ts";
|
|
17
18
|
import { dbPath, openStore } from "./store.ts";
|
|
18
19
|
import { formatTranscriptLine } from "./transcript.ts";
|
|
19
20
|
import { makeTracker } from "./tracker/github.ts";
|
|
@@ -31,6 +32,8 @@ import type {
|
|
|
31
32
|
} from "./types.ts";
|
|
32
33
|
|
|
33
34
|
const REFRESH_MS = 1_000;
|
|
35
|
+
/** How long an armed pause confirmation stays valid (#997). */
|
|
36
|
+
const PAUSE_CONFIRM_MS = 8_000;
|
|
34
37
|
const HEALTH_REFRESH_MS = 10_000;
|
|
35
38
|
/** Base cadence for the tracker label read, and the ceiling the idle backoff
|
|
36
39
|
* in {@link nextLabelDelayMs} doubles toward. Separate from the health gate
|
|
@@ -141,7 +144,7 @@ export interface BoardHealth {
|
|
|
141
144
|
|
|
142
145
|
interface BoardHealthProbe {
|
|
143
146
|
health: BoardHealth;
|
|
144
|
-
pausedPhases: ReadonlyMap<number,
|
|
147
|
+
pausedPhases: ReadonlyMap<number, WorkerPauseView>;
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/**
|
|
@@ -191,7 +194,7 @@ export interface BoardSnapshot {
|
|
|
191
194
|
project: ProjectConfig;
|
|
192
195
|
status: StatusSnapshot;
|
|
193
196
|
health: BoardHealth;
|
|
194
|
-
pausedPhases: ReadonlyMap<number,
|
|
197
|
+
pausedPhases: ReadonlyMap<number, WorkerPauseView>;
|
|
195
198
|
labels: BoardLabels;
|
|
196
199
|
/** Live re-verification of pushed rows (#173): run id → what the tracker says
|
|
197
200
|
* the PR looks like now. Rendered as a `now:` suffix on the card. */
|
|
@@ -570,7 +573,7 @@ function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
|
|
|
570
573
|
function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] {
|
|
571
574
|
const endedAt = run.endedAt ?? snapshot.now;
|
|
572
575
|
const duration = humanDuration(endedAt - run.startedAt);
|
|
573
|
-
const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue) : undefined;
|
|
576
|
+
const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue)?.phase : undefined;
|
|
574
577
|
const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : "";
|
|
575
578
|
const lines = [
|
|
576
579
|
// The class, when the sweep has attached one and nothing has recovered it
|
|
@@ -833,7 +836,7 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
|
|
|
833
836
|
// #173: a blocked/failed run whose label is gone is the reason this card is
|
|
834
837
|
// parked; the header says so rather than presenting the state as current.
|
|
835
838
|
const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
|
|
836
|
-
const pausePhase = snapshot.pausedPhases.get(run.issue);
|
|
839
|
+
const pausePhase = snapshot.pausedPhases.get(run.issue)?.phase;
|
|
837
840
|
const metadata = [
|
|
838
841
|
styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
|
|
839
842
|
...(pausePhase === undefined
|
|
@@ -869,7 +872,7 @@ function renderHelp(width: number, height: number): string[] {
|
|
|
869
872
|
"↑/↓ or k/j select card",
|
|
870
873
|
"Enter inspect/follow transcript",
|
|
871
874
|
"u unblock selected blocked, failed or orphaned issue",
|
|
872
|
-
"space pause/resume selected worker",
|
|
875
|
+
"space pause/resume selected worker (asks for a confirming press)",
|
|
873
876
|
"i open selected issue",
|
|
874
877
|
"p open selected pull request",
|
|
875
878
|
"r refresh health now",
|
|
@@ -964,7 +967,7 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProb
|
|
|
964
967
|
pausedPhases:
|
|
965
968
|
daemon === "ok"
|
|
966
969
|
? workerPhasesFromHealthz(health?.body, project.name)
|
|
967
|
-
: new Map<number,
|
|
970
|
+
: new Map<number, WorkerPauseView>(),
|
|
968
971
|
};
|
|
969
972
|
}
|
|
970
973
|
|
|
@@ -1065,6 +1068,31 @@ async function unblock(project: ProjectConfig, issue: number): Promise<string> {
|
|
|
1065
1068
|
return summarizeUnblockOutput(issue, stdout);
|
|
1066
1069
|
}
|
|
1067
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* What one spacebar press on a RUNNING card does (#997): the first press only
|
|
1073
|
+
* arms the toggle and says so; the confirming second press on the same card,
|
|
1074
|
+
* within {@link PAUSE_CONFIRM_MS}, performs it. Pure, so the gate itself is
|
|
1075
|
+
* provable: an unconfirmed press must change nothing but the armed state —
|
|
1076
|
+
* one stray spacebar parked a live worker for 14 minutes on 2026-08-23.
|
|
1077
|
+
*/
|
|
1078
|
+
export function pauseKeyDecision(
|
|
1079
|
+
pending: { issue: number; at: number } | undefined,
|
|
1080
|
+
issue: number,
|
|
1081
|
+
phase: WorkerPausePhase | undefined,
|
|
1082
|
+
now: number,
|
|
1083
|
+
):
|
|
1084
|
+
| { kind: "confirm"; pending: undefined }
|
|
1085
|
+
| { kind: "arm"; pending: { issue: number; at: number }; notice: string } {
|
|
1086
|
+
if (pending?.issue === issue && now - pending.at <= PAUSE_CONFIRM_MS) {
|
|
1087
|
+
return { kind: "confirm", pending: undefined };
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
kind: "arm",
|
|
1091
|
+
pending: { issue, at: now },
|
|
1092
|
+
notice: `press space again to confirm ${phase === "paused" ? "resume" : "pause"} of #${issue}`,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1068
1096
|
async function toggleWorkerPause(
|
|
1069
1097
|
project: ProjectConfig,
|
|
1070
1098
|
issue: number,
|
|
@@ -1083,7 +1111,7 @@ async function toggleWorkerPause(
|
|
|
1083
1111
|
{
|
|
1084
1112
|
method: "PUT",
|
|
1085
1113
|
headers: { "content-type": "application/json" },
|
|
1086
|
-
body: JSON.stringify({ project: project.name }),
|
|
1114
|
+
body: JSON.stringify({ project: project.name, source: "board" }),
|
|
1087
1115
|
},
|
|
1088
1116
|
);
|
|
1089
1117
|
const payload = (await response.json()) as { error?: unknown; phase?: unknown };
|
|
@@ -1267,6 +1295,8 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1267
1295
|
]);
|
|
1268
1296
|
let health = healthProbe.health;
|
|
1269
1297
|
let pausedPhases = healthProbe.pausedPhases;
|
|
1298
|
+
// An armed-but-unconfirmed pause toggle (#997): issue and arm time.
|
|
1299
|
+
let pendingPause: { issue: number; at: number } | undefined;
|
|
1270
1300
|
let healthAt = Date.now();
|
|
1271
1301
|
let healthRefresh: Promise<void> | undefined;
|
|
1272
1302
|
// The tracker read is gated separately from health and the plan allowance:
|
|
@@ -1400,6 +1430,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1400
1430
|
});
|
|
1401
1431
|
const name = key.name ?? key.sequence;
|
|
1402
1432
|
if (name === "refresh" || name === "resize") continue;
|
|
1433
|
+
// #997: an armed pause confirmation survives only until the next
|
|
1434
|
+
// keypress — anything but the confirming space disarms it.
|
|
1435
|
+
if (name !== "space") pendingPause = undefined;
|
|
1403
1436
|
// Past this point the key came from a person, not the refresh timer:
|
|
1404
1437
|
// someone watching the board gets the base cadence back immediately.
|
|
1405
1438
|
labelDelay = LABEL_REFRESH_MS;
|
|
@@ -1479,11 +1512,19 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1479
1512
|
if (name === "space") {
|
|
1480
1513
|
const lane = COLUMN_DEFS[cursor.column]?.key;
|
|
1481
1514
|
if (card === undefined || card.kind !== "run" || lane !== "running") {
|
|
1515
|
+
pendingPause = undefined;
|
|
1482
1516
|
notice = "pause/resume is available for RUNNING cards";
|
|
1483
1517
|
} else {
|
|
1484
1518
|
const issue = cardIssue(card);
|
|
1485
|
-
|
|
1486
|
-
|
|
1519
|
+
const phase = pausedPhases.get(issue)?.phase;
|
|
1520
|
+
const decision = pauseKeyDecision(pendingPause, issue, phase, Date.now());
|
|
1521
|
+
pendingPause = decision.pending;
|
|
1522
|
+
if (decision.kind === "confirm") {
|
|
1523
|
+
notice = await toggleWorkerPause(project, issue, phase);
|
|
1524
|
+
healthAt = 0;
|
|
1525
|
+
} else {
|
|
1526
|
+
notice = decision.notice;
|
|
1527
|
+
}
|
|
1487
1528
|
}
|
|
1488
1529
|
continue;
|
|
1489
1530
|
}
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { loadConfig } from "./config.ts";
|
|
|
12
12
|
import { COMMAND_MANIFEST, renderUsage, type CommandManifestEntry } from "./command-manifest.ts";
|
|
13
13
|
import { armCommand } from "./commands/arm.ts";
|
|
14
14
|
import { boardCommand } from "./commands/board.ts";
|
|
15
|
+
import { companionCommand } from "./commands/companion.ts";
|
|
15
16
|
import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
|
|
16
17
|
import { daemonCommand } from "./commands/daemon.ts";
|
|
17
18
|
import { dashboardCommand } from "./commands/dashboard.ts";
|
|
@@ -278,6 +279,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
|
|
|
278
279
|
doctor: () => doctorCommand(ctx),
|
|
279
280
|
ledger: () => ledgerCommand(ctx),
|
|
280
281
|
board: () => boardCommand(ctx),
|
|
282
|
+
companion: () => companionCommand(ctx),
|
|
281
283
|
dashboard: () => dashboardCommand(ctx),
|
|
282
284
|
hold: () => holdCommand(ctx),
|
|
283
285
|
drain: () => drainCommand(ctx),
|
package/src/command-manifest.ts
CHANGED
|
@@ -156,6 +156,16 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
156
156
|
usage: ["board [--project NAME] [--json]"],
|
|
157
157
|
flags: [project(), toggle("--json", "print the stable board JSON shape")],
|
|
158
158
|
},
|
|
159
|
+
{
|
|
160
|
+
name: "companion",
|
|
161
|
+
description: "render the recovery companion pane's decisions and watches view",
|
|
162
|
+
scope: "project",
|
|
163
|
+
usage: ["companion decisions [--project NAME] [--once]"],
|
|
164
|
+
flags: [
|
|
165
|
+
project(),
|
|
166
|
+
toggle("--once", "draw one frame and exit instead of holding the pane"),
|
|
167
|
+
],
|
|
168
|
+
},
|
|
159
169
|
{
|
|
160
170
|
name: "dashboard",
|
|
161
171
|
description: "serve the read-only browser dashboard",
|
package/src/commands/arm.ts
CHANGED
|
@@ -7,11 +7,39 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
-
import { armTicks } from "../fleet.ts";
|
|
10
|
+
import { armFleet, armTicks } from "../fleet.ts";
|
|
11
11
|
import { withProgress } from "../ui/progress.ts";
|
|
12
12
|
|
|
13
13
|
export async function armCommand(ctx: CommandContext): Promise<void> {
|
|
14
|
-
|
|
14
|
+
const targets = ctx.targetProjects();
|
|
15
|
+
// One ceremony for the whole fleet (#991). Two projects used to mean two
|
|
16
|
+
// sequential handshakes with two codes in one chat, though nothing about the
|
|
17
|
+
// fleet's state differed between them. `arm --project X` is untouched: it is
|
|
18
|
+
// still exactly one project, one challenge, one marker.
|
|
19
|
+
if (ctx.projectFlag === undefined && targets.length > 1) {
|
|
20
|
+
const r = await withProgress(
|
|
21
|
+
"arm: verifying one arming proof for the fleet…",
|
|
22
|
+
"Arming proof verified",
|
|
23
|
+
() =>
|
|
24
|
+
armFleet(
|
|
25
|
+
targets.map((project) => project.name),
|
|
26
|
+
{ progress: (line) => process.stdout.write(`${line}\n`) },
|
|
27
|
+
),
|
|
28
|
+
{ plainMessage: true },
|
|
29
|
+
);
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
`ARMED — one inbound round-trip proved with owner ${r.owner}; ticks are now live for ${String(r.armed.length)} project(s).\n` +
|
|
32
|
+
r.armed
|
|
33
|
+
.map(
|
|
34
|
+
(project) =>
|
|
35
|
+
` ${project.project}: marker ${project.path}${project.alreadyArmed ? " (replaced previous marker)" : ""}`,
|
|
36
|
+
)
|
|
37
|
+
.join("\n") +
|
|
38
|
+
"\n",
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const project of targets) {
|
|
15
43
|
// Proof-neutral wording: `claim-only` performs no Telegram send, so the
|
|
16
44
|
// progress line cannot promise a challenge that never goes out (#613). The
|
|
17
45
|
// result line names the proof that actually armed it.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `omp-conductor companion decisions` — the recovery companion pane's own
|
|
3
|
+
* renderer (#994).
|
|
4
|
+
*
|
|
5
|
+
* Why a command and not `watch '<one-shot>'`, which is what recovery used to
|
|
6
|
+
* spin up: `watch` re-forks its argument every interval, and the
|
|
7
|
+
* foreground-process transition that produces made herdr repaint the pane —
|
|
8
|
+
* a flash every 60 seconds, reported live during a fleet restart. Measured at
|
|
9
|
+
* the time: exactly one full erase at startup in both `watch` and `watch -c`,
|
|
10
|
+
* ~610 bytes for three renders, and zero ANSI bytes in the command's own
|
|
11
|
+
* output. So neither `watch`'s drawing nor `-c` was the cause; the re-fork was.
|
|
12
|
+
* The board pane, a single long-lived process holding its terminal, never
|
|
13
|
+
* flashed — and this is that shape.
|
|
14
|
+
*
|
|
15
|
+
* Two properties are load-bearing:
|
|
16
|
+
*
|
|
17
|
+
* - **One process, held.** The pane's foreground stays this process between
|
|
18
|
+
* renders, which is exactly what recovery's liveness check reads
|
|
19
|
+
* (`foreground_verdict`: a bare shell means the pane is at a prompt and gets
|
|
20
|
+
* an `agent start`). A `while`-loop in `sh` would have stopped the flashing
|
|
21
|
+
* and broken that, because its foreground process is named `sh`.
|
|
22
|
+
* - **Redraw in place, only on change.** Cursor-home plus erase-to-end-of-line
|
|
23
|
+
* per line, and the leftover tail of a shorter frame erased explicitly. No
|
|
24
|
+
* full-screen clear, so there is nothing to see between two identical frames
|
|
25
|
+
* — the print-and-sleep loop this lineage started with appended instead, and
|
|
26
|
+
* a pane left open overnight was a mile of identical lists.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { companionFrame } from "../companion-view.ts";
|
|
30
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
31
|
+
import { dbPath, openStore } from "../store.ts";
|
|
32
|
+
import type { CommandContext } from "./context.ts";
|
|
33
|
+
|
|
34
|
+
/** Seconds between reads. The list moves when a human or a condition moves it,
|
|
35
|
+
* neither of which is fast; a shorter interval would spend the fleet's
|
|
36
|
+
* process budget to display the same frame. */
|
|
37
|
+
export const COMPANION_REFRESH_SECONDS = 60;
|
|
38
|
+
|
|
39
|
+
/** Cursor to home, and erase from the cursor to the end of the line. Nothing
|
|
40
|
+
* clears the whole screen: a full erase is what a reader perceives as a
|
|
41
|
+
* flash, and it is unnecessary when every line is overwritten in place. */
|
|
42
|
+
const HOME = "\u001B[H";
|
|
43
|
+
const ERASE_LINE = "\u001B[K";
|
|
44
|
+
const ERASE_BELOW = "\u001B[J";
|
|
45
|
+
|
|
46
|
+
/** One frame, drawn over whatever the last one left. `previous` is the line
|
|
47
|
+
* count of the last frame so a shorter one erases its own leftovers instead of
|
|
48
|
+
* leaving stale rows that read as current. */
|
|
49
|
+
export function drawFrame(lines: readonly string[], previous: number): string {
|
|
50
|
+
const body = lines.map((line) => `${line}${ERASE_LINE}`).join("\n");
|
|
51
|
+
// A frame that shrank leaves rows below it; erase from the end of the new
|
|
52
|
+
// content down rather than clearing first, which would flash.
|
|
53
|
+
return lines.length < previous ? `${HOME}${body}\n${ERASE_BELOW}` : `${HOME}${body}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function companionCommand(ctx: CommandContext): Promise<void> {
|
|
57
|
+
const sub = ctx.argv[1];
|
|
58
|
+
if (sub !== "decisions") {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`omp-conductor: unknown companion view "${sub ?? ""}" — expected decisions\n`,
|
|
61
|
+
);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
65
|
+
const once = ctx.argv.includes("--once");
|
|
66
|
+
|
|
67
|
+
let previous = 0;
|
|
68
|
+
let stopped = false;
|
|
69
|
+
const stop = (): void => {
|
|
70
|
+
stopped = true;
|
|
71
|
+
};
|
|
72
|
+
process.on("SIGINT", stop);
|
|
73
|
+
process.on("SIGTERM", stop);
|
|
74
|
+
|
|
75
|
+
while (!stopped) {
|
|
76
|
+
// Opened per render, closed immediately: a companion pane lives for days,
|
|
77
|
+
// and a handle held that long across daemon restarts and db snapshots is a
|
|
78
|
+
// handle to a file that may no longer be the store.
|
|
79
|
+
const store = openStore(dbPath());
|
|
80
|
+
let lines: string[];
|
|
81
|
+
try {
|
|
82
|
+
lines = companionFrame(store.openDecisions(project.name));
|
|
83
|
+
} finally {
|
|
84
|
+
store.close();
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(drawFrame(lines, previous));
|
|
87
|
+
previous = lines.length;
|
|
88
|
+
if (once) return;
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
const timer = setTimeout(resolve, COMPANION_REFRESH_SECONDS * 1_000);
|
|
91
|
+
// Never hold the process open past a signal: the pane is closed by
|
|
92
|
+
// closing the pane, and a lingering timer would outlive it.
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
const poll = setInterval(() => {
|
|
95
|
+
if (!stopped) return;
|
|
96
|
+
clearInterval(poll);
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve();
|
|
99
|
+
}, 250);
|
|
100
|
+
poll.unref?.();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/commands/context.ts
CHANGED
|
@@ -81,6 +81,7 @@ export type CommandScope = "project" | "host" | "fleet" | "none";
|
|
|
81
81
|
export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
|
|
82
82
|
// project — exactly one project; findProject demands --project when several
|
|
83
83
|
"brief-upgrade": "project",
|
|
84
|
+
companion: "project",
|
|
84
85
|
decision: "project",
|
|
85
86
|
drain: "project",
|
|
86
87
|
event: "project",
|
package/src/commands/worker.ts
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed view the recovery companion pane renders (#994).
|
|
3
|
+
*
|
|
4
|
+
* Two defects produced this module, and they were one decision:
|
|
5
|
+
*
|
|
6
|
+
* - The pane re-forked `watch -c -n 60 '<one-shot>'`, and every 60 seconds the
|
|
7
|
+
* foreground-process transition made herdr repaint it — a visible flash. The
|
|
8
|
+
* board pane, one long-lived process holding its terminal, never flashed. So
|
|
9
|
+
* the fix is a process that persists between renders, which is what
|
|
10
|
+
* {@link companionFrame} exists to feed.
|
|
11
|
+
* - It rendered `decision list` alone, which on this fleet meant a whole pane
|
|
12
|
+
* refreshing forever to display `no open decisions` while nine watches, four
|
|
13
|
+
* of them `condition:met`, were invisible in it.
|
|
14
|
+
*
|
|
15
|
+
* The split is deliberate and this is the only place it is composed: operator
|
|
16
|
+
* decisions are rare and already interrupt through Telegram when they matter,
|
|
17
|
+
* while watches are the live state a glanceable pane is *for* — and a met
|
|
18
|
+
* condition is the one row in either list that wants eyes immediately.
|
|
19
|
+
*
|
|
20
|
+
* An empty section is omitted entirely rather than printed with a placeholder.
|
|
21
|
+
* A pane showing nothing is honest; a pane showing `no open decisions` forever
|
|
22
|
+
* is the noise the operator objected to.
|
|
23
|
+
*
|
|
24
|
+
* `decision list` and `watch list` keep their own contracts untouched — this is
|
|
25
|
+
* the pane's composition, not those commands' output.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { DecisionRecord } from "./types.ts";
|
|
29
|
+
|
|
30
|
+
/** The marker that makes an actionable watch findable at a glance. Uppercase
|
|
31
|
+
* rather than coloured: the pane inherits whatever palette the terminal has,
|
|
32
|
+
* and the flash this module replaced was chased through an ANSI hypothesis. */
|
|
33
|
+
export const MET_MARKER = "[ACT NOW]";
|
|
34
|
+
|
|
35
|
+
/** The single line shown when neither list has anything. Quiet, and still
|
|
36
|
+
* proof the pane is alive rather than blank because something died. */
|
|
37
|
+
export const IDLE_LINE = "nothing open";
|
|
38
|
+
|
|
39
|
+
/** One row, reduced to what the pane shows. */
|
|
40
|
+
function row(record: DecisionRecord, now: number): string {
|
|
41
|
+
const ageHours = Math.max(0, Math.round((now - record.askedAt) / 3_600_000));
|
|
42
|
+
const condition =
|
|
43
|
+
record.condition === undefined ? "-" : record.conditionMetAt === undefined ? "pending" : "met";
|
|
44
|
+
const met = condition === "met" ? `${MET_MARKER} ` : "";
|
|
45
|
+
return ` ${met}${record.id} ${String(ageHours)}h blocks:${record.blocks ?? "-"} condition:${condition} ${record.question}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The frame to draw, as lines.
|
|
50
|
+
*
|
|
51
|
+
* Decisions above watches, because only a decision wants an answer. Met
|
|
52
|
+
* conditions are hoisted to the top of the watch section: the whole reason to
|
|
53
|
+
* render watches at all is that one of them may be actionable, and a reader who
|
|
54
|
+
* has to scan nine rows for the one that matters is reading a log, not a board.
|
|
55
|
+
*/
|
|
56
|
+
export function companionFrame(
|
|
57
|
+
open: readonly DecisionRecord[],
|
|
58
|
+
now: number = Date.now(),
|
|
59
|
+
): string[] {
|
|
60
|
+
const decisions = open.filter((d) => d.kind !== "watch");
|
|
61
|
+
const watches = open.filter((d) => d.kind === "watch");
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
if (decisions.length > 0) {
|
|
65
|
+
lines.push(`operator decisions (${String(decisions.length)}) — these want an answer`);
|
|
66
|
+
for (const decision of decisions) lines.push(row(decision, now));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (watches.length > 0) {
|
|
70
|
+
const met = watches.filter((w) => w.conditionMetAt !== undefined);
|
|
71
|
+
const pending = watches.filter((w) => w.conditionMetAt === undefined);
|
|
72
|
+
if (lines.length > 0) lines.push("");
|
|
73
|
+
lines.push(
|
|
74
|
+
`watches (${String(watches.length)}) — no answer needed` +
|
|
75
|
+
(met.length === 0 ? "" : `, ${String(met.length)} to act on now`),
|
|
76
|
+
);
|
|
77
|
+
for (const watch of [...met, ...pending]) lines.push(row(watch, now));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return lines.length === 0 ? [IDLE_LINE] : lines;
|
|
81
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -397,7 +397,13 @@ const projectSchema = z
|
|
|
397
397
|
})
|
|
398
398
|
.strict(),
|
|
399
399
|
queueLabel: z.string().min(1, "must be a non-empty string — it is the human sign-off gate"),
|
|
400
|
-
|
|
400
|
+
// The grooming trigger (#988): an integer ≥ 1, or the literal "always" —
|
|
401
|
+
// groom on demand every tick while ungroomed candidates remain. Unlike the
|
|
402
|
+
// opaque model selectors below, this field's usable vocabulary is closed,
|
|
403
|
+
// so the schema states it and a wrong value fails the load with an
|
|
404
|
+
// authored line instead of silently degrading the fleet's grooming duty
|
|
405
|
+
// to the default.
|
|
406
|
+
groomBelow: z.union([z.number().int().min(1), z.literal("always")]).optional(),
|
|
401
407
|
stateLabels: stateLabelsSchema.optional(),
|
|
402
408
|
routing: routingSchema.optional(),
|
|
403
409
|
caps: capsSchema.optional(),
|
package/src/config.ts
CHANGED
|
@@ -847,6 +847,9 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
|
|
|
847
847
|
const pathStr = dottedPath(rel);
|
|
848
848
|
const found = (at?: readonly PropertyKey[]): string => FOUND(rawAt(root, at ?? fullPath));
|
|
849
849
|
|
|
850
|
+
if (rel.length === 1 && rel[0] === "groomBelow") {
|
|
851
|
+
return `groomBelow must be an integer ≥ 1 or the literal "always", found ${found()}`;
|
|
852
|
+
}
|
|
850
853
|
if (rel.length === 1 && rel[0] === "releasePolicy") {
|
|
851
854
|
return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`;
|
|
852
855
|
}
|
|
@@ -1195,11 +1198,11 @@ function finalizeProject(
|
|
|
1195
1198
|
const label = `project "${name}"`;
|
|
1196
1199
|
const trackerRepo = ((p["tracker"] as Raw | undefined)?.["repo"]) as string;
|
|
1197
1200
|
|
|
1201
|
+
// The zod union already enforced `integer ≥ 1 | "always"`; this only
|
|
1202
|
+
// narrows the type for the assembled ProjectConfig (#988).
|
|
1198
1203
|
const rawGroomBelow = p["groomBelow"];
|
|
1199
1204
|
const groomBelow =
|
|
1200
|
-
typeof rawGroomBelow === "number"
|
|
1201
|
-
? rawGroomBelow
|
|
1202
|
-
: undefined;
|
|
1205
|
+
rawGroomBelow === "always" ? "always" : typeof rawGroomBelow === "number" ? rawGroomBelow : undefined;
|
|
1203
1206
|
|
|
1204
1207
|
const rawRouting = p["routing"] as Raw | undefined;
|
|
1205
1208
|
const rawPrefix = rawRouting?.["labelPrefix"];
|