agent-coord-mcp 0.26.6 → 0.26.7
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 +4 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/attention.js +73 -0
- package/dist/tools/attention.js.map +1 -0
- package/dist/tools/events.js +171 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/records.js +107 -1
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +23 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +126 -11
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/worktrees.js +30 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +2 -2
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/coord-attention-clock.mjs +122 -0
- package/scripts/coord-stall-clock.mjs +125 -0
- package/src/server.ts +22 -0
- package/src/tools/attention.ts +91 -0
- package/src/tools/events.ts +199 -0
- package/src/tools/records.ts +110 -2
- package/src/tools/registry.ts +23 -4
- package/src/tools/stall.ts +130 -13
- package/src/tools/worktrees.ts +28 -0
package/src/tools/records.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "@davidbalzan/groundwork-seam";
|
|
28
28
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
29
29
|
import { haltState } from "./stall.js";
|
|
30
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
30
31
|
|
|
31
32
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
32
33
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -198,6 +199,44 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
198
199
|
|
|
199
200
|
// ---------- claim ----------
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Insert or replace ONE row in the workstreams table, as TEXT.
|
|
204
|
+
*
|
|
205
|
+
* 2.4b: "a verb only fails closed if it is the ONLY path — nothing currently
|
|
206
|
+
* stops a coordinator editing the board directly instead of calling `claim`."
|
|
207
|
+
* `claim` used to RETURN a `boardHunk` for someone to paste by hand, which is
|
|
208
|
+
* the same discipline with a nicer API. Every legacy row on the board today is
|
|
209
|
+
* hand-written, and that is why `stall_check`'s vcs half has nothing to
|
|
210
|
+
* resolve: a pasted row carries a path, a `claim`-written row carries a real
|
|
211
|
+
* branch ref.
|
|
212
|
+
*
|
|
213
|
+
* Text insertion rather than a re-render: the rest of the file stays
|
|
214
|
+
* byte-identical, the same reason `land` appends its DONE line as text. A board
|
|
215
|
+
* this verb rewrote wholesale would be a diff nobody could review.
|
|
216
|
+
*/
|
|
217
|
+
export function upsertBoardRow(text: string, agentId: string, row: string): { text: string; action: "inserted" | "replaced" | "unchanged" } {
|
|
218
|
+
const lines = text.split("\n");
|
|
219
|
+
const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
|
|
220
|
+
if (header === -1) return { text, action: "unchanged" };
|
|
221
|
+
// The table ends at the first line that is not a row.
|
|
222
|
+
let end = header + 1;
|
|
223
|
+
while (end < lines.length && /^\s*\|/.test(lines[end])) end++;
|
|
224
|
+
|
|
225
|
+
// OWNER MATCHED ON THE CELL, NOT ON THE WHOLE LINE. An agent id appearing in
|
|
226
|
+
// a "Last note" cell is not that agent's row — the occurrence-vs-position
|
|
227
|
+
// defect this repo has re-derived at four granularities.
|
|
228
|
+
const ownerOf = (l: string) => (l.split("|")[2] ?? "").replace(/[`*\s]/g, "");
|
|
229
|
+
const existing = lines.findIndex((l, i) => i > header + 1 && i < end && ownerOf(l) === agentId);
|
|
230
|
+
|
|
231
|
+
if (existing !== -1) {
|
|
232
|
+
if (lines[existing] === row) return { text, action: "unchanged" };
|
|
233
|
+
lines[existing] = row;
|
|
234
|
+
return { text: lines.join("\n"), action: "replaced" };
|
|
235
|
+
}
|
|
236
|
+
lines.splice(end, 0, row);
|
|
237
|
+
return { text: lines.join("\n"), action: "inserted" };
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
export const claimSchema = {
|
|
202
241
|
project: z.string().min(1),
|
|
203
242
|
agentId: z.string().min(1),
|
|
@@ -205,9 +244,10 @@ export const claimSchema = {
|
|
|
205
244
|
repo: z.string().optional(),
|
|
206
245
|
base: z.string().optional(),
|
|
207
246
|
task: z.string().optional(),
|
|
247
|
+
write: z.boolean().optional(),
|
|
208
248
|
};
|
|
209
249
|
|
|
210
|
-
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string }) {
|
|
250
|
+
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
|
|
211
251
|
const halt = haltState();
|
|
212
252
|
if (halt.halted) {
|
|
213
253
|
return {
|
|
@@ -255,12 +295,52 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
255
295
|
};
|
|
256
296
|
}
|
|
257
297
|
|
|
298
|
+
// A NEW TASK DOES NOT START FROM A STALE TREE.
|
|
299
|
+
//
|
|
300
|
+
// `ensure_worktree` is idempotent and reuses the agent's existing tree, which
|
|
301
|
+
// is right mid-slice (1.3) and wrong here: `claim` MEANS "start something
|
|
302
|
+
// new", and a tree left on last week's base produces the confidently-wrong
|
|
303
|
+
// inventories the worker card warns about, with nothing about the result
|
|
304
|
+
// looking stale.
|
|
305
|
+
//
|
|
306
|
+
// A freshly CREATED tree is cut from origin/<base> and needs no check — this
|
|
307
|
+
// only ever fires on reuse.
|
|
308
|
+
if (!wt.created && wt.atBase === false) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false as const,
|
|
311
|
+
error:
|
|
312
|
+
`cannot claim: the worktree at ${wt.path} is NOT at ${wt.base}` +
|
|
313
|
+
`${wt.behindBy ? ` (${wt.behindBy} commit(s) behind)` : ""} — it is on '${wt.branch}' at ${String(wt.sha).slice(0, 8)}. ` +
|
|
314
|
+
`A new task started here would be based on stale content, and nothing about the result would look stale. ` +
|
|
315
|
+
`Run \`refresh_worktrees\` to fast-forward idle trees, or finish and land the work already in it.`,
|
|
316
|
+
item: { id: item.id, priority: item.priority },
|
|
317
|
+
worktree: { path: wt.path, branch: wt.branch, sha: wt.sha, base: wt.base, atBase: false },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
|
|
322
|
+
|
|
323
|
+
// 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
|
|
324
|
+
// write:true, matching `land`. A returned hunk that a human pastes is the
|
|
325
|
+
// same discipline with a nicer API, and the pasted rows are why the board
|
|
326
|
+
// carries paths where a claim would have carried a resolvable branch ref.
|
|
327
|
+
let board: { action: string; path?: string } = { action: "reported" };
|
|
328
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
329
|
+
if (!b) {
|
|
330
|
+
board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
331
|
+
} else if (args.write) {
|
|
332
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
333
|
+
if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
334
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
335
|
+
}
|
|
336
|
+
|
|
258
337
|
return {
|
|
259
338
|
ok: true as const,
|
|
260
339
|
project: args.project,
|
|
261
340
|
agentId: args.agentId,
|
|
262
341
|
item: { id: item.id, priority: item.priority, text: item.text },
|
|
263
|
-
boardHunk
|
|
342
|
+
boardHunk,
|
|
343
|
+
board,
|
|
264
344
|
worktreeEnsured: true,
|
|
265
345
|
worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base },
|
|
266
346
|
};
|
|
@@ -404,6 +484,33 @@ export async function landTool(args: {
|
|
|
404
484
|
}
|
|
405
485
|
}
|
|
406
486
|
|
|
487
|
+
// 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
|
|
488
|
+
//
|
|
489
|
+
// Read back from disk, AFTER the write, and refuse to emit anything whose ref
|
|
490
|
+
// is not there. The ordering is the guarantee: an event cannot exist without
|
|
491
|
+
// the record entry that caused it, because the record is what is consulted to
|
|
492
|
+
// decide whether to emit. An event stream that can say "task X complete"
|
|
493
|
+
// while DONE.md does not is a second source of truth, and record-vs-state
|
|
494
|
+
// divergence is the defect this fleet hit most this week.
|
|
495
|
+
//
|
|
496
|
+
// Reported, never thrown: a delivery failure must not undo a merge that has
|
|
497
|
+
// already happened. `land` is a RECORDER.
|
|
498
|
+
let events: { emitted: RecordEvent[]; deliveries: unknown[]; refused: string[] } = { emitted: [], deliveries: [], refused: [] };
|
|
499
|
+
if (args.write && target) {
|
|
500
|
+
const after = readDoc(repo, DONE_DOC);
|
|
501
|
+
const recordText = after?.text ?? "";
|
|
502
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(target.text) };
|
|
503
|
+
const derived = eventIsDerived(recordText, ev);
|
|
504
|
+
if (!derived.ok) {
|
|
505
|
+
events.refused.push(derived.error);
|
|
506
|
+
} else {
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
const { subs, deliveries } = evaluate(readSubs(), ev, now);
|
|
509
|
+
commitEvaluation(subs);
|
|
510
|
+
events = { emitted: [ev], deliveries, refused: [] };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
407
514
|
return {
|
|
408
515
|
ok: true as const,
|
|
409
516
|
project: args.project,
|
|
@@ -411,6 +518,7 @@ export async function landTool(args: {
|
|
|
411
518
|
comparedAgainst: ref,
|
|
412
519
|
landedIn: landedIn.slice(0, 8),
|
|
413
520
|
queueItem: target ? { id: target.id, closed: true, textUnchanged: true } : null,
|
|
521
|
+
events,
|
|
414
522
|
candidates: target
|
|
415
523
|
? undefined
|
|
416
524
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
package/src/tools/registry.ts
CHANGED
|
@@ -259,10 +259,29 @@ export async function listAgentsTool() {
|
|
|
259
259
|
|
|
260
260
|
const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
261
261
|
for (const [id, entry] of Object.entries(current)) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
// A LIVE TRANSPORT PROTECTS AN AGENT FROM EVICTION. IT DOES NOT STAMP IT.
|
|
263
|
+
//
|
|
264
|
+
// This loop used to write `entry.lastHeartbeat = now` here, and
|
|
265
|
+
// `agents.json` is ONE store shared by every fleet on the machine while
|
|
266
|
+
// `list_agents` takes no project argument — so a call from one fleet
|
|
267
|
+
// rewrote every other fleet's timestamps. Another fleet did not ask for
|
|
268
|
+
// that, cannot see it, and it is not ours to write.
|
|
269
|
+
//
|
|
270
|
+
// kit#105 removed the fabricated value from the RESPONSE; the WRITE
|
|
271
|
+
// stayed, so every consumer reading the file directly still saw
|
|
272
|
+
// freshness this call had invented.
|
|
273
|
+
//
|
|
274
|
+
// AND IT MASKED STALL DETECTION. `stall_check` reads `lastHeartbeat` to
|
|
275
|
+
// find agents that have gone quiet. Because any `list_agents` call
|
|
276
|
+
// refreshed every live-transport agent, that clock could never age past
|
|
277
|
+
// the threshold, so no-heartbeat could not fire for exactly the agents
|
|
278
|
+
// most likely to be stuck — alive, attached, and doing nothing.
|
|
279
|
+
//
|
|
280
|
+
// Nothing is lost: the pusher calls `heartbeat` every 60s
|
|
281
|
+
// (scripts/coord-pusher.mjs:181), so an attached agent has a REAL
|
|
282
|
+
// heartbeat. This stamp only ever overwrote a true value with a
|
|
283
|
+
// simultaneous one.
|
|
284
|
+
if (liveTransports.has(id)) continue;
|
|
266
285
|
if (now - entry.lastHeartbeat > EVICT_MS) {
|
|
267
286
|
evicted.push(id);
|
|
268
287
|
delete current[id];
|
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 { 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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/worktrees.ts
CHANGED
|
@@ -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
|
: {}),
|