omp-conductor 0.20.0 → 0.20.2
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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/commands/companion.ts +52 -16
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +31 -14
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +4 -3
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +204 -28
- package/src/doctor.ts +27 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +358 -5
- package/src/fleet.ts +55 -25
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/orchestrator-tick.ts +302 -24
- package/src/reports.ts +4 -1
- package/src/settlement.ts +133 -12
- package/src/status-render.ts +38 -9
- package/src/store.ts +64 -15
- package/src/to-spec.ts +285 -24
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +49 -3
- package/src/verbs/server.ts +16 -12
- package/src/worker.ts +149 -35
package/src/diff-flags.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* repository.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import type { FileLane, PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
|
|
40
|
+
import type { FileLane, PrDiff, PrDiffFile, SettlementFlag, SettlementFlagKind } from "./types.ts";
|
|
41
41
|
|
|
42
42
|
// ------------------------------------------------------------------ diff parse
|
|
43
43
|
|
|
@@ -438,20 +438,125 @@ const ASSERTION =
|
|
|
438
438
|
/^(?:await\s+)?(?:expect|assert|assert_[a-z_]+|assertEquals?|assertTrue|assertFalse|assertThat|assertRaises|assertRaisesRegex|self\.assert[A-Za-z]*|should|chai\.|t\.(?:Error|Fatal)f?|require\.[A-Z][A-Za-z]*|Expect)\s*[.(]/;
|
|
439
439
|
|
|
440
440
|
/**
|
|
441
|
-
* A named timeout and its value
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
441
|
+
* A named timeout and its value, but only on the test runner's own timeout
|
|
442
|
+
* surface ({@link onRunnerSurface}) — a matching key handed to the code under
|
|
443
|
+
* test is a domain parameter, not a runner deadline (#1062). Only a *raised*
|
|
444
|
+
* one is a finding — a brand-new timeout on a new test is not a weakening — so
|
|
445
|
+
* the value is compared against the same key on the pre-image side and silence
|
|
446
|
+
* is the answer whenever the key appears on only one side.
|
|
445
447
|
*/
|
|
446
448
|
const TIMEOUT =
|
|
447
|
-
/\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*[:=(]\s*(\d[\d_]*)/gi;
|
|
449
|
+
/\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*([:=(])\s*(\d[\d_]*)/gi;
|
|
450
|
+
|
|
451
|
+
/** Identifiers that name the test runner itself when a timeout-shaped key is
|
|
452
|
+
* called on or passed to them. `t` is the test context (vitest, node:test, a
|
|
453
|
+
* Go `*testing.T` helper); `pytest` names the Python runner module. */
|
|
454
|
+
const RUNNER_BINDINGS: Record<string, true> = {
|
|
455
|
+
test: true,
|
|
456
|
+
it: true,
|
|
457
|
+
describe: true,
|
|
458
|
+
context: true,
|
|
459
|
+
suite: true,
|
|
460
|
+
bench: true,
|
|
461
|
+
jest: true,
|
|
462
|
+
t: true,
|
|
463
|
+
pytest: true,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Whether a {@link TIMEOUT} match sits on the runner's own timeout surface of
|
|
468
|
+
* its line, as opposed to an argument handed to the code under test.
|
|
469
|
+
*
|
|
470
|
+
* The audit sees hunks, not files, so the judgement is structural on the
|
|
471
|
+
* changed line alone, and anything it cannot vouch for stays silent: a flag is
|
|
472
|
+
* advisory, and a false positive costs the trust in every flag after it
|
|
473
|
+
* (measured: #614/#896 read a raised `timeoutMs` passed to `arm(...)` — the
|
|
474
|
+
* challenge window of the code under test — as a weakened test).
|
|
475
|
+
*
|
|
476
|
+
* Two positions qualify, both on the runner's own call:
|
|
477
|
+
*
|
|
478
|
+
* - the key is called on the runner — `jest.setTimeout(...)`,
|
|
479
|
+
* `t.timeout(...)`, `test.setTimeout(...)`, `pytest.mark.timeout(...)` —
|
|
480
|
+
* the dotted receiver before the key starts with a runner binding;
|
|
481
|
+
* - the key is a property of an object literal that is a direct argument of a
|
|
482
|
+
* runner call — the trailing per-test configuration: `test("x", fn, {
|
|
483
|
+
* timeout: 45_000 })`, `test.use({ retries: 3 })`,
|
|
484
|
+
* `describe.configure({ retries: 3 })`.
|
|
485
|
+
*
|
|
486
|
+
* Everything else is the code under test: `await arm({ timeoutMs: 60_000 })`,
|
|
487
|
+
* `const opts = { timeout: 45000 }`, `server.setTimeout(30_000)`. A shape
|
|
488
|
+
* whose decisive frame is on another line — `}, { timeout: 45000 });` after a
|
|
489
|
+
* multi-line `test(` — cannot be vouched for from the line alone and also
|
|
490
|
+
* stays silent.
|
|
491
|
+
*/
|
|
492
|
+
function onRunnerSurface(code: string, match: RegExpMatchArray): boolean {
|
|
493
|
+
const key = match[1]?.toLowerCase() ?? "";
|
|
494
|
+
if (RUNNER_BINDINGS[key.split(".")[0] ?? ""] === true) return true;
|
|
495
|
+
|
|
496
|
+
const before = code.slice(0, match.index);
|
|
497
|
+
// The dotted receiver the key is called on: `t.timeout(5000)` reads `t.`,
|
|
498
|
+
// `pytest.mark.timeout(500)` reads `pytest.mark.`. No receiver — a bare
|
|
499
|
+
// `timeout: 5000` property — falls through to the config-object rule.
|
|
500
|
+
let at = before.length - 1;
|
|
501
|
+
while (at >= 0 && /[A-Za-z0-9_$.]/.test(before[at] ?? "")) at--;
|
|
502
|
+
const receiver = before.slice(at + 1).replace(/\.$/, "").split(".")[0];
|
|
503
|
+
if (RUNNER_BINDINGS[receiver ?? ""] === true) return true;
|
|
504
|
+
|
|
505
|
+
// A property key (`key: value`) needs the object around it to be the
|
|
506
|
+
// runner's own configuration; a `key = value` assignment or a bare
|
|
507
|
+
// `key(5000)` call on the line is never that.
|
|
508
|
+
if (match[2] !== ":") return false;
|
|
509
|
+
return inRunnerConfigObject(before);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
type RunnerFrame = { kind: "call"; base: string | undefined } | { kind: "obj" };
|
|
513
|
+
|
|
514
|
+
/** Whether the key sits in an object literal that is a direct argument of a
|
|
515
|
+
* call on a runner binding — the trailing per-test configuration:
|
|
516
|
+
* `test("x", fn, { timeout: 45_000 })`, `test.use({ retries: 3 })`,
|
|
517
|
+
* `describe.configure({ retries: 3 })`. The frames are walked over the line
|
|
518
|
+
* prefix only, with strings already stripped by {@link splitCode}, so the
|
|
519
|
+
* object's nesting inside the call — a direct argument versus a property of
|
|
520
|
+
* a nested object or of the callback's own body — decides the verdict. */
|
|
521
|
+
function inRunnerConfigObject(before: string): boolean {
|
|
522
|
+
const frames: RunnerFrame[] = [];
|
|
523
|
+
for (let at = 0; at < before.length; at++) {
|
|
524
|
+
const ch = before[at];
|
|
525
|
+
if (ch === "(") {
|
|
526
|
+
// The dotted name the call was opened on, if any: `test.use(` reads
|
|
527
|
+
// `test.use`. No name — an arrow's parameter list, or a call on a
|
|
528
|
+
// previous call's result — means the object below it is not a runner
|
|
529
|
+
// surface.
|
|
530
|
+
let end = at;
|
|
531
|
+
while (end > 0 && /[A-Za-z0-9_$.]/.test(before[end - 1] ?? "")) end--;
|
|
532
|
+
const chain = before.slice(end, at);
|
|
533
|
+
frames.push({
|
|
534
|
+
kind: "call",
|
|
535
|
+
base: chain.length > 0 && !chain.endsWith(".") ? chain : undefined,
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (ch === "{" || ch === "[") {
|
|
540
|
+
frames.push({ kind: "obj" });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (ch === ")" || ch === "}" || ch === "]") frames.pop();
|
|
544
|
+
}
|
|
545
|
+
const object = frames.at(-1);
|
|
546
|
+
if (object?.kind !== "obj") return false;
|
|
547
|
+
const enclosing = frames.at(-2);
|
|
548
|
+
if (enclosing?.kind !== "call") return false;
|
|
549
|
+
const firstSegment = enclosing.base?.split(".")[0];
|
|
550
|
+
return firstSegment !== undefined && RUNNER_BINDINGS[firstSegment] === true;
|
|
551
|
+
}
|
|
448
552
|
|
|
449
553
|
function timeouts(text: string): { key: string; value: number }[] {
|
|
450
554
|
const found: { key: string; value: number }[] = [];
|
|
451
555
|
for (const match of text.matchAll(TIMEOUT)) {
|
|
452
556
|
const key = match[1]?.toLowerCase();
|
|
453
|
-
const raw = match[
|
|
557
|
+
const raw = match[3]?.replaceAll("_", "");
|
|
454
558
|
if (key === undefined || raw === undefined) continue;
|
|
559
|
+
if (!onRunnerSurface(text, match)) continue;
|
|
455
560
|
const value = Number(raw);
|
|
456
561
|
if (Number.isSafeInteger(value)) found.push({ key, value });
|
|
457
562
|
}
|
|
@@ -552,21 +657,24 @@ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
|
|
|
552
657
|
|
|
553
658
|
/**
|
|
554
659
|
* Whether a diff path counts as inside the declared lane: an explicitly
|
|
555
|
-
* declared path,
|
|
556
|
-
*
|
|
557
|
-
* (
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* is
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
* name
|
|
660
|
+
* declared path, the co-located test of one, or a descendant of a declared
|
|
661
|
+
* directory (#1091) — admission's grammar accepts a trailing-`/` entry
|
|
662
|
+
* ("everything under `src/`"), so settlement honours the same contract rather
|
|
663
|
+
* than narrowing it back to exact files. The co-located allowance is the
|
|
664
|
+
* "obviously intended" case: `foo.ts` vouches for `foo.test.ts`. Other test
|
|
665
|
+
* shapes (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule
|
|
666
|
+
* is the shape the fleet actually uses, and a lane that wants a
|
|
667
|
+
* differently-shaped sibling declares it. Deliberately one-directional: a lane
|
|
668
|
+
* that declares a *test* file does not vouch for its source, because declaring
|
|
669
|
+
* the test alone is a narrower promise and widening it silently is exactly
|
|
670
|
+
* what this flag exists to name. The directory rule keeps its trailing-slash
|
|
671
|
+
* boundary explicit: the entry already carries the separator, so `foo/`
|
|
672
|
+
* covers `foo/bar.ts` at any depth and can never vouch for `foobar/x.ts`.
|
|
566
673
|
*/
|
|
567
674
|
function withinLane(path: string, declared: readonly string[]): boolean {
|
|
568
675
|
if (declared.includes(path)) return true;
|
|
569
676
|
for (const d of declared) {
|
|
677
|
+
if (d.endsWith("/") && path.startsWith(d)) return true;
|
|
570
678
|
if (coLocatedTest(d) === path) return true;
|
|
571
679
|
}
|
|
572
680
|
return false;
|
|
@@ -588,11 +696,22 @@ function coLocatedTest(declared: string): string | undefined {
|
|
|
588
696
|
* The lane is the effective declaration admission resolved at dispatch —
|
|
589
697
|
* `effectiveLane(body, comments)`, so a pre-dispatch comment beats an older
|
|
590
698
|
* body declaration — and the flag uses that resolved snapshot, never a re-parse
|
|
591
|
-
* of the body.
|
|
699
|
+
* of the body. Each finding names the delivered files outside it, which is the
|
|
592
700
|
* part a reviewer is worst placed to notice: the diff's own file list is the
|
|
593
701
|
* only surface that shows the escape, and reading PR file lists by hand is
|
|
594
702
|
* exactly what nothing else in the loop does.
|
|
595
703
|
*
|
|
704
|
+
* An escape splits into up to two findings because it is up to two different
|
|
705
|
+
* events (#1096): an undeclared production module is scope escaping into code,
|
|
706
|
+
* while an undeclared test file is usually coverage arriving beside the
|
|
707
|
+
* behaviour it pins — the #1064/#1080 shape, required work the declaration
|
|
708
|
+
* could not have named. Naming the class on each finding keeps the second
|
|
709
|
+
* readable as what it usually is without letting it disguise the first. The
|
|
710
|
+
* declared source's own `.test` twin is admitted by {@link withinLane}, so
|
|
711
|
+
* the obvious case raises nothing — and the rule stays one-directional, so a
|
|
712
|
+
* lane declaring only the test still names its source when the worker edits
|
|
713
|
+
* it.
|
|
714
|
+
*
|
|
596
715
|
* Fail-open, like admission: an issue with no lane declaration has nothing to
|
|
597
716
|
* escape, so no flag — a flag on every undeclared run would be noise within a
|
|
598
717
|
* day, worse than no flag. Advisory like every other flag here: a widened lane
|
|
@@ -605,11 +724,23 @@ function detectLaneEscape(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
605
724
|
.map((f) => f.path)
|
|
606
725
|
.filter((path) => !withinLane(path, lane.files));
|
|
607
726
|
if (outside.length === 0) return;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
727
|
+
// Production first: the rarer, heavier event leads the report block.
|
|
728
|
+
const production = outside.filter((path) => !isTestPath(path));
|
|
729
|
+
const otherTests = outside.filter((path) => isTestPath(path));
|
|
730
|
+
if (production.length > 0) {
|
|
731
|
+
flags.push({
|
|
732
|
+
kind: "lane-escape",
|
|
733
|
+
file: "(lane)",
|
|
734
|
+
detail: `PR diff touches production files outside the declared file lane: ${production.join(", ")}`,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (otherTests.length > 0) {
|
|
738
|
+
flags.push({
|
|
739
|
+
kind: "lane-escape",
|
|
740
|
+
file: "(lane)",
|
|
741
|
+
detail: `PR diff touches unrelated test files outside the declared file lane: ${otherTests.join(", ")}`,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
613
744
|
}
|
|
614
745
|
|
|
615
746
|
function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
@@ -1124,6 +1255,21 @@ const RENDERED_FLAGS = 20;
|
|
|
1124
1255
|
|
|
1125
1256
|
const HEADING = "settlement audit";
|
|
1126
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* The kinds that mean coverage got weaker — the quiet signals the audit
|
|
1260
|
+
* exists to deliver, and the family mining.ts mines. Presentation keeps them
|
|
1261
|
+
* ahead of everything else and lane escapes last (#1096): a check that fires
|
|
1262
|
+
* on half of all merges gets skimmed, and a skimmed finding must not sit
|
|
1263
|
+
* between the reviewer and these. Kept in step with `WEAKENING_KINDS` in
|
|
1264
|
+
* mining.ts, which is the same four kinds spelled for mining.
|
|
1265
|
+
*/
|
|
1266
|
+
const TEST_INTEGRITY_KINDS: Partial<Record<SettlementFlagKind, true>> = {
|
|
1267
|
+
"test-file-deleted": true,
|
|
1268
|
+
"test-disabled": true,
|
|
1269
|
+
"assertions-removed": true,
|
|
1270
|
+
"test-timeout-raised": true,
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1127
1273
|
/**
|
|
1128
1274
|
* The flag block appended to a settlement report, or no lines at all.
|
|
1129
1275
|
*
|
|
@@ -1148,7 +1294,17 @@ export function formatSettlementFlags(
|
|
|
1148
1294
|
`${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
|
|
1149
1295
|
(diff.truncated ? ", and the PR diff was too large to read in full" : ""),
|
|
1150
1296
|
];
|
|
1151
|
-
|
|
1297
|
+
// Presentation order is structural, not incidental: test-integrity flags
|
|
1298
|
+
// first, then everything else, lane escapes last (#1096). The analyser
|
|
1299
|
+
// emits them in roughly this order already, but this surface also renders
|
|
1300
|
+
// stored rows and post-settlement appends, whose order nobody chose.
|
|
1301
|
+
const rank = (flag: SettlementFlag): number =>
|
|
1302
|
+
TEST_INTEGRITY_KINDS[flag.kind] === true ? 0 : flag.kind === "lane-escape" ? 2 : 1;
|
|
1303
|
+
const ordered = flags
|
|
1304
|
+
.map((flag, index) => ({ flag, index }))
|
|
1305
|
+
.sort((a, b) => rank(a.flag) - rank(b.flag) || a.index - b.index)
|
|
1306
|
+
.map((entry) => entry.flag);
|
|
1307
|
+
for (const flag of ordered.slice(0, RENDERED_FLAGS)) {
|
|
1152
1308
|
if (flag.kind === "pr-adopted") {
|
|
1153
1309
|
lines.push(` ${flag.kind} — ${flag.detail}`);
|
|
1154
1310
|
continue;
|
|
@@ -1165,10 +1321,30 @@ export function formatSettlementFlags(
|
|
|
1165
1321
|
}
|
|
1166
1322
|
|
|
1167
1323
|
/** The one-line form for `omp-conductor status`, where a flagged run has to be
|
|
1168
|
-
* visible long after its escalation was delivered and deduplicated.
|
|
1324
|
+
* visible long after its escalation was delivered and deduplicated. Lane
|
|
1325
|
+
* escapes are presented apart from the test-integrity family (#1096), so the
|
|
1326
|
+
* noisy check cannot bury the quiet one in one comma join. */
|
|
1169
1327
|
export function settlementFlagSummary(flags: readonly SettlementFlag[] | undefined): string | undefined {
|
|
1170
1328
|
if (flags === undefined || flags.length === 0) return undefined;
|
|
1171
|
-
const
|
|
1329
|
+
const integrity = new Set<SettlementFlagKind>();
|
|
1330
|
+
const lane = new Set<SettlementFlagKind>();
|
|
1331
|
+
const rest = new Set<SettlementFlagKind>();
|
|
1332
|
+
for (const { kind } of flags) {
|
|
1333
|
+
if (TEST_INTEGRITY_KINDS[kind] === true) integrity.add(kind);
|
|
1334
|
+
else if (kind === "lane-escape") lane.add(kind);
|
|
1335
|
+
else rest.add(kind);
|
|
1336
|
+
}
|
|
1337
|
+
const segments: { label?: string; kinds: SettlementFlagKind[] }[] = [];
|
|
1338
|
+
if (integrity.size > 0) segments.push({ label: "test-integrity", kinds: [...integrity] });
|
|
1339
|
+
if (rest.size > 0) segments.push({ kinds: [...rest] });
|
|
1340
|
+
if (lane.size > 0) segments.push({ label: "lane", kinds: [...lane] });
|
|
1341
|
+
// One family alone reads exactly as before — a label with nothing to
|
|
1342
|
+
// separate is furniture.
|
|
1343
|
+
const parts = segments.map((segment) =>
|
|
1344
|
+
segments.length > 1 && segment.label !== undefined
|
|
1345
|
+
? `${segment.label}: ${segment.kinds.join(", ")}`
|
|
1346
|
+
: segment.kinds.join(", "),
|
|
1347
|
+
);
|
|
1172
1348
|
const loud = flags.some((f) => f.unattributed === true) ? ", some unattributed" : "";
|
|
1173
|
-
return `${HEADING}: ${flags.length} flag(s) — ${
|
|
1349
|
+
return `${HEADING}: ${flags.length} flag(s) — ${parts.join("; ")}${loud}`;
|
|
1174
1350
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
killOf,
|
|
72
72
|
readTelegramChannel,
|
|
73
73
|
readTelegramDmOwner,
|
|
74
|
+
readTelegramMisroutes,
|
|
74
75
|
readTelegramPollState,
|
|
75
76
|
resolveClaimedSessionFile,
|
|
76
77
|
resolveProjectClaim,
|
|
@@ -82,6 +83,7 @@ import {
|
|
|
82
83
|
type TelegramChannelState,
|
|
83
84
|
type TelegramDmOwnerState,
|
|
84
85
|
type TelegramPollState,
|
|
86
|
+
type TelegramMisroute,
|
|
85
87
|
} from "./escalate.ts";
|
|
86
88
|
import {
|
|
87
89
|
herdrConductorPluginConfigDir,
|
|
@@ -286,6 +288,11 @@ export interface DoctorDeps {
|
|
|
286
288
|
* `unavailable` when the registry cannot be read or does not parse — the
|
|
287
289
|
* caller must not read that as "no claims" (#626). */
|
|
288
290
|
claimedTopics?: () => ClaimedTopicsResult;
|
|
291
|
+
/** Recorded flat-chat deliveries this project's stale pin already caused
|
|
292
|
+
* (#1094), read from conductor state. The topic-pin row renders them as
|
|
293
|
+
* observed evidence until the pin is repaired — rows match the *current*
|
|
294
|
+
* pin id, so re-pinning is what retires them. */
|
|
295
|
+
topicMisroutes?: () => readonly TelegramMisroute[];
|
|
289
296
|
/** Whether the bridge's access.json runs per-session topic tidy on. */
|
|
290
297
|
topicsTidy?: () => boolean;
|
|
291
298
|
/** The typed bot.lock poll-ownership read, told apart so absent/malformed
|
|
@@ -1345,8 +1352,8 @@ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1345
1352
|
if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
|
|
1346
1353
|
return warnFinding(
|
|
1347
1354
|
"arm-ack",
|
|
1348
|
-
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused)
|
|
1349
|
-
`
|
|
1355
|
+
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused); the fleet session notifies the operator and clears it once its notice goes out, or the next arm replaces it`,
|
|
1356
|
+
`the code is dead — no reply to it can arm. Re-run the ceremony: \`omp-conductor arm --project ${p.name}\``,
|
|
1350
1357
|
);
|
|
1351
1358
|
}
|
|
1352
1359
|
if (sighting.acknowledgedAt !== undefined) {
|
|
@@ -1390,14 +1397,24 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1390
1397
|
`[${p.name}] no pinned topic id — sends go to the flat chat (live-claim substitution only follows a stale pin)`,
|
|
1391
1398
|
);
|
|
1392
1399
|
}
|
|
1400
|
+
// #1094: sends that already rode the flat chat because of THIS pin turn the
|
|
1401
|
+
// warnings below from hypothetical into observed. Rows are keyed to the pin
|
|
1402
|
+
// id, so re-pinning retires them — nothing deletes them.
|
|
1403
|
+
const misroutes = probes.topicMisroutes().filter(
|
|
1404
|
+
(row) => row.project === p.name && row.staleTopicId === pinned,
|
|
1405
|
+
);
|
|
1406
|
+
const misrouteNote =
|
|
1407
|
+
misroutes.length === 0
|
|
1408
|
+
? ""
|
|
1409
|
+
: ` It has happened: ${misroutes.length} send(s) were delivered to the flat chat because of this pin, last at ${new Date(misroutes[misroutes.length - 1]!.at).toISOString()}`;
|
|
1393
1410
|
const result = probes.claimedTopics();
|
|
1394
1411
|
if (result.kind !== "ok") {
|
|
1395
1412
|
return warnFinding(
|
|
1396
1413
|
"topic-pin",
|
|
1397
1414
|
`[${p.name}] pinned topic ${pinned} — ` +
|
|
1398
1415
|
(result.kind === "missing"
|
|
1399
|
-
? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project`
|
|
1400
|
-
: `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project`),
|
|
1416
|
+
? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`
|
|
1417
|
+
: `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`),
|
|
1401
1418
|
`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
1419
|
);
|
|
1403
1420
|
}
|
|
@@ -1423,7 +1440,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1423
1440
|
if (dead.length > 0) {
|
|
1424
1441
|
return warnFinding(
|
|
1425
1442
|
"topic-pin",
|
|
1426
|
-
`[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}`,
|
|
1443
|
+
`[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}${misrouteNote}`,
|
|
1427
1444
|
`run /cleanup in Telegram so the bridge closes those topics and drops their rows itself, then re-run setup to re-pin escalation.telegramTopicId to a live claim — never delete rows from threads.json by hand, because the row is the only index to the remote topic (#987)`,
|
|
1428
1445
|
);
|
|
1429
1446
|
}
|
|
@@ -1437,7 +1454,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1437
1454
|
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1438
1455
|
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
|
|
1439
1456
|
}
|
|
1440
|
-
const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
|
|
1457
|
+
const match = resolveProjectClaim(claims, p.name, probes.pidAlive, p.workspaceRoot);
|
|
1441
1458
|
if (match.kind === "match") {
|
|
1442
1459
|
return passFinding(
|
|
1443
1460
|
"topic-pin",
|
|
@@ -1454,7 +1471,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1454
1471
|
return warnFinding(
|
|
1455
1472
|
"topic-pin",
|
|
1456
1473
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and several live claims answer to this project ` +
|
|
1457
|
-
`(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin`,
|
|
1474
|
+
`(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin${misrouteNote}`,
|
|
1458
1475
|
`make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — live-claim substitution follows the unique claim from there`,
|
|
1459
1476
|
);
|
|
1460
1477
|
}
|
|
@@ -1463,14 +1480,14 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1463
1480
|
"topic-pin",
|
|
1464
1481
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, no live claim answers ` +
|
|
1465
1482
|
`to this project, and this host runs topicsTidy — the pin is vestigial and can never be live again ` +
|
|
1466
|
-
`(every pane exit closes its topic); sends keep the stale pin`,
|
|
1483
|
+
`(every pane exit closes its topic); sends keep the stale pin${misrouteNote}`,
|
|
1467
1484
|
`turn topicsTidy off, then re-run setup to re-pin escalation.telegramTopicId to a currently claimed topic`,
|
|
1468
1485
|
);
|
|
1469
1486
|
}
|
|
1470
1487
|
return warnFinding(
|
|
1471
1488
|
"topic-pin",
|
|
1472
1489
|
`[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}), ` +
|
|
1473
|
-
`and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat`,
|
|
1490
|
+
`and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat${misrouteNote}`,
|
|
1474
1491
|
`update escalation.telegramTopicId to a currently claimed topic, or re-run setup to re-pin`,
|
|
1475
1492
|
);
|
|
1476
1493
|
}
|
|
@@ -2163,6 +2180,7 @@ export function defaultProbes(): Probes {
|
|
|
2163
2180
|
herdrConfig: defaultHerdrConfig,
|
|
2164
2181
|
herdrEnv: defaultHerdrEnv,
|
|
2165
2182
|
claimedTopics: () => claimedTelegramTopics(),
|
|
2183
|
+
topicMisroutes: () => readTelegramMisroutes(),
|
|
2166
2184
|
topicsTidy: () => telegramTopicsTidy(),
|
|
2167
2185
|
pollState: () => readTelegramPollState(),
|
|
2168
2186
|
dmOwner: () => readTelegramDmOwner(),
|