agent-coord-mcp 0.19.0 → 0.19.1
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/README.md +11 -1
- package/dist/server.js +127 -9
- package/dist/server.js.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +24 -5
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/registry.js +69 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/transport.js +97 -7
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +28 -8
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +36 -4
- package/dist/work.js.map +1 -1
- package/hooks/tmux-pusher.mjs +6 -0
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/coord-pusher.mjs +39 -9
- package/src/server.ts +131 -9
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/transport.ts +116 -6
- package/src/tools/work.ts +28 -7
- package/src/work.ts +64 -7
package/src/tools/admin.ts
CHANGED
|
@@ -118,11 +118,22 @@ export async function pruneTool(args: {
|
|
|
118
118
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
119
119
|
const decisionCutoff = Date.now() - decisionDays * 24 * 60 * 60 * 1000;
|
|
120
120
|
const dryRun = args.dryRun ?? false;
|
|
121
|
-
// room
|
|
122
|
-
// the
|
|
121
|
+
// `room` scopes every sweep to that channel; `targets` narrows which sweeps
|
|
122
|
+
// run. They compose: `room` only changes the DEFAULT target set, so an
|
|
123
|
+
// explicit `targets` always wins.
|
|
124
|
+
//
|
|
125
|
+
// WHY (regression): this previously read `scopedRoom ? ["rooms"] : args.targets`,
|
|
126
|
+
// which silently DISCARDED an explicit `targets` whenever `room` was passed.
|
|
127
|
+
// `prune {room, targets:["members"], dryRun:true}` therefore reported
|
|
128
|
+
// `orphanMembers: []` because the member sweep never ran — a caller asking
|
|
129
|
+
// "which members are phantoms in this room" got a clean bill of health that
|
|
130
|
+
// had not been computed, while `roomMessages` (the one target the override
|
|
131
|
+
// left enabled) reported real messages a live run would have archived. Field
|
|
132
|
+
// report: two members that `ping` called `unregistered` were invisible here.
|
|
133
|
+
// A sweep must never answer a question it did not evaluate.
|
|
123
134
|
const scopedRoom = args.room ? normalizeRoom(args.room) : undefined;
|
|
124
135
|
const targets = new Set<PruneTarget>(
|
|
125
|
-
scopedRoom ? ["rooms"] :
|
|
136
|
+
args.targets ?? (scopedRoom ? ["rooms"] : [...PRUNE_TARGETS])
|
|
126
137
|
);
|
|
127
138
|
const keep = (e: { ts: number; kind?: string }) => keepEntry(e, cutoff, decisionCutoff);
|
|
128
139
|
|
|
@@ -133,6 +144,10 @@ export async function pruneTool(args: {
|
|
|
133
144
|
const staleAgent = (m: string) => !knownAgents.has(m) || (reg[m]?.lastHeartbeat ?? 0) <= cutoff;
|
|
134
145
|
|
|
135
146
|
const channels = scopedRoom ? [scopedRoom] : Object.keys(await getRooms());
|
|
147
|
+
// The membership sweeps walk the room registry rather than `channels`, so they
|
|
148
|
+
// need the scope predicate explicitly — without it, `room` scoped the message
|
|
149
|
+
// sweep while membership silently swept EVERY room.
|
|
150
|
+
const inScope = (chan: string) => !scopedRoom || chan === scopedRoom;
|
|
136
151
|
|
|
137
152
|
if (dryRun) {
|
|
138
153
|
let roomMessages = 0;
|
|
@@ -159,6 +174,7 @@ export async function pruneTool(args: {
|
|
|
159
174
|
if (targets.has("members")) {
|
|
160
175
|
const rooms = await getRooms();
|
|
161
176
|
for (const [chan, e] of Object.entries(rooms)) {
|
|
177
|
+
if (!inScope(chan)) continue;
|
|
162
178
|
const remaining: string[] = [];
|
|
163
179
|
for (const m of e.members ?? []) {
|
|
164
180
|
if (!knownAgents.has(m)) orphanMembers.add(m);
|
|
@@ -264,7 +280,8 @@ export async function pruneTool(args: {
|
|
|
264
280
|
const archivedRooms: string[] = [];
|
|
265
281
|
if (targets.has("members")) {
|
|
266
282
|
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
267
|
-
for (const e of Object.
|
|
283
|
+
for (const [chan, e] of Object.entries(current)) {
|
|
284
|
+
if (!inScope(chan)) continue;
|
|
268
285
|
if ((e.members?.length ?? 0) === 0) continue;
|
|
269
286
|
e.members = (e.members ?? []).filter((m) => {
|
|
270
287
|
if (!knownAgents.has(m)) {
|
|
@@ -284,7 +301,7 @@ export async function pruneTool(args: {
|
|
|
284
301
|
if (args.archiveEmptyRooms ?? true) {
|
|
285
302
|
const rooms = await getRooms();
|
|
286
303
|
for (const [chan, e] of Object.entries(rooms)) {
|
|
287
|
-
if (chan === DEFAULT_ROOM || (e.members?.length ?? 0) > 0) continue;
|
|
304
|
+
if (chan === DEFAULT_ROOM || !inScope(chan) || (e.members?.length ?? 0) > 0) continue;
|
|
288
305
|
const file = roomFile(chan);
|
|
289
306
|
const msgs = await readJsonl<Message>(file);
|
|
290
307
|
const lastTs = msgs[msgs.length - 1]?.ts ?? 0;
|
package/src/tools/registry.ts
CHANGED
|
@@ -46,7 +46,10 @@ import {
|
|
|
46
46
|
transportFile,
|
|
47
47
|
TRANSPORT_DIR,
|
|
48
48
|
updateJson,
|
|
49
|
+
listSessionFiles,
|
|
50
|
+
readJsonStrict,
|
|
49
51
|
type RoomRegistry,
|
|
52
|
+
type SessionBinding,
|
|
50
53
|
} from "../store.js";
|
|
51
54
|
import { recordAuthorityFor, resolveRole, roleInputSchema, type RoleArg } from "../roles.js";
|
|
52
55
|
import {
|
|
@@ -73,6 +76,11 @@ export const registerSchema = {
|
|
|
73
76
|
agentId: z.string().min(1),
|
|
74
77
|
project: z.string().optional(),
|
|
75
78
|
role: roleInputSchema.optional(),
|
|
79
|
+
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
80
|
+
// that is LIVE on the bus refuses unless the call presents that agent's
|
|
81
|
+
// token (tokens.json / coord-token) or force:true. Ignored once bound.
|
|
82
|
+
token: z.string().optional(),
|
|
83
|
+
force: z.boolean().optional(),
|
|
76
84
|
};
|
|
77
85
|
|
|
78
86
|
// Work out what `role`/`roleId` should become, or why the update is refused.
|
|
@@ -299,6 +307,94 @@ export function isPidAlive(pid: number): boolean {
|
|
|
299
307
|
}
|
|
300
308
|
}
|
|
301
309
|
|
|
310
|
+
// ---------- first-claim liveness evidence ----------
|
|
311
|
+
// What the TOFU binding guard (server.ts guardFirstClaim) consults before a
|
|
312
|
+
// fresh session may claim an id. Three independent signals say "this id is
|
|
313
|
+
// currently active": a fresh registry heartbeat, a live transport marker, and
|
|
314
|
+
// a live session-binding marker from another pid. The verdict distinguishes
|
|
315
|
+
// VERIFIED ABSENT (state readable, id not live → free to bind; refusing here
|
|
316
|
+
// would break all onboarding) from CANNOT VERIFY (a state file exists but is
|
|
317
|
+
// unreadable → the guard must refuse rather than treat corruption as absence).
|
|
318
|
+
// `samePane`: a live local pusher for the claimed id types into THIS process's
|
|
319
|
+
// own tmux pane — two sessions cannot share a pane, so this is the same seat
|
|
320
|
+
// restarting in place, not a second session claiming a live id.
|
|
321
|
+
|
|
322
|
+
export type ClaimEvidence = {
|
|
323
|
+
live: boolean;
|
|
324
|
+
verifiable: boolean;
|
|
325
|
+
samePane: boolean;
|
|
326
|
+
boundElsewhere: number;
|
|
327
|
+
reasons: string[];
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
|
|
331
|
+
const reasons: string[] = [];
|
|
332
|
+
let verifiable = true;
|
|
333
|
+
let samePane = false;
|
|
334
|
+
let heartbeatFresh = false;
|
|
335
|
+
let markerLive = false;
|
|
336
|
+
let boundElsewhere = 0;
|
|
337
|
+
|
|
338
|
+
let reg: AgentRegistry = {};
|
|
339
|
+
try {
|
|
340
|
+
reg = await readJsonStrict<AgentRegistry>(AGENTS_FILE, {});
|
|
341
|
+
} catch {
|
|
342
|
+
verifiable = false;
|
|
343
|
+
reasons.push("agents.json exists but cannot be parsed — heartbeat liveness is unverifiable");
|
|
344
|
+
}
|
|
345
|
+
const entry = reg[agentId];
|
|
346
|
+
if (entry && now - entry.lastHeartbeat < STALE_MS) {
|
|
347
|
+
heartbeatFresh = true;
|
|
348
|
+
reasons.push(`fresh registry heartbeat ${Math.floor((now - entry.lastHeartbeat) / 1000)}s ago`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let marker: TransportMarker | null = null;
|
|
352
|
+
try {
|
|
353
|
+
marker = await readJsonStrict<TransportMarker | null>(transportFile(agentId), null);
|
|
354
|
+
} catch {
|
|
355
|
+
verifiable = false;
|
|
356
|
+
reasons.push("transport marker exists but cannot be parsed — transport liveness is unverifiable");
|
|
357
|
+
}
|
|
358
|
+
if (marker && isMarkerLive(marker, reg, now)) {
|
|
359
|
+
markerLive = true;
|
|
360
|
+
reasons.push(
|
|
361
|
+
`live ${marker.transport} transport (pid ${marker.pid}${marker.tmuxTarget ? `, pane ${marker.tmuxTarget}` : ""})`,
|
|
362
|
+
);
|
|
363
|
+
if (
|
|
364
|
+
marker.transport === "tmux-push" &&
|
|
365
|
+
marker.tmuxTarget &&
|
|
366
|
+
process.env.TMUX_PANE &&
|
|
367
|
+
marker.tmuxTarget === process.env.TMUX_PANE
|
|
368
|
+
) {
|
|
369
|
+
samePane = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const file of await listSessionFiles()) {
|
|
374
|
+
let s: SessionBinding | null = null;
|
|
375
|
+
try {
|
|
376
|
+
s = await readJsonStrict<SessionBinding | null>(file, null);
|
|
377
|
+
} catch {
|
|
378
|
+
verifiable = false;
|
|
379
|
+
reasons.push(`session binding ${path.basename(file)} cannot be parsed — unverifiable (doctor fix cleans it)`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
|
|
383
|
+
if (isPidAlive(s.pid)) {
|
|
384
|
+
boundElsewhere++;
|
|
385
|
+
reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
live: heartbeatFresh || markerLive || boundElsewhere > 0,
|
|
391
|
+
verifiable,
|
|
392
|
+
samePane,
|
|
393
|
+
boundElsewhere,
|
|
394
|
+
reasons,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
302
398
|
// ---------- rename_agent (NICK) ----------
|
|
303
399
|
|
|
304
400
|
export const renameAgentSchema = {
|
package/src/tools/transport.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
inboxFile,
|
|
32
32
|
listCursorFiles,
|
|
33
33
|
listInboxFiles,
|
|
34
|
+
listSessionFiles,
|
|
34
35
|
listTransportFiles,
|
|
36
|
+
type SessionBinding,
|
|
35
37
|
logFile,
|
|
36
38
|
memberRooms,
|
|
37
39
|
normalizeRoom,
|
|
@@ -192,7 +194,21 @@ export const sendCommandSchema = {
|
|
|
192
194
|
|
|
193
195
|
// A receipt as the pusher writes it. `submitted` is present only on control
|
|
194
196
|
// receipts from a pusher new enough to VERIFY submission (v0.19.0+).
|
|
195
|
-
|
|
197
|
+
// `scriptMtime` is the reporting pusher's build identity — the same
|
|
198
|
+
// module-graph stamp the transport marker carries (newest mtime across the
|
|
199
|
+
// pusher's entry file AND its hooks/ imports, per #28: the stale part is as
|
|
200
|
+
// likely submit.mjs as the entrypoint). It is honesty, not security: a lying
|
|
201
|
+
// pusher defeats it, exactly like report_transport. The value is that a
|
|
202
|
+
// "confirmed" can be tied to the code that did the confirming.
|
|
203
|
+
type Receipt = {
|
|
204
|
+
id: string;
|
|
205
|
+
ts: number;
|
|
206
|
+
control?: boolean;
|
|
207
|
+
submitted?: boolean;
|
|
208
|
+
verified?: boolean;
|
|
209
|
+
reason?: string;
|
|
210
|
+
scriptMtime?: number;
|
|
211
|
+
};
|
|
196
212
|
|
|
197
213
|
// Poll an agent's receipt log until a receipt for `msgId` appears or the
|
|
198
214
|
// deadline passes. Returns the receipt, or null on timeout. File-only — no
|
|
@@ -221,18 +237,38 @@ async function waitForReceipt(agentId: string, msgId: string, timeoutMs: number)
|
|
|
221
237
|
// pending with a reason the caller can act on. Reporting an unverified
|
|
222
238
|
// submission as confirmed is the defect this exists to remove: a check that
|
|
223
239
|
// cannot fail loudly is worse than no check.
|
|
240
|
+
//
|
|
241
|
+
// `pusherSourceMtime` (the caller passes newestPusherSourceMtime()) lets a
|
|
242
|
+
// CONFIRMED verdict carry a note when the reporting pusher's build identity
|
|
243
|
+
// is behind the on-disk pusher source, or absent entirely. The note never
|
|
244
|
+
// downgrades the verdict — the command demonstrably ran — it says whose
|
|
245
|
+
// verification logic said so. Absence of the stamp reads as UNKNOWN, never
|
|
246
|
+
// as fresh (same ruling as doctor's stale-pusher-script / provenance
|
|
247
|
+
// checks: absence is not exemption), and the absence note is issued before
|
|
248
|
+
// the on-disk comparison so an unstattable hooks dir cannot silence it.
|
|
224
249
|
export function deliveryOutcome(
|
|
225
250
|
agentId: string,
|
|
226
251
|
receipt: Receipt | null,
|
|
227
252
|
timeoutMs: number,
|
|
228
|
-
|
|
253
|
+
pusherSourceMtime?: number,
|
|
254
|
+
): { delivery: "confirmed" | "pending"; at?: number; reason?: string; note?: string } {
|
|
229
255
|
if (!receipt) {
|
|
230
256
|
return {
|
|
231
257
|
delivery: "pending",
|
|
232
258
|
reason: `no delivery receipt from '${agentId}' within ${timeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
|
|
233
259
|
};
|
|
234
260
|
}
|
|
235
|
-
if (receipt.submitted === true)
|
|
261
|
+
if (receipt.submitted === true) {
|
|
262
|
+
let note: string | undefined;
|
|
263
|
+
if (receipt.scriptMtime === undefined) {
|
|
264
|
+
note = `'${agentId}' confirmed the submission, but its pusher carries no build-identity stamp — the pusher predates receipt provenance and this confirmation cannot be tied to any known code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
|
|
265
|
+
} else if (pusherSourceMtime !== undefined && receipt.scriptMtime < pusherSourceMtime - 1) {
|
|
266
|
+
const loaded = new Date(receipt.scriptMtime).toISOString();
|
|
267
|
+
const ondisk = new Date(pusherSourceMtime).toISOString();
|
|
268
|
+
note = `'${agentId}' confirmed the submission, but its pusher loaded its code at ${loaded} and the on-disk pusher source is newer (${ondisk}) — the verification logic behind this confirmation predates the current code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
|
|
269
|
+
}
|
|
270
|
+
return { delivery: "confirmed", at: receipt.ts, ...(note ? { note } : {}) };
|
|
271
|
+
}
|
|
236
272
|
if (receipt.submitted === false) {
|
|
237
273
|
return {
|
|
238
274
|
delivery: "pending",
|
|
@@ -351,7 +387,7 @@ export async function sendCommandTool(args: {
|
|
|
351
387
|
const wait = args.waitForDelivery ?? true;
|
|
352
388
|
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
353
389
|
const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
|
|
354
|
-
const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs) : null;
|
|
390
|
+
const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs, newestPusherSourceMtime()) : null;
|
|
355
391
|
const confirmed = outcome?.delivery === "confirmed";
|
|
356
392
|
// After /clear the receiver forgets its identity and that it's bus-attached
|
|
357
393
|
// (the system prompt isn't re-applied because /clear isn't a session
|
|
@@ -373,7 +409,10 @@ export async function sendCommandTool(args: {
|
|
|
373
409
|
delivery: outcome.delivery,
|
|
374
410
|
confirmed,
|
|
375
411
|
...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
|
|
376
|
-
|
|
412
|
+
// A confirmed delivery can still warn: the note names a reporting
|
|
413
|
+
// pusher whose build identity is stale or absent. `confirmed`
|
|
414
|
+
// stays true — the command ran; the warning is about who said so.
|
|
415
|
+
...(confirmed ? (outcome.note ? { warning: outcome.note } : {}) : { warning: outcome.reason }),
|
|
377
416
|
}
|
|
378
417
|
: {}),
|
|
379
418
|
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
|
|
@@ -409,11 +448,13 @@ export async function sendCommandTool(args: {
|
|
|
409
448
|
let confirmed: string[] = [];
|
|
410
449
|
let pending: string[] = [];
|
|
411
450
|
let pendingReasons: string[] = [];
|
|
451
|
+
let confirmNotes: string[] = [];
|
|
412
452
|
if (wait) {
|
|
453
|
+
const sourceMtime = newestPusherSourceMtime();
|
|
413
454
|
const results = await Promise.all(
|
|
414
455
|
delivered.map(async (m) => ({
|
|
415
456
|
m,
|
|
416
|
-
outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs),
|
|
457
|
+
outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs, sourceMtime),
|
|
417
458
|
})),
|
|
418
459
|
);
|
|
419
460
|
confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
|
|
@@ -421,6 +462,9 @@ export async function sendCommandTool(args: {
|
|
|
421
462
|
pendingReasons = results
|
|
422
463
|
.filter((r) => r.outcome.delivery !== "confirmed")
|
|
423
464
|
.map((r) => `${r.m}: ${r.outcome.reason}`);
|
|
465
|
+
confirmNotes = results
|
|
466
|
+
.filter((r) => r.outcome.delivery === "confirmed" && r.outcome.note)
|
|
467
|
+
.map((r) => r.outcome.note!);
|
|
424
468
|
}
|
|
425
469
|
// Same post-/clear re-anchor as the DM path — one reminder per delivered
|
|
426
470
|
// member, in their own inbox, with their own agentId in the body.
|
|
@@ -444,6 +488,9 @@ export async function sendCommandTool(args: {
|
|
|
444
488
|
warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
|
|
445
489
|
}
|
|
446
490
|
: {}),
|
|
491
|
+
// Confirmed members whose reporting pusher is stale or unstamped —
|
|
492
|
+
// the confirmations stand, the notes say whose code issued them.
|
|
493
|
+
...(confirmNotes.length ? { notes: confirmNotes } : {}),
|
|
447
494
|
}
|
|
448
495
|
: {}),
|
|
449
496
|
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
|
|
@@ -712,6 +759,11 @@ export const joinSchema = {
|
|
|
712
759
|
// false → never; object → attach with overrides.
|
|
713
760
|
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
714
761
|
readInbox: z.boolean().optional(),
|
|
762
|
+
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
763
|
+
// that is LIVE on the bus refuses unless the call presents that agent's
|
|
764
|
+
// token (tokens.json / coord-token) or force:true. Ignored once bound.
|
|
765
|
+
token: z.string().optional(),
|
|
766
|
+
force: z.boolean().optional(),
|
|
715
767
|
};
|
|
716
768
|
|
|
717
769
|
export async function joinTool(args: {
|
|
@@ -827,6 +879,13 @@ export const reportReceiptSchema = {
|
|
|
827
879
|
submitted: z.boolean().optional(),
|
|
828
880
|
verified: z.boolean().optional(),
|
|
829
881
|
reason: z.string().optional(),
|
|
882
|
+
// Build identity of the reporting pusher: newest mtime across its loaded
|
|
883
|
+
// module graph (entry file + hooks/ imports), sampled once at its startup —
|
|
884
|
+
// the same basis report_transport's scriptMtime uses. Absent → the receipt's
|
|
885
|
+
// provenance is UNKNOWN and deliveryOutcome says so; the server never
|
|
886
|
+
// defaults it (a default here would be assume-fresh, the twin of the
|
|
887
|
+
// assume-success `submitted` refuses to invent).
|
|
888
|
+
scriptMtime: z.number().optional(),
|
|
830
889
|
};
|
|
831
890
|
|
|
832
891
|
// Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
|
|
@@ -852,6 +911,7 @@ export async function reportReceiptTool(args: {
|
|
|
852
911
|
submitted?: boolean;
|
|
853
912
|
verified?: boolean;
|
|
854
913
|
reason?: string;
|
|
914
|
+
scriptMtime?: number;
|
|
855
915
|
}) {
|
|
856
916
|
const receipt: Receipt & { agentId: string; from?: string } = {
|
|
857
917
|
id: args.id,
|
|
@@ -862,6 +922,7 @@ export async function reportReceiptTool(args: {
|
|
|
862
922
|
...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
|
|
863
923
|
...(args.verified !== undefined ? { verified: args.verified } : {}),
|
|
864
924
|
...(args.reason !== undefined ? { reason: args.reason } : {}),
|
|
925
|
+
...(args.scriptMtime !== undefined ? { scriptMtime: args.scriptMtime } : {}),
|
|
865
926
|
};
|
|
866
927
|
await appendJsonl(receiptFile(args.agentId), receipt);
|
|
867
928
|
return { ok: true, receipt };
|
|
@@ -1184,6 +1245,55 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1184
1245
|
});
|
|
1185
1246
|
}
|
|
1186
1247
|
|
|
1248
|
+
// 1d. Duplicate session bindings — two live MCP sessions bound to one agent
|
|
1249
|
+
// id means two processes are ACTING as the same agent (the
|
|
1250
|
+
// disavow-liaison shape: a dev session bound onto a live worker's id;
|
|
1251
|
+
// force/token make that possible on purpose, this makes it visible).
|
|
1252
|
+
// Bindings are per-process closure state, so this reads the on-disk
|
|
1253
|
+
// session markers stdio servers write at bind time. A marker whose pid
|
|
1254
|
+
// is dead is litter from a killed session (default signal death skips
|
|
1255
|
+
// exit handlers) — cleaned under fix. Live duplicates are NOT auto-
|
|
1256
|
+
// fixable: doctor cannot know which of two running sessions is the
|
|
1257
|
+
// impostor; the wrong session should `quit` (its marker clears on exit).
|
|
1258
|
+
{
|
|
1259
|
+
const byAgent = new Map<string, { pid: number; via: string; boundAt: number }[]>();
|
|
1260
|
+
const stale: string[] = [];
|
|
1261
|
+
for (const file of await listSessionFiles()) {
|
|
1262
|
+
const s = await readJson<SessionBinding | null>(file, null);
|
|
1263
|
+
if (!s || typeof s.pid !== "number" || !s.agentId || !isPidAlive(s.pid)) {
|
|
1264
|
+
stale.push(path.basename(file));
|
|
1265
|
+
if (fix) {
|
|
1266
|
+
await deleteFile(file);
|
|
1267
|
+
fixed.push(`deleted stale session binding ${path.basename(file)}`);
|
|
1268
|
+
}
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
const list = byAgent.get(s.agentId) ?? [];
|
|
1272
|
+
list.push({ pid: s.pid, via: s.via ?? "unknown", boundAt: s.boundAt ?? 0 });
|
|
1273
|
+
byAgent.set(s.agentId, list);
|
|
1274
|
+
}
|
|
1275
|
+
const dupes: string[] = [];
|
|
1276
|
+
for (const [id, list] of byAgent) {
|
|
1277
|
+
if (list.length < 2) continue;
|
|
1278
|
+
dupes.push(
|
|
1279
|
+
`${id} — ${list
|
|
1280
|
+
.map((b) => `pid ${b.pid} (via ${b.via}, bound ${b.boundAt ? new Date(b.boundAt).toISOString() : "unknown"})`)
|
|
1281
|
+
.join(" AND ")}`,
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
findings.push({
|
|
1285
|
+
check: "duplicate-session-binding",
|
|
1286
|
+
level: dupes.length ? "warn" : "ok",
|
|
1287
|
+
detail: dupes.length
|
|
1288
|
+
? `${dupes.length} agent id(s) bound by more than one live session — two processes are acting as the same agent. Decide which is legitimate; the other should quit (its binding clears on exit).`
|
|
1289
|
+
: stale.length
|
|
1290
|
+
? `no duplicate session bindings (${stale.length} stale binding file(s) from dead sessions${fix ? " — cleaned" : "; run doctor with fix:true to clean"})`
|
|
1291
|
+
: "no duplicate session bindings",
|
|
1292
|
+
fixable: true,
|
|
1293
|
+
items: dupes.length ? dupes : undefined,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1187
1297
|
// 2. Orphan room memberships (member not in the registry).
|
|
1188
1298
|
{
|
|
1189
1299
|
const orphans = new Set<string>();
|
package/src/tools/work.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseWorkDoc,
|
|
14
14
|
queueItemsOf,
|
|
15
15
|
renderWorkDoc,
|
|
16
|
+
workDocIssues,
|
|
16
17
|
} from "../work.js";
|
|
17
18
|
import { loadScopes, ownsDocument } from "./scopes.js";
|
|
18
19
|
import { AGENTS_FILE } from "../store.js";
|
|
@@ -110,18 +111,28 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
|
|
|
110
111
|
const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs };
|
|
111
112
|
await saveState(state);
|
|
112
113
|
|
|
114
|
+
const allIssues = docs.flatMap((d) => workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
|
|
113
115
|
return {
|
|
114
116
|
ok: true as const,
|
|
115
117
|
project: args.project,
|
|
116
118
|
repo,
|
|
117
119
|
file: workFile(args.project),
|
|
118
|
-
imported: docs.map((d) =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
imported: docs.map((d) => {
|
|
121
|
+
const issues = workDocIssues(d.doc);
|
|
122
|
+
return {
|
|
123
|
+
path: d.path,
|
|
124
|
+
kind: d.kind,
|
|
125
|
+
queue: queueItemsOf(d.doc).length,
|
|
126
|
+
done: doneEntriesOf(d.doc).length,
|
|
127
|
+
board: boardRowsOf(d.doc).length,
|
|
128
|
+
...(issues.length ? { issues } : {}),
|
|
129
|
+
};
|
|
130
|
+
}),
|
|
131
|
+
...(allIssues.length
|
|
132
|
+
? {
|
|
133
|
+
warning: `${allIssues.length} table row(s) were refused a record (wrong column count) and kept verbatim — the documents round-trip unchanged, but these rows are invisible to board consumers until fixed. See imported[].issues.`,
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
125
136
|
note: "the markdown remains authoritative — this store is a derived index",
|
|
126
137
|
};
|
|
127
138
|
}
|
|
@@ -161,10 +172,12 @@ export async function listWorkTool(args: {
|
|
|
161
172
|
const queue: QueueItem[] = [];
|
|
162
173
|
const done: DoneEntry[] = [];
|
|
163
174
|
const board: BoardRow[] = [];
|
|
175
|
+
const issues: string[] = [];
|
|
164
176
|
for (const d of state.docs) {
|
|
165
177
|
queue.push(...queueItemsOf(d.doc));
|
|
166
178
|
done.push(...doneEntriesOf(d.doc));
|
|
167
179
|
board.push(...boardRowsOf(d.doc));
|
|
180
|
+
issues.push(...workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
|
|
@@ -178,6 +191,9 @@ export async function listWorkTool(args: {
|
|
|
178
191
|
...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
|
|
179
192
|
...(args.kind === "done" || args.kind === undefined ? { done } : {}),
|
|
180
193
|
...(args.kind === "board" || args.kind === undefined ? { board } : {}),
|
|
194
|
+
// A row refused for wrong arity is absent from `board` — say so rather
|
|
195
|
+
// than let the absence read as "that lane doesn't exist".
|
|
196
|
+
...(issues.length ? { issues } : {}),
|
|
181
197
|
};
|
|
182
198
|
}
|
|
183
199
|
|
|
@@ -245,11 +261,16 @@ export async function exportWorkTool(args: {
|
|
|
245
261
|
: undefined;
|
|
246
262
|
|
|
247
263
|
if (write && rendered !== current) await fsp.writeFile(target, rendered, "utf8");
|
|
264
|
+
const issues = workDocIssues(d.doc);
|
|
248
265
|
files.push({
|
|
249
266
|
path: d.path,
|
|
250
267
|
bytes: Buffer.byteLength(rendered, "utf8"),
|
|
251
268
|
identical: rendered === current,
|
|
252
269
|
written: write && rendered !== current,
|
|
270
|
+
// Refused rows replay verbatim — the write is byte-faithful — but a
|
|
271
|
+
// caller rewriting a document should hear that some rows carry no
|
|
272
|
+
// record, rather than infer health from `identical:true`.
|
|
273
|
+
...(issues.length ? { issues } : {}),
|
|
253
274
|
...(scope ? { scope } : {}),
|
|
254
275
|
});
|
|
255
276
|
}
|
package/src/work.ts
CHANGED
|
@@ -63,6 +63,33 @@ export type BoardRow = {
|
|
|
63
63
|
raw?: string;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
// The one arity BoardRow can hold. boardCells() and the parser's arity guard
|
|
67
|
+
// both reference this constant so the two sides cannot drift; a test pins
|
|
68
|
+
// boardCells().length === BOARD_ARITY.
|
|
69
|
+
export const BOARD_ARITY = 5;
|
|
70
|
+
|
|
71
|
+
// A table row whose column count does not match BoardRow. It is REFUSED a
|
|
72
|
+
// record (boardRowsOf never returns it) but PRESERVED byte-exactly — the
|
|
73
|
+
// alternative, destructuring whatever arity into a fixed 5-tuple, silently
|
|
74
|
+
// narrowed 6-column rows and widened 4-column ones on export (hit live
|
|
75
|
+
// 2026-07-29: #38's extra `Pane` column was dropped on write-back). Refusing
|
|
76
|
+
// the row rather than throwing keeps one bad row from taking down the whole
|
|
77
|
+
// document's import. The schema decision stays with the board owner: widening
|
|
78
|
+
// BoardRow is a deliberate act (change BOARD_ARITY, boardCells, and this
|
|
79
|
+
// guard together), never something a docs commit does by accident.
|
|
80
|
+
export type MalformedRow = {
|
|
81
|
+
malformed: true;
|
|
82
|
+
// Replayed byte-exactly on render — never narrowed, never widened.
|
|
83
|
+
verbatim: string;
|
|
84
|
+
// Names the line, quotes the row, states expected/actual — loud enough to
|
|
85
|
+
// fix from the message alone.
|
|
86
|
+
issue: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function isMalformedRow(r: BoardRow | MalformedRow): r is MalformedRow {
|
|
90
|
+
return (r as MalformedRow).malformed === true;
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
// A parsed document: an ordered block list. `text` blocks are verbatim lines
|
|
67
94
|
// (never interpreted); the others carry records rendered back in place.
|
|
68
95
|
export type Block =
|
|
@@ -71,7 +98,7 @@ export type Block =
|
|
|
71
98
|
| { kind: "done"; entries: DoneEntry[] }
|
|
72
99
|
// `header` and `align` are kept as raw lines for the same reason as
|
|
73
100
|
// BoardRow.raw — the alignment row (`|---|---|`) has no canonical form.
|
|
74
|
-
| { kind: "board"; header: string; align: string; rows: BoardRow[] };
|
|
101
|
+
| { kind: "board"; header: string; align: string; rows: (BoardRow | MalformedRow)[] };
|
|
75
102
|
|
|
76
103
|
export type WorkDoc = {
|
|
77
104
|
// Kept so an export can be written back with the exact byte tail it had.
|
|
@@ -182,11 +209,13 @@ function boardCells(r: BoardRow): string[] {
|
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
// Replay the original line when the fields still say what it said; re-render
|
|
185
|
-
// once anything actually changed.
|
|
186
|
-
|
|
212
|
+
// once anything actually changed. A malformed row has no fields to have
|
|
213
|
+
// changed — it replays its bytes, always.
|
|
214
|
+
export function renderBoardRow(r: BoardRow | MalformedRow): string {
|
|
215
|
+
if (isMalformedRow(r)) return r.verbatim;
|
|
187
216
|
if (r.raw !== undefined) {
|
|
188
217
|
const cells = splitRow(r.raw);
|
|
189
|
-
if (cells.length ===
|
|
218
|
+
if (cells.length === BOARD_ARITY && cells.every((c, i) => c === boardCells(r)[i])) return r.raw;
|
|
190
219
|
}
|
|
191
220
|
return renderRow(boardCells(r));
|
|
192
221
|
}
|
|
@@ -280,11 +309,28 @@ export function parseWorkDoc(source: string): WorkDoc {
|
|
|
280
309
|
|
|
281
310
|
// Lanes table: a header row followed by an alignment row.
|
|
282
311
|
if (TABLE_ROW.test(line) && TABLE_ALIGN.test(lines[i + 1] ?? "")) {
|
|
283
|
-
const rows: BoardRow[] = [];
|
|
312
|
+
const rows: (BoardRow | MalformedRow)[] = [];
|
|
284
313
|
let j = i + 2;
|
|
285
314
|
for (; j < lines.length && TABLE_ROW.test(lines[j] ?? ""); j++) {
|
|
286
315
|
const raw = lines[j] ?? "";
|
|
287
|
-
const
|
|
316
|
+
const cells = splitRow(raw);
|
|
317
|
+
// Arity guard: a row BoardRow cannot hold is refused a record, kept
|
|
318
|
+
// verbatim, and named loudly — destructuring it into the 5-tuple is
|
|
319
|
+
// exactly the silent narrow/widen this exists to prevent. An empty
|
|
320
|
+
// trailing cell (`| a | b | c | d | |`) is still five columns and
|
|
321
|
+
// still a legitimate row.
|
|
322
|
+
if (cells.length !== BOARD_ARITY) {
|
|
323
|
+
rows.push({
|
|
324
|
+
malformed: true,
|
|
325
|
+
verbatim: raw,
|
|
326
|
+
issue:
|
|
327
|
+
`board row at line ${j + 1} has ${cells.length} column(s), expected ${BOARD_ARITY} — ` +
|
|
328
|
+
`refused (kept verbatim, excluded from board records). Fix the row, or widen BoardRow ` +
|
|
329
|
+
`deliberately (BOARD_ARITY + boardCells + this guard together). Row: ${raw}`,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = cells;
|
|
288
334
|
rows.push({ id: idFor("b", lane), lane, owner, state, currentSlice, nextGo, raw });
|
|
289
335
|
}
|
|
290
336
|
flushText();
|
|
@@ -325,5 +371,16 @@ export function doneEntriesOf(doc: WorkDoc): DoneEntry[] {
|
|
|
325
371
|
}
|
|
326
372
|
|
|
327
373
|
export function boardRowsOf(doc: WorkDoc): BoardRow[] {
|
|
328
|
-
return doc.blocks.flatMap((b) =>
|
|
374
|
+
return doc.blocks.flatMap((b) =>
|
|
375
|
+
b.kind === "board" ? b.rows.filter((r): r is BoardRow => !isMalformedRow(r)) : [],
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Every refused row's issue, in document order — what makes the parse-time
|
|
380
|
+
// rejection LOUD at the tool layer (import_work/list_work/export_work all
|
|
381
|
+
// surface it) instead of a filtered-out record nobody notices.
|
|
382
|
+
export function workDocIssues(doc: WorkDoc): string[] {
|
|
383
|
+
return doc.blocks.flatMap((b) =>
|
|
384
|
+
b.kind === "board" ? b.rows.filter(isMalformedRow).map((r) => r.issue) : [],
|
|
385
|
+
);
|
|
329
386
|
}
|