agent-coord-mcp 0.26.23 → 0.26.25
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/dist/capabilities.js +180 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +172 -6
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +60 -53
- package/dist/server-spread.js.map +1 -1
- package/dist/server.js +41 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +20 -2
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/herdr-delivery.js +86 -26
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +221 -0
- package/dist/tools/herdr-tail.js.map +1 -0
- package/dist/tools/jsonl-offsets.js +37 -0
- package/dist/tools/jsonl-offsets.js.map +1 -0
- package/dist/tools/messaging.js +18 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/queue-write.js +4 -1
- package/dist/tools/queue-write.js.map +1 -1
- package/dist/tools/record-events.js +42 -4
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +24 -5
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +56 -21
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +73 -9
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/tick.js +77 -0
- package/dist/tools/tick.js.map +1 -0
- package/dist/tools/transport.js +4 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +203 -19
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +1 -1
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/types.js +41 -0
- package/dist/transports/types.js.map +1 -1
- package/hooks/control-bytes.mjs +69 -0
- package/hooks/submit.mjs +227 -9
- package/hooks/tier.mjs +7 -1
- package/hooks/tmux-pusher.mjs +5 -1
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +5 -1
- package/src/capabilities.ts +183 -4
- package/src/gated-head.ts +193 -6
- package/src/server-spread.ts +52 -4
- package/src/server.ts +38 -1
- package/src/tools/away.ts +38 -2
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +244 -0
- package/src/tools/jsonl-offsets.ts +29 -0
- package/src/tools/messaging.ts +20 -0
- package/src/tools/queue-write.ts +4 -1
- package/src/tools/record-events.ts +46 -4
- package/src/tools/records.ts +24 -4
- package/src/tools/registry.ts +56 -20
- package/src/tools/stall.ts +80 -12
- package/src/tools/tick.ts +117 -0
- package/src/tools/transport.ts +5 -4
- package/src/transports/herdr.ts +251 -19
- package/src/transports/index.ts +1 -1
- package/src/transports/types.ts +71 -0
package/src/tools/stall.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* from "nothing ran". A check that only speaks when it fires cannot be told from
|
|
16
16
|
* a broken one.
|
|
17
17
|
*/
|
|
18
|
-
import { isLocallyProbeable } from "../transports/index.js";
|
|
18
|
+
import { activeTransport, isLocallyProbeable } from "../transports/index.js";
|
|
19
|
+
import { readFleetTick, tickByAgent, tickVerdict, type FleetTick, type SeatTick } from "./tick.js";
|
|
19
20
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
20
21
|
import { execFileSync } from "node:child_process";
|
|
21
22
|
import path from "node:path";
|
|
@@ -24,7 +25,7 @@ import { parseWorkDoc, workstreamsV1RowsOf, workstreamsExtensionsOf, isMalformed
|
|
|
24
25
|
import { ROOT, AGENTS_FILE, readJson } from "../store.js";
|
|
25
26
|
import { classifyBoardRef, refInCell, rowKindOf, cellKindOf } from "./board-ref.js";
|
|
26
27
|
import { loadLiveTransports } from "./registry.js";
|
|
27
|
-
import { verdictsFor, shaAgrees, verdictShasIn } from "../gated-head.js";
|
|
28
|
+
import { verdictsFor, shaAgrees, verdictShasIn, verdictBoundTo } from "../gated-head.js";
|
|
28
29
|
import { closingsIn, ACCEPTED_FORMS, type Closing } from "../closing-line.js";
|
|
29
30
|
|
|
30
31
|
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
@@ -142,6 +143,8 @@ export type StallHit = StallHitBody & { audience: HitAudience };
|
|
|
142
143
|
export type StallHitBody =
|
|
143
144
|
| { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
|
|
144
145
|
| { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
|
|
146
|
+
/** ⟨q-1c95f7d4⟩ Task 5 — an EXTERNAL observer says the seat is waiting on a person. Not inferred from the branch, and not a verdict about the lane. */
|
|
147
|
+
| { kind: "seat-blocked"; agentId: string; stream: string; source: string; why: string }
|
|
145
148
|
/**
|
|
146
149
|
* ⟨q-7b2f6c04⟩ — THE CLAIM AXIS: a lane claimed (its row entered in-flight,
|
|
147
150
|
* read from the board's git history) whose branch has nothing on origin —
|
|
@@ -262,7 +265,28 @@ export type RepoArtefacts = {
|
|
|
262
265
|
*/
|
|
263
266
|
recentMerges?: RecentMerge[] | null;
|
|
264
267
|
};
|
|
265
|
-
export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[] };
|
|
268
|
+
export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[]; files?: string[] };
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* ⛔ AN EXPRESS-BY-DESIGN MERGE WITH NO VERDICT IS NOT A DEFECT — ruled by the
|
|
272
|
+
* coordinator 2026-09-15 after this predicate reported `#966` as a stall. `#966` is
|
|
273
|
+
* ONE FILE, `+3/-3`, on `docs/WORKSTREAMS.md`, and David's standing rule is that ANY
|
|
274
|
+
* `.md` change goes direct to main: no PR, no branch, no CI. It legitimately required
|
|
275
|
+
* no verdict, so reporting it trained the room to ignore the check.
|
|
276
|
+
*
|
|
277
|
+
* ⭐ THE EXEMPTION IS MEASURED FROM THE MERGE ITSELF, NOT FROM A LABEL SOMEBODY HAS TO
|
|
278
|
+
* REMEMBER TO SET. A docs-only file set is a fact `gh` already returns; a lane marker
|
|
279
|
+
* is a convention, and this row exists because a convention with zero adoption silently
|
|
280
|
+
* broke the instrument that depended on it.
|
|
281
|
+
*
|
|
282
|
+
* ⚠ AND IT COVERS ONLY THE DERIVABLE HALF, WHICH IS SAID RATHER THAN IMPLIED: a SMALL
|
|
283
|
+
* CODE PR merged express is also exempt by policy and is NOT visible in the file set.
|
|
284
|
+
* That needs a real lane input from the caller — `expressPrs` below — which nothing
|
|
285
|
+
* populates yet. So: docs-only is closed here; express-code remains open, and a caller
|
|
286
|
+
* that knows the lane can pass it without touching this file.
|
|
287
|
+
*/
|
|
288
|
+
export const isDocsOnlyMerge = (files: string[] | undefined): boolean =>
|
|
289
|
+
Array.isArray(files) && files.length > 0 && files.every((f) => /\.mdx?$/i.test(String(f)));
|
|
266
290
|
const NET_TIMEOUT_MS = 15_000;
|
|
267
291
|
export const realArtefacts = (repo: string): RepoArtefacts => {
|
|
268
292
|
let openPrs: OpenPrFact[] | null = null;
|
|
@@ -283,11 +307,12 @@ export const realArtefacts = (repo: string): RepoArtefacts => {
|
|
|
283
307
|
} catch { /* landedness falls back to patch ids; said so by the caller */ }
|
|
284
308
|
let recentMerges: RecentMerge[] | null = null;
|
|
285
309
|
try {
|
|
286
|
-
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
|
|
310
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments,files"], {
|
|
287
311
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
288
312
|
});
|
|
289
|
-
recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[] }[]).map((p) => ({
|
|
313
|
+
recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[]; files?: { path?: string }[] }[]).map((p) => ({
|
|
290
314
|
n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
|
|
315
|
+
files: (p.files ?? []).map((f) => String(f.path ?? "")).filter(Boolean),
|
|
291
316
|
}));
|
|
292
317
|
} catch { /* said by the caller */ }
|
|
293
318
|
let remoteHeads: { name: string; sha: string }[] | null = null;
|
|
@@ -344,7 +369,7 @@ export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
|
|
|
344
369
|
*/
|
|
345
370
|
export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
|
|
346
371
|
// The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
|
|
347
|
-
export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
|
|
372
|
+
export { VERDICT_COMMENT, verdictShasIn, verdictClaimsIn, verdictBoundTo } from "../gated-head.js";
|
|
348
373
|
/** Items named ON A GO LINE of a `go` record — position, not mention. */
|
|
349
374
|
export function routedItemsIn(logText: string): { itemId: string; by: string; ts: number }[] {
|
|
350
375
|
const out: { itemId: string; by: string; ts: number }[] = [];
|
|
@@ -467,6 +492,8 @@ export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[
|
|
|
467
492
|
|
|
468
493
|
export function conventionChecks(input: {
|
|
469
494
|
recentMerges: RecentMerge[] | null | undefined;
|
|
495
|
+
/** PRs the caller knows merged express. Nothing populates this yet — see isDocsOnlyMerge. */
|
|
496
|
+
expressPrs?: number[];
|
|
470
497
|
routingLogText: string;
|
|
471
498
|
queueText: string | null;
|
|
472
499
|
boardRows: WorkstreamsV1Row[];
|
|
@@ -475,7 +502,7 @@ export function conventionChecks(input: {
|
|
|
475
502
|
}): {
|
|
476
503
|
hits: StallHit[];
|
|
477
504
|
unmeasurable: { agentId: string; value: string; why: string }[];
|
|
478
|
-
merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean }[] | null;
|
|
505
|
+
merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean; exempt?: string }[] | null;
|
|
479
506
|
routed: { itemId: string; by: string; minutes: number; open: boolean | null; onBoard: boolean; inWindow: boolean }[];
|
|
480
507
|
closings: (Closing & { inWindow: boolean })[];
|
|
481
508
|
} {
|
|
@@ -483,6 +510,7 @@ export function conventionChecks(input: {
|
|
|
483
510
|
const hits: StallHitBody[] = [];
|
|
484
511
|
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
485
512
|
let merges: ReturnType<typeof conventionChecks>["merges"] = null;
|
|
513
|
+
const expressPrs = new Set(input.expressPrs ?? []);
|
|
486
514
|
if (input.recentMerges === null || input.recentMerges === undefined) {
|
|
487
515
|
unmeasurable.push({ agentId: "-", value: "gh pr list --state merged (with comments)", why: "recent merges could not be read from gh — convention (e), verdicts on the PR, is UNMEASURED, which is not the same as kept" });
|
|
488
516
|
} else {
|
|
@@ -492,12 +520,25 @@ export function conventionChecks(input: {
|
|
|
492
520
|
const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
|
|
493
521
|
const inWindow = input.now - mergedMs <= windowMs;
|
|
494
522
|
const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
523
|
+
// ⛔ KEYED ON THE CLAIM, NOT ON A HEADER PHRASE. This used to test `verdictShasIn`,
|
|
524
|
+
// i.e. #313's `QA GATE — **PASS** @ sha` wording, which had ZERO adoption: on the 9
|
|
525
|
+
// merges of 2026-09-15 it matched 0 lines while all 9 carried a verdict, so this hit
|
|
526
|
+
// fired 9 times on 9 correct merges. `verdictBoundTo` asks the binding question
|
|
527
|
+
// instead — a disposition, on one line, at a sha equal to the head that merged — and
|
|
528
|
+
// accepts the split halves (#964's CONTENT PASS and READINESS RELEASED were two
|
|
529
|
+
// separate comments).
|
|
530
|
+
const bound = verdictBoundTo(m.comments, m.headRefOid);
|
|
531
|
+
const verdictAtHead = bound.bound;
|
|
532
|
+
// ⛔ EXEMPT BEFORE REPORTING, NEVER AFTER. A docs-only merge (or one the caller
|
|
533
|
+
// names as express) owed no verdict, so it is not a hit — it is not a defect
|
|
534
|
+
// whose report we suppress. `exempt` is carried on the merge row so a reader can
|
|
535
|
+
// see the population and why a member left it.
|
|
536
|
+
const exemptWhy = isDocsOnlyMerge(m.files) ? "docs-only" : expressPrs.has(m.n) ? "express lane (caller-supplied)" : null;
|
|
537
|
+
merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption, ...(exemptWhy ? { exempt: exemptWhy } : {}) });
|
|
538
|
+
if (inWindow && !beforeAdoption && !verdictAtHead && !exemptWhy) {
|
|
498
539
|
hits.push({
|
|
499
540
|
kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
|
|
500
|
-
why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR (${m.comments.length} comment(s)
|
|
541
|
+
why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR — ${bound.why} (${m.comments.length} comment(s)). Convention (e): the verdict lives on the PR, not only on the bus. ⚠ This predicate does NOT know about the express lane, so an express merge (no QA verdict by design) still reports here — see the kit row on #966.`,
|
|
501
542
|
});
|
|
502
543
|
}
|
|
503
544
|
}
|
|
@@ -1221,6 +1262,8 @@ export async function stallCheckTool(
|
|
|
1221
1262
|
artefacts: (repo: string) => RepoArtefacts = realArtefacts,
|
|
1222
1263
|
readRooms: () => string = readAllRoomLogs,
|
|
1223
1264
|
readInboxes: () => string = readAllInboxLogs,
|
|
1265
|
+
/** ⟨q-1c95f7d4⟩ the transport the external tick is read through; injected by tests. */
|
|
1266
|
+
tickTransport: Parameters<typeof readFleetTick>[1] = activeTransport(),
|
|
1224
1267
|
) {
|
|
1225
1268
|
const repo = args.repo ?? process.cwd();
|
|
1226
1269
|
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
@@ -1230,6 +1273,11 @@ export async function stallCheckTool(
|
|
|
1230
1273
|
if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
|
|
1231
1274
|
|
|
1232
1275
|
const liveTransports = await loadLiveTransports();
|
|
1276
|
+
// ⟨q-1c95f7d4⟩ Task 5 — the EXTERNAL tick, read once per run for the whole fleet. On a
|
|
1277
|
+
// fleet with no herdr seat this asks nothing and answers "measured NOTHING", which is
|
|
1278
|
+
// what keeps a blind axis from reading as a calm one.
|
|
1279
|
+
const fleetTick: FleetTick = await readFleetTick(liveTransports.values(), tickTransport);
|
|
1280
|
+
const tickOf = tickByAgent(fleetTick);
|
|
1233
1281
|
const boardText = readFileSync(board, "utf8");
|
|
1234
1282
|
const boardDoc = parseWorkDoc(boardText);
|
|
1235
1283
|
const rows = workstreamsV1RowsOf(boardDoc);
|
|
@@ -1338,7 +1386,7 @@ export async function stallCheckTool(
|
|
|
1338
1386
|
* axes is how all three stayed hidden; every scored row now says which
|
|
1339
1387
|
* predicate reached a verdict about it, or that none could.
|
|
1340
1388
|
*/
|
|
1341
|
-
type Axis = "vcs" | "review" | "claim" | "landed";
|
|
1389
|
+
type Axis = "vcs" | "review" | "claim" | "landed" | "tick";
|
|
1342
1390
|
const axes: { agentId: string; stream: string; axis: Axis | null; measurable: boolean; why?: string }[] = [];
|
|
1343
1391
|
const onAxis = (agentId: string, row: { stream: string }, axis: Axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
|
|
1344
1392
|
const offAxis = (agentId: string, row: { stream: string }, why: string) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
|
|
@@ -1375,6 +1423,21 @@ export async function stallCheckTool(
|
|
|
1375
1423
|
measured.add(agentId);
|
|
1376
1424
|
continue;
|
|
1377
1425
|
}
|
|
1426
|
+
// ⟨q-1c95f7d4⟩ 5.2 — THE TICK IS APPLIED FIRST AND SUPPRESSES NOTHING. It may add a
|
|
1427
|
+
// hit and it may credit coverage; every branch and heartbeat predicate below still
|
|
1428
|
+
// runs and still reaches its own verdict, because "the seat is moving" and "the work
|
|
1429
|
+
// is moving" are different questions and only one of them is answered here.
|
|
1430
|
+
const tickSeat: SeatTick | undefined = tickOf.get(agentId);
|
|
1431
|
+
const tick = tickVerdict(tickSeat);
|
|
1432
|
+
if (tick && tickSeat) {
|
|
1433
|
+
if (tick.measured) {
|
|
1434
|
+
onAxis(agentId, row, "tick");
|
|
1435
|
+
if (tick.hit && tickSeat.readable) hits.push({ kind: "seat-blocked", agentId, stream: row.stream.slice(0, 60), source: tickSeat.source, why: tick.why });
|
|
1436
|
+
} else {
|
|
1437
|
+
// 5.3 — A HERDR SEAT WHOSE SIGNAL CANNOT BE READ IS UNMEASURABLE, NEVER HEALTHY.
|
|
1438
|
+
unmeasurable.push({ agentId, value: tickSeat.transport, why: `external tick unreadable: ${tick.why}` });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1378
1441
|
const entry = reg[agentId];
|
|
1379
1442
|
if (!entry) {
|
|
1380
1443
|
// NOT SKIPPED IN SILENCE. An owner with no registry entry has no
|
|
@@ -1670,6 +1733,11 @@ export async function stallCheckTool(
|
|
|
1670
1733
|
notWatched,
|
|
1671
1734
|
// ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
|
|
1672
1735
|
axes,
|
|
1736
|
+
// ⟨q-1c95f7d4⟩ Task 5 — ONE ENTRY PER SEAT WITH AN EXTERNAL OBSERVER, and nothing that
|
|
1737
|
+
// grows with the fleet's branches or history. Measured on this fleet the day it was
|
|
1738
|
+
// added: 0 seats carry a herdr marker, so this key is 121 bytes of an answer whose
|
|
1739
|
+
// other keys are 76 KB (`stranded.pushedBranches` alone is 35.7 KB).
|
|
1740
|
+
tick: fleetTick,
|
|
1673
1741
|
stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
|
|
1674
1742
|
conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
|
|
1675
1743
|
mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⟨q-1c95f7d4⟩ PHASE 5.4 TASK 5 — THE EXTERNAL TICK, OVER THE FLEET.
|
|
3
|
+
*
|
|
4
|
+
* `stall_check` infers. Its liveness axis is pid-existence (and for a local `tmux-push`
|
|
5
|
+
* seat nothing writes heartbeats at all), its activity axis is VCS commits, and between
|
|
6
|
+
* them a seat that is THINKING and a seat that is WEDGED are the same reading. herdr
|
|
7
|
+
* observes the pane from outside the process and answers `idle | working | blocked`, which
|
|
8
|
+
* is a signal about the SEAT rather than about its branch.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ EVIDENCE, NEVER A VERDICT (5.2). Nothing here decides a lane's state: the board says
|
|
11
|
+
* what work exists, the existing axes say whether it moved, and the tick says whether the
|
|
12
|
+
* seat is moving. A `working` tick does NOT cancel a stalled branch — a seat can be busy on
|
|
13
|
+
* the wrong thing — and this module therefore never removes a hit and never marks a row
|
|
14
|
+
* healthy. It adds evidence and it credits COVERAGE, which is the one thing a real
|
|
15
|
+
* observation is allowed to do.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ A SEAT WITH NO TICK SOURCE PRODUCES NO ENTRY, AND THAT IS THE POINT (5.3). tmux seats
|
|
18
|
+
* are not given an invented tick: they keep exactly the unmeasurable story they had. A
|
|
19
|
+
* HERDR seat whose signal cannot be read produces an entry that says so, so `0 of N
|
|
20
|
+
* readable` reads BLIND and never CALM.
|
|
21
|
+
*/
|
|
22
|
+
import type { Transport, TransportMarker, TickState } from "../transports/types.js";
|
|
23
|
+
import { activeTransport } from "../transports/index.js";
|
|
24
|
+
|
|
25
|
+
export type SeatTick =
|
|
26
|
+
| { agentId: string; transport: string; readable: true; state: TickState; source: string }
|
|
27
|
+
| { agentId: string; transport: string; readable: false; why: string };
|
|
28
|
+
|
|
29
|
+
export type FleetTick = {
|
|
30
|
+
/** One entry per seat whose transport HAS an external observer. Never a tmux seat. */
|
|
31
|
+
seats: SeatTick[];
|
|
32
|
+
/** Seats whose transport has no tick source at all — named, never silently skipped. */
|
|
33
|
+
withoutSource: string[];
|
|
34
|
+
readable: number;
|
|
35
|
+
states: Partial<Record<TickState, number>>;
|
|
36
|
+
/** Said in the answer itself: a fleet with a tick source and nothing readable is BLIND. */
|
|
37
|
+
note: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const NO_SOURCE = "this seat's transport has no external observer to ask — its coverage is unchanged by the tick";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the tick for every live marker. `transport` is injected by tests; in the server it
|
|
44
|
+
* is whatever the configured transport resolved to, so a fleet running tmux asks nothing
|
|
45
|
+
* and gets an empty, explicitly-blind answer rather than a fabricated one.
|
|
46
|
+
*/
|
|
47
|
+
export async function readFleetTick(
|
|
48
|
+
markers: Iterable<TransportMarker>,
|
|
49
|
+
transport: Transport | null | undefined = activeTransport(),
|
|
50
|
+
): Promise<FleetTick> {
|
|
51
|
+
const seats: SeatTick[] = [];
|
|
52
|
+
const withoutSource: string[] = [];
|
|
53
|
+
const states: Partial<Record<TickState, number>> = {};
|
|
54
|
+
for (const marker of markers) {
|
|
55
|
+
// The ACTIVE transport is the only thing that can answer, and only for its own kind:
|
|
56
|
+
// a tmux-push server holding a herdr marker (the 3.4 disagreement state) must not
|
|
57
|
+
// claim a reading it cannot take.
|
|
58
|
+
if (!transport || typeof transport.readTick !== "function" || marker.transport !== transport.kind) {
|
|
59
|
+
withoutSource.push(marker.agentId);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let reading;
|
|
63
|
+
try {
|
|
64
|
+
reading = await transport.readTick(marker);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
reading = { readable: false as const, why: `the transport threw while reading the tick: ${(e as Error).message}` };
|
|
67
|
+
}
|
|
68
|
+
if (reading.readable) {
|
|
69
|
+
seats.push({ agentId: marker.agentId, transport: marker.transport, readable: true, state: reading.state, source: reading.source });
|
|
70
|
+
states[reading.state] = (states[reading.state] ?? 0) + 1;
|
|
71
|
+
} else {
|
|
72
|
+
seats.push({ agentId: marker.agentId, transport: marker.transport, readable: false, why: reading.why });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const readable = seats.filter((s) => s.readable).length;
|
|
76
|
+
return {
|
|
77
|
+
seats,
|
|
78
|
+
withoutSource,
|
|
79
|
+
readable,
|
|
80
|
+
states,
|
|
81
|
+
note:
|
|
82
|
+
seats.length === 0
|
|
83
|
+
? `no seat on this bus has an external tick source (${withoutSource.length} seat(s) on a transport that cannot be asked) — the tick measured NOTHING, which is not the same as a calm fleet`
|
|
84
|
+
: readable === 0
|
|
85
|
+
? `${seats.length} seat(s) have a tick source and NONE answered — this fleet is BLIND on the tick axis, not quiet`
|
|
86
|
+
: `${readable} of ${seats.length} seat(s) with a tick source answered`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Index by agentId for the per-row lookup in `stall_check`. */
|
|
91
|
+
export const tickByAgent = (tick: FleetTick): Map<string, SeatTick> => new Map(tick.seats.map((s) => [s.agentId, s]));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ⟨q-1c95f7d4⟩ 5.2 — WHAT A READING MEANS FOR A SCORED LANE, as one function so the rule
|
|
95
|
+
* is testable without a board, a repo or a registry.
|
|
96
|
+
*
|
|
97
|
+
* `blocked` is the reading this task was filed for: herdr's blocked means the seat is
|
|
98
|
+
* WAITING ON A PERSON, and an in-flight lane whose seat waits on a person while the fleet
|
|
99
|
+
* is away is unattended work that no VCS or heartbeat axis can see. It is reported as a
|
|
100
|
+
* hit. `working` and `idle` are coverage and nothing else — explicitly NOT a clean bill,
|
|
101
|
+
* because the branch axes answer a different question and keep their own verdicts.
|
|
102
|
+
*/
|
|
103
|
+
export function tickVerdict(seat: SeatTick | undefined): { measured: boolean; hit: boolean; why: string } | null {
|
|
104
|
+
if (!seat) return null;
|
|
105
|
+
if (!seat.readable) return { measured: false, hit: false, why: seat.why };
|
|
106
|
+
if (seat.state === "blocked")
|
|
107
|
+
return {
|
|
108
|
+
measured: true,
|
|
109
|
+
hit: true,
|
|
110
|
+
why: `${seat.source} reports the seat BLOCKED — waiting on a person, which no branch or heartbeat axis can see. Evidence about the SEAT, not a verdict about the lane: the row's own axes still say whether the work moved.`,
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
measured: true,
|
|
114
|
+
hit: false,
|
|
115
|
+
why: `${seat.source} reports the seat ${seat.state} — coverage, not a clean bill: a seat can be busy on the wrong thing, so the branch axes keep their own verdicts.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/tools/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
1
|
+
import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
|
|
2
2
|
import {
|
|
3
3
|
TMUX_PUSH,
|
|
4
4
|
registerTmuxHost,
|
|
@@ -685,7 +685,8 @@ export async function attachAgentTool(args: {
|
|
|
685
685
|
agentId: args.agentId,
|
|
686
686
|
transport: HERDR,
|
|
687
687
|
target: marker.target,
|
|
688
|
-
pid:
|
|
688
|
+
pid: marker.pid,
|
|
689
|
+
pidWhy: marker.pidWhy,
|
|
689
690
|
rooms: marker.rooms,
|
|
690
691
|
note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
|
|
691
692
|
};
|
|
@@ -711,10 +712,10 @@ export async function attachAgentTool(args: {
|
|
|
711
712
|
|
|
712
713
|
// If something's already attached, refuse rather than spawn a second pusher.
|
|
713
714
|
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
714
|
-
if (
|
|
715
|
+
if (markerHoldsLiveProcess(existing)) {
|
|
715
716
|
return {
|
|
716
717
|
ok: false,
|
|
717
|
-
error: `agent '${args.agentId}' already has a live ${existing
|
|
718
|
+
error: `agent '${args.agentId}' already has a live ${existing!.transport} attached (pid ${existing!.pid}). Call detach_agent first.`,
|
|
718
719
|
existing,
|
|
719
720
|
};
|
|
720
721
|
}
|