agent-coord-mcp 0.18.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 +13 -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 +134 -25
- 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/submit.mjs +136 -14
- package/hooks/tmux-pusher.mjs +6 -0
- package/package.json +1 -1
- package/scripts/check-self-dependency.mjs +22 -10
- 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 +154 -23
- 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 };
|
|
@@ -983,12 +1044,22 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
983
1044
|
{
|
|
984
1045
|
const localPusherMtime = newestPusherSourceMtime();
|
|
985
1046
|
const stale: string[] = [];
|
|
1047
|
+
const unverifiable: string[] = [];
|
|
986
1048
|
for (const fname of await listTransportFiles()) {
|
|
987
1049
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
988
1050
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
989
1051
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
990
|
-
if (marker.transport !== "tmux-push") continue; // remote = can't verify
|
|
991
|
-
if (marker.scriptMtime === undefined)
|
|
1052
|
+
if (marker.transport !== "tmux-push") continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1053
|
+
if (marker.scriptMtime === undefined) {
|
|
1054
|
+
// ABSENCE IS NOT EXEMPTION. The field's own writer once dropped it,
|
|
1055
|
+
// and the silent skip here meant the check was disabled by the very
|
|
1056
|
+
// thing it monitors — a live pusher we cannot verify is a warn, not
|
|
1057
|
+
// an ok (credit agent-coordination-david-dev). Same flip, same
|
|
1058
|
+
// commit, as serverBuildMtime below: the two checks must never
|
|
1059
|
+
// disagree about what absence means.
|
|
1060
|
+
unverifiable.push(`${marker.agentId} (pid ${marker.pid}, no scriptMtime stamp — cannot verify; detach_agent + attach_agent to re-stamp)`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
992
1063
|
if (localPusherMtime === undefined) continue;
|
|
993
1064
|
if (marker.scriptMtime < localPusherMtime - 1) { // -1ms slack for fs mtime rounding
|
|
994
1065
|
const loaded = new Date(marker.scriptMtime).toISOString();
|
|
@@ -996,14 +1067,15 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
996
1067
|
stale.push(`${marker.agentId} (pid ${marker.pid}, loaded ${loaded}, on-disk now ${ondisk})`);
|
|
997
1068
|
}
|
|
998
1069
|
}
|
|
1070
|
+
const bad = [...stale, ...unverifiable];
|
|
999
1071
|
findings.push({
|
|
1000
1072
|
check: "stale-pusher-script",
|
|
1001
|
-
level:
|
|
1002
|
-
detail:
|
|
1003
|
-
? `${stale.length} attached pusher(s) running pre-upgrade code — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
|
|
1073
|
+
level: bad.length ? "warn" : "ok",
|
|
1074
|
+
detail: bad.length
|
|
1075
|
+
? `${stale.length} attached pusher(s) running pre-upgrade code and ${unverifiable.length} whose freshness cannot be verified (no stamp) — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
|
|
1004
1076
|
: "all attached pushers are running the current on-disk script",
|
|
1005
1077
|
fixable: false,
|
|
1006
|
-
items:
|
|
1078
|
+
items: bad.length ? bad : undefined,
|
|
1007
1079
|
});
|
|
1008
1080
|
}
|
|
1009
1081
|
|
|
@@ -1062,17 +1134,26 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1062
1134
|
{
|
|
1063
1135
|
const onDisk = onDiskBuildMtime();
|
|
1064
1136
|
const outdated: string[] = [];
|
|
1137
|
+
const unverifiable: string[] = [];
|
|
1065
1138
|
for (const fname of await listTransportFiles()) {
|
|
1066
1139
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
1067
1140
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
1068
1141
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
1069
|
-
if (marker.transport !== "tmux-push") continue; // remote = can't verify
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1142
|
+
if (marker.transport !== "tmux-push") continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1143
|
+
if (marker.serverBuildMtime === undefined) {
|
|
1144
|
+
// ABSENCE IS NOT EXEMPTION — flipped in the same commit as the
|
|
1145
|
+
// scriptMtime absence above, so the two checks can never disagree
|
|
1146
|
+
// about what a missing stamp means. An unstamped marker was written
|
|
1147
|
+
// by a pre-provenance server (or by hand): precisely the population
|
|
1148
|
+
// this check exists to police, and the one it must not exempt.
|
|
1149
|
+
// Transition is deliberately correct-and-loud: after the upgrade,
|
|
1150
|
+
// every pre-existing marker warns at once, each cleared by a session
|
|
1151
|
+
// restart + re-attach — the burst is also the only external view of
|
|
1152
|
+
// which sessions still run pre-provenance servers, since a stale
|
|
1153
|
+
// server cannot self-report (see 1b²).
|
|
1154
|
+
unverifiable.push(`${marker.agentId} (no serverBuildMtime stamp — stamped by a pre-provenance server; restart that session, then detach_agent + attach_agent)`);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1076
1157
|
if (onDisk === undefined) continue;
|
|
1077
1158
|
if (marker.serverBuildMtime < onDisk - 1) {
|
|
1078
1159
|
const stamped = new Date(marker.serverBuildMtime).toISOString();
|
|
@@ -1080,14 +1161,15 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1080
1161
|
outdated.push(`${marker.agentId} (stamped by server build ${stamped}, on-disk build ${current})`);
|
|
1081
1162
|
}
|
|
1082
1163
|
}
|
|
1164
|
+
const bad = [...outdated, ...unverifiable];
|
|
1083
1165
|
findings.push({
|
|
1084
1166
|
check: "marker-server-provenance",
|
|
1085
|
-
level:
|
|
1086
|
-
detail:
|
|
1087
|
-
? `${outdated.length} transport marker(s) stamped by a server build older than dist/ — the stamping/spawn logic (freshness basis, pusher argv) predates the current code. Restart that agent's session, then detach_agent + attach_agent.`
|
|
1167
|
+
level: bad.length ? "warn" : "ok",
|
|
1168
|
+
detail: bad.length
|
|
1169
|
+
? `${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.`
|
|
1088
1170
|
: "all local transport markers were stamped by the current server build",
|
|
1089
1171
|
fixable: false,
|
|
1090
|
-
items:
|
|
1172
|
+
items: bad.length ? bad : undefined,
|
|
1091
1173
|
});
|
|
1092
1174
|
}
|
|
1093
1175
|
|
|
@@ -1163,6 +1245,55 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1163
1245
|
});
|
|
1164
1246
|
}
|
|
1165
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
|
+
|
|
1166
1297
|
// 2. Orphan room memberships (member not in the registry).
|
|
1167
1298
|
{
|
|
1168
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
|
}
|