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.
@@ -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 agent's
83
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
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
 
@@ -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,
@@ -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
- const raw = (row.branchWorktree.match(/`([^`]+)`/)?.[1] ?? "").trim();
347
- if (!raw) {
348
- unmeasurable.push({ agentId, value: "", why: "no value in the Branch · Worktree cell" });
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
- let sha = "";
352
- try {
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: raw, minutes: Math.round(age / 60000) });
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: raw, why: "resolved as a ref but its log could not be read" });
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 at least one predicate
384
- // produced a verdict about it; a row where every predicate came back
385
- // unmeasurable was looked at and not measured, and counting it as checked is
386
- // how "the clock ran" gets mistaken for "the fleet is observed".
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);
@@ -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 agent's
931
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
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 && isMarkerLive(marker, reg, now)) live.add(marker.agentId);
1756
+ if (!marker) continue;
1757
+ markerByAgent.set(marker.agentId, marker);
1758
+ if (isMarkerLive(marker, reg, now)) live.add(marker.agentId);
1735
1759
  }
1736
- const stale: string[] = [];
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 > EVICT_MS) stale.push(`${id} (${Math.floor((now - a.lastHeartbeat) / 3600000)}h)`);
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 ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them` : "no stale agents",
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.