omp-conductor 0.19.6 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +27 -2
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7832
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/dashboard/app.js
CHANGED
|
@@ -574,6 +574,62 @@ function orDash(value, format) {
|
|
|
574
574
|
return value === null || value === undefined ? "—" : format(value);
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
/**
|
|
578
|
+
* The attribution block (phase 4): where the spend went, as opposed to what it
|
|
579
|
+
* bought. Rendered in the empty case too — a dry queue is exactly when the
|
|
580
|
+
* daemon's own grooming sessions run, and that spend bought no outcome, which
|
|
581
|
+
* is the number an operator most wants to see.
|
|
582
|
+
*/
|
|
583
|
+
function renderAttribution(out, report) {
|
|
584
|
+
const models = report.models ?? [];
|
|
585
|
+
if (models.length > 0) {
|
|
586
|
+
const box = el("div", "chart");
|
|
587
|
+
box.appendChild(el("h4", undefined, "Per model (grouped on the model actually billed)"));
|
|
588
|
+
const rows = el("div", "summary");
|
|
589
|
+
for (const m of models) {
|
|
590
|
+
// Nothing metered at all is stated as unknown: "$0.00 metered" is true and
|
|
591
|
+
// still reads as free, which is the misreading this panel exists to
|
|
592
|
+
// prevent — the same rule the CLI's human form follows.
|
|
593
|
+
const cost =
|
|
594
|
+
m.spendUsd === 0 && m.unmeteredRuns === m.runs
|
|
595
|
+
? "cost unknown — no run on this model metered"
|
|
596
|
+
: `${usd(m.spendUsd)} metered · ${orDash(m.spendPerMerge, usd)} / merge` +
|
|
597
|
+
(m.unmeteredRuns > 0 ? ` · ${m.unmeteredRuns} unmetered` : "");
|
|
598
|
+
rows.appendChild(statLine(m.model, `${m.runs} run(s) · ${m.merges} merge(s) · ${cost}`));
|
|
599
|
+
}
|
|
600
|
+
box.appendChild(rows);
|
|
601
|
+
out.appendChild(box);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const ran = (report.sessions ?? []).filter((s) => s.sessions > 0);
|
|
605
|
+
const unmeteredRoles = report.unmeteredRoles ?? [];
|
|
606
|
+
// Silence rather than a row of zeros: "no grooming ran" and "grooming cost
|
|
607
|
+
// nothing" are different claims, and only the first would be true.
|
|
608
|
+
if (ran.length === 0 && unmeteredRoles.length === 0) return;
|
|
609
|
+
const box = el("div", "chart");
|
|
610
|
+
box.appendChild(el("h4", undefined, "Daemon-owned sessions (not worker runs)"));
|
|
611
|
+
const rows = el("div", "summary");
|
|
612
|
+
for (const s of ran) {
|
|
613
|
+
// An unmetered session's cost is unknown, so it is stated as a count and
|
|
614
|
+
// never folded into the dollar figure as a zero.
|
|
615
|
+
const cost =
|
|
616
|
+
s.spendUsd === 0 && s.unmeteredSessions === s.sessions
|
|
617
|
+
? "cost unknown — no session metered"
|
|
618
|
+
: `${usd(s.spendUsd)} metered` +
|
|
619
|
+
(s.unmeteredSessions > 0
|
|
620
|
+
? ` · ${s.unmeteredSessions} unmetered (cost unknown, not $0.00)`
|
|
621
|
+
: "");
|
|
622
|
+
rows.appendChild(statLine(s.role, `${s.sessions} session(s) · ${s.turns} turn(s) · ${cost}`));
|
|
623
|
+
}
|
|
624
|
+
for (const role of unmeteredRoles) {
|
|
625
|
+
rows.appendChild(
|
|
626
|
+
statLine(role, "unmetered — no usage events observable, so the cost is unknown"),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
box.appendChild(rows);
|
|
630
|
+
out.appendChild(box);
|
|
631
|
+
}
|
|
632
|
+
|
|
577
633
|
function hours(ms) {
|
|
578
634
|
return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
579
635
|
}
|
|
@@ -594,6 +650,7 @@ async function refreshStats() {
|
|
|
594
650
|
),
|
|
595
651
|
);
|
|
596
652
|
out.appendChild(statLine("GitHub API calls", String(report.ghCalls)));
|
|
653
|
+
renderAttribution(out, report);
|
|
597
654
|
return;
|
|
598
655
|
}
|
|
599
656
|
|
|
@@ -657,6 +714,7 @@ async function refreshStats() {
|
|
|
657
714
|
(v) => String(v),
|
|
658
715
|
),
|
|
659
716
|
);
|
|
717
|
+
renderAttribution(out, report);
|
|
660
718
|
}
|
|
661
719
|
|
|
662
720
|
statsWindowBar.addEventListener("click", (event) => {
|
|
@@ -47,6 +47,7 @@ import { makeTracker } from "../tracker/github.ts";
|
|
|
47
47
|
import { dbPath, openStore } from "../store.ts";
|
|
48
48
|
import { loadConfig, resolveCaps } from "../config.ts";
|
|
49
49
|
import { acknowledgeUpgradeRecovery } from "../upgrade-verify.ts";
|
|
50
|
+
import { httpAuthHeader, missingHttpTokenMessage } from "../http-token.ts";
|
|
50
51
|
import type { ProjectConfig } from "../types.ts";
|
|
51
52
|
|
|
52
53
|
/** The source string every dashboard mutation attributes itself with. */
|
|
@@ -265,8 +266,24 @@ export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDep
|
|
|
265
266
|
|
|
266
267
|
// ------------------------------------------------------- production wiring --
|
|
267
268
|
|
|
268
|
-
/**
|
|
269
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Forward to the living daemon's own HTTP surface, or report there is none.
|
|
271
|
+
*
|
|
272
|
+
* Every route this forwards to is mutating, so the request carries the
|
|
273
|
+
* daemon's bearer token (Phase 4). Note this is deliberately *not* the
|
|
274
|
+
* dashboard's own token: the browser authenticated to the dashboard, and the
|
|
275
|
+
* dashboard authenticates to the daemon as a second, separately-scoped hop.
|
|
276
|
+
* A missing daemon token is its own answer rather than the 502 "no living
|
|
277
|
+
* daemon" — the daemon may be perfectly alive; what is missing is the
|
|
278
|
+
* credential file, and telling the operator "start the daemon" when it is
|
|
279
|
+
* already running would send them chasing the wrong thing.
|
|
280
|
+
*
|
|
281
|
+
* Exported so the suite can drive the *production* forwarder — the one that
|
|
282
|
+
* actually builds the request — against a stub daemon. Every other test injects
|
|
283
|
+
* `ControlDeps.proxy`, which by construction cannot prove this function sends
|
|
284
|
+
* the credential.
|
|
285
|
+
*/
|
|
286
|
+
export async function defaultProxy(
|
|
270
287
|
project: ProjectConfig,
|
|
271
288
|
path: string,
|
|
272
289
|
body: unknown,
|
|
@@ -277,13 +294,15 @@ async function defaultProxy(
|
|
|
277
294
|
// would apply an operator's action to the wrong fleet.
|
|
278
295
|
if (record === undefined) return undefined;
|
|
279
296
|
if (record.project !== undefined && record.project !== project.name) return undefined;
|
|
297
|
+
const auth = httpAuthHeader();
|
|
298
|
+
if (auth === undefined) return { status: 503, body: { error: missingHttpTokenMessage() } };
|
|
280
299
|
// Cheap liveness first, so an unreachable daemon is a 502 rather than a
|
|
281
300
|
// mutation attempt that hangs the browser.
|
|
282
301
|
if (!(await healthCheck(record.port)).ok) return undefined;
|
|
283
302
|
try {
|
|
284
303
|
const res = await fetch(`http://127.0.0.1:${record.port}${path}`, {
|
|
285
304
|
method: "PUT",
|
|
286
|
-
headers: { "content-type": "application/json" },
|
|
305
|
+
headers: { "content-type": "application/json", ...auth },
|
|
287
306
|
body: JSON.stringify(body),
|
|
288
307
|
signal: AbortSignal.timeout(10_000),
|
|
289
308
|
});
|
package/src/dashboard/server.ts
CHANGED
|
@@ -331,6 +331,10 @@ async function statsProducer(name: string, since: string): Promise<StatsReport |
|
|
|
331
331
|
window,
|
|
332
332
|
ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
|
|
333
333
|
runs: store.statsRuns(name, window.sinceEpochMs),
|
|
334
|
+
// Phase 4 attribution: daemon-owned session spend lives in its own table,
|
|
335
|
+
// deliberately outside `runs`, so it is a second read rather than more
|
|
336
|
+
// rows in the first one.
|
|
337
|
+
sessions: store.sessionSpendSince(name, window.sinceEpochMs),
|
|
334
338
|
});
|
|
335
339
|
} finally {
|
|
336
340
|
store.close();
|
package/src/diff-flags.ts
CHANGED
|
@@ -813,14 +813,35 @@ function claimRegion(body: string): string {
|
|
|
813
813
|
return next === null ? rest : rest.slice(0, marker[0].length + next.index);
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
/**
|
|
817
|
+
* An executable token: the shape a claimed command's first word must have to
|
|
818
|
+
* be a program somebody could have run. Either an ordinary command name — an
|
|
819
|
+
* ASCII letter or `_` first, then letters, digits, `_`, `.`, `+`, `-` (`bun`,
|
|
820
|
+
* `bash`, `cd`, `export`, `omp-conductor`, `python3.12`) — or a `./`-relative
|
|
821
|
+
* executable path built from those same characters plus `/`
|
|
822
|
+
* (`./scripts/deploy.sh`).
|
|
823
|
+
*
|
|
824
|
+
* Nothing else is a command, and that exclusion is the whole point (#1039):
|
|
825
|
+
* this predicate used to accept any first word that merely lacked a `/`, so
|
|
826
|
+
* the malformed-JSON fixture `{not json` described in a PR's verified prose
|
|
827
|
+
* became a "claimed" command with no transcript match, and a green PR settled
|
|
828
|
+
* with a false `claimed-proof-missing`. The grammar therefore refuses
|
|
829
|
+
* punctuation-led fragments (`{not`, `{"kind":`, `[1,`), quote-led values
|
|
830
|
+
* (`"some value"`), tokens carrying `:` or `=` (config expressions like
|
|
831
|
+
* `retry.modelFallback: true`, env assignments), digit-led data literals
|
|
832
|
+
* (timestamps like `2026-08-24 09:14Z`) and flag-led spans (`--json`). An
|
|
833
|
+
* absolute path stays excluded as it always was: it carries `/` without the
|
|
834
|
+
* leading `./`.
|
|
835
|
+
*/
|
|
836
|
+
const EXECUTABLE_TOKEN = /^(?:[A-Za-z_][A-Za-z0-9_.+-]*|\.\/[A-Za-z0-9_.+\-/]+)$/;
|
|
837
|
+
|
|
816
838
|
/** One backticked span that reads as a whole command: at least two shell words
|
|
817
|
-
* and a first word that is an
|
|
839
|
+
* and a first word that is an {@link EXECUTABLE_TOKEN}.
|
|
818
840
|
* A span that names a single file or a config key is not a command claim. */
|
|
819
841
|
function claimedCommand(span: string): boolean {
|
|
820
842
|
const words = span.split(/\s+/).filter((word) => word.length > 0);
|
|
821
843
|
if (words.length < 2) return false;
|
|
822
|
-
|
|
823
|
-
return first.startsWith("./") || !first.includes("/");
|
|
844
|
+
return EXECUTABLE_TOKEN.test(words[0] ?? "");
|
|
824
845
|
}
|
|
825
846
|
|
|
826
847
|
/**
|
package/src/doctor.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
claimedTelegramTopics,
|
|
69
69
|
lockPidAlive,
|
|
70
70
|
pidAlive,
|
|
71
|
+
killOf,
|
|
71
72
|
readTelegramChannel,
|
|
72
73
|
readTelegramDmOwner,
|
|
73
74
|
readTelegramPollState,
|
|
@@ -311,12 +312,11 @@ export interface DoctorDeps {
|
|
|
311
312
|
* claim-only verdict checks only. */
|
|
312
313
|
armScanDirs?: (project: ProjectConfig) => readonly string[];
|
|
313
314
|
/** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
|
|
314
|
-
* topics.ts semantics (EPERM is dead).
|
|
315
|
+
* topics.ts semantics (EPERM is dead). The one claim-liveness fact in this
|
|
316
|
+
* package: topic-pin's dead partition, its exact-pin check, project
|
|
317
|
+
* resolution and the telegram-plumbing verdict all derive from it, so two
|
|
318
|
+
* rows cannot answer one claim differently (#987). */
|
|
315
319
|
pidAlive?: (pid: number) => boolean;
|
|
316
|
-
/** Whether one claimed topic is live — the single fact `topic-pin` and
|
|
317
|
-
* `telegram-plumbing` both read, so they cannot disagree about a claim
|
|
318
|
-
* (#987). Defaults to {@link claimIsLive}. */
|
|
319
|
-
claimIsLive?: (claim: { pid?: number }) => boolean;
|
|
320
320
|
/** The provider allowance window nearest its ceiling, from `omp usage --json`
|
|
321
321
|
* — what binds a subscription-billed fleet instead of a dollar cap (#984).
|
|
322
322
|
* `undefined` when the provider reports no comparable window. */
|
|
@@ -1401,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1401
1401
|
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
|
|
1402
1402
|
);
|
|
1403
1403
|
}
|
|
1404
|
-
// One liveness fact
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1404
|
+
// One liveness fact for everything this probe decides — the dead partition,
|
|
1405
|
+
// the exact-pin check below and identity resolution all read
|
|
1406
|
+
// `probes.pidAlive` through the same helper, so diverging injections cannot
|
|
1407
|
+
// split the rows' answer again (#987). Before #987 every registry row was
|
|
1408
|
+
// treated as live here, so a pin aimed at a corpse read PASS while the
|
|
1409
|
+
// plumbing row called the same claim dead.
|
|
1410
|
+
const isLive = (claim: { pid?: number }): boolean => claimIsLive(claim, killOf(probes.pidAlive));
|
|
1407
1411
|
const all = result.claims;
|
|
1408
|
-
const claims = all.filter(
|
|
1409
|
-
const dead = all.filter((claim) => !
|
|
1412
|
+
const claims = all.filter(isLive);
|
|
1413
|
+
const dead = all.filter((claim) => !isLive(claim));
|
|
1410
1414
|
// Named, never dropped: the row is the only index to the remote topic, so
|
|
1411
1415
|
// deleting it strands the topic instead of cleaning it up.
|
|
1412
1416
|
const deadNote =
|
|
@@ -1428,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1428
1432
|
`[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
|
|
1429
1433
|
);
|
|
1430
1434
|
}
|
|
1435
|
+
// `claims` is already the live partition, so a dead pinned row cannot win
|
|
1436
|
+
// this check (#987).
|
|
1431
1437
|
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1432
1438
|
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
|
|
1433
1439
|
}
|
|
1434
|
-
const match = resolveProjectClaim(claims, p.name);
|
|
1440
|
+
const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
|
|
1435
1441
|
if (match.kind === "match") {
|
|
1436
1442
|
return passFinding(
|
|
1437
1443
|
"topic-pin",
|
|
@@ -2192,7 +2198,6 @@ export function defaultProbes(): Probes {
|
|
|
2192
2198
|
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
2193
2199
|
},
|
|
2194
2200
|
pidAlive,
|
|
2195
|
-
claimIsLive: (claim) => claimIsLive(claim),
|
|
2196
2201
|
allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
|
|
2197
2202
|
lockPidAlive,
|
|
2198
2203
|
lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
|
package/src/escalate.ts
CHANGED
|
@@ -467,11 +467,13 @@ export interface ClaimedTopic {
|
|
|
467
467
|
* own maintenance, and every topic-addressed send silently degrades to the main
|
|
468
468
|
* chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
|
|
469
469
|
*
|
|
470
|
-
* The operator's pin still wins whenever it is live. Only a pin that
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
470
|
+
* The operator's pin still wins whenever it is live. Only a pin that no *live*
|
|
471
|
+
* claim carries is replaced, and only by the claim this project can be
|
|
472
|
+
* identified with — so a deliberately separate alerts topic is never hijacked
|
|
473
|
+
* by the pane's own thread. A dead row wearing the pin id does not keep it in
|
|
474
|
+
* place: the row is the bridge's past, not a destination (#987). Unavailable
|
|
475
|
+
* bridge state changes nothing, and #318's stale-topic retry remains the last
|
|
476
|
+
* line of defence.
|
|
475
477
|
*
|
|
476
478
|
* Identity is read from the herdr space first, and only then from the claim's
|
|
477
479
|
* title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
|
|
@@ -483,14 +485,19 @@ export interface ClaimedTopic {
|
|
|
483
485
|
* The substitution is logged, naming the project and which identity answered.
|
|
484
486
|
* Never an id: a log line is a place these leak from.
|
|
485
487
|
*/
|
|
486
|
-
export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
|
|
488
|
+
export function resolveProjectTopicId(project: ProjectConfig, alive?: (pid: number) => boolean): number | undefined {
|
|
487
489
|
const pinned = project.escalation.telegramTopicId;
|
|
488
490
|
if (pinned === undefined) return undefined;
|
|
489
491
|
const result = claimedTelegramTopics();
|
|
490
492
|
if (result.kind !== "ok" || result.claims.length === 0) return pinned;
|
|
491
493
|
const claims = result.claims;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
+
// The pin wins only while a *live* claim carries it; a dead pinned row falls
|
|
495
|
+
// through to the live-claim substitution below instead of winning the
|
|
496
|
+
// exact-pin check (#987).
|
|
497
|
+
if (claims.some((claim) => claim.threadId === pinned && claimIsLive(claim, alive === undefined ? undefined : killOf(alive)))) {
|
|
498
|
+
return pinned;
|
|
499
|
+
}
|
|
500
|
+
const match = claimForProject(claims, project.name, alive);
|
|
494
501
|
if (match === undefined) return pinned;
|
|
495
502
|
warn(
|
|
496
503
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
@@ -519,25 +526,32 @@ export type ProjectClaim =
|
|
|
519
526
|
* How the live claims answer to a project, identity spellings in order:
|
|
520
527
|
* unique herdr space, then unique title.
|
|
521
528
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
+
* Only live claims take part ({@link claimIsLive}): a dead row neither
|
|
530
|
+
* answers on its own nor turns this project's one live pane into an
|
|
531
|
+
* ambiguity (#987). Ambiguity is not a coin toss: paging the wrong project's
|
|
532
|
+
* topic is worse than the flat-chat degrade #318 already handles, and
|
|
533
|
+
* scanning the wrong pane's session misses the reply entirely. The title is
|
|
534
|
+
* a fallback for bridges that never captured a space, so it runs only when
|
|
535
|
+
* *no* live claim carries the project's space — several live claims wearing
|
|
536
|
+
* the space is an ambiguity the title cannot resolve, because the titled
|
|
537
|
+
* claim may be a sibling pane, not this project (#626).
|
|
529
538
|
*/
|
|
530
539
|
export function resolveProjectClaim(
|
|
531
540
|
claims: readonly ClaimedTopic[],
|
|
532
541
|
projectName: string,
|
|
542
|
+
alive?: (pid: number) => boolean,
|
|
533
543
|
): ProjectClaim {
|
|
534
|
-
|
|
544
|
+
// Dead claims do not vote: a closed pane's row must not turn this project's
|
|
545
|
+
// one live claim into an ambiguity, and must not answer by title on its own
|
|
546
|
+
// (#987). Ambiguity among genuinely live claims is unchanged (#626).
|
|
547
|
+
const live = claims.filter((claim) => claimIsLive(claim, alive === undefined ? undefined : killOf(alive)));
|
|
548
|
+
const bySpace = live.filter((claim) => claim.workspaceLabel === projectName);
|
|
535
549
|
if (bySpace.length > 0) {
|
|
536
550
|
return bySpace.length === 1
|
|
537
551
|
? { kind: "match", claim: bySpace[0]! }
|
|
538
552
|
: { kind: "ambiguous", claimants: bySpace };
|
|
539
553
|
}
|
|
540
|
-
const byTitle =
|
|
554
|
+
const byTitle = live.filter((claim) => claim.name === projectName);
|
|
541
555
|
if (byTitle.length === 0) return { kind: "none" };
|
|
542
556
|
return byTitle.length === 1
|
|
543
557
|
? { kind: "match", claim: byTitle[0]! }
|
|
@@ -556,8 +570,9 @@ export function resolveProjectClaim(
|
|
|
556
570
|
export function claimForProject(
|
|
557
571
|
claims: readonly ClaimedTopic[],
|
|
558
572
|
projectName: string,
|
|
573
|
+
alive?: (pid: number) => boolean,
|
|
559
574
|
): ClaimedTopic | undefined {
|
|
560
|
-
const match = resolveProjectClaim(claims, projectName);
|
|
575
|
+
const match = resolveProjectClaim(claims, projectName, alive);
|
|
561
576
|
return match.kind === "match" ? match.claim : undefined;
|
|
562
577
|
}
|
|
563
578
|
|
|
@@ -574,10 +589,13 @@ export function claimForProject(
|
|
|
574
589
|
* the claim names no file — the caller then falls back to the cwd-derived
|
|
575
590
|
* directory, which is the honest answer for a host that predates claims.
|
|
576
591
|
*/
|
|
577
|
-
export function resolveClaimedSessionFile(
|
|
592
|
+
export function resolveClaimedSessionFile(
|
|
593
|
+
project: ProjectConfig,
|
|
594
|
+
alive?: (pid: number) => boolean,
|
|
595
|
+
): string | undefined {
|
|
578
596
|
const result = claimedTelegramTopics();
|
|
579
597
|
if (result.kind !== "ok" || result.claims.length === 0) return undefined;
|
|
580
|
-
return claimForProject(result.claims, project.name)?.sessionFile;
|
|
598
|
+
return claimForProject(result.claims, project.name, alive)?.sessionFile;
|
|
581
599
|
}
|
|
582
600
|
|
|
583
601
|
/**
|
|
@@ -1111,7 +1129,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
|
1111
1129
|
* Throwing is how `pidAlive` spells "dead", which is the contract it applies to
|
|
1112
1130
|
* whatever this returns.
|
|
1113
1131
|
*/
|
|
1114
|
-
function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1132
|
+
export function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1115
1133
|
return (target) => {
|
|
1116
1134
|
if (!alive(target)) throw new Error("not alive");
|
|
1117
1135
|
};
|
package/src/failure-class.ts
CHANGED
|
@@ -62,6 +62,16 @@ const INFRA_CHECK_STATES: Record<string, true> = {
|
|
|
62
62
|
/** States that mean "this check has a verdict and it is good". */
|
|
63
63
|
const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The runner's own sentence for "the agent under this job went away": written
|
|
67
|
+
* when the runner service is stopped, auto-updates, or is torn down under a
|
|
68
|
+
* live job. Named once because two different accountings quote it — the
|
|
69
|
+
* implementation-attempt waiver below ({@link INFRA_LOG_SIGNATURES}, #177) and
|
|
70
|
+
* the review-correction waiver ({@link runnerInfraFailure}) — and a second
|
|
71
|
+
* spelling of it would waive one while charging the other.
|
|
72
|
+
*/
|
|
73
|
+
const RUNNER_SHUTDOWN_SIGNAL = "the runner has received a shutdown signal";
|
|
74
|
+
|
|
65
75
|
/**
|
|
66
76
|
* Substrings in a failed check's log that prove the failure was infrastructure,
|
|
67
77
|
* not the diff (#177). Each is a registry/docker/runner fault a worker cannot
|
|
@@ -87,7 +97,7 @@ const INFRA_LOG_SIGNATURES = [
|
|
|
87
97
|
// the runner's, and must not be waived (#637, #639).
|
|
88
98
|
"response status code does not indicate success: 429 (too many requests)",
|
|
89
99
|
"failed to resolve source metadata for",
|
|
90
|
-
|
|
100
|
+
RUNNER_SHUTDOWN_SIGNAL,
|
|
91
101
|
"could not resolve host",
|
|
92
102
|
];
|
|
93
103
|
|
|
@@ -103,6 +113,70 @@ export function infraLogSignature(log: string): string | undefined {
|
|
|
103
113
|
return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
|
|
104
114
|
}
|
|
105
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Runner-infrastructure wordings: a red check whose own text says the *runner*
|
|
118
|
+
* failed, not the diff (Phase 3, #1043 lane). Matched lowercased as substrings
|
|
119
|
+
* against the failing job's log — the same surface {@link infraLogSignature}
|
|
120
|
+
* reads — and each entry is the real GitHub Actions sentence, verified against
|
|
121
|
+
* the runner's own reports rather than invented:
|
|
122
|
+
*
|
|
123
|
+
* Kept as its own list rather than folded into {@link INFRA_LOG_SIGNATURES}
|
|
124
|
+
* for two reasons. The consumers differ: that list waives an *implementation
|
|
125
|
+
* attempt* for a settled run (#177), this one waives a *correction round* for
|
|
126
|
+
* a live pull request's red check. And {@link infraSignatureVersion} is a
|
|
127
|
+
* persisted cursor fingerprint — growing that list restarts the historical
|
|
128
|
+
* reconciliation, which recognising a runner-lost red has no business doing.
|
|
129
|
+
* The one overlap, {@link RUNNER_SHUTDOWN_SIGNAL}, is shared by reference so
|
|
130
|
+
* the two accountings can never disagree about that wording.
|
|
131
|
+
*/
|
|
132
|
+
const RUNNER_INFRA_SIGNATURES = [
|
|
133
|
+
// "The self-hosted runner: <name> lost communication with the server." and
|
|
134
|
+
// its hosted sibling "The hosted runner: <name> lost communication with the
|
|
135
|
+
// server." — the runner process was killed, starved or cut off mid-job
|
|
136
|
+
// (actions/runner#3539, community#84877, community#173431). The name varies,
|
|
137
|
+
// so only the invariant tail is matched.
|
|
138
|
+
"lost communication with the server",
|
|
139
|
+
// The runner service was stopped or auto-updated under the job: an
|
|
140
|
+
// infrastructure cancellation, not a verdict on the diff.
|
|
141
|
+
RUNNER_SHUTDOWN_SIGNAL,
|
|
142
|
+
// "The job was not acquired by Runner of type hosted even after multiple
|
|
143
|
+
// attempts" — the job never reached a runner at all, so nothing in the diff
|
|
144
|
+
// was ever executed (community#186216, community#165287).
|
|
145
|
+
"was not acquired by runner",
|
|
146
|
+
// "The hosted runner encountered an error while running your job. (Error
|
|
147
|
+
// Type: Failure)." — GitHub's own statement that its runner broke
|
|
148
|
+
// (community#126539). The parenthesised error type varies; the sentence does
|
|
149
|
+
// not.
|
|
150
|
+
"the hosted runner encountered an error while running your job",
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The runner-infrastructure wording a red CI check's text carries, or
|
|
155
|
+
* `undefined` when it carries none.
|
|
156
|
+
*
|
|
157
|
+
* Exists so the review-correction accounting can decline to charge a
|
|
158
|
+
* correction round for a red that the worker's diff did not cause: a lost
|
|
159
|
+
* runner, a runner shut down under the job, or a job no runner ever picked up.
|
|
160
|
+
* The return value is the matched wording, so whatever waives a round can say
|
|
161
|
+
* *which* sentence waived it instead of asserting "infra" unexplained.
|
|
162
|
+
*
|
|
163
|
+
* Fails closed in both directions, and deliberately narrower than it could be:
|
|
164
|
+
*
|
|
165
|
+
* - Unclassifiable, empty or unread text is `undefined` — an unexplained red
|
|
166
|
+
* still counts, and a log-fetch failure hands this function nothing to match,
|
|
167
|
+
* so it never waives.
|
|
168
|
+
* - "The operation was canceled." is *not* here: it is what a failed `needs:`
|
|
169
|
+
* dependency and an operator's own cancel both print, so matching it would
|
|
170
|
+
* waive rounds for ordinary red.
|
|
171
|
+
* - "…has exceeded the maximum execution time of N minutes" is *not* here
|
|
172
|
+
* either: a job that ran until the ceiling is usually a hanging test or an
|
|
173
|
+
* infinite loop, which is exactly the diff's problem to fix.
|
|
174
|
+
*/
|
|
175
|
+
export function runnerInfraFailure(log: string): string | undefined {
|
|
176
|
+
const lower = log.toLowerCase();
|
|
177
|
+
return RUNNER_INFRA_SIGNATURES.find((signature) => lower.includes(signature));
|
|
178
|
+
}
|
|
179
|
+
|
|
106
180
|
/**
|
|
107
181
|
* The cap kills that produced nothing: the worker reached a ceiling with no PR,
|
|
108
182
|
* no observed head and no salvage commit.
|