omp-conductor 0.19.1 → 0.19.3
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/REFERENCE.md +1 -1
- package/package.json +2 -2
- package/schema/config.schema.json +13 -1
- package/src/arm-challenge.ts +92 -16
- package/src/board.ts +50 -9
- package/src/check-browser-js.ts +72 -0
- 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 +197 -14
- package/src/dashboard/app.js +4 -4
- 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 +275 -27
- package/src/orchestrator-tick.ts +115 -34
- package/src/settlement.ts +167 -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 +38 -2
- package/src/types.ts +39 -2
- package/src/usage.ts +25 -0
package/src/failure-class.ts
CHANGED
|
@@ -170,6 +170,16 @@ const START_FAILURE_SIGNATURES = [
|
|
|
170
170
|
// symptom this replaced (the child dying on the peer import) already read as
|
|
171
171
|
// a start failure, so charging one here would be a regression dressed as a fix.
|
|
172
172
|
"worker identity unavailable",
|
|
173
|
+
// The 2026-08-2x host faults (#986), each recorded verbatim in `lastError`
|
|
174
|
+
// by the launcher or the worker identity gate — all before the session took
|
|
175
|
+
// a turn:
|
|
176
|
+
// - the child died before it could connect to the dispatcher;
|
|
177
|
+
// - node could not resolve a package out of the worker harness tree;
|
|
178
|
+
// - the worker's settings file was unreadable (the EACCES outage).
|
|
179
|
+
"exited 1 before connecting",
|
|
180
|
+
"cannot find package",
|
|
181
|
+
"enoent while resolving package",
|
|
182
|
+
"failed to read settings config",
|
|
173
183
|
] as const;
|
|
174
184
|
|
|
175
185
|
/**
|
|
@@ -395,6 +405,128 @@ export function adminRestartAttribution(lastError: string | undefined): string |
|
|
|
395
405
|
return firstLine.trim();
|
|
396
406
|
}
|
|
397
407
|
|
|
408
|
+
/** The launcher's own statement that the session child died mid-run, before
|
|
409
|
+
* the session ended. Written by `omp.ts` when the child exits while the
|
|
410
|
+
* dispatcher still owns it; the exit code is the child's own, so a signal
|
|
411
|
+
* death reads >=128 (143 = SIGTERM, 137 = SIGKILL) — an external kill,
|
|
412
|
+
* never anything the worker's work decided (#986). */
|
|
413
|
+
const CHILD_EXIT_MARKER = /session child exited (\d+) before the session ended/;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Evidence that this run's session child was terminated by a signal, or
|
|
417
|
+
* `undefined` when it was not.
|
|
418
|
+
*
|
|
419
|
+
* Read from the surfaces a run persists — `lastError` first, then `report`,
|
|
420
|
+
* because the prompt-throw path records the launcher's message as the report
|
|
421
|
+
* and leaves `lastError` empty. Deliberately narrowed to signal-range codes:
|
|
422
|
+
* a clean nonzero exit is some process's verdict nobody has named yet, and
|
|
423
|
+
* naming it `admin-kill` would waive an attempt that may have been spent.
|
|
424
|
+
*/
|
|
425
|
+
export function childSignalDeath(
|
|
426
|
+
run: Pick<RunRecord, "lastError" | "report">,
|
|
427
|
+
): string | undefined {
|
|
428
|
+
const text = run.lastError ?? run.report;
|
|
429
|
+
if (text === undefined) return undefined;
|
|
430
|
+
const match = CHILD_EXIT_MARKER.exec(text);
|
|
431
|
+
if (match === null) return undefined;
|
|
432
|
+
const code = Number(match[1]);
|
|
433
|
+
if (!Number.isFinite(code) || code < 128) return undefined;
|
|
434
|
+
return match[0];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The reason the settle sweep writes beside a reviewer-closed PR
|
|
438
|
+
* (`settlementFor`), matched as recorded evidence so rows written before
|
|
439
|
+
* that sweep stamped a class read the same way (#986). */
|
|
440
|
+
const REVIEWER_CLOSED_MARKER = "closed without merging";
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Evidence that a reviewer closed this run's pushed work without merging it,
|
|
444
|
+
* or `undefined` when nothing says so. Two independent signals, either
|
|
445
|
+
* sufficient: the tracker fact (`facts.pr === "closed"`), or the settle
|
|
446
|
+
* sweep's own recorded reason in `lastError`. A review decision is not a
|
|
447
|
+
* worker failure, which is exactly what `returned-for-revision` means; the
|
|
448
|
+
* only new reading here is applying it to rows whose PR closed while the row
|
|
449
|
+
* itself had already gone terminal-failed.
|
|
450
|
+
*/
|
|
451
|
+
export function reviewerClosed(
|
|
452
|
+
run: Pick<RunRecord, "lastError" | "prUrl">,
|
|
453
|
+
facts: ClassifyFacts,
|
|
454
|
+
): string | undefined {
|
|
455
|
+
if (run.lastError !== undefined && run.lastError.includes(REVIEWER_CLOSED_MARKER)) {
|
|
456
|
+
return run.lastError.split("\n")[0]?.trim();
|
|
457
|
+
}
|
|
458
|
+
if (facts.pr === "closed" && run.prUrl !== undefined) {
|
|
459
|
+
return `${run.prUrl} closed without merging`;
|
|
460
|
+
}
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The harness's own name for giving up on a provider that kept answering with
|
|
465
|
+
* empty assistant turns: it retries, then ends the session on the retry cap
|
|
466
|
+
* and says so in its maintenance routing (`route: "empty-stop-retry-cap"`,
|
|
467
|
+
* beside `Assistant returned empty stop after retry cap; try switching
|
|
468
|
+
* models`). A closed marker written by the harness, not vendor prose. */
|
|
469
|
+
const EMPTY_STOP_MARKER = "empty-stop-retry-cap";
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Evidence that the provider answered with empty turns until the harness's own
|
|
473
|
+
* retry cap ended the session, or `undefined` when nothing recorded says so
|
|
474
|
+
* (#986).
|
|
475
|
+
*
|
|
476
|
+
* Read from the terminal evidence the settlement sweep recorded, because this
|
|
477
|
+
* marker is written by the harness's session log rather than by the transcript
|
|
478
|
+
* or the row — the run itself has no error, no verdict and no idea it died,
|
|
479
|
+
* which is precisely why two of these read as `unknown` on 2026-08-23 at
|
|
480
|
+
* 100/180 and 54/180 turns while a third run on the same model finished green
|
|
481
|
+
* in the same window.
|
|
482
|
+
*
|
|
483
|
+
* Provider flake, not worker logic: the recovery continues from whatever the
|
|
484
|
+
* attempt pushed rather than charging an implementation attempt for it.
|
|
485
|
+
*/
|
|
486
|
+
export function modelEmptyStop(
|
|
487
|
+
run: Pick<RunRecord, "terminalEvidence">,
|
|
488
|
+
): string | undefined {
|
|
489
|
+
if (run.terminalEvidence === undefined) return undefined;
|
|
490
|
+
if (!run.terminalEvidence.includes(EMPTY_STOP_MARKER)) return undefined;
|
|
491
|
+
return run.terminalEvidence;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Evidence that this run ended without ever delivering a settlement verdict,
|
|
496
|
+
* or `undefined` when something on the row says otherwise (#986).
|
|
497
|
+
*
|
|
498
|
+
* The shape: state `failed`, turns taken, no error anywhere, and a final
|
|
499
|
+
* report whose words are mid-delivery narration — "Now I'll execute …", "Type
|
|
500
|
+
* check passes. Now the test suite:" — with no `state:`/`status:` line the
|
|
501
|
+
* result parser could read. The session reached its worker and died between
|
|
502
|
+
* turns without handing over; measured over two weeks of the reference fleet,
|
|
503
|
+
* this one shape was half of all `unknown`.
|
|
504
|
+
*
|
|
505
|
+
* Deliberately narrow at every edge: turn zero belongs to
|
|
506
|
+
* {@link neverStarted}, any error text keeps the row `unknown` (an
|
|
507
|
+
* unrecognised error says something happened), and an absent report leaves
|
|
508
|
+
* genuinely nothing to name. The class charges its attempt exactly as
|
|
509
|
+
* `unknown` did — only the name becomes specific.
|
|
510
|
+
*/
|
|
511
|
+
export function noVerdictExit(run: RunRecord): string | undefined {
|
|
512
|
+
if (run.state !== "failed") return undefined;
|
|
513
|
+
if (run.turns <= 0) return undefined;
|
|
514
|
+
// Blank counts as absent: a row whose `lastError` is an empty string carries
|
|
515
|
+
// no error information, and reading it as "something happened" is what kept
|
|
516
|
+
// four real rows unnamed. Any non-blank text, recognised or not, still wins —
|
|
517
|
+
// an unrecognised error says something happened.
|
|
518
|
+
if (run.lastError !== undefined && run.lastError.trim() !== "") return undefined;
|
|
519
|
+
if (run.report === undefined || run.report.trim() === "") return undefined;
|
|
520
|
+
// Mirror the two verdict spellings the result path reads (`worker.ts`
|
|
521
|
+
// STATE_LINE_PATTERN for prose settlements, `renderSettlement`'s leading
|
|
522
|
+
// `status:` line for structured yields). Either one present means a verdict
|
|
523
|
+
// was delivered and this function must stay silent.
|
|
524
|
+
if (/^state:\s*\S+\s*$/im.test(run.report)) return undefined;
|
|
525
|
+
if (/^status:\s*\S+\s*$/im.test(run.report)) return undefined;
|
|
526
|
+
const lastWords = run.report.split("\n")[0]?.trim() ?? "";
|
|
527
|
+
return `the session ended without delivering a settlement verdict; last words: "${lastWords.slice(0, 80)}"`;
|
|
528
|
+
}
|
|
529
|
+
|
|
398
530
|
export function classifyRun(
|
|
399
531
|
run: RunRecord,
|
|
400
532
|
facts: ClassifyFacts,
|
|
@@ -424,6 +556,23 @@ export function classifyRun(
|
|
|
424
556
|
};
|
|
425
557
|
}
|
|
426
558
|
|
|
559
|
+
// The launcher's own statement that the session child died on a signal
|
|
560
|
+
// mid-run: an external kill — a drain, a restart, the host — not anything
|
|
561
|
+
// the worker's work decided (#986). Ahead of the provider branches on
|
|
562
|
+
// purpose: a transcript can carry an older, long-recovered stream fault,
|
|
563
|
+
// and the launcher's terminal statement outranks whatever the session said
|
|
564
|
+
// earlier. Requeued free like every other administrative kill.
|
|
565
|
+
if (run.state === "failed") {
|
|
566
|
+
const marker = childSignalDeath(run);
|
|
567
|
+
if (marker !== undefined) {
|
|
568
|
+
return {
|
|
569
|
+
cls: "admin-kill",
|
|
570
|
+
recovery: "requeue",
|
|
571
|
+
evidence: `killed externally (${marker}) — the session child was terminated, not finished`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
427
576
|
// A billing state, not an implementation failure. Its own class because it is
|
|
428
577
|
// the one outage an operator fixes with a card rather than a diagnosis, and
|
|
429
578
|
// because burying a self-describing provider error in `unknown` erodes what
|
|
@@ -507,6 +656,19 @@ export function classifyRun(
|
|
|
507
656
|
}
|
|
508
657
|
}
|
|
509
658
|
|
|
659
|
+
// A reviewer closed this run's pushed work without merging it: a review
|
|
660
|
+
// decision, not a worker failure (#986). Two rows reach here — the settle
|
|
661
|
+
// sweep's own recorded reason in `lastError` on a row that predates the
|
|
662
|
+
// class, and a live `closed` tracker fact on a row whose worker died after
|
|
663
|
+
// pushing but before claiming. Recovery `none` mirrors the settle sweep
|
|
664
|
+
// exactly: the queue label is the remedy, and no action is performed here.
|
|
665
|
+
if (run.state === "failed") {
|
|
666
|
+
const closed = reviewerClosed(run, facts);
|
|
667
|
+
if (closed !== undefined) {
|
|
668
|
+
return { cls: "returned-for-revision", recovery: "none", evidence: closed };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
510
672
|
if (run.state === "blocked") {
|
|
511
673
|
return {
|
|
512
674
|
cls: "question",
|
|
@@ -639,6 +801,36 @@ export function classifyRun(
|
|
|
639
801
|
}
|
|
640
802
|
}
|
|
641
803
|
|
|
804
|
+
// The provider answered with empty turns until the harness gave up (#986).
|
|
805
|
+
// Below every branch that reads a real error on purpose: a row carrying its
|
|
806
|
+
// own provider fault is that fault, and this marker only decides rows that
|
|
807
|
+
// would otherwise be `unknown`. Continues rather than requeues, because the
|
|
808
|
+
// attempt's pushed work is the honest place to resume from, and a provider
|
|
809
|
+
// flake must not charge an implementation attempt (#1001). `killed` rows
|
|
810
|
+
// never reach here — a cap kill is the operative fact about a run that hit
|
|
811
|
+
// its ceiling, whatever the provider did earlier in the session.
|
|
812
|
+
if (run.state === "failed") {
|
|
813
|
+
const emptyStop = modelEmptyStop(run);
|
|
814
|
+
if (emptyStop !== undefined) {
|
|
815
|
+
return {
|
|
816
|
+
cls: "model-empty-stop",
|
|
817
|
+
recovery: "continue",
|
|
818
|
+
evidence: `the provider returned empty turns until the harness's retry cap ended the session (${emptyStop}) — turns ${run.turns}/${run.maxTurns}`,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The session reached its worker and died between turns without handing over
|
|
824
|
+
// a verdict (#986): the largest single shape inside `unknown`. Named rather
|
|
825
|
+
// than requeued blind — an operator reading `status` learns the run stopped
|
|
826
|
+
// mid-delivery, which is a different question from "nothing is known".
|
|
827
|
+
if (run.state === "failed") {
|
|
828
|
+
const narration = noVerdictExit(run);
|
|
829
|
+
if (narration !== undefined) {
|
|
830
|
+
return { cls: "no-verdict", recovery: "escalate", evidence: narration };
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
642
834
|
// Deliberately escalate rather than retry. An unrecognised shape is a gap in
|
|
643
835
|
// this table, and a silent requeue would spend a budget on a cause nobody has
|
|
644
836
|
// named — the exact behaviour #132 exists to end.
|
package/src/fleet.ts
CHANGED
|
@@ -24,7 +24,12 @@ import {
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join, sep } from "node:path";
|
|
26
26
|
import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
clearArmTransaction,
|
|
29
|
+
FLEET_ARM_KEY,
|
|
30
|
+
readArmAcknowledgement,
|
|
31
|
+
recordArmChallenge,
|
|
32
|
+
} from "./arm-challenge.ts";
|
|
28
33
|
import {
|
|
29
34
|
claimedTelegramTopics,
|
|
30
35
|
lockPidAlive,
|
|
@@ -398,14 +403,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
398
403
|
return { path, alreadyArmed, owner: channel.owner, proof };
|
|
399
404
|
}
|
|
400
405
|
|
|
406
|
+
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
407
|
+
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
401
408
|
const code = makeChallengeCode();
|
|
409
|
+
// Self-describing (#991): with two live challenges in one chat the operator
|
|
410
|
+
// was working out which was which from message order, and nothing said how
|
|
411
|
+
// long a code stayed good — so the safe move was to scroll for the newest,
|
|
412
|
+
// which is the work conductor-owned verification was meant to end.
|
|
402
413
|
const text =
|
|
403
414
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
404
415
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
416
|
+
`Valid for ${armClock(timeoutMs)}. ` +
|
|
405
417
|
`Nothing will be dispatched until that reply is seen in the orchestrator session.`;
|
|
406
|
-
|
|
407
|
-
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
408
|
-
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
409
418
|
const sentAt = (deps.now ?? Date.now)();
|
|
410
419
|
// The orchestrator's inbound adapter can only acknowledge an *active*
|
|
411
420
|
// challenge, so the authenticated pending record (hash + expiry, never the
|
|
@@ -462,6 +471,149 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
462
471
|
return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
|
|
463
472
|
}
|
|
464
473
|
|
|
474
|
+
export interface FleetArmResult {
|
|
475
|
+
/** Every project this ceremony armed, in configuration order. */
|
|
476
|
+
armed: { project: string; path: string; alreadyArmed: boolean }[];
|
|
477
|
+
owner: string;
|
|
478
|
+
/** The transaction the single reply proved. */
|
|
479
|
+
challengeId: string;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* One ceremony for the whole fleet (#991).
|
|
484
|
+
*
|
|
485
|
+
* Arming was per project: a two-project fleet meant two sequential handshakes
|
|
486
|
+
* with two codes in one chat, though nothing about the fleet's state differed
|
|
487
|
+
* between them. This sends **one** challenge and arms every configured project
|
|
488
|
+
* from the single matching reply — which is a fleet-wide pending record, not a
|
|
489
|
+
* loop that sends N challenges and waits for N replies. That loop is the
|
|
490
|
+
* current friction with one command wrapped around it.
|
|
491
|
+
*
|
|
492
|
+
* Every project is validated before anything is sent, so a fleet whose second
|
|
493
|
+
* project has no `armedFile` refuses the ceremony instead of arming the first
|
|
494
|
+
* and then failing — a half-armed fleet is worse than an unarmed one, because
|
|
495
|
+
* only one of its halves dispatches.
|
|
496
|
+
*
|
|
497
|
+
* A project whose declared proof is `claim-only` is armed by this too: an
|
|
498
|
+
* authenticated round-trip is strictly stronger than the plumbing verdict that
|
|
499
|
+
* policy would have accepted, so satisfying the weaker gate with the stronger
|
|
500
|
+
* proof cannot weaken it.
|
|
501
|
+
*/
|
|
502
|
+
export async function armFleet(
|
|
503
|
+
projectNames: readonly string[],
|
|
504
|
+
deps: ArmDeps = {},
|
|
505
|
+
): Promise<FleetArmResult> {
|
|
506
|
+
if (projectNames.length === 0) throw new Error("arm: no projects are configured");
|
|
507
|
+
|
|
508
|
+
// Resolve and validate every project first. Nothing is sent and no marker is
|
|
509
|
+
// written until the whole fleet is known to be armable.
|
|
510
|
+
const targets: { project: string; armedFile: string; accessFile: string; cwd: string; stateKey?: string }[] = [];
|
|
511
|
+
for (const name of projectNames) {
|
|
512
|
+
const tick = resolveTickConfig(name);
|
|
513
|
+
if (tick.kind === "invalid") throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
|
|
514
|
+
if (tick.kind === "absent") {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`no ${TICK_CONFIG_FILE} under ${tickConfigSearchRoots(name).join(" or ")} for ${name} — ` +
|
|
517
|
+
`nothing would read an arm marker; drop a tick config first`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (tick.config.armedFile === undefined) {
|
|
521
|
+
throw new Error(`${tick.path} has no armedFile — ${name}'s heartbeat is ungated; add armedFile before arming`);
|
|
522
|
+
}
|
|
523
|
+
if (tick.config.accessFile === undefined) {
|
|
524
|
+
throw new Error(`${tick.path} has no accessFile — arm cannot prove an inbound channel for ${name}`);
|
|
525
|
+
}
|
|
526
|
+
targets.push({
|
|
527
|
+
project: name,
|
|
528
|
+
armedFile: tick.config.armedFile,
|
|
529
|
+
accessFile: tick.config.accessFile,
|
|
530
|
+
cwd: tick.cwd,
|
|
531
|
+
...(tick.config.project === undefined ? {} : { stateKey: tick.config.project }),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// One bot, one chat: the channel is a host fact, so the first target's
|
|
536
|
+
// access file speaks for the fleet. A project pointing at a different file
|
|
537
|
+
// would be a different bot, which this ceremony cannot span — and would
|
|
538
|
+
// announce itself here as a down channel rather than silently arming.
|
|
539
|
+
const first = targets[0]!;
|
|
540
|
+
const channel = readPairedChannel(first.accessFile);
|
|
541
|
+
if (channel.kind === "down") {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`escalation channel is not up (${first.accessFile}): ${channel.reason} — ` +
|
|
544
|
+
`pair the bot (/telegram pair) and enable the bridge (/telegram on) before arming`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const token = readBotToken();
|
|
548
|
+
if (token === undefined) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`no TELEGRAM_BOT_TOKEN in ${join(telegramStateDir(), ".env")} — the arm challenge cannot send without it`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The first project that resolves a live topic carries the ceremony, and the
|
|
555
|
+
// message says so: a fleet-wide question still has to land somewhere an
|
|
556
|
+
// operator is reading, and every session's adapter can acknowledge it because
|
|
557
|
+
// the pending record is fleet-wide rather than topic-scoped.
|
|
558
|
+
let sendTopic: number | undefined;
|
|
559
|
+
for (const target of targets) {
|
|
560
|
+
try {
|
|
561
|
+
sendTopic = resolveProjectTopicId(findProject(loadConfig(), target.project));
|
|
562
|
+
} catch {
|
|
563
|
+
continue; /* no project config — try the next */
|
|
564
|
+
}
|
|
565
|
+
if (sendTopic !== undefined) break;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
569
|
+
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
570
|
+
const code = makeChallengeCode();
|
|
571
|
+
const names = targets.map((t) => t.project).join(", ");
|
|
572
|
+
const text =
|
|
573
|
+
`Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
|
|
574
|
+
`Reply to this chat with exactly:\n${code}\n` +
|
|
575
|
+
`Valid for ${armClock(timeoutMs)}, and one reply arms all of them. ` +
|
|
576
|
+
`Nothing will be dispatched until that reply is seen in the orchestrator session.`;
|
|
577
|
+
|
|
578
|
+
const sentAt = (deps.now ?? Date.now)();
|
|
579
|
+
const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs);
|
|
580
|
+
try {
|
|
581
|
+
await send(token, channel.owner, text, sendTopic);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
clearArmTransaction(FLEET_ARM_KEY, challengeId);
|
|
584
|
+
throw new Error(
|
|
585
|
+
`arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
deps.progress?.(
|
|
589
|
+
`arm: one challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} for ${names} — ` +
|
|
590
|
+
`waiting up to ${armClock(timeoutMs)} for the reply. Dispatch stays held until it arrives.`,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
|
|
594
|
+
clearArmTransaction(FLEET_ARM_KEY, challengeId);
|
|
595
|
+
throw new Error(
|
|
596
|
+
`arm: the fleet challenge was never acknowledged in time — NOTHING armed.\n` +
|
|
597
|
+
`No acknowledgement for challenge ${challengeId} arrived, so no project's marker was written.\n` +
|
|
598
|
+
`Inbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
599
|
+
` * is the bridge polling? attach and run: /telegram status\n` +
|
|
600
|
+
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
601
|
+
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
602
|
+
` * did you reply in the chat the challenge names, not another one?\n`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Proven once, applied to every project. Markers are written after the proof,
|
|
607
|
+
// so a refused ceremony leaves the fleet exactly as it was.
|
|
608
|
+
const armed = targets.map((target) => {
|
|
609
|
+
const state = resolveArmState(target.armedFile, target.project);
|
|
610
|
+
writeArmedMarker(target.armedFile, channel.owner, state);
|
|
611
|
+
return { project: target.project, path: target.armedFile, alreadyArmed: state.armed };
|
|
612
|
+
});
|
|
613
|
+
clearArmTransaction(FLEET_ARM_KEY, challengeId);
|
|
614
|
+
return { armed, owner: channel.owner, challengeId };
|
|
615
|
+
}
|
|
616
|
+
|
|
465
617
|
export interface HoldResult {
|
|
466
618
|
wasPaused: boolean;
|
|
467
619
|
/** Absent when the caller kept the heartbeat armed. */
|
|
@@ -746,6 +898,18 @@ export interface WorkerPaneDeps {
|
|
|
746
898
|
*/
|
|
747
899
|
export const WORKER_PANE_SOURCE = "omp-conductor";
|
|
748
900
|
|
|
901
|
+
/**
|
|
902
|
+
* How many times this daemon re-establishes one run's pane before it stops
|
|
903
|
+
* trying (#998).
|
|
904
|
+
*
|
|
905
|
+
* The pathology being bounded is unboundedness, not frequency: a pane that
|
|
906
|
+
* cannot be created is not going to start working on the next pass, and the
|
|
907
|
+
* retries actively make it worse — `pane_split_failed: ghostty error -2` is the
|
|
908
|
+
* terminal the loop eventually produces for itself. A run with no pane keeps
|
|
909
|
+
* working; only its representation is missing.
|
|
910
|
+
*/
|
|
911
|
+
export const PANE_REATTEMPT_MAX = 3;
|
|
912
|
+
|
|
749
913
|
export function workerPaneLabel(identity: WorkerPaneIdentity): string {
|
|
750
914
|
return `worker-${identity.project}-${identity.issue}-a${identity.attempt}-${identity.runId.slice(0, 8)}`;
|
|
751
915
|
}
|
|
@@ -764,16 +928,46 @@ function defaultViewer(identity: WorkerPaneIdentity): readonly string[] {
|
|
|
764
928
|
return ["omp-conductor", "tail", String(identity.issue), "--project", identity.project];
|
|
765
929
|
}
|
|
766
930
|
|
|
931
|
+
/**
|
|
932
|
+
* The pane id inside `herdr pane split`'s answer.
|
|
933
|
+
*
|
|
934
|
+
* herdr answers every CLI call with a one-line JSON envelope
|
|
935
|
+
* (`{"id":"cli:pane:split","result":{"pane":{"pane_id":"w2:pT",…}}}`), so raw
|
|
936
|
+
* stdout is never a pane id. Passing it through verbatim made every worker
|
|
937
|
+
* launch fail `rename` with `pane_not_found` while leaking the pane it had just
|
|
938
|
+
* created (#992) — and the test fake that answered a bare `%7` is why the suite
|
|
939
|
+
* agreed. Parse the field; a shape this does not recognise is no pane id at
|
|
940
|
+
* all, never a best guess scraped out of the payload.
|
|
941
|
+
*/
|
|
942
|
+
function parsePaneId(stdout: string): string | undefined {
|
|
943
|
+
const line = firstLine(stdout);
|
|
944
|
+
if (line === "") return undefined;
|
|
945
|
+
let payload: unknown;
|
|
946
|
+
try {
|
|
947
|
+
payload = JSON.parse(line);
|
|
948
|
+
} catch {
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
if (payload === null || typeof payload !== "object") return undefined;
|
|
952
|
+
const result = (payload as Record<string, unknown>)["result"];
|
|
953
|
+
if (result === null || typeof result !== "object") return undefined;
|
|
954
|
+
const pane = (result as Record<string, unknown>)["pane"];
|
|
955
|
+
if (pane === null || typeof pane !== "object") return undefined;
|
|
956
|
+
const paneId = (pane as Record<string, unknown>)["pane_id"];
|
|
957
|
+
return typeof paneId === "string" && paneId.trim() !== "" ? paneId.trim() : undefined;
|
|
958
|
+
}
|
|
959
|
+
|
|
767
960
|
/**
|
|
768
961
|
* Create the pane, run the follower in it, and report the child's identity and
|
|
769
962
|
* initial state — or say exactly why it could not.
|
|
770
963
|
*
|
|
771
964
|
* Every step is checked, and the first failure returns `unavailable` with the
|
|
772
|
-
* reason
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
965
|
+
* reason — and closes the pane the split had already created, because a
|
|
966
|
+
* partially established representation is worse than none: it is a pane the
|
|
967
|
+
* operator would read as a tracked worker, and before #992 it was left behind
|
|
968
|
+
* on every single launch. What this deliberately does NOT do is decide what a
|
|
969
|
+
* failure means for the launch — failing closed versus running degraded is
|
|
970
|
+
* #841's policy, and inventing it here would pre-empt it.
|
|
777
971
|
*/
|
|
778
972
|
export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDeps = {}): WorkerPaneOutcome {
|
|
779
973
|
const run = deps.run ?? realHerdrRun;
|
|
@@ -785,14 +979,19 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
|
|
|
785
979
|
if (!split.ok) {
|
|
786
980
|
return { kind: "unavailable", reason: `herdr pane split failed: ${firstLine(split.stderr) || "no output"}` };
|
|
787
981
|
}
|
|
788
|
-
const paneId =
|
|
789
|
-
if (paneId ===
|
|
982
|
+
const paneId = parsePaneId(split.stdout);
|
|
983
|
+
if (paneId === undefined) {
|
|
790
984
|
return { kind: "unavailable", reason: "herdr pane split reported no pane id" };
|
|
791
985
|
}
|
|
986
|
+
// The split has created a pane. From here every exit must take it with it.
|
|
987
|
+
const abandon = (reason: string): WorkerPaneOutcome => {
|
|
988
|
+
run([...base, "close", paneId]);
|
|
989
|
+
return { kind: "unavailable", reason };
|
|
990
|
+
};
|
|
792
991
|
|
|
793
992
|
const named = run([...base, "rename", paneId, label]);
|
|
794
993
|
if (!named.ok) {
|
|
795
|
-
return
|
|
994
|
+
return abandon(`herdr pane rename failed: ${firstLine(named.stderr) || "no output"}`);
|
|
796
995
|
}
|
|
797
996
|
|
|
798
997
|
// Identity before display: the pane must be attributable to this exact run
|
|
@@ -813,15 +1012,12 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
|
|
|
813
1012
|
WORKER_PANE_SOURCE,
|
|
814
1013
|
]);
|
|
815
1014
|
if (!identified.ok) {
|
|
816
|
-
return {
|
|
817
|
-
kind: "unavailable",
|
|
818
|
-
reason: `herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`,
|
|
819
|
-
};
|
|
1015
|
+
return abandon(`herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`);
|
|
820
1016
|
}
|
|
821
1017
|
|
|
822
1018
|
const started = run([...base, "run", paneId, ...deps.viewer?.(identity) ?? defaultViewer(identity)]);
|
|
823
1019
|
if (!started.ok) {
|
|
824
|
-
return
|
|
1020
|
+
return abandon(`herdr pane run failed: ${firstLine(started.stderr) || "no output"}`);
|
|
825
1021
|
}
|
|
826
1022
|
|
|
827
1023
|
// A worker that has just spawned is working by definition. The ongoing
|
|
@@ -829,7 +1025,7 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
|
|
|
829
1025
|
// events — never from the pane's output.
|
|
830
1026
|
const reported = reportWorkerPaneState(paneId, label, "working", { run, session });
|
|
831
1027
|
if (!reported.ok) {
|
|
832
|
-
return
|
|
1028
|
+
return abandon(reported.reason);
|
|
833
1029
|
}
|
|
834
1030
|
return { kind: "tracked", paneId, label, pid: identity.pid };
|
|
835
1031
|
}
|
|
@@ -982,8 +1178,15 @@ export type WorkerPaneReconciliation =
|
|
|
982
1178
|
| { kind: "intact"; runId: string; paneId: string }
|
|
983
1179
|
/** Herdr lost the pane (a restart); a new one now represents the same child. */
|
|
984
1180
|
| { kind: "reassociated"; runId: string; paneId: string; label: string }
|
|
985
|
-
/** No representation, and the reason. The run keeps working regardless.
|
|
986
|
-
|
|
1181
|
+
/** No representation, and the reason. The run keeps working regardless.
|
|
1182
|
+
* `attempted` marks the ones that spent a re-establishment attempt (#998),
|
|
1183
|
+
* so the caller can bound them; the no-pid case costs nothing. */
|
|
1184
|
+
| { kind: "untracked"; runId: string; reason: string; attempted?: true }
|
|
1185
|
+
/** The attempt budget for this run is spent, so nothing was tried this pass
|
|
1186
|
+
* (#998). Before this existed, a run whose pane could not be created was
|
|
1187
|
+
* retried on every reconciliation pass forever — and each attempt leaked a
|
|
1188
|
+
* pane until #992, ending in `ghostty error -2` once enough had piled up. */
|
|
1189
|
+
| { kind: "attempts-exhausted"; runId: string; attempts: number }
|
|
987
1190
|
/** A conductor pane whose run is not live: handed back to Herdr. */
|
|
988
1191
|
| { kind: "stale-released"; paneId: string; runId: string }
|
|
989
1192
|
| { kind: "stale-release-failed"; paneId: string; runId: string; reason: string };
|
|
@@ -1011,6 +1214,10 @@ export type WorkerPaneReconciliation =
|
|
|
1011
1214
|
export function reconcileWorkerPanes(
|
|
1012
1215
|
live: readonly LiveWorkerPane[],
|
|
1013
1216
|
deps: WorkerPaneDeps = {},
|
|
1217
|
+
/** Attempts already spent per run id, owned by the caller so this stays pure
|
|
1218
|
+
* and a daemon restart legitimately gets a fresh budget (#998). */
|
|
1219
|
+
attempts: ReadonlyMap<string, number> = new Map(),
|
|
1220
|
+
maxAttempts = PANE_REATTEMPT_MAX,
|
|
1014
1221
|
): { ok: true; outcomes: WorkerPaneReconciliation[] } | { ok: false; reason: string } {
|
|
1015
1222
|
const listed = listWorkerPanes(deps);
|
|
1016
1223
|
if (!listed.ok) return { ok: false, reason: listed.reason };
|
|
@@ -1036,6 +1243,13 @@ export function reconcileWorkerPanes(
|
|
|
1036
1243
|
});
|
|
1037
1244
|
continue;
|
|
1038
1245
|
}
|
|
1246
|
+
// The budget, checked before the attempt: an exhausted run is skipped
|
|
1247
|
+
// silently here and reported once by the caller (#998).
|
|
1248
|
+
const spent = attempts.get(worker.runId) ?? 0;
|
|
1249
|
+
if (spent >= maxAttempts) {
|
|
1250
|
+
outcomes.push({ kind: "attempts-exhausted", runId: worker.runId, attempts: spent });
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1039
1253
|
const opened = openWorkerPane(
|
|
1040
1254
|
{
|
|
1041
1255
|
project: worker.project,
|
|
@@ -1050,7 +1264,7 @@ export function reconcileWorkerPanes(
|
|
|
1050
1264
|
outcomes.push(
|
|
1051
1265
|
opened.kind === "tracked"
|
|
1052
1266
|
? { kind: "reassociated", runId: worker.runId, paneId: opened.paneId, label: opened.label }
|
|
1053
|
-
: { kind: "untracked", runId: worker.runId, reason: opened.reason },
|
|
1267
|
+
: { kind: "untracked", runId: worker.runId, reason: opened.reason, attempted: true },
|
|
1054
1268
|
);
|
|
1055
1269
|
}
|
|
1056
1270
|
|
|
@@ -1717,12 +1931,20 @@ export function codeGraphFromHealthz(body: string | undefined, project: string):
|
|
|
1717
1931
|
}
|
|
1718
1932
|
|
|
1719
1933
|
|
|
1934
|
+
/** One live pause as `/healthz` reports it: the phase, plus who asked and
|
|
1935
|
+
* when where the daemon recorded provenance (#997). */
|
|
1936
|
+
export interface WorkerPauseView {
|
|
1937
|
+
phase: WorkerPausePhase;
|
|
1938
|
+
source?: string;
|
|
1939
|
+
pausedAtMs?: number;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1720
1942
|
/** Live worker pause phases from a daemon `/healthz` body for one project. */
|
|
1721
1943
|
export function workerPhasesFromHealthz(
|
|
1722
1944
|
body: string | undefined,
|
|
1723
1945
|
project: string,
|
|
1724
|
-
): ReadonlyMap<number,
|
|
1725
|
-
const phases = new Map<number,
|
|
1946
|
+
): ReadonlyMap<number, WorkerPauseView> {
|
|
1947
|
+
const phases = new Map<number, WorkerPauseView>();
|
|
1726
1948
|
if (body === undefined) return phases;
|
|
1727
1949
|
try {
|
|
1728
1950
|
const payload = JSON.parse(body) as unknown;
|
|
@@ -1757,7 +1979,15 @@ export function workerPhasesFromHealthz(
|
|
|
1757
1979
|
(issue as number) > 0 &&
|
|
1758
1980
|
(phase === "pausing" || phase === "paused")
|
|
1759
1981
|
) {
|
|
1760
|
-
|
|
1982
|
+
const source = Reflect.get(worker, "source");
|
|
1983
|
+
const pausedAtMs = Reflect.get(worker, "pausedAtMs");
|
|
1984
|
+
phases.set(issue as number, {
|
|
1985
|
+
phase,
|
|
1986
|
+
...(typeof source === "string" && source !== "" ? { source } : {}),
|
|
1987
|
+
...(Number.isSafeInteger(pausedAtMs) && (pausedAtMs as number) > 0
|
|
1988
|
+
? { pausedAtMs: pausedAtMs as number }
|
|
1989
|
+
: {}),
|
|
1990
|
+
});
|
|
1761
1991
|
}
|
|
1762
1992
|
}
|
|
1763
1993
|
} catch {
|
|
@@ -1840,7 +2070,7 @@ export type FleetStatusReport = StatusSnapshot & {
|
|
|
1840
2070
|
brief: string | undefined;
|
|
1841
2071
|
decisions: string | undefined;
|
|
1842
2072
|
failureClasses: string | undefined;
|
|
1843
|
-
workerPhases: { issue: number;
|
|
2073
|
+
workerPhases: { issue: number; view: WorkerPauseView }[];
|
|
1844
2074
|
intake: string | undefined;
|
|
1845
2075
|
/** The project-scoped durable to-spec grooming lifecycle as status lines
|
|
1846
2076
|
* (#809), or nothing when there is nothing to report. */
|
|
@@ -1892,7 +2122,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1892
2122
|
// throwing probe cannot leak its handle.
|
|
1893
2123
|
const store = openStore(dbPath());
|
|
1894
2124
|
const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
|
|
1895
|
-
([issue,
|
|
2125
|
+
([issue, view]) => ({ issue, view }),
|
|
1896
2126
|
);
|
|
1897
2127
|
// The newest host-wide stop/restart provenance (#378). Read here — not in
|
|
1898
2128
|
// `statusSnapshot`, which is synchronous and belongs to the daemon module —
|
|
@@ -1959,7 +2189,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
|
|
|
1959
2189
|
report.brief,
|
|
1960
2190
|
report.decisions,
|
|
1961
2191
|
report.failureClasses,
|
|
1962
|
-
new Map(report.workerPhases.map(({ issue,
|
|
2192
|
+
new Map(report.workerPhases.map(({ issue, view }) => [issue, view])),
|
|
1963
2193
|
report.intake,
|
|
1964
2194
|
report.lastStop,
|
|
1965
2195
|
report.siblings,
|
|
@@ -2008,6 +2238,10 @@ function briefStatusLine(project: ProjectConfig): string | undefined {
|
|
|
2008
2238
|
* empty because empty is the healthy answer. `classified, no action` is the
|
|
2009
2239
|
* signal — review returns and holds — named as such, never as a backlog.
|
|
2010
2240
|
*/
|
|
2241
|
+
/** How many surviving `unknown` rows a status render explains (#986). A board
|
|
2242
|
+
* is read at a glance: enough to show a pattern, never a backlog dump. */
|
|
2243
|
+
const UNKNOWN_EVIDENCE_SHOWN = 5;
|
|
2244
|
+
|
|
2011
2245
|
function failureClassBlock(projectName: string): string | undefined {
|
|
2012
2246
|
const path = dbPath();
|
|
2013
2247
|
if (!existsSync(path)) return undefined;
|
|
@@ -2030,6 +2264,20 @@ function failureClassBlock(projectName: string): string | undefined {
|
|
|
2030
2264
|
...recorded.map((c) => ` ${c.cls.padEnd(24)}${c.n}`),
|
|
2031
2265
|
);
|
|
2032
2266
|
}
|
|
2267
|
+
// A surviving `unknown` is an unnamed cause, and the only way anyone closes
|
|
2268
|
+
// that gap is by seeing what the settle sweep actually knew about the row
|
|
2269
|
+
// (#986). Bounded to the newest few: this is a status board, not a log, and
|
|
2270
|
+
// a fleet with a long unknown history must not turn one render into a scan.
|
|
2271
|
+
const unknown = store.unknownRuns(projectName, UNKNOWN_EVIDENCE_SHOWN);
|
|
2272
|
+
const withEvidence = unknown.filter((run) => run.terminalEvidence !== undefined);
|
|
2273
|
+
if (withEvidence.length > 0) {
|
|
2274
|
+
lines.push(
|
|
2275
|
+
"unknown, with terminal evidence",
|
|
2276
|
+
...withEvidence.map(
|
|
2277
|
+
(run) => ` #${run.issue} ${(run.terminalEvidence ?? "").slice(0, 90)}`,
|
|
2278
|
+
),
|
|
2279
|
+
);
|
|
2280
|
+
}
|
|
2033
2281
|
return lines.join("\n");
|
|
2034
2282
|
} catch {
|
|
2035
2283
|
return undefined;
|