agent-coord-mcp 0.26.12 → 0.26.13

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.
@@ -93,9 +93,9 @@ export function markRunFailure(reason: string): void {
93
93
  writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
94
94
  }
95
95
 
96
- type RunMark = { at: number; hits: number; checked: number; failed?: string };
96
+ type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string };
97
97
 
98
- function markRun(result: { hits: StallHit[]; checked: number }) {
98
+ function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
99
99
  mkdirSync(ROOT, { recursive: true });
100
100
  let history: RunMark[] = [];
101
101
  try {
@@ -103,7 +103,12 @@ function markRun(result: { hits: StallHit[]; checked: number }) {
103
103
  } catch {
104
104
  /* first run */
105
105
  }
106
- history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked });
106
+ // COVERAGE IS RECORDED WITH THE RUN, because a status tool that cannot
107
+ // express its own blindness is worse than no status tool. Measured: the clock
108
+ // reported `runs 8, failures 0` — all green — while every one of those runs
109
+ // had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
110
+ // instrument meant to cover an absence.
111
+ history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
107
112
  // A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
108
113
  // list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
109
114
  // are different states and only the first is a healthy fleet.
@@ -114,7 +119,7 @@ export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
114
119
 
115
120
  /**
116
121
  * Is the clock alive? Readable by a human or another check, which is the point —
117
- * MISS is silent to the duty officer, not to the record.
122
+ * MISS is silent to the WATCHER, not to the record.
118
123
  */
119
124
  export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
120
125
  const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
@@ -150,11 +155,19 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
150
155
  const stale = age >= maxAge;
151
156
  const misses = history.filter((h) => !h.failed && h.hits === 0).length;
152
157
  const hits = history.filter((h) => !h.failed && h.hits > 0).length;
158
+ // A RUN THAT COVERED NOTHING IS NOT A RUN THAT FOUND NOTHING. Marks written
159
+ // before coverage was recorded carry no `measurable` field: they are reported
160
+ // as UNKNOWN rather than assumed covered, because assuming is what produced
161
+ // the green this exists to correct.
162
+ const graded = history.filter((h) => !h.failed && typeof h.measurable === "number");
163
+ const blindRuns = graded.filter((h) => h.checked > 0 && h.measurable === 0).length;
164
+ const ungraded = history.filter((h) => !h.failed && typeof h.measurable !== "number").length;
165
+ const lastBlind = last && !last.failed && (last.checked ?? 0) > 0 && last.measurable === 0;
153
166
  return {
154
167
  // A FAILING CLOCK IS NOT A HEALTHY ONE. It writes marks on schedule, so the
155
168
  // age looks fresh while nothing is being measured — a fresh heartbeat from
156
169
  // a stuck agent, one level up.
157
- ok: !stale && !lastFailed,
170
+ ok: !stale && !lastFailed && !lastBlind,
158
171
  ...(stale
159
172
  ? {
160
173
  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.`,
@@ -163,12 +176,28 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
163
176
  ? {
164
177
  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
178
  }
166
- : {}),
179
+ : lastBlind
180
+ ? {
181
+ error:
182
+ `the clock is RUNNING and BLIND: its last run checked ${last?.checked} in-flight row(s) and could measure ${last?.measurable} of them. ` +
183
+ `It fires on schedule, reports no hits, and that "no hits" is not evidence of a healthy fleet — it is evidence of nothing. ` +
184
+ `Read stall_check's own 'unmeasurable' list for the cause; the usual one is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref.`,
185
+ }
186
+ : {}),
167
187
  lastRanMinutesAgo: Math.round(age / 60000),
168
188
  runs: history.length,
169
189
  misses,
170
190
  hits,
171
191
  failures: failures.length,
192
+ // The number the old shape could not express.
193
+ coverage: {
194
+ lastRun: last && !last.failed ? { checked: last.checked, measurable: last.measurable ?? null } : null,
195
+ blindRuns,
196
+ ungraded,
197
+ note:
198
+ "blindRuns are runs that checked rows and measured none of them — green by every other field. " +
199
+ "ungraded are marks written before coverage was recorded: UNKNOWN, never assumed covered.",
200
+ },
172
201
  };
173
202
  }
174
203
 
@@ -190,10 +219,27 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
190
219
  const hits: StallHit[] = [];
191
220
  /** Rows whose VCS activity could not be measured. NOT stall claims. */
192
221
  const unmeasurable: { agentId: string; value: string; why: string }[] = [];
222
+ /**
223
+ * Rows a predicate actually reached a VERDICT about — hit or clean.
224
+ *
225
+ * Tracked explicitly rather than inferred from "2 predicates minus the
226
+ * unmeasurable ones", because a predicate that is SKIPPED pushes no
227
+ * unmeasurable entry and the arithmetic then counts it as having measured.
228
+ * That is the same defect as the `continue` above, one level up: a silent
229
+ * skip and a clean pass produce the same number.
230
+ */
231
+ const measured = new Set<string>();
193
232
 
194
233
  for (const row of inFlight) {
195
234
  const agentId = row.owner.replace(/[`*]/g, "").trim();
196
235
  const entry = reg[agentId];
236
+ if (!entry) {
237
+ // NOT SKIPPED IN SILENCE. An owner with no registry entry has no
238
+ // heartbeat to read, which is a missing signal and must be reported as
239
+ // one — a row whose owner the bus has never heard of is exactly the row
240
+ // you would want named before going away.
241
+ unmeasurable.push({ agentId, value: "", why: "no registry entry for this owner — there is no heartbeat to read, and the name may not be an agent id at all" });
242
+ }
197
243
  if (entry) {
198
244
  const age = now - entry.lastHeartbeat;
199
245
  // A HEARTBEAT IS ONLY EVIDENCE WHERE SOMETHING WRITES ONE.
@@ -230,12 +276,51 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
230
276
  value: marker.transport,
231
277
  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
278
  });
233
- continue;
234
- }
235
- if (age > limit) {
279
+ // FALL THROUGH TO THE VCS PREDICATE — do not `continue`.
280
+ //
281
+ // This used to skip the row entirely, and the consequence was total: on
282
+ // this fleet, 3 of 3 in-flight rows were local tmux-push agents, so
283
+ // EVERY row exited here and the check reported `checked 3 · measurable
284
+ // 0`. The half that was blind (heartbeat, deliberately, for the reason
285
+ // above) was taking the half that works (vcs activity) down with it.
286
+ //
287
+ // The two predicates answer different questions and only one of them is
288
+ // unanswerable for a local transport. "Is it alive" has no source here;
289
+ // "is its branch moving" has a perfectly good one, and it is the half
290
+ // that catches the case this verb exists for — alive and not
291
+ // progressing. An unmeasurable heartbeat is a missing signal, not a
292
+ // reason to stop measuring the signal that is present.
293
+ } else if (age > limit) {
236
294
  hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
295
+ measured.add(agentId);
237
296
  continue;
238
297
  }
298
+ // A FRESH HEARTBEAT IS *NOT* A VERDICT, AND THIS IS A CORRECTION TO WHAT
299
+ // THIS CODE CLAIMED WHEN IT MERGED.
300
+ //
301
+ // It read "a fresh heartbeat IS a verdict — alive" and credited coverage
302
+ // for it. Measured since, by reading agents.json directly (`list_agents`
303
+ // refreshes the CALLER's mark, so it cannot be used to measure the
304
+ // caller): an agent's mark aged from 9611s to 9623s across a `post_status`
305
+ // call. BUS TOOL CALLS DO NOT WRITE HEARTBEATS.
306
+ //
307
+ // `heartbeat` age is TIME SINCE JOIN. Not activity, and not even
308
+ // time-since-restart — a server can restart mid-session without the mark
309
+ // moving, because nothing rejoined. So freshness says "recently joined",
310
+ // and crediting it as coverage lets `/coord-away` arm on a fleet whose
311
+ // only evidence is that somebody reconnected.
312
+ //
313
+ // The inverse is worse and is why this is not merely tidiness: reading
314
+ // age as activity marks the two most CONTINUOUSLY ACTIVE agents on a bus
315
+ // as stale at 2.6h, while a freshly-rejoined idle agent reads healthy —
316
+ // a false-stall generator aimed at exactly the agents that must not be
317
+ // false-stalled while nobody is watching.
318
+ //
319
+ // The HIT above is kept: for an agent with no transport at all, "has not
320
+ // rejoined and has no live marker" is still the death signal Task 3.5
321
+ // specifies. What is removed is the coverage credit for freshness, which
322
+ // leaves coverage resting on the vcs predicate — the one that measures
323
+ // something the agent DID.
239
324
  }
240
325
  // A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
241
326
  // the case "notice the room" never catches: the pane is responsive, so nobody
@@ -286,6 +371,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
286
371
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
287
372
  }).trim();
288
373
  const age = now - Date.parse(iso);
374
+ measured.add(agentId);
289
375
  if (age > limit) {
290
376
  hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
291
377
  }
@@ -294,8 +380,15 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
294
380
  }
295
381
  }
296
382
 
297
- const result = { hits, checked: inFlight.length, unmeasurable };
298
- markRun(result);
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".
387
+ const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
388
+ const blind = [...new Set(owners)].filter((id) => !measured.has(id));
389
+ const measurable = Math.max(0, inFlight.length - blind.length);
390
+ const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
391
+ markRun({ hits, checked: inFlight.length, measurable });
299
392
  return {
300
393
  ok: true as const,
301
394
  ...result,
@@ -124,7 +124,20 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
124
124
  }
125
125
 
126
126
  const reachable = transportLive && paneAlive !== false;
127
- const alive = reachable || heartbeatFresh;
127
+ // heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
128
+ // A local tmux-push transport's liveness is its pid (isPidAlive) —
129
+ // hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
130
+ // since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
131
+ // rule for exactly this transport type). Crediting it here for a
132
+ // tmux-push agent produced the incident this fix exists for: `alive` and
133
+ // `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
134
+ // false, and the top-line boolean never surfaced the disagreement — a
135
+ // status layer discarding what its own lower layer already reported, the
136
+ // same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
137
+ // an agent with no probeable marker at all, has no pid to fall back on —
138
+ // there heartbeat genuinely IS the liveness mechanism, unchanged.
139
+ const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
140
+ const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
128
141
 
129
142
  let echoSent = false;
130
143
  if (args.echo && alive) {
@@ -146,6 +159,11 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
146
159
  registered: true,
147
160
  heartbeatFresh,
148
161
  heartbeatAgeSec,
162
+ // Whether heartbeatFresh above is real evidence for this transport, or
163
+ // just time-since-join. A reader who sees `heartbeatFresh: false` next
164
+ // to `heartbeatValid: false` should not read that as a dissenting
165
+ // signal — there is no signal there to dissent.
166
+ heartbeatValid: heartbeatIsValidSignal,
149
167
  transport: marker?.transport ?? null,
150
168
  transportLive,
151
169
  ...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),