agent-coord-mcp 0.26.15 → 0.26.16

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.
@@ -0,0 +1,194 @@
1
+ /*
2
+ * IS THIS BOARD CELL'S REF A PER-AGENT ACTIVITY SIGNAL?
3
+ *
4
+ * One classifier, used by the CHECKER that reads the cell and the VERB that
5
+ * writes it. A rule enforced at the read and not the write is a rule the write
6
+ * defeats: `claim` writes these rows, so if a bad ref is refusable by
7
+ * `stall_check` and writable by `claim`, the verb the fleet is being pushed
8
+ * toward becomes the thing that manufactures the bad row.
9
+ *
10
+ * FOUR WAYS THIS ONE CELL HAS BEEN WRONG, all measured on this fleet:
11
+ *
12
+ * `docs/*` a PATH — refused today, correctly
13
+ * `main` a SHARED ref — resolved, and reported the
14
+ * whole fleet's activity as one agent's, so the
15
+ * row could never stall
16
+ * `worker-3/task13-…` LOCAL-ONLY — resolved for its author and for
17
+ * nobody else, so the same board read
18
+ * differently from two checkouts
19
+ * `worker-1/task16-3-…` MERGED — resolves everywhere and is FROZEN by
20
+ * construction, so it reports an ever-growing
21
+ * stall on a lane that is finished
22
+ *
23
+ * THREE OF THOSE FOUR RESOLVED CLEANLY. So the test cannot be "does it
24
+ * resolve" — that check passed on every bad case except the path. The property
25
+ * is narrower and it is the one thing all four violate:
26
+ *
27
+ * THE CELL MUST NAME A REF WHOSE MOVEMENT IS THIS AGENT'S WORK.
28
+ *
29
+ * Which decomposes into three questions git can actually answer:
30
+ *
31
+ * scoped does the ref name begin with this agent's id? That is the ONLY
32
+ * per-agent signal this repo carries. Author cannot serve: measured
33
+ * on 60 commits of `origin/main`, every one is authored "David
34
+ * Balzan", so an `--author` filter would match every agent on every
35
+ * ref forever WHILE LOOKING SCOPED.
36
+ * shared is it resolvable from ANY checkout, not just the one that happens
37
+ * to have fetched it? That means the remote-tracking form.
38
+ * live is it still ahead of the base? A merged branch's ref never moves
39
+ * again, so measuring it produces a number that only grows.
40
+ *
41
+ * WHAT THIS DELIBERATELY DOES NOT DO: guess. Every rejection is reported with
42
+ * the reason and the population it judged, and an UNPUSHED branch is called
43
+ * unpushed rather than stalled — a freshly claimed lane has no shared evidence
44
+ * yet, and saying "stalled" there would be the manufactured-narrowing error
45
+ * pointed at a new lane.
46
+ */
47
+ import { execFileSync } from "node:child_process";
48
+
49
+ export type BoardRefVerdict =
50
+ | { kind: "measurable"; ref: string }
51
+ | { kind: "empty"; why: string }
52
+ | { kind: "path"; why: string }
53
+ | { kind: "shared"; why: string }
54
+ | { kind: "unscoped"; why: string }
55
+ | { kind: "local-only"; why: string }
56
+ | { kind: "unpushed"; why: string }
57
+ | { kind: "merged"; why: string };
58
+
59
+ const git = (repo: string, args: string[]): string | null => {
60
+ try {
61
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+ const resolves = (repo: string, ref: string): boolean => !!git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
67
+
68
+ /** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
69
+ export function refInCell(cell: string): string {
70
+ return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
71
+ }
72
+
73
+ /** The remote-tracking form a board cell should name for `branch`. */
74
+ export const boardRefFor = (branch: string): string => (branch.startsWith("origin/") ? branch : `origin/${branch}`);
75
+
76
+ /**
77
+ * Classify one cell for one agent.
78
+ *
79
+ * `base` is the ref a merged branch would be contained in — `origin/main` by
80
+ * convention, passed rather than assumed so a consumer fleet on another default
81
+ * branch is not silently misjudged.
82
+ */
83
+ export function classifyBoardRef(
84
+ repo: string,
85
+ agentId: string,
86
+ cell: string,
87
+ base = "origin/main",
88
+ ): BoardRefVerdict {
89
+ const raw = refInCell(cell);
90
+ if (!raw) return { kind: "empty", why: "no ref in the Branch · Worktree cell" };
91
+
92
+ const bare = raw.replace(/^origin\//, "");
93
+
94
+ // A SHARED REF FIRST, because `main` resolves and would otherwise be measured.
95
+ // Named explicitly rather than inferred from scoping, so the reason a reader
96
+ // gets is the real one.
97
+ const shared = new Set(["main", "master", "develop", "trunk", "HEAD"]);
98
+ if (shared.has(bare)) {
99
+ return {
100
+ kind: "shared",
101
+ why:
102
+ `'${raw}' is a SHARED branch — every agent merges into it, so its movement is the fleet's activity and not '${agentId}'s. ` +
103
+ `A row pointing here can never stall, which is a manufactured green rather than a measurement.`,
104
+ };
105
+ }
106
+
107
+ // SCOPED BY NAME — but only refusing a ref that belongs to a DIFFERENT agent,
108
+ // never one that is merely unconventional.
109
+ //
110
+ // Authorship cannot carry ownership here (every commit in this repo is
111
+ // authored by the same person, so `--author` would match every agent on every
112
+ // ref while looking scoped), which leaves the branch-naming convention
113
+ // `<agentId>/<slice>`. But REQUIRING that convention would make a repo that
114
+ // does not use it universally blind — and a narrowing that disables the check
115
+ // everywhere is worse than the defect it fixes. So a plain `feature-x` is
116
+ // allowed and `<someone-else>/x` is refused: the second is positively
117
+ // someone else's work, the first is only unlabelled.
118
+ const owner = bare.includes("/") ? bare.slice(0, bare.indexOf("/")) : null;
119
+ if (owner && owner !== agentId && /^[\w.-]+-(worker|aide|qa|ci|coordinator)(-\d+)?$/.test(owner)) {
120
+ return {
121
+ kind: "unscoped",
122
+ why:
123
+ `'${raw}' is scoped to '${owner}', not '${agentId}' — its movement is that agent's work. ` +
124
+ `A per-agent signal needs a ref only this agent writes, and the branch name is the only ownership marker this repo carries.`,
125
+ };
126
+ }
127
+
128
+ const remote = `origin/${bare}`;
129
+ const remoteResolves = resolves(repo, remote);
130
+ const localResolves = resolves(repo, bare);
131
+
132
+ // NO SHARED FORM CAN EXIST, so the local ref is the best evidence there is.
133
+ // Without this a repo with no `origin` remote would report every row
134
+ // unmeasurable forever — the check disabled everywhere by a rule meant to
135
+ // make it honest. Preferring the shared form is right; requiring one that
136
+ // cannot exist is not.
137
+ if (!remoteResolves && !resolves(repo, "origin/HEAD") && git(repo, ["remote", "get-url", "origin"]) === null) {
138
+ if (!localResolves) {
139
+ return { kind: "path", why: `'${raw}' does not resolve as a git ref in a repo with no remote — it is a path or glob` };
140
+ }
141
+ return { kind: "measurable", ref: bare };
142
+ }
143
+
144
+ if (!remoteResolves) {
145
+ // Local-only vs never-pushed are different facts and only one is a defect
146
+ // in the CELL. Both are unmeasurable, and neither is a stall.
147
+ if (localResolves) {
148
+ return {
149
+ kind: "local-only",
150
+ why:
151
+ `'${raw}' resolves in THIS checkout and not on the remote, so the same board reads differently from another checkout — ` +
152
+ `whether it is measurable depends on who last ran \`git fetch\`, which is a property of the reader rather than of the work.`,
153
+ };
154
+ }
155
+ return {
156
+ kind: "unpushed",
157
+ why:
158
+ `'${remote}' does not exist — the branch has not been pushed, so there is no shared evidence of activity yet. ` +
159
+ `That is an ABSENCE of evidence on a new lane, not a stall.`,
160
+ };
161
+ }
162
+
163
+ // MERGED IS FROZEN. Its ref can never move again, so any age computed from it
164
+ // only grows — an ever-worsening stall on a lane that is finished.
165
+ //
166
+ // `--is-ancestor` ALONE CANNOT SEE IT, and this fleet is the case that proves
167
+ // it: we SQUASH-merge, so the branch's commits never become ancestors of the
168
+ // base. Measured on a branch I knew was merged — `--is-ancestor` said NO
169
+ // while the work was plainly on main. A merged-detector that cannot detect
170
+ // this repo's own merge strategy is inert, which is worse than absent because
171
+ // it reads as covered.
172
+ //
173
+ // `git cherry` compares PATCH IDS, so a squashed single-commit branch reports
174
+ // `-` (already upstream). It is not complete either — squashing several
175
+ // commits into one changes the patch id — so BOTH tests run and either one
176
+ // answering yes is enough. Neither is asked to be sufficient alone.
177
+ if (resolves(repo, base)) {
178
+ const ancestor = git(repo, ["merge-base", "--is-ancestor", remote, base]) !== null;
179
+ const cherry = git(repo, ["cherry", base, remote]);
180
+ const allUpstream =
181
+ cherry !== null && cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
182
+ if (ancestor || allUpstream) {
183
+ return {
184
+ kind: "merged",
185
+ why:
186
+ `'${remote}' has already landed in ${base} (${ancestor ? "an ancestor" : "every commit's patch is upstream — a squash merge"}) — ` +
187
+ `so this ref is FROZEN and can never move again. ` +
188
+ `Measuring it reports a stall that grows forever on a finished lane. The row needs the agent's current slice, or an idle marker.`,
189
+ };
190
+ }
191
+ }
192
+
193
+ return { kind: "measurable", ref: remote };
194
+ }
@@ -27,6 +27,7 @@ import {
27
27
  sweepTagOf,
28
28
  } from "@davidbalzan/groundwork-seam";
29
29
  import { ensureWorktreeTool } from "./worktrees.js";
30
+ import { boardRefFor } from "./board-ref.js";
30
31
  import { haltState } from "./stall.js";
31
32
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
32
33
 
@@ -340,7 +341,20 @@ export async function claimTool(args: { project: string; agentId: string; itemId
340
341
  };
341
342
  }
342
343
 
343
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
344
+ // THE CELL NAMES THE REMOTE-TRACKING REF, not the bare local branch.
345
+ //
346
+ // A bare name resolves in the checkout that created it and nowhere else, so
347
+ // the same board read differently from two checkouts and whether a row was
348
+ // measurable depended on who last ran `git fetch` — a property of the reader
349
+ // rather than of the work. Writing `origin/<branch>` makes the cell mean the
350
+ // same thing everywhere.
351
+ //
352
+ // It does not resolve YET, because a freshly cut branch is unpushed. That is
353
+ // correct and `stall_check` says so in those words: an unpushed lane has no
354
+ // shared evidence of activity, which is an absence of evidence rather than a
355
+ // stall. Enforced at the WRITE as well as the read (board-ref.ts) — a rule the
356
+ // writer can defeat is a rule the writer defeats.
357
+ const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${boardRefFor(wt.branch ?? "")}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
344
358
 
345
359
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
346
360
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -472,10 +472,25 @@ export type ClaimEvidence = {
472
472
  samePane: boolean;
473
473
  boundElsewhere: number;
474
474
  reasons: string[];
475
+ /**
476
+ * SESSION processes still holding this identity, structured rather than
477
+ * buried in `reasons` prose.
478
+ *
479
+ * A live SESSION binding is not the same fact as a live TRANSPORT marker, and
480
+ * conflating them is what let a refusal read as routine. The marker's pid is
481
+ * the pusher daemon — expected, and doing its job. A session binding for this
482
+ * id, alive, that is not this process, is a LEFTOVER PROCESS holding an
483
+ * identity: yesterday two sessions held worker-3's, and the refusal that
484
+ * reported it sent the reader to re-join.
485
+ *
486
+ * Named so the reader can look at the pid instead of routing around it.
487
+ */
488
+ leakedSessions: { pid: number; via: string }[];
475
489
  };
476
490
 
477
491
  export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
478
492
  const reasons: string[] = [];
493
+ const leakedSessions: { pid: number; via: string }[] = [];
479
494
  let verifiable = true;
480
495
  let samePane = false;
481
496
  let heartbeatFresh = false;
@@ -529,6 +544,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
529
544
  if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
530
545
  if (isPidAlive(s.pid)) {
531
546
  boundElsewhere++;
547
+ leakedSessions.push({ pid: s.pid, via: String(s.via ?? "unknown") });
532
548
  reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
533
549
  }
534
550
  }
@@ -539,6 +555,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
539
555
  samePane,
540
556
  boundElsewhere,
541
557
  reasons,
558
+ leakedSessions,
542
559
  };
543
560
  }
544
561
 
@@ -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);