agent-coord-mcp 0.26.15 → 0.26.17
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/server.js +88 -12
- package/dist/server.js.map +1 -1
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/messaging.js +42 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +114 -8
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +8 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +25 -24
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +68 -7
- package/dist/tools/transport.js.map +1 -1
- package/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +152 -91
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +89 -9
- package/src/tools/board-ref.ts +239 -0
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +122 -8
- package/src/tools/registry.ts +22 -2
- package/src/tools/shared.ts +10 -0
- package/src/tools/stall.ts +38 -24
- package/src/tools/transport.ts +67 -6
package/src/tools/registry.ts
CHANGED
|
@@ -79,8 +79,11 @@ export const registerSchema = {
|
|
|
79
79
|
project: z.string().optional(),
|
|
80
80
|
role: roleInputSchema.optional(),
|
|
81
81
|
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
82
|
-
// that is LIVE on the bus refuses unless the call presents that
|
|
83
|
-
// token (tokens.json / coord-token)
|
|
82
|
+
// that is PROVABLY LIVE on the bus refuses unless the call presents that
|
|
83
|
+
// agent's token (tokens.json / coord-token) — `force` alone no longer
|
|
84
|
+
// overrides a provably live incumbent (q-314e0187). `force` still bypasses
|
|
85
|
+
// the guard when liveness cannot be verified, or the id is verified absent.
|
|
86
|
+
// Both ignored once bound.
|
|
84
87
|
token: z.string().optional(),
|
|
85
88
|
force: z.boolean().optional(),
|
|
86
89
|
// PROSE-ONLY EXEMPTION (Phase 5.1 Task 12.8). `true` grants it, `false`
|
|
@@ -472,10 +475,25 @@ export type ClaimEvidence = {
|
|
|
472
475
|
samePane: boolean;
|
|
473
476
|
boundElsewhere: number;
|
|
474
477
|
reasons: string[];
|
|
478
|
+
/**
|
|
479
|
+
* SESSION processes still holding this identity, structured rather than
|
|
480
|
+
* buried in `reasons` prose.
|
|
481
|
+
*
|
|
482
|
+
* A live SESSION binding is not the same fact as a live TRANSPORT marker, and
|
|
483
|
+
* conflating them is what let a refusal read as routine. The marker's pid is
|
|
484
|
+
* the pusher daemon — expected, and doing its job. A session binding for this
|
|
485
|
+
* id, alive, that is not this process, is a LEFTOVER PROCESS holding an
|
|
486
|
+
* identity: yesterday two sessions held worker-3's, and the refusal that
|
|
487
|
+
* reported it sent the reader to re-join.
|
|
488
|
+
*
|
|
489
|
+
* Named so the reader can look at the pid instead of routing around it.
|
|
490
|
+
*/
|
|
491
|
+
leakedSessions: { pid: number; via: string }[];
|
|
475
492
|
};
|
|
476
493
|
|
|
477
494
|
export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
|
|
478
495
|
const reasons: string[] = [];
|
|
496
|
+
const leakedSessions: { pid: number; via: string }[] = [];
|
|
479
497
|
let verifiable = true;
|
|
480
498
|
let samePane = false;
|
|
481
499
|
let heartbeatFresh = false;
|
|
@@ -529,6 +547,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
|
|
|
529
547
|
if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
|
|
530
548
|
if (isPidAlive(s.pid)) {
|
|
531
549
|
boundElsewhere++;
|
|
550
|
+
leakedSessions.push({ pid: s.pid, via: String(s.via ?? "unknown") });
|
|
532
551
|
reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
|
|
533
552
|
}
|
|
534
553
|
}
|
|
@@ -539,6 +558,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
|
|
|
539
558
|
samePane,
|
|
540
559
|
boundElsewhere,
|
|
541
560
|
reasons,
|
|
561
|
+
leakedSessions,
|
|
542
562
|
};
|
|
543
563
|
}
|
|
544
564
|
|
package/src/tools/shared.ts
CHANGED
|
@@ -182,6 +182,16 @@ export type Message = {
|
|
|
182
182
|
// (or any other message) carries the parent message id so consumers can
|
|
183
183
|
// mark the packet answered. Absent on every v1/v2 message — those stay valid.
|
|
184
184
|
inReplyTo?: string;
|
|
185
|
+
// PROVENANCE (q-314e0187): the pid of the process that wrote this entry,
|
|
186
|
+
// and its tmux pane when known. `from` names an IDENTITY, not a PROCESS —
|
|
187
|
+
// two live sessions bound to the same agent id write byte-identical `from`
|
|
188
|
+
// values, and that is exactly the condition that let worker-3 truthfully
|
|
189
|
+
// deny sending a message that exists under its name on 2026-08-31: both
|
|
190
|
+
// sessions' accounts were true, and nothing on the message could say which
|
|
191
|
+
// wrote it. Cross-reference against sessions/*.json (SessionBinding) or
|
|
192
|
+
// `ps -p <pid>` to attribute a disputed entry. Absent on every message
|
|
193
|
+
// written before this field existed — that is UNKNOWN, not "same process".
|
|
194
|
+
provenance?: { pid: number; tmuxPane?: string };
|
|
185
195
|
};
|
|
186
196
|
|
|
187
197
|
// THE retention predicate — one definition, three call sites (prune,
|
package/src/tools/stall.ts
CHANGED
|
@@ -21,6 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
|
|
23
23
|
import { ROOT, AGENTS_FILE, readJson } from "../store.js";
|
|
24
|
+
import { classifyBoardRef, refInCell } from "./board-ref.js";
|
|
24
25
|
import { loadLiveTransports } from "./registry.js";
|
|
25
26
|
|
|
26
27
|
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
@@ -31,7 +32,19 @@ const haltFile = () => path.join(ROOT, "halt.json");
|
|
|
31
32
|
|
|
32
33
|
export type StallHit =
|
|
33
34
|
| { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
|
|
34
|
-
| { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
|
|
35
|
+
| { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
|
|
36
|
+
/**
|
|
37
|
+
* A 🚧 row whose branch HAS ALREADY LANDED. Not a stall — the opposite: the
|
|
38
|
+
* work finished and nobody closed the row.
|
|
39
|
+
*
|
|
40
|
+
* It exists because the VCS axis was catching this BY ACCIDENT and is about to
|
|
41
|
+
* stop. A merged ref is frozen, so it reported an ever-growing "no activity"
|
|
42
|
+
* age, and that false stall was the only thing surfacing a real routing
|
|
43
|
+
* failure: a lane sitting In Progress with nothing routed to it. Making the
|
|
44
|
+
* stall honest would have silently removed the signal, so the signal gets its
|
|
45
|
+
* own name instead of inheriting a wrong one.
|
|
46
|
+
*/
|
|
47
|
+
| { kind: "stale-row"; agentId: string; branch: string; why: string };
|
|
35
48
|
|
|
36
49
|
// ---------- halt ----------
|
|
37
50
|
|
|
@@ -343,47 +356,48 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
343
356
|
// heartbeats. An unresolvable value is REPORTED as unmeasurable rather than
|
|
344
357
|
// skipped in silence: a silent `continue` and a healthy agent produce the
|
|
345
358
|
// same output, which is the failure this whole verb exists to avoid.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
359
|
+
// ONE CLASSIFIER, SHARED WITH `claim` (board-ref.ts). The old block asked
|
|
360
|
+
// only "does it resolve", and three of the four ways this cell has been
|
|
361
|
+
// wrong resolved cleanly — a shared ref, a local-only ref, and a merged
|
|
362
|
+
// ref. Resolution was never the property; the property is whether the ref's
|
|
363
|
+
// movement is THIS AGENT'S WORK.
|
|
364
|
+
const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
|
|
365
|
+
if (verdict.kind === "merged") {
|
|
366
|
+
// A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
|
|
367
|
+
// did learn something about this lane: its work landed and its row is
|
|
368
|
+
// stale. Reported as its own kind rather than as a stall, which is what
|
|
369
|
+
// the frozen ref was reporting it as.
|
|
370
|
+
hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
|
|
371
|
+
measured.add(agentId);
|
|
349
372
|
continue;
|
|
350
373
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
sha = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${raw}^{commit}`], {
|
|
354
|
-
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
355
|
-
}).trim();
|
|
356
|
-
} catch {
|
|
357
|
-
sha = "";
|
|
358
|
-
}
|
|
359
|
-
if (!sha) {
|
|
360
|
-
unmeasurable.push({
|
|
361
|
-
agentId,
|
|
362
|
-
value: raw,
|
|
363
|
-
why: `'${raw}' does not resolve as a git ref — it is a path or glob, and \`git log <path>\` would silently report that PATH's last commit as this agent's activity`,
|
|
364
|
-
});
|
|
374
|
+
if (verdict.kind !== "measurable") {
|
|
375
|
+
unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
|
|
365
376
|
continue;
|
|
366
377
|
}
|
|
367
378
|
try {
|
|
368
379
|
// The resolved SHA, and `--`: a ref can then never be re-read as a
|
|
369
380
|
// pathspec, which is the ambiguity that produced the wrong number.
|
|
381
|
+
const sha = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${verdict.ref}^{commit}`], {
|
|
382
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
383
|
+
}).trim();
|
|
370
384
|
const iso = execFileSync("git", ["log", "-1", "--format=%cI", sha, "--"], {
|
|
371
385
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
372
386
|
}).trim();
|
|
373
387
|
const age = now - Date.parse(iso);
|
|
374
388
|
measured.add(agentId);
|
|
375
389
|
if (age > limit) {
|
|
376
|
-
hits.push({ kind: "no-vcs-activity", agentId, branch:
|
|
390
|
+
hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
|
|
377
391
|
}
|
|
378
392
|
} catch {
|
|
379
|
-
unmeasurable.push({ agentId, value:
|
|
393
|
+
unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
|
|
380
394
|
}
|
|
381
395
|
}
|
|
382
396
|
|
|
383
|
-
// COVERAGE, not "did it run". A row is COVERED when
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
397
|
+
// COVERAGE, not "did it run". A row is COVERED when a predicate produced a
|
|
398
|
+
// verdict about it; a row where every predicate came back unmeasurable was
|
|
399
|
+
// looked at and not measured, and counting it as checked is how "the clock
|
|
400
|
+
// ran" gets mistaken for "the fleet is observed".
|
|
387
401
|
const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
|
|
388
402
|
const blind = [...new Set(owners)].filter((id) => !measured.has(id));
|
|
389
403
|
const measurable = Math.max(0, inFlight.length - blind.length);
|
package/src/tools/transport.ts
CHANGED
|
@@ -927,8 +927,11 @@ export const joinSchema = {
|
|
|
927
927
|
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
928
928
|
readInbox: z.boolean().optional(),
|
|
929
929
|
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
930
|
-
// that is LIVE on the bus refuses unless the call presents that
|
|
931
|
-
// token (tokens.json / coord-token)
|
|
930
|
+
// that is PROVABLY LIVE on the bus refuses unless the call presents that
|
|
931
|
+
// agent's token (tokens.json / coord-token) — `force` alone no longer
|
|
932
|
+
// overrides a provably live incumbent (q-314e0187). `force` still bypasses
|
|
933
|
+
// the guard when liveness cannot be verified, or the id is verified absent.
|
|
934
|
+
// Both ignored once bound.
|
|
932
935
|
token: z.string().optional(),
|
|
933
936
|
force: z.boolean().optional(),
|
|
934
937
|
// Prose-only exemption from the typed-record rule — see registerSchema.
|
|
@@ -1724,27 +1727,85 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1724
1727
|
}
|
|
1725
1728
|
|
|
1726
1729
|
// 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
|
|
1730
|
+
//
|
|
1731
|
+
// Task 5.3/22.1: "27 stale" collapsed two different populations that look
|
|
1732
|
+
// identical in a flat heartbeat-age list — 14 agents whose PANE was still
|
|
1733
|
+
// genuinely current (their marker's pid had died, e.g. a `/mcp` reconnect
|
|
1734
|
+
// spawned a new process in the SAME pane, but the pane itself never went
|
|
1735
|
+
// anywhere) and 13 agents that were ORPHANS with no pane behind them at
|
|
1736
|
+
// all, aged 19–45h. An orphan is stale by construction and can NEVER
|
|
1737
|
+
// clear — reporting it beside a merely-slow-heartbeat agent manufactures a
|
|
1738
|
+
// permanent false alarm in the same bucket as a real one.
|
|
1739
|
+
//
|
|
1740
|
+
// THE DISCRIMINATOR: a marker file that still exists on disk (even though
|
|
1741
|
+
// its own pid/heartbeat check already failed `isMarkerLive`) and names a
|
|
1742
|
+
// `tmuxTarget` `tmux has-session` still confirms. `has-session` checks the
|
|
1743
|
+
// PANE, not the recorded pid — so it survives exactly the reconnect shape
|
|
1744
|
+
// above, the same probe `wedged-local-pushers` already uses one direction
|
|
1745
|
+
// over (there: a live pid whose pane died; here: a dead/expired marker
|
|
1746
|
+
// whose pane did not). No marker at all, or a marker whose pane is
|
|
1747
|
+
// confirmed gone, is the only shape left — and that is a genuine orphan.
|
|
1727
1748
|
{
|
|
1728
1749
|
// Compute liveness WITHOUT deleting dead markers — loadLiveTransports
|
|
1729
1750
|
// prunes as a side effect, which would make this read-only check mutate
|
|
1730
1751
|
// state (and pre-empt the orphan-marker fix in check 1).
|
|
1731
1752
|
const live = new Set<string>();
|
|
1753
|
+
const markerByAgent = new Map<string, TransportMarker>();
|
|
1732
1754
|
for (const fname of await listTransportFiles()) {
|
|
1733
1755
|
const marker = await readJson<TransportMarker | null>(path.join(TRANSPORT_DIR, fname), null);
|
|
1734
|
-
if (marker
|
|
1756
|
+
if (!marker) continue;
|
|
1757
|
+
markerByAgent.set(marker.agentId, marker);
|
|
1758
|
+
if (isMarkerLive(marker, reg, now)) live.add(marker.agentId);
|
|
1735
1759
|
}
|
|
1736
|
-
|
|
1760
|
+
// Without a tmux binary we cannot probe a pane at all — every stale
|
|
1761
|
+
// agent is reported as an orphan candidate rather than silently split,
|
|
1762
|
+
// same posture `wedged-local-pushers` takes.
|
|
1763
|
+
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
|
1764
|
+
const paneAlive = (target: string): boolean =>
|
|
1765
|
+
tmuxAvailable && spawnSync("tmux", ["has-session", "-t", target]).status === 0;
|
|
1766
|
+
|
|
1767
|
+
const paneConfirmed: string[] = [];
|
|
1768
|
+
const orphans: string[] = [];
|
|
1737
1769
|
for (const [id, a] of Object.entries(reg)) {
|
|
1738
1770
|
if (live.has(id)) continue;
|
|
1739
|
-
if (now - a.lastHeartbeat
|
|
1771
|
+
if (now - a.lastHeartbeat <= EVICT_MS) continue;
|
|
1772
|
+
const age = `${Math.floor((now - a.lastHeartbeat) / 3600000)}h`;
|
|
1773
|
+
const marker = markerByAgent.get(id);
|
|
1774
|
+
if (marker?.transport === "tmux-push" && marker.tmuxTarget && paneAlive(marker.tmuxTarget)) {
|
|
1775
|
+
paneConfirmed.push(`${id} (${age}, pane '${marker.tmuxTarget}' still current)`);
|
|
1776
|
+
} else {
|
|
1777
|
+
orphans.push(`${id} (${age})`);
|
|
1778
|
+
}
|
|
1740
1779
|
}
|
|
1780
|
+
const stale = [...paneConfirmed, ...orphans];
|
|
1741
1781
|
findings.push({
|
|
1742
1782
|
check: "stale-agents",
|
|
1743
1783
|
level: stale.length ? "warn" : "ok",
|
|
1744
|
-
detail: stale.length
|
|
1784
|
+
detail: stale.length
|
|
1785
|
+
? `${stale.length} agent(s) past the eviction window — next list_agents will drop them ` +
|
|
1786
|
+
`(${paneConfirmed.length} pane-confirmed current, ${orphans.length} genuine orphan(s) with no live pane behind them)`
|
|
1787
|
+
: "no stale agents",
|
|
1745
1788
|
fixable: false,
|
|
1746
1789
|
items: stale.length ? stale : undefined,
|
|
1747
1790
|
});
|
|
1791
|
+
// A SEPARATE finding, not a sub-line: "stale-agents" answers "how many
|
|
1792
|
+
// will list_agents drop", which is true of both buckets equally — an
|
|
1793
|
+
// orphan and a pane-confirmed agent are dropped from the SAME list the
|
|
1794
|
+
// same way. "orphan-agents" answers the different question this task is
|
|
1795
|
+
// actually about — which of those, if any, can never clear on their
|
|
1796
|
+
// own — and reports it even when it is empty, so a checker that only
|
|
1797
|
+
// speaks when it fires cannot be told from one that never ran.
|
|
1798
|
+
findings.push({
|
|
1799
|
+
check: "orphan-agents",
|
|
1800
|
+
level: orphans.length ? "warn" : "ok",
|
|
1801
|
+
detail: orphans.length
|
|
1802
|
+
? `${orphans.length} stale agent(s) have no live tmux pane behind them — permanent, will not clear on their own (unregister or let eviction drop them)`
|
|
1803
|
+
: tmuxAvailable
|
|
1804
|
+
? `no orphans among ${stale.length} stale agent(s)${stale.length ? " — all pane-confirmed current" : ""}`
|
|
1805
|
+
: "tmux not available — could not distinguish orphans from pane-confirmed stale agents",
|
|
1806
|
+
fixable: false,
|
|
1807
|
+
items: orphans.length ? orphans : undefined,
|
|
1808
|
+
});
|
|
1748
1809
|
}
|
|
1749
1810
|
|
|
1750
1811
|
// 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.
|