agent-coord-mcp 0.26.22 → 0.26.24
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 +70 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +160 -5
- package/dist/gated-head.js.map +1 -1
- package/dist/tools/away.js +20 -2
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +9 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +8 -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 +85 -9
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +15 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +10 -1
- package/dist/tools/seat-build.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 +50 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +381 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/types.js +41 -0
- package/dist/transports/types.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +73 -0
- package/src/gated-head.ts +181 -5
- package/src/tools/away.ts +38 -2
- package/src/tools/event-kinds.ts +2 -1
- package/src/tools/events.ts +9 -2
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +8 -0
- package/src/tools/queue-write.ts +4 -1
- package/src/tools/record-events.ts +46 -4
- package/src/tools/records.ts +74 -8
- package/src/tools/registry.ts +14 -1
- package/src/tools/seat-build.ts +8 -1
- package/src/tools/stall.ts +80 -12
- package/src/tools/tick.ts +117 -0
- package/src/tools/transport.ts +48 -0
- package/src/transports/herdr.ts +385 -0
- package/src/transports/index.ts +10 -4
- package/src/transports/types.ts +66 -0
package/src/tools/registry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { detachAgentTool } from "./transport.js";
|
|
2
|
-
import { isLocallyProbeable, isRemoteTmuxKind, targetOf } from "../transports/index.js";
|
|
2
|
+
import { isLocallyProbeable, isRemoteTmuxKind, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
|
|
3
3
|
import { readAway, secondCoordinatorRefusal } from "./away.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync, openSync, statSync, watch } from "node:fs";
|
|
@@ -525,6 +525,19 @@ export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: n
|
|
|
525
525
|
const entry = reg[marker.agentId];
|
|
526
526
|
return !!entry && now - entry.lastHeartbeat < STALE_MS;
|
|
527
527
|
}
|
|
528
|
+
// Phase 5.4 Task 4 — a herdr marker has no pid (0): liveness is the PANE, asked of
|
|
529
|
+
// herdr itself. "Could not ask" keeps the marker (unknown is not dead); only herdr
|
|
530
|
+
// saying pane_not_found lets the registry drop it.
|
|
531
|
+
if (marker.transport === HERDR) {
|
|
532
|
+
const t = targetOf(marker);
|
|
533
|
+
if (!t) return false;
|
|
534
|
+
// Ask through the WIRED herdr transport (its runner is what tests inject and what the
|
|
535
|
+
// fleet configured). A server with no herdr transport wired cannot ask, and "cannot
|
|
536
|
+
// ask" keeps the marker: the herdr-configured server reaps its own dead panes.
|
|
537
|
+
const active = activeTransport();
|
|
538
|
+
const exists = active?.kind === HERDR && active instanceof HerdrTransport ? active.paneExists(t) : null;
|
|
539
|
+
return exists !== false;
|
|
540
|
+
}
|
|
528
541
|
return isPidAlive(marker.pid);
|
|
529
542
|
}
|
|
530
543
|
|
package/src/tools/seat-build.ts
CHANGED
|
@@ -104,7 +104,11 @@ export function seatBuildOf(input: { agentId: string; marker: TransportMarker |
|
|
|
104
104
|
// ---- pusher half
|
|
105
105
|
let pusher: SeatBuild["pusher"];
|
|
106
106
|
const empty = { pid: null, alive: null, keyedByAgentArg: null, startedAt: null, hookPath: null, hookMtime: null, sameTreeAsServer: null, current: null };
|
|
107
|
-
if (
|
|
107
|
+
if (marker && marker.transport === "herdr") {
|
|
108
|
+
// Phase 5.4 Task 4 — a herdr seat has NO pusher: delivery is in-process by the server,
|
|
109
|
+
// so the seat's build is the server half alone, and this says so instead of reading pid 0.
|
|
110
|
+
pusher = { ...empty, note: `herdr socket transport (pane ${marker.target ?? marker.tmuxTarget ?? "?"}): no pusher process — delivery is in-process by the answering server, so the server half is the whole seat` };
|
|
111
|
+
} else if (!marker || typeof marker.pid !== "number") {
|
|
108
112
|
pusher = { ...empty, note: "no transport marker for this seat — no pusher pid to read" };
|
|
109
113
|
} else {
|
|
110
114
|
const facts = ps(marker.pid);
|
|
@@ -183,6 +187,9 @@ export function seatBuildOf(input: { agentId: string; marker: TransportMarker |
|
|
|
183
187
|
} else if (s === false && p === true) {
|
|
184
188
|
verdict = "half-restarted";
|
|
185
189
|
statement = `${agentId}: HALF-RESTARTED the other way — pusher on the installed hook, server started before the ${day(server!.buildMtime!)} install. Reload the server (relaunch the MCP client for this seat).`;
|
|
190
|
+
} else if (marker?.transport === "herdr" && s !== null) {
|
|
191
|
+
verdict = s ? "current" : "stale";
|
|
192
|
+
statement = `${agentId}: ${s ? "current" : "stale"} — herdr socket transport, no pusher; the server ${s ? "started after" : "predates"} the ${day(server!.buildMtime!)} install and is the whole seat.`;
|
|
186
193
|
} else if (s === null && p !== null) {
|
|
187
194
|
verdict = "unknown";
|
|
188
195
|
statement = `${agentId}: pusher ${p ? "current" : "STALE — started before the installed hook"}; server UNOBSERVABLE from here (${srv.note}).`;
|
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
|
@@ -2,6 +2,8 @@ import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
|
2
2
|
import {
|
|
3
3
|
TMUX_PUSH,
|
|
4
4
|
registerTmuxHost,
|
|
5
|
+
activeTransport,
|
|
6
|
+
HERDR,
|
|
5
7
|
type TmuxHost,
|
|
6
8
|
isLocallyProbeable,
|
|
7
9
|
isTmuxKind,
|
|
@@ -480,6 +482,20 @@ export async function sendCommandTool(args: {
|
|
|
480
482
|
// DM: target must itself be tmux-attached.
|
|
481
483
|
if (args.to) {
|
|
482
484
|
const marker = liveTmux.get(args.to);
|
|
485
|
+
// Phase 5.4 Task 4 — a herdr seat takes the control IN-PROCESS: no inbox message, no
|
|
486
|
+
// pusher receipt; the transport types it, verifies it left the input, and answers.
|
|
487
|
+
const herdrMarker = marker ? undefined : (await loadLiveTransports()).get(args.to);
|
|
488
|
+
const activeT = activeTransport();
|
|
489
|
+
if (herdrMarker?.transport === HERDR) {
|
|
490
|
+
if (activeT?.kind !== HERDR) {
|
|
491
|
+
return { ok: false, error: `'${args.to}' is attached through herdr but this server's active transport is ${activeT?.kind ?? "none"} — a mixed fleet; the server configured for herdr must send this control` };
|
|
492
|
+
}
|
|
493
|
+
const r = await activeT.sendControl(herdrMarker, cmd as "clear" | "compact" | "reload-skills");
|
|
494
|
+
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
495
|
+
if (r.ok && reminderMs > 0) scheduleReminders(args.from, [args.to], reminderMs, args.reminderText);
|
|
496
|
+
const extra = r as { enters?: number; note?: string };
|
|
497
|
+
return { ok: r.ok, command: text, delivered: r.ok ? [args.to] : [], transport: HERDR, delivery: r.ok ? "confirmed" : "refused", ...(r.error ? { error: r.error } : {}), ...(extra.enters !== undefined ? { enters: extra.enters } : {}), ...(extra.note ? { note: extra.note } : {}) };
|
|
498
|
+
}
|
|
483
499
|
if (!marker) {
|
|
484
500
|
return {
|
|
485
501
|
ok: false,
|
|
@@ -648,6 +664,32 @@ export async function attachAgentTool(args: {
|
|
|
648
664
|
debounceMs?: number;
|
|
649
665
|
}) {
|
|
650
666
|
// Resolve target: explicit arg > MCP server's own TMUX_PANE env.
|
|
667
|
+
// Phase 5.4 Task 4 — a fleet configured for herdr attaches through the transport: no
|
|
668
|
+
// pusher is spawned, the marker carries pid 0 and a herdr pane id, and every refusal
|
|
669
|
+
// names herdr (an absent binary is never a silent fall-through to tmux).
|
|
670
|
+
const activeT = activeTransport();
|
|
671
|
+
if (activeT?.kind === HERDR) {
|
|
672
|
+
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
673
|
+
if (existing) await deleteFile(transportFile(args.agentId));
|
|
674
|
+
let marker: TransportMarker;
|
|
675
|
+
try {
|
|
676
|
+
marker = await activeT.attach({ agentId: args.agentId, target: args.tmuxTarget, includeRoom: args.includeRoom, allowlist: args.allowlist, debounceMs: args.debounceMs });
|
|
677
|
+
} catch (e) {
|
|
678
|
+
return { ok: false, error: (e as Error).message };
|
|
679
|
+
}
|
|
680
|
+
marker = { ...marker, serverBuildMtime: SERVER_BUILD_MTIME };
|
|
681
|
+
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
682
|
+
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
agentId: args.agentId,
|
|
686
|
+
transport: HERDR,
|
|
687
|
+
target: marker.target,
|
|
688
|
+
pid: 0,
|
|
689
|
+
rooms: marker.rooms,
|
|
690
|
+
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
|
+
}
|
|
651
693
|
const target = args.tmuxTarget ?? process.env.TMUX_PANE;
|
|
652
694
|
if (!target) {
|
|
653
695
|
return {
|
|
@@ -808,6 +850,12 @@ export async function detachAgentTool(args: { agentId: string }) {
|
|
|
808
850
|
const marker = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
809
851
|
let killed = false;
|
|
810
852
|
let unverified = false;
|
|
853
|
+
if (marker?.transport === HERDR) {
|
|
854
|
+
// No pusher to kill: the socket transport has no process of its own. The marker is
|
|
855
|
+
// the whole attachment, and removing it is the detach.
|
|
856
|
+
await deleteFile(transportFile(args.agentId));
|
|
857
|
+
return { ok: true, agentId: args.agentId, killed: false, hadMarker: true, transport: HERDR, note: "herdr socket transport: no pusher process to stop; marker removed" };
|
|
858
|
+
}
|
|
811
859
|
if (marker && isPidAlive(marker.pid)) {
|
|
812
860
|
// ALIVE IS NOT ENOUGH — VERIFY IT IS A PUSHER BEFORE SIGNALLING IT.
|
|
813
861
|
//
|