agent-coord-mcp 0.19.0 → 0.23.0
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 +12 -2
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +164 -42
- 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/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +97 -7
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +98 -24
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -259
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +16 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-pusher.mjs +39 -9
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +181 -47
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/transport.ts +116 -6
- package/src/tools/work.ts +118 -30
- package/src/work.ts +31 -329
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
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/render.ts
CHANGED
|
@@ -1,80 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Every consumer downstream of a message — hooks/tier.mjs's prefix table, the
|
|
4
|
-
// UI's alert parser, a human reading a tmux pane — reads `text`. Phase 8 adds
|
|
5
|
-
// `record` alongside it, so the rendering must reproduce the byte layout those
|
|
6
|
-
// consumers already expect. Nothing downstream changes while agents migrate.
|
|
7
|
-
//
|
|
8
|
-
// This is a pure function of the record: no clock, no I/O, no registry. It
|
|
9
|
-
// never sees `from`, `to`, or anything the sender could use it to forge.
|
|
10
|
-
|
|
11
|
-
import type { DecisionPayload, MessageRecord, SummaryPayload, VerdictPayload } from "./shared.js";
|
|
12
|
-
|
|
13
|
-
// The case-sensitive prefixes at byte 0 of `text`, exactly as classifyTier
|
|
14
|
-
// matches them. `decision` is absent because it renders as a multi-line block,
|
|
15
|
-
// not a one-liner.
|
|
16
|
-
const PREFIX: Record<string, string> = {
|
|
17
|
-
blocker: "BLOCKER",
|
|
18
|
-
risk: "RISK",
|
|
19
|
-
done: "DONE",
|
|
20
|
-
fyi: "FYI",
|
|
21
|
-
action: "AGENT_ACTION",
|
|
22
|
-
go: "GO",
|
|
23
|
-
scope: "SCOPE CHANGE",
|
|
24
|
-
// `verdict` has no prefix in the v1 vocabulary — it is new in Phase 8. A gate
|
|
25
|
-
// PASS/FAIL was previously posted as prose, which is why verdicts could not
|
|
26
|
-
// be routed. Rendering it under its own prefix gives it one.
|
|
27
|
-
verdict: "VERDICT",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function isNonEmpty(v: unknown): v is string {
|
|
31
|
-
return typeof v === "string" && v.trim().length > 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The playbook's decision packet (§Decision Packet Format), byte-for-byte —
|
|
35
|
-
// the UI parses this into a clickable decision card, and a layout that drifts
|
|
36
|
-
// still alerts loudly but loses the card. Returns null when any of the five
|
|
37
|
-
// fields is missing, so the caller can reject rather than emit a half packet.
|
|
38
|
-
function renderDecision(p: DecisionPayload): string | null {
|
|
39
|
-
if (!isNonEmpty(p.title) || !isNonEmpty(p.context)) return null;
|
|
40
|
-
if (!isNonEmpty(p.recommendation) || !isNonEmpty(p.ifNoAction)) return null;
|
|
41
|
-
if (!Array.isArray(p.options) || p.options.length === 0) return null;
|
|
42
|
-
if (!p.options.every(isNonEmpty)) return null;
|
|
43
|
-
return [
|
|
44
|
-
`DAVID_DECISION: ${p.title}`,
|
|
45
|
-
`Context: ${p.context}`,
|
|
46
|
-
"Options:",
|
|
47
|
-
...p.options.map((o, i) => `${i + 1}. ${o}`),
|
|
48
|
-
`Recommendation: ${p.recommendation}`,
|
|
49
|
-
`If no action: ${p.ifNoAction}`,
|
|
50
|
-
].join("\n");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// `VERDICT: PASS <sha> — <notes>`. The sha is not optional in the rendering:
|
|
54
|
-
// a verdict that doesn't name the commit it was issued against is
|
|
55
|
-
// unfalsifiable the moment the branch moves.
|
|
56
|
-
function renderVerdict(p: VerdictPayload): string | null {
|
|
57
|
-
if (p.result !== "pass" && p.result !== "fail") return null;
|
|
58
|
-
if (!isNonEmpty(p.headRefOid)) return null;
|
|
59
|
-
const head = `${PREFIX.verdict}: ${p.result.toUpperCase()} ${p.headRefOid}`;
|
|
60
|
-
return isNonEmpty(p.notes) ? `${head} — ${p.notes}` : head;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Render a record to its text form, or null when the payload can't support one
|
|
64
|
-
// (absent, or missing a field the layout needs). Null is not an error here —
|
|
65
|
-
// the caller decides whether a record without a rendering is fatal, and it only
|
|
66
|
-
// is when there's no author-supplied `text` to fall back on.
|
|
67
|
-
export function renderRecord(record: MessageRecord): string | null {
|
|
68
|
-
if (!record || typeof record.type !== "string") return null;
|
|
69
|
-
const payload = record.payload as unknown;
|
|
70
|
-
if (payload === undefined || payload === null) return null;
|
|
71
|
-
if (typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
72
|
-
|
|
73
|
-
if (record.type === "decision") return renderDecision(payload as DecisionPayload);
|
|
74
|
-
if (record.type === "verdict") return renderVerdict(payload as VerdictPayload);
|
|
75
|
-
|
|
76
|
-
const prefix = PREFIX[record.type];
|
|
77
|
-
if (!prefix) return null;
|
|
78
|
-
const { summary } = payload as SummaryPayload;
|
|
79
|
-
return isNonEmpty(summary) ? `${prefix}: ${summary}` : null;
|
|
80
|
-
}
|
|
1
|
+
export { renderRecord, PREFIX } from "@davidbalzan/groundwork-seam/protocol";
|
package/src/tools/shared.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Citation,
|
|
3
|
+
DecisionPayload,
|
|
4
|
+
MessageRecord,
|
|
5
|
+
MessageRecordType,
|
|
6
|
+
SummaryPayload,
|
|
7
|
+
SummaryRecordType,
|
|
8
|
+
VerdictPayload,
|
|
9
|
+
} from "@davidbalzan/groundwork-seam/protocol";
|
|
1
10
|
import { randomUUID } from "node:crypto";
|
|
2
11
|
import { existsSync, openSync, watch } from "node:fs";
|
|
3
12
|
import { promises as fsp } from "node:fs";
|
|
@@ -92,82 +101,16 @@ export type TransportMarker = {
|
|
|
92
101
|
|
|
93
102
|
export type AgentRegistry = Record<string, AgentEntry>;
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
export type {
|
|
105
|
+
Citation,
|
|
106
|
+
MessageRecordType,
|
|
107
|
+
DecisionPayload,
|
|
108
|
+
VerdictPayload,
|
|
109
|
+
SummaryPayload,
|
|
110
|
+
SummaryRecordType,
|
|
111
|
+
MessageRecord,
|
|
101
112
|
};
|
|
102
113
|
|
|
103
|
-
// The protocol vocabulary the fleet already speaks. Today these live as
|
|
104
|
-
// case-sensitive prefixes at byte 0 of `text` (hooks/tier.mjs), parsed a
|
|
105
|
-
// second time by the UI for alert priority — so a greeting before the prefix
|
|
106
|
-
// silently downgrades a production blocker, and the two parsers can disagree
|
|
107
|
-
// about the same message. As a field there is nothing to mis-parse.
|
|
108
|
-
export type MessageRecordType =
|
|
109
|
-
| "blocker" // BLOCKER: work cannot continue
|
|
110
|
-
| "decision" // DAVID_DECISION: the human must decide
|
|
111
|
-
| "risk" // RISK: quality/security/cost/product risk
|
|
112
|
-
| "done" // DONE: completed work, must cite
|
|
113
|
-
| "fyi" // FYI: no action needed
|
|
114
|
-
| "action" // AGENT_ACTION: another agent can handle it
|
|
115
|
-
| "go" // GO: a work order
|
|
116
|
-
| "scope" // SCOPE CHANGE: amends a contract in flight
|
|
117
|
-
| "verdict"; // (new) a gate PASS/FAIL — has no prefix today
|
|
118
|
-
|
|
119
|
-
// The five fields of the playbook's decision packet (§Decision Packet Format).
|
|
120
|
-
// Named to match that layout because `renderRecord` reproduces it byte-for-byte
|
|
121
|
-
// — the UI parses the rendering into a clickable decision card.
|
|
122
|
-
export type DecisionPayload = {
|
|
123
|
-
title: string;
|
|
124
|
-
context: string;
|
|
125
|
-
options: string[];
|
|
126
|
-
recommendation: string;
|
|
127
|
-
ifNoAction: string;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
// A gate verdict. `headRefOid` pins WHICH commit was gated: a PASS that doesn't
|
|
131
|
-
// name the sha it was issued against is unfalsifiable once the branch moves.
|
|
132
|
-
export type VerdictPayload = {
|
|
133
|
-
result: "pass" | "fail";
|
|
134
|
-
headRefOid: string;
|
|
135
|
-
notes?: string;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// Everything else carries prose. One line, because these render as
|
|
139
|
-
// `<PREFIX>: <summary>` and a paragraph in a tmux pane is a wall, not a status.
|
|
140
|
-
export type SummaryPayload = { summary: string };
|
|
141
|
-
|
|
142
|
-
// Message types whose payload is just a summary.
|
|
143
|
-
export type SummaryRecordType = "blocker" | "risk" | "fyi" | "action" | "go" | "scope";
|
|
144
|
-
|
|
145
|
-
// Structured counterpart to `text`, NOT a replacement: `text` is the rendering,
|
|
146
|
-
// and a tmux pane can only receive text. A v1 agent that has never heard of
|
|
147
|
-
// `record` omits it entirely and behaves byte-identically.
|
|
148
|
-
//
|
|
149
|
-
// `payload` stays OPTIONAL on every arm — Phase 8 is additive, so no new
|
|
150
|
-
// required field may appear on the wire. What is pinned is the shape *if* a
|
|
151
|
-
// payload is supplied: a `decision` carrying three of its five fields is
|
|
152
|
-
// structurally wrong for the type it claims and is rejected, while a payload
|
|
153
|
-
// with extra unknown keys passes through untouched (a v3 sender must not be
|
|
154
|
-
// broken by a v2 server, and stripping would silently drop data on the way to
|
|
155
|
-
// disk).
|
|
156
|
-
//
|
|
157
|
-
// `cites` is likewise optional here. `done` requires a PR citation, but that is
|
|
158
|
-
// enforced in sendMessageTool as a plain {ok:false,error} rather than a schema
|
|
159
|
-
// rejection — see the identity-binding precedent in src/server.ts.
|
|
160
|
-
//
|
|
161
|
-
// UNTRUSTED, exactly like `from`. A peer can claim any type here, so trust
|
|
162
|
-
// decisions (the SCOPE countersignature, gate-runner routing) must still
|
|
163
|
-
// resolve the sender against the registry — typed is not the same as
|
|
164
|
-
// authenticated, and `record` must never become a path to setting `urgent`.
|
|
165
|
-
export type MessageRecord =
|
|
166
|
-
| { type: "decision"; payload?: DecisionPayload; cites?: Citation[] }
|
|
167
|
-
| { type: "verdict"; payload?: VerdictPayload; cites?: Citation[] }
|
|
168
|
-
| { type: "done"; payload?: SummaryPayload; cites?: Citation[] }
|
|
169
|
-
| { type: SummaryRecordType; payload?: SummaryPayload; cites?: Citation[] };
|
|
170
|
-
|
|
171
114
|
export type Message = {
|
|
172
115
|
id: string;
|
|
173
116
|
ts: number;
|
|
@@ -215,9 +158,7 @@ export type Message = {
|
|
|
215
158
|
//
|
|
216
159
|
// Deliberately structural about `record`: it takes anything with the two
|
|
217
160
|
// fields, so status entries and archive rows can be passed without a cast.
|
|
218
|
-
export
|
|
219
|
-
return e?.kind === "decision" || e?.record?.type === "decision";
|
|
220
|
-
}
|
|
161
|
+
export { isDecision } from "@davidbalzan/groundwork-seam/protocol";
|
|
221
162
|
|
|
222
163
|
export type StatusEntry = {
|
|
223
164
|
id: string;
|
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>();
|