agent-coord-mcp 0.26.6 → 0.26.8

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.
@@ -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 { loadLiveTransports } from "./registry.js";
24
25
 
25
26
  const BOARD_DOC = "docs/WORKSTREAMS.md";
26
27
  const STALL_MS = 30 * 60 * 1000;
@@ -71,9 +72,32 @@ export function haltState(): { halted: boolean; reason?: string; by?: string; at
71
72
  // ---------- the run mark ----------
72
73
 
73
74
  /** Every run leaves this, HIT or MISS. It is what makes a dead clock visible. */
75
+ /**
76
+ * Record a run that FAILED.
77
+ *
78
+ * Acceptance from the queue item, and the clause most easily skipped: "a
79
+ * scheduled check reports its FETCH FAILURES, or a broken check is
80
+ * indistinguishable from a quiet registry". Without this, a clock that fires
81
+ * every 30 minutes and throws every time leaves NO marks at all — identical on
82
+ * disk to a clock that was never installed.
83
+ */
84
+ export function markRunFailure(reason: string): void {
85
+ mkdirSync(ROOT, { recursive: true });
86
+ let history: RunMark[] = [];
87
+ try {
88
+ history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
89
+ } catch {
90
+ /* first run */
91
+ }
92
+ history.push({ at: Date.now(), hits: 0, checked: 0, failed: reason });
93
+ writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
94
+ }
95
+
96
+ type RunMark = { at: number; hits: number; checked: number; failed?: string };
97
+
74
98
  function markRun(result: { hits: StallHit[]; checked: number }) {
75
99
  mkdirSync(ROOT, { recursive: true });
76
- let history: { at: number; hits: number; checked: number }[] = [];
100
+ let history: RunMark[] = [];
77
101
  try {
78
102
  history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
79
103
  } catch {
@@ -94,7 +118,7 @@ export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
94
118
  */
95
119
  export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
96
120
  const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
97
- let history: { at: number; hits: number; checked: number }[] = [];
121
+ let history: RunMark[] = [];
98
122
  try {
99
123
  history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
100
124
  } catch {
@@ -105,6 +129,12 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
105
129
  };
106
130
  }
107
131
  const last = history[history.length - 1];
132
+ // A RUN THAT FAILED IS NOT A RUN THAT PASSED. Without this the clock reads
133
+ // "fresh" off a mark it wrote while erroring — the age is honest and the
134
+ // health is not, which is the same shape as a fresh heartbeat from a stuck
135
+ // agent.
136
+ const failures = history.filter((h) => h.failed);
137
+ const lastFailed = last?.failed;
108
138
  const age = Date.now() - (last?.at ?? 0);
109
139
  // `>=`, NOT `>`, AND THE DIFFERENCE IS A REAL RACE RATHER THAN PEDANTRY.
110
140
  //
@@ -118,18 +148,27 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
118
148
  // 0-minute window means nothing is ever fresh, which is what a caller asking
119
149
  // for one means.
120
150
  const stale = age >= maxAge;
121
- const misses = history.filter((h) => h.hits === 0).length;
151
+ const misses = history.filter((h) => !h.failed && h.hits === 0).length;
152
+ const hits = history.filter((h) => !h.failed && h.hits > 0).length;
122
153
  return {
123
- ok: !stale,
154
+ // A FAILING CLOCK IS NOT A HEALTHY ONE. It writes marks on schedule, so the
155
+ // age looks fresh while nothing is being measured — a fresh heartbeat from
156
+ // a stuck agent, one level up.
157
+ ok: !stale && !lastFailed,
124
158
  ...(stale
125
159
  ? {
126
160
  error: `stall_check last ran ${Math.round(age / 60000)}m ago, past the ${args.maxAgeMinutes ?? 60}m window — THE CLOCK IS STOPPED. No alerts is not the same as no stalls.`,
127
161
  }
128
- : {}),
162
+ : lastFailed
163
+ ? {
164
+ error: `the clock is RUNNING but its last run FAILED: ${lastFailed}. It is firing on schedule and measuring nothing, which reads as fresh and is not.`,
165
+ }
166
+ : {}),
129
167
  lastRanMinutesAgo: Math.round(age / 60000),
130
168
  runs: history.length,
131
169
  misses,
132
- hits: history.length - misses,
170
+ hits,
171
+ failures: failures.length,
133
172
  };
134
173
  }
135
174
 
@@ -143,17 +182,56 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
143
182
  const board = path.join(repo, BOARD_DOC);
144
183
  if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
145
184
 
185
+ const liveTransports = await loadLiveTransports();
146
186
  const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
147
187
  const inFlight = rows.filter((r) => /🚧/.test(r.status));
148
188
  const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
149
189
  const now = Date.now();
150
190
  const hits: StallHit[] = [];
191
+ /** Rows whose VCS activity could not be measured. NOT stall claims. */
192
+ const unmeasurable: { agentId: string; value: string; why: string }[] = [];
151
193
 
152
194
  for (const row of inFlight) {
153
195
  const agentId = row.owner.replace(/[`*]/g, "").trim();
154
196
  const entry = reg[agentId];
155
197
  if (entry) {
156
198
  const age = now - entry.lastHeartbeat;
199
+ // A HEARTBEAT IS ONLY EVIDENCE WHERE SOMETHING WRITES ONE.
200
+ //
201
+ // `heartbeat` is called by the PUSHER, never by the agent. Only
202
+ // `coord-pusher.mjs` (the REMOTE pusher) calls it, and it must: a remote
203
+ // marker cannot be pid-probed across machines, so its liveness IS the
204
+ // heartbeat. `hooks/tmux-pusher.mjs` — what this fleet actually runs —
205
+ // never calls it, because a LOCAL marker's liveness is `isPidAlive`.
206
+ //
207
+ // So for a local transport there is no heartbeat SOURCE at all. Before
208
+ // kit#137, `list_agents` stamped these agents and that fabrication was
209
+ // the only thing keeping the field moving; removing it left the field
210
+ // honest and empty. Measured: three attached, working agents at an
211
+ // IDENTICAL 44.7m — the uniform signature of one shared cause, not three
212
+ // stalls.
213
+ //
214
+ // WHY NOT JUST MAKE tmux-pusher HEARTBEAT: because the signal would mean
215
+ // "the pusher process is alive", which `isPidAlive` already answers for
216
+ // local markers. During the 17-hour stall every transport was live the
217
+ // whole time, so a pusher heartbeat would have read FRESH for all 17
218
+ // hours. It would restore a field without restoring a detector — and the
219
+ // case this verb exists for is exactly the one it would miss. The vcs
220
+ // half is what catches "alive and not progressing"; saying so is more
221
+ // honest than a green field.
222
+ //
223
+ // Unknown is not stalled (kit#138). This does NOT narrow Task 3.5: an
224
+ // agent with no transport, or a REMOTE one where the heartbeat genuinely
225
+ // is the liveness mechanism, still HITs on a dead heartbeat.
226
+ const marker = liveTransports.get(agentId);
227
+ if (age > limit && marker && marker.transport === "tmux-push") {
228
+ unmeasurable.push({
229
+ agentId,
230
+ value: marker.transport,
231
+ why: `heartbeat is ${Math.round(age / 60000)}m old, but nothing writes heartbeats for a local 'tmux-push' transport — hooks/tmux-pusher.mjs does not call heartbeat, and this marker's liveness is its pid. There is no heartbeat SOURCE here, so the age measures nothing about this agent`,
232
+ });
233
+ continue;
234
+ }
157
235
  if (age > limit) {
158
236
  hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
159
237
  continue;
@@ -162,22 +240,61 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
162
240
  // A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
163
241
  // the case "notice the room" never catches: the pane is responsive, so nobody
164
242
  // looks. Ask the branch instead.
165
- const branch = (row.branchWorktree.match(/`([^`]+)`/)?.[1] ?? "").trim();
166
- if (!branch || !/\//.test(branch)) continue;
243
+ //
244
+ // RESOLVE A REF, AND SAY SO WHEN THE VALUE IS NOT ONE.
245
+ //
246
+ // `git log -1 <value>` accepts a PATHSPEC exactly as readily as a ref, and
247
+ // the old guard only required a `/` — which every path has. So the board's
248
+ // `Branch · Worktree` cells, which hold PATHS, were fed to git and silently
249
+ // measured as paths: the aide was reported at 2,271 minutes, the age of
250
+ // `docs/phases/phase5`'s last commit, not of any activity by that agent.
251
+ // Verified: `docs/phases/phase5` does not resolve as a ref, yet
252
+ // `git log -1 --format=%cI docs/phases/phase5` returns a date.
253
+ //
254
+ // Other rows read plausibly only by coincidence — a path that happens to be
255
+ // committed often looks like an active branch.
256
+ //
257
+ // UNKNOWN IS NOT STALLED, which this verb already gets right for
258
+ // heartbeats. An unresolvable value is REPORTED as unmeasurable rather than
259
+ // skipped in silence: a silent `continue` and a healthy agent produce the
260
+ // same output, which is the failure this whole verb exists to avoid.
261
+ const raw = (row.branchWorktree.match(/`([^`]+)`/)?.[1] ?? "").trim();
262
+ if (!raw) {
263
+ unmeasurable.push({ agentId, value: "", why: "no value in the Branch · Worktree cell" });
264
+ continue;
265
+ }
266
+ let sha = "";
267
+ try {
268
+ sha = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${raw}^{commit}`], {
269
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
270
+ }).trim();
271
+ } catch {
272
+ sha = "";
273
+ }
274
+ if (!sha) {
275
+ unmeasurable.push({
276
+ agentId,
277
+ value: raw,
278
+ 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`,
279
+ });
280
+ continue;
281
+ }
167
282
  try {
168
- const iso = execFileSync("git", ["log", "-1", "--format=%cI", branch], {
283
+ // The resolved SHA, and `--`: a ref can then never be re-read as a
284
+ // pathspec, which is the ambiguity that produced the wrong number.
285
+ const iso = execFileSync("git", ["log", "-1", "--format=%cI", sha, "--"], {
169
286
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
170
287
  }).trim();
171
288
  const age = now - Date.parse(iso);
172
289
  if (age > limit) {
173
- hits.push({ kind: "no-vcs-activity", agentId, branch, minutes: Math.round(age / 60000) });
290
+ hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
174
291
  }
175
292
  } catch {
176
- /* unknown branch: not a stall claim we can make */
293
+ unmeasurable.push({ agentId, value: raw, why: "resolved as a ref but its log could not be read" });
177
294
  }
178
295
  }
179
296
 
180
- const result = { hits, checked: inFlight.length };
297
+ const result = { hits, checked: inFlight.length, unmeasurable };
181
298
  markRun(result);
182
299
  return {
183
300
  ok: true as const,
@@ -188,7 +305,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
188
305
  dm: hits.length > 0,
189
306
  note:
190
307
  hits.length === 0
191
- ? `MISS — ${inFlight.length} in-flight row(s), none stalled. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
308
+ ? `MISS — ${inFlight.length} in-flight row(s), none stalled${unmeasurable.length ? `; ${unmeasurable.length} row(s) UNMEASURABLE for VCS activity (${unmeasurable.map((u) => u.agentId).join(", ")}) — reported, not counted as healthy` : ""}. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
192
309
  : undefined,
193
310
  };
194
311
  }
@@ -1338,7 +1338,6 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1338
1338
  // newest hooks file). Local tmux-push only, same as 1b.
1339
1339
  {
1340
1340
  const onDisk = onDiskBuildMtime();
1341
- const outdated: string[] = [];
1342
1341
  const unverifiable: string[] = [];
1343
1342
  for (const fname of await listTransportFiles()) {
1344
1343
  const file = path.join(TRANSPORT_DIR, fname);
@@ -1361,18 +1360,45 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1361
1360
  }
1362
1361
  if (onDisk === undefined) continue;
1363
1362
  if (marker.serverBuildMtime < onDisk - 1) {
1363
+ // A COMPONENT CAN REPORT ITS OWN FRESHNESS; A SIBLING'S RECORD OF IT
1364
+ // GOES STALE SILENTLY. That asymmetry is the whole correction here.
1365
+ //
1366
+ // `serverBuildMtime` is stamped inside `attach_agent` — by the PUSHER's
1367
+ // attach, once — so it records what the server was AT THE LAST ATTACH.
1368
+ // A server-only restart (David's `/mcp`) reloads the server and touches
1369
+ // no marker, so this stamp cannot be refreshed by the thing it
1370
+ // describes and CANNOT DISTINGUISH a restarted server from an
1371
+ // un-restarted one.
1372
+ //
1373
+ // Measured on three agents: each answered `list_subscriptions` — a tool
1374
+ // that does not exist before 0.26.7 — while its marker still stamped a
1375
+ // pre-0.26.7 build. The stamp was 2.2 hours behind the installed dist
1376
+ // on a server demonstrably running the new code.
1377
+ //
1378
+ // So this is reported as what it IS: the marker predates the build, and
1379
+ // the server's actual state is UNKNOWN from here. Calling it an
1380
+ // outdated server is a claim this record cannot support, and it priced
1381
+ // a restart decision on a count where at least three entries had
1382
+ // already restarted.
1383
+ //
1384
+ // The pusher half is untouched and remains valid: `scriptMtime` is
1385
+ // self-reported by the process it describes.
1364
1386
  const stamped = new Date(marker.serverBuildMtime).toISOString();
1365
1387
  const current = new Date(onDisk).toISOString();
1366
- outdated.push(`${marker.agentId} (stamped by server build ${stamped}, on-disk build ${current})`);
1388
+ unverifiable.push(
1389
+ `${marker.agentId} (marker stamped at the last ATTACH from server build ${stamped}, on-disk build ${current} — this says the MARKER is behind, NOT that the server is: a server-only restart refreshes no marker. Exercise the server to settle it, or detach_agent + attach_agent to refresh the stamp)`,
1390
+ );
1367
1391
  }
1368
1392
  }
1369
- const bad = [...outdated, ...unverifiable];
1393
+ // `outdated` is gone, not emptied: nothing can populate it any more, and a
1394
+ // bucket that always reports 0 is a claim the reader still has to discount.
1395
+ const bad = unverifiable;
1370
1396
  findings.push({
1371
1397
  check: "marker-server-provenance",
1372
1398
  level: bad.length ? "warn" : "ok",
1373
1399
  detail: bad.length
1374
- ? `${outdated.length} transport marker(s) stamped by a server build older than dist/ and ${unverifiable.length} with no provenance stamp at all — the stamping/spawn logic (freshness basis, pusher argv) predates or cannot be tied to the current code. Restart that agent's session, then detach_agent + attach_agent.`
1375
- : "all local transport markers were stamped by the current server build",
1400
+ ? `${bad.length} transport marker(s) cannot report their server's build: the stamp is written by attach_agent, so it records the server AT THE LAST ATTACH and a server-only restart refreshes nothing. This says the MARKER is behind or absent — NOT that the server is stale. Exercise the server to settle it (a tool that exists only in the newer build), or detach_agent + attach_agent to refresh the stamp. Restart the session too if the server itself is old.`
1401
+ : "every local transport marker was stamped at an attach against the current build — which says the markers are current, not that every server is",
1376
1402
  fixable: false,
1377
1403
  items: bad.length ? bad : undefined,
1378
1404
  });
@@ -173,6 +173,28 @@ async function create(a: {
173
173
  // made: a verb that silently returns a tree on a different branch than asked
174
174
  // for is the adjacent-answer shape.
175
175
  const head = git(existing.path, ["rev-parse", "HEAD"]);
176
+ // IS THE REUSED TREE ACTUALLY AT THE BASE IT CLAIMS?
177
+ //
178
+ // The verb warned when an existing tree was on a different BRANCH and said
179
+ // nothing about it being behind the BASE — so a tree left on last week's
180
+ // main was handed back as ready. Starting a new task there produces the
181
+ // "stale main -> confidently-wrong inventories" failure the worker card
182
+ // already warns about, and nothing about the result looks stale.
183
+ //
184
+ // Reported here, not refused: an existing tree legitimately holds
185
+ // in-progress work mid-slice (1.3). The refusal belongs to `claim`, which
186
+ // is the verb that means "start something new".
187
+ let atBase = false;
188
+ let behindBy: number | null = null;
189
+ try {
190
+ const baseSha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
191
+ atBase = head === baseSha;
192
+ if (!atBase) behindBy = Number(git(existing.path, ["rev-list", "--count", `HEAD..${ref}`])) || 0;
193
+ } catch {
194
+ // NOT MEASURED IS NOT AT-BASE. Leaving `atBase` false is the safe
195
+ // direction: it makes `claim` ask rather than assume.
196
+ atBase = false;
197
+ }
176
198
  return {
177
199
  ok: true as const,
178
200
  path: existing.path,
@@ -180,6 +202,8 @@ async function create(a: {
180
202
  branch: existing.branch,
181
203
  created: false,
182
204
  base: ref,
205
+ atBase,
206
+ behindBy: behindBy ?? 0,
183
207
  ...(existing.branch !== branch
184
208
  ? { warning: `existing tree is on '${existing.branch}', not the '${branch}' this call would have created — reusing it, NOT re-pointing it` }
185
209
  : {}),
@@ -198,6 +222,10 @@ async function create(a: {
198
222
  branch,
199
223
  created: true,
200
224
  base: ref,
225
+ // Cut from `sha`, which IS origin/<base> — true by construction here, and
226
+ // stated so callers need not special-case "created" to know it.
227
+ atBase: true,
228
+ behindBy: 0,
201
229
  ...(a.ephemeral
202
230
  ? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
203
231
  : {}),