agent-coord-mcp 0.26.4 → 0.26.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/prefix.js +64 -0
- package/dist/prefix.js.map +1 -0
- package/dist/server.js +28 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +122 -0
- package/dist/tools/away.js.map +1 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/records.js +545 -0
- package/dist/tools/records.js.map +1 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/rotate.js +143 -0
- package/dist/tools/rotate.js.map +1 -0
- package/dist/tools/shared.js +9 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +179 -0
- package/dist/tools/stall.js.map +1 -0
- package/dist/tools/transport.js +172 -16
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +264 -0
- package/dist/tools/worktrees.js.map +1 -0
- package/package.json +2 -2
- package/scripts/check-test-count.mjs +1 -1
- package/src/prefix.ts +72 -0
- package/src/server.ts +116 -3
- package/src/tools/away.ts +121 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/records.ts +639 -0
- package/src/tools/registry.ts +22 -1
- package/src/tools/rotate.ts +180 -0
- package/src/tools/shared.ts +24 -0
- package/src/tools/stall.ts +194 -0
- package/src/tools/transport.ts +178 -3
- package/src/tools/worktrees.ts +283 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `rotate` — handover-to-self across a context clear (Phase 5 Task 5).
|
|
3
|
+
*
|
|
4
|
+
* THE PACKET IS A SNAPSHOT, AND A CONTEXT RESET IS PRECISELY WHEN NOBODY CAN
|
|
5
|
+
* CHECK IT. After `/clear` the agent has no memory to contradict the packet
|
|
6
|
+
* with: whatever it says becomes the world. So the packet is written from
|
|
7
|
+
* TOOLS AND `gh`, never from chat memory — memory is the one source that
|
|
8
|
+
* cannot be re-derived after the reset it is meant to survive — and on the
|
|
9
|
+
* far side it is RECONCILED against live state before any work is done.
|
|
10
|
+
*
|
|
11
|
+
* A packet that is merely READ on resume is a stale world restored with
|
|
12
|
+
* confidence. That is the failure this verb exists to prevent, so `missionHint`
|
|
13
|
+
* is named a HINT in the type and treated as one in code: it is the only field
|
|
14
|
+
* that cannot be verified, and it never gets to assert anything.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { ROOT } from "../store.js";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* 5.4 — JOB IDS ARE AN ALLOWLIST.
|
|
24
|
+
*
|
|
25
|
+
* `archive-done` is deliberately ABSENT and stays absent: it is an arithmetic
|
|
26
|
+
* job over DONE.md that belongs to Groundwork and an existing QUEUE item, and
|
|
27
|
+
* a rotation is the worst possible moment to run one. Rotation exists to carry
|
|
28
|
+
* state ACROSS a reset intact; a verb that also rewrites the records it is
|
|
29
|
+
* carrying cannot be checked afterwards by the agent that ran it.
|
|
30
|
+
*/
|
|
31
|
+
export const ROTATE_JOBS = ["reseed-only", "phase-boundary"] as const;
|
|
32
|
+
export type RotateJob = (typeof ROTATE_JOBS)[number];
|
|
33
|
+
|
|
34
|
+
export type OpenPr = { n: number; headRefOid: string };
|
|
35
|
+
export type RotatePacket = {
|
|
36
|
+
agentId: string;
|
|
37
|
+
job: RotateJob;
|
|
38
|
+
rooms: string[];
|
|
39
|
+
name: string;
|
|
40
|
+
/** A HINT, never an assertion — the one field nothing can verify. */
|
|
41
|
+
missionHint: string;
|
|
42
|
+
atSha: string;
|
|
43
|
+
openPrs: OpenPr[];
|
|
44
|
+
at: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const packetFile = (agentId: string) => path.join(ROOT, "rotate", `${agentId}.json`);
|
|
48
|
+
|
|
49
|
+
export type RepoFacts = { dirty: string; sha: string; openPrs: OpenPr[] };
|
|
50
|
+
const realFacts = (repo: string): RepoFacts => {
|
|
51
|
+
const git = (args: string[]) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
52
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
53
|
+
return {
|
|
54
|
+
dirty: git(["status", "--porcelain"]),
|
|
55
|
+
sha: git(["rev-parse", "HEAD"]),
|
|
56
|
+
openPrs: (JSON.parse(out) as { number: number; headRefOid: string }[]).map((p) => ({ n: p.number, headRefOid: p.headRefOid })),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const rotateSchema = {
|
|
61
|
+
agentId: z.string().min(1),
|
|
62
|
+
job: z.string().min(1),
|
|
63
|
+
repo: z.string().optional(),
|
|
64
|
+
rooms: z.array(z.string()).optional(),
|
|
65
|
+
missionHint: z.string().optional(),
|
|
66
|
+
write: z.boolean().optional(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export async function rotateTool(
|
|
70
|
+
args: { agentId: string; job: string; repo?: string; rooms?: string[]; missionHint?: string; write?: boolean },
|
|
71
|
+
facts: (repo: string) => RepoFacts = realFacts,
|
|
72
|
+
) {
|
|
73
|
+
const repo = args.repo ?? process.cwd();
|
|
74
|
+
|
|
75
|
+
// 5.4 — unknown job refused BY NAME, and `archive-done` gets its own reason
|
|
76
|
+
// so the refusal reads as a decision rather than a typo.
|
|
77
|
+
if (!(ROTATE_JOBS as readonly string[]).includes(args.job)) {
|
|
78
|
+
const extra = args.job === "archive-done" ? ` 'archive-done' is deliberately not a rotation job: it rewrites the records the rotation is carrying, and a reset is the one moment nobody can check the result. It belongs to Groundwork and its existing QUEUE item.` : "";
|
|
79
|
+
return { ok: false as const, error: `'${args.job}' is not a rotate job. Allowed: ${ROTATE_JOBS.join(", ")}.${extra}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let f: RepoFacts;
|
|
83
|
+
try {
|
|
84
|
+
f = facts(repo);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return { ok: false as const, error: `could not read live state (${String((e as Error).message).split("\n")[0]}) — a packet built from anything but live state is chat memory with a filename.` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5.2 — MID-SLICE DIRTY REFUSES THE CLEAR.
|
|
90
|
+
//
|
|
91
|
+
// Uncommitted work is the one thing a packet cannot carry: it is not in the
|
|
92
|
+
// repo, not on a branch, and not in any tool's answer, so after `/clear` no
|
|
93
|
+
// reconciliation can discover it ever existed. It does not get lost loudly —
|
|
94
|
+
// it gets lost silently, which is why this is a refusal and not a warning.
|
|
95
|
+
if (f.dirty) {
|
|
96
|
+
const n = f.dirty.split("\n").filter(Boolean).length;
|
|
97
|
+
return {
|
|
98
|
+
ok: false as const,
|
|
99
|
+
error: `${n} uncommitted change(s) — refusing to rotate mid-slice. Uncommitted work is the one thing a packet cannot carry: after the clear, nothing can discover it existed. Commit it or stash it deliberately, then rotate.`,
|
|
100
|
+
dirty: f.dirty.split("\n").filter(Boolean),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const packet: RotatePacket = {
|
|
105
|
+
agentId: args.agentId,
|
|
106
|
+
job: args.job as RotateJob,
|
|
107
|
+
rooms: args.rooms ?? [],
|
|
108
|
+
name: args.agentId,
|
|
109
|
+
missionHint: args.missionHint ?? "",
|
|
110
|
+
atSha: f.sha,
|
|
111
|
+
openPrs: f.openPrs,
|
|
112
|
+
at: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!args.write) return { ok: true as const, written: false as const, packet, note: `packet built from live state; pass write:true to persist it before /clear.` };
|
|
116
|
+
mkdirSync(path.dirname(packetFile(args.agentId)), { recursive: true });
|
|
117
|
+
writeFileSync(packetFile(args.agentId), `${JSON.stringify(packet, null, 2)}\n`);
|
|
118
|
+
return { ok: true as const, written: true as const, packet, path: packetFile(args.agentId) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── 5.3 / 5.5 — reconcile before working, or refuse to work ───────────────── */
|
|
122
|
+
|
|
123
|
+
export type Divergence = { field: string; packet: string; live: string; note: string };
|
|
124
|
+
|
|
125
|
+
export const rotateReconcileSchema = { agentId: z.string().min(1), repo: z.string().optional() };
|
|
126
|
+
|
|
127
|
+
export async function rotateReconcileTool(
|
|
128
|
+
args: { agentId: string; repo?: string },
|
|
129
|
+
facts: (repo: string) => RepoFacts = realFacts,
|
|
130
|
+
) {
|
|
131
|
+
const repo = args.repo ?? process.cwd();
|
|
132
|
+
const f0 = packetFile(args.agentId);
|
|
133
|
+
if (!existsSync(f0)) return { ok: false as const, error: `no rotate packet for '${args.agentId}'. A reseeded agent with no packet has nothing to reconcile against and must not infer its state — ask for a GO.` };
|
|
134
|
+
|
|
135
|
+
let packet: RotatePacket;
|
|
136
|
+
try {
|
|
137
|
+
packet = JSON.parse(readFileSync(f0, "utf8")) as RotatePacket;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return { ok: false as const, error: `packet for '${args.agentId}' is unreadable (${String((e as Error).message).split("\n")[0]}) — an unparseable packet is not an empty one; do not proceed as if there were no prior state.` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let f: RepoFacts;
|
|
143
|
+
try {
|
|
144
|
+
f = facts(repo);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// NOT RECONCILED IS NOT RECONCILED-CLEAN.
|
|
147
|
+
return { ok: false as const, error: `could not read live state to reconcile (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and matching.`, packet };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const live = new Map(f.openPrs.map((p) => [p.n, p.headRefOid]));
|
|
151
|
+
const divergences: Divergence[] = [];
|
|
152
|
+
|
|
153
|
+
// 5.5 — THE PACKET'S OPEN PRs ARE CLAIMS, NOT FACTS.
|
|
154
|
+
//
|
|
155
|
+
// A PR that merged during the reset is the dangerous direction: the packet
|
|
156
|
+
// says "open", the agent resumes and keeps working a branch that is already
|
|
157
|
+
// in main, and every one of its next steps is coherent and wrong.
|
|
158
|
+
for (const p of packet.openPrs ?? []) {
|
|
159
|
+
if (!live.has(p.n)) divergences.push({ field: `pr#${p.n}`, packet: "open", live: "not open", note: `#${p.n} is no longer open — it merged or closed during the reset. Do NOT resume work on it as open.` });
|
|
160
|
+
else if (live.get(p.n) !== p.headRefOid) divergences.push({ field: `pr#${p.n}`, packet: p.headRefOid, live: String(live.get(p.n)), note: `#${p.n} advanced during the reset — the packet's head is stale; re-read before acting.` });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (packet.atSha && f.sha !== packet.atSha)
|
|
164
|
+
divergences.push({ field: "atSha", packet: packet.atSha, live: f.sha, note: `the repo moved during the reset; anything the packet said about the tree may be stale.` });
|
|
165
|
+
|
|
166
|
+
// The verdict names its POPULATION, and `missionHint` is excluded from it on
|
|
167
|
+
// purpose: it is unverifiable, so counting it as "reconciled" would be a
|
|
168
|
+
// clean report over something never checked.
|
|
169
|
+
const verdict = {
|
|
170
|
+
reconciled: (packet.openPrs?.length ?? 0) + (packet.atSha ? 1 : 0),
|
|
171
|
+
divergences,
|
|
172
|
+
unverifiable: ["missionHint"],
|
|
173
|
+
missionHint: packet.missionHint,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (divergences.length)
|
|
177
|
+
return { ok: false as const, error: `${divergences.length} divergence(s) between packet and live state — REFUSING to report ready. A packet read without reconciliation is a stale world restored with confidence.`, verdict, packet };
|
|
178
|
+
|
|
179
|
+
return { ok: true as const, ready: true as const, verdict, packet, note: `packet matches live state. 'missionHint' is a HINT and was NOT verified — treat it as a prompt, never as an instruction you have confirmed.` };
|
|
180
|
+
}
|
package/src/tools/shared.ts
CHANGED
|
@@ -97,8 +97,32 @@ export type TransportMarker = {
|
|
|
97
97
|
// it for a session restart + re-attach. Absent on markers written by older
|
|
98
98
|
// versions (treated as "unknown, skip", deliberately mirroring scriptMtime).
|
|
99
99
|
serverBuildMtime?: number;
|
|
100
|
+
// Does this transport carry ROOM traffic, or DMs only?
|
|
101
|
+
//
|
|
102
|
+
// A pusher started with `--no-room` delivers inbox messages and nothing
|
|
103
|
+
// else, and until this field existed the marker looked identical to a full
|
|
104
|
+
// one — so `status` said `attached: true` and an agent sat with its room
|
|
105
|
+
// feed off while every reading said healthy. That is how worker-2 missed
|
|
106
|
+
// its channel traffic.
|
|
107
|
+
//
|
|
108
|
+
// ABSENT MEANS UNKNOWN, NEVER "ON" — deliberately mirroring scriptMtime and
|
|
109
|
+
// serverBuildMtime above. A marker from an older pusher cannot tell us, and
|
|
110
|
+
// reporting an unasked question as full capability is the defect this field
|
|
111
|
+
// exists to remove.
|
|
112
|
+
rooms?: boolean;
|
|
100
113
|
};
|
|
101
114
|
|
|
115
|
+
/**
|
|
116
|
+
* How to REPORT a transport's room capability.
|
|
117
|
+
*
|
|
118
|
+
* Three states, not two. `undefined` is a marker written before the field
|
|
119
|
+
* existed: it cannot answer, and "unknown" is the only truthful report. The
|
|
120
|
+
* defect being fixed is precisely a two-state reading of a three-state world —
|
|
121
|
+
* `attached: true` covered "full", "DM-only", and "cannot say" alike.
|
|
122
|
+
*/
|
|
123
|
+
export const roomFeedOf = (m: { rooms?: boolean } | null | undefined): "on" | "off" | "unknown" =>
|
|
124
|
+
!m || m.rooms === undefined ? "unknown" : m.rooms ? "on" : "off";
|
|
125
|
+
|
|
102
126
|
export type AgentRegistry = Record<string, AgentEntry>;
|
|
103
127
|
|
|
104
128
|
export type {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `stall_check` — a predicate over the board and the bus, plus a HALT flag.
|
|
3
|
+
*
|
|
4
|
+
* Stall has been "notice the room" until now, which means it is noticed when
|
|
5
|
+
* somebody happens to look. The measured cost elsewhere: ~42 hours of dead merge
|
|
6
|
+
* path across four incidents in three days, with `main` advancing throughout so
|
|
7
|
+
* every commit-based liveness check read green.
|
|
8
|
+
*
|
|
9
|
+
* A MISS IS SILENT TO THE DUTY OFFICER AND NEVER SILENT TO THE RECORD.
|
|
10
|
+
*
|
|
11
|
+
* This is the whole design constraint (3.3b). "HIT DMs, MISS silent" answers the
|
|
12
|
+
* noise question and makes a DEAD CLOCK look exactly like a healthy fleet — the
|
|
13
|
+
* absence-read-as-evidence rule, inside the verb built to watch for absence. So
|
|
14
|
+
* every run leaves a mark, HIT or MISS, and "no alert" becomes distinguishable
|
|
15
|
+
* from "nothing ran". A check that only speaks when it fires cannot be told from
|
|
16
|
+
* a broken one.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
|
|
23
|
+
import { ROOT, AGENTS_FILE, readJson } from "../store.js";
|
|
24
|
+
|
|
25
|
+
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
26
|
+
const STALL_MS = 30 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
const runFile = () => path.join(ROOT, "stall-check.json");
|
|
29
|
+
const haltFile = () => path.join(ROOT, "halt.json");
|
|
30
|
+
|
|
31
|
+
export type StallHit =
|
|
32
|
+
| { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
|
|
33
|
+
| { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number };
|
|
34
|
+
|
|
35
|
+
// ---------- halt ----------
|
|
36
|
+
|
|
37
|
+
export const setHaltSchema = {
|
|
38
|
+
reason: z.string().min(1),
|
|
39
|
+
by: z.string().min(1),
|
|
40
|
+
clear: z.boolean().optional(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A NAMED state, never a mood.
|
|
45
|
+
*
|
|
46
|
+
* "Production feels down" is not a halt: a halt blocks every claim in the fleet,
|
|
47
|
+
* so the thing that sets it must be nameable and therefore arguable — a board
|
|
48
|
+
* cutover, a cited `BLOCKER:`, a documented red pipeline. The reason is required
|
|
49
|
+
* for that reason, not for the log.
|
|
50
|
+
*/
|
|
51
|
+
export async function setHaltTool(args: { reason: string; by: string; clear?: boolean }) {
|
|
52
|
+
mkdirSync(ROOT, { recursive: true });
|
|
53
|
+
if (args.clear) {
|
|
54
|
+
writeFileSync(haltFile(), JSON.stringify({ halted: false, clearedBy: args.by, clearedAt: Date.now(), lastReason: args.reason }, null, 2));
|
|
55
|
+
return { ok: true as const, halted: false, clearedBy: args.by };
|
|
56
|
+
}
|
|
57
|
+
const state = { halted: true, reason: args.reason, by: args.by, at: Date.now() };
|
|
58
|
+
writeFileSync(haltFile(), JSON.stringify(state, null, 2));
|
|
59
|
+
return { ok: true as const, ...state };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function haltState(): { halted: boolean; reason?: string; by?: string; at?: number } {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(haltFile(), "utf8"));
|
|
65
|
+
return raw?.halted ? raw : { halted: false };
|
|
66
|
+
} catch {
|
|
67
|
+
return { halted: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- the run mark ----------
|
|
72
|
+
|
|
73
|
+
/** Every run leaves this, HIT or MISS. It is what makes a dead clock visible. */
|
|
74
|
+
function markRun(result: { hits: StallHit[]; checked: number }) {
|
|
75
|
+
mkdirSync(ROOT, { recursive: true });
|
|
76
|
+
let history: { at: number; hits: number; checked: number }[] = [];
|
|
77
|
+
try {
|
|
78
|
+
history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
|
|
79
|
+
} catch {
|
|
80
|
+
/* first run */
|
|
81
|
+
}
|
|
82
|
+
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked });
|
|
83
|
+
// A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
|
|
84
|
+
// list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
|
|
85
|
+
// are different states and only the first is a healthy fleet.
|
|
86
|
+
writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Is the clock alive? Readable by a human or another check, which is the point —
|
|
93
|
+
* MISS is silent to the duty officer, not to the record.
|
|
94
|
+
*/
|
|
95
|
+
export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
96
|
+
const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
|
|
97
|
+
let history: { at: number; hits: number; checked: number }[] = [];
|
|
98
|
+
try {
|
|
99
|
+
history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
ok: false as const,
|
|
103
|
+
error:
|
|
104
|
+
"stall_check has NEVER run — no run mark exists. That is not a quiet fleet, it is an unwatched one: a check that only speaks when it fires cannot be told from a broken one.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const last = history[history.length - 1];
|
|
108
|
+
const age = Date.now() - (last?.at ?? 0);
|
|
109
|
+
// `>=`, NOT `>`, AND THE DIFFERENCE IS A REAL RACE RATHER THAN PEDANTRY.
|
|
110
|
+
//
|
|
111
|
+
// With `>`, a window of 0 and a mark written in the SAME MILLISECOND gives
|
|
112
|
+
// `0 > 0` = false: the clock reads FRESH at the instant it was asked to treat
|
|
113
|
+
// everything as stale. It passed locally and on one CI run and failed on
|
|
114
|
+
// another — green in two environments, red in one — because it depended on at
|
|
115
|
+
// least a millisecond elapsing.
|
|
116
|
+
//
|
|
117
|
+
// The window having ELAPSED is the condition, so equality is inside it: a
|
|
118
|
+
// 0-minute window means nothing is ever fresh, which is what a caller asking
|
|
119
|
+
// for one means.
|
|
120
|
+
const stale = age >= maxAge;
|
|
121
|
+
const misses = history.filter((h) => h.hits === 0).length;
|
|
122
|
+
return {
|
|
123
|
+
ok: !stale,
|
|
124
|
+
...(stale
|
|
125
|
+
? {
|
|
126
|
+
error: `stall_check last ran ${Math.round(age / 60000)}m ago, past the ${args.maxAgeMinutes ?? 60}m window — THE CLOCK IS STOPPED. No alerts is not the same as no stalls.`,
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
lastRanMinutesAgo: Math.round(age / 60000),
|
|
130
|
+
runs: history.length,
|
|
131
|
+
misses,
|
|
132
|
+
hits: history.length - misses,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- the predicate ----------
|
|
137
|
+
|
|
138
|
+
export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
|
|
139
|
+
|
|
140
|
+
export async function stallCheckTool(args: { repo?: string; stallMinutes?: number }) {
|
|
141
|
+
const repo = args.repo ?? process.cwd();
|
|
142
|
+
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
143
|
+
const board = path.join(repo, BOARD_DOC);
|
|
144
|
+
if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
|
|
145
|
+
|
|
146
|
+
const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
|
|
147
|
+
const inFlight = rows.filter((r) => /🚧/.test(r.status));
|
|
148
|
+
const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const hits: StallHit[] = [];
|
|
151
|
+
|
|
152
|
+
for (const row of inFlight) {
|
|
153
|
+
const agentId = row.owner.replace(/[`*]/g, "").trim();
|
|
154
|
+
const entry = reg[agentId];
|
|
155
|
+
if (entry) {
|
|
156
|
+
const age = now - entry.lastHeartbeat;
|
|
157
|
+
if (age > limit) {
|
|
158
|
+
hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
|
|
163
|
+
// the case "notice the room" never catches: the pane is responsive, so nobody
|
|
164
|
+
// looks. Ask the branch instead.
|
|
165
|
+
const branch = (row.branchWorktree.match(/`([^`]+)`/)?.[1] ?? "").trim();
|
|
166
|
+
if (!branch || !/\//.test(branch)) continue;
|
|
167
|
+
try {
|
|
168
|
+
const iso = execFileSync("git", ["log", "-1", "--format=%cI", branch], {
|
|
169
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
170
|
+
}).trim();
|
|
171
|
+
const age = now - Date.parse(iso);
|
|
172
|
+
if (age > limit) {
|
|
173
|
+
hits.push({ kind: "no-vcs-activity", agentId, branch, minutes: Math.round(age / 60000) });
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
/* unknown branch: not a stall claim we can make */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = { hits, checked: inFlight.length };
|
|
181
|
+
markRun(result);
|
|
182
|
+
return {
|
|
183
|
+
ok: true as const,
|
|
184
|
+
...result,
|
|
185
|
+
halted: haltState().halted,
|
|
186
|
+
// MISS is silent to the DUTY OFFICER — the caller decides whether to DM — and
|
|
187
|
+
// never silent to the record, which markRun just wrote.
|
|
188
|
+
dm: hits.length > 0,
|
|
189
|
+
note:
|
|
190
|
+
hits.length === 0
|
|
191
|
+
? `MISS — ${inFlight.length} in-flight row(s), none stalled. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
|
|
192
|
+
: undefined,
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/tools/transport.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
2
2
|
import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
|
|
3
|
+
import { prefixOf, prefixVerdict } from "../prefix.js";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
3
5
|
import { registerTool } from "./registry.js";
|
|
4
6
|
import { roleInputSchema, type RoleArg } from "../roles.js";
|
|
5
7
|
import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
|
|
@@ -307,13 +309,69 @@ function defaultReminderText(agentId: string): string {
|
|
|
307
309
|
);
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/*
|
|
313
|
+
* REMINDERS ARE OBSERVABLE, because "scheduled" is not "delivered".
|
|
314
|
+
*
|
|
315
|
+
* `send_command` returns `reminderScheduled` the instant the timer is set, and
|
|
316
|
+
* the delivery is an async lockfile-protected append whose only failure path
|
|
317
|
+
* was a line on stderr. So a reminder could be LATE or LOST and the caller had
|
|
318
|
+
* already been told it was handled — the same claim-without-a-completion-signal
|
|
319
|
+
* shape as an empty check rollup reading green.
|
|
320
|
+
*
|
|
321
|
+
* MEASURED: `withLock` is configured `retries: 10, minTimeout: 20,
|
|
322
|
+
* maxTimeout: 200`, so the append is PERMITTED well over a second of backoff.
|
|
323
|
+
* Under sustained inbox contention the reminder lands at ~420ms. Any assertion
|
|
324
|
+
* with a wall-clock budget below the lock's own policy is asserting something
|
|
325
|
+
* the system never promised — which is what made tools.test.mjs:156 fail under
|
|
326
|
+
* full-suite load and pass everywhere else.
|
|
327
|
+
*
|
|
328
|
+
* A longer sleep would convert that race into a slower race. This gives the
|
|
329
|
+
* work a real completion signal instead, so callers and tests can await the
|
|
330
|
+
* thing itself rather than guess at a duration.
|
|
331
|
+
*/
|
|
332
|
+
type PendingReminder = { done: Promise<void>; timer: NodeJS.Timeout };
|
|
333
|
+
const pendingReminders = new Set<PendingReminder>();
|
|
334
|
+
|
|
335
|
+
/** Reminders whose delivery FAILED, kept so a drop is observable, not stderr-only. */
|
|
336
|
+
export const reminderFailures: { to: string; error: string; at: number }[] = [];
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolves once every scheduled reminder has been delivered or has failed.
|
|
340
|
+
*
|
|
341
|
+
* Re-`ref`s the pending timers for the duration of the wait. The reminder timer
|
|
342
|
+
* is deliberately `unref`d so it never holds the MCP server open on its own —
|
|
343
|
+
* but that also means the event loop will not wait for it, and awaiting an
|
|
344
|
+
* unref'd timer's promise hangs until the loop drains. A caller that has
|
|
345
|
+
* explicitly asked to wait is stating the opposite intent, so the ref is
|
|
346
|
+
* restored for exactly that window and dropped again afterwards.
|
|
347
|
+
*
|
|
348
|
+
* (The unref has a production consequence worth naming: if the server's
|
|
349
|
+
* transport closes before the timer fires, the reminder is dropped. That is a
|
|
350
|
+
* separate defect from the one this function fixes — reported, not silently
|
|
351
|
+
* papered over.)
|
|
352
|
+
*/
|
|
353
|
+
export function remindersSettled(): Promise<void> {
|
|
354
|
+
const pending = [...pendingReminders];
|
|
355
|
+
for (const p of pending) p.timer.ref();
|
|
356
|
+
return Promise.all(pending.map((p) => p.done)).then(() => {
|
|
357
|
+
for (const p of pending) p.timer.unref();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
310
361
|
function scheduleReminders(
|
|
311
362
|
from: string,
|
|
312
363
|
recipients: string[],
|
|
313
364
|
delayMs: number,
|
|
314
365
|
override: string | undefined,
|
|
315
366
|
): void {
|
|
367
|
+
// Registered BEFORE the timer fires, so awaiting settlement covers the delay
|
|
368
|
+
// as well as the write — otherwise a caller could observe "nothing pending"
|
|
369
|
+
// during the window between scheduling and firing.
|
|
370
|
+
let markDone: () => void = () => {};
|
|
371
|
+
const done = new Promise<void>((resolve) => { markDone = resolve; });
|
|
372
|
+
let entry: PendingReminder;
|
|
316
373
|
const t = setTimeout(async () => {
|
|
374
|
+
try {
|
|
317
375
|
for (const r of recipients) {
|
|
318
376
|
try {
|
|
319
377
|
const reminder: Message = {
|
|
@@ -328,12 +386,20 @@ function scheduleReminders(
|
|
|
328
386
|
};
|
|
329
387
|
await appendJsonl(inboxFile(r), reminder);
|
|
330
388
|
} catch (e) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
389
|
+
const error = (e as Error)?.message ?? String(e);
|
|
390
|
+
// Recorded, not just printed: a dropped reminder leaves a just-cleared
|
|
391
|
+
// agent contextless, and stderr is not somewhere anyone looks for that.
|
|
392
|
+
reminderFailures.push({ to: r, error, at: Date.now() });
|
|
393
|
+
process.stderr.write(`[send_command] post-/clear reminder to '${r}' failed: ${error}\n`);
|
|
334
394
|
}
|
|
335
395
|
}
|
|
396
|
+
} finally {
|
|
397
|
+
pendingReminders.delete(entry);
|
|
398
|
+
markDone();
|
|
399
|
+
}
|
|
336
400
|
}, delayMs);
|
|
401
|
+
entry = { done, timer: t };
|
|
402
|
+
pendingReminders.add(entry);
|
|
337
403
|
// Don't keep the event loop alive solely for the reminder — the MCP server's
|
|
338
404
|
// transport already holds it open as long as it's connected.
|
|
339
405
|
if (typeof t.unref === "function") t.unref();
|
|
@@ -639,6 +705,11 @@ export async function attachAgentTool(args: {
|
|
|
639
705
|
// code, and after an in-place rebuild the two differ (that difference is
|
|
640
706
|
// exactly what doctor's provenance check exists to surface).
|
|
641
707
|
serverBuildMtime: SERVER_BUILD_MTIME,
|
|
708
|
+
// WHAT this transport carries, recorded by the code that decides it.
|
|
709
|
+
// `includeRoom` is what the pusher is actually spawned with a few lines
|
|
710
|
+
// above, so the marker cannot claim a capability the process was not
|
|
711
|
+
// given — the marker and the spawn come from one value, not two.
|
|
712
|
+
rooms: includeRoom,
|
|
642
713
|
};
|
|
643
714
|
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
644
715
|
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
@@ -941,6 +1012,10 @@ export const reportTransportSchema = {
|
|
|
941
1012
|
// counterpart has been upgraded since. The remote pusher passes
|
|
942
1013
|
// `(await fsp.stat(__filename)).mtimeMs`; absent → doctor skips the check.
|
|
943
1014
|
scriptMtime: z.number().optional(),
|
|
1015
|
+
// Whether this pusher carries room traffic as well as DMs. Absent → the
|
|
1016
|
+
// marker cannot say, and every reader reports UNKNOWN rather than assuming
|
|
1017
|
+
// a full transport (the worker-2 shape).
|
|
1018
|
+
rooms: z.boolean().optional(),
|
|
944
1019
|
};
|
|
945
1020
|
|
|
946
1021
|
// Called by an external push daemon (typically scripts/coord-pusher.mjs on a
|
|
@@ -954,6 +1029,7 @@ export async function reportTransportTool(args: {
|
|
|
954
1029
|
host?: string;
|
|
955
1030
|
since?: number;
|
|
956
1031
|
scriptMtime?: number;
|
|
1032
|
+
rooms?: boolean;
|
|
957
1033
|
}) {
|
|
958
1034
|
const marker: TransportMarker = {
|
|
959
1035
|
agentId: args.agentId,
|
|
@@ -963,6 +1039,7 @@ export async function reportTransportTool(args: {
|
|
|
963
1039
|
host: args.host,
|
|
964
1040
|
since: args.since ?? Date.now(),
|
|
965
1041
|
scriptMtime: args.scriptMtime,
|
|
1042
|
+
rooms: args.rooms,
|
|
966
1043
|
};
|
|
967
1044
|
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
968
1045
|
return { ok: true, marker };
|
|
@@ -1212,6 +1289,25 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1212
1289
|
});
|
|
1213
1290
|
}
|
|
1214
1291
|
|
|
1292
|
+
// 1b³. WHERE A NEW GLOBAL INSTALL WOULD LAND, which is a different question
|
|
1293
|
+
// from which copy is running (1b² above) and neither substitutes. On
|
|
1294
|
+
// this box `npm prefix -g` and the fleet's actual load path pointed at
|
|
1295
|
+
// two different nvm versions, so an install that printed success would
|
|
1296
|
+
// have updated a copy nothing loads.
|
|
1297
|
+
{
|
|
1298
|
+
const loadPrefix = prefixOf(BUILD_DIR);
|
|
1299
|
+
let installPrefix: string | null = null;
|
|
1300
|
+
let npmPath: string | undefined;
|
|
1301
|
+
try {
|
|
1302
|
+
installPrefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1303
|
+
npmPath = execFileSync("which", ["npm"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
1304
|
+
} catch {
|
|
1305
|
+
installPrefix = null;
|
|
1306
|
+
}
|
|
1307
|
+
const v = prefixVerdict(loadPrefix, installPrefix, npmPath);
|
|
1308
|
+
findings.push({ check: "install-prefix", level: v.level, detail: v.detail, fixable: false });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1215
1311
|
// 1b²ᵇ. The affirmative catch for merged-but-never-rebuilt: src/ newer than
|
|
1216
1312
|
// the compiled build means no restart can help — the artifact every
|
|
1217
1313
|
// future session will load is already behind the code. Distinct from
|
|
@@ -1633,6 +1729,85 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1633
1729
|
});
|
|
1634
1730
|
}
|
|
1635
1731
|
|
|
1732
|
+
// 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.
|
|
1733
|
+
//
|
|
1734
|
+
// Check 6 reports "these N agents are stale", which is the detection half and
|
|
1735
|
+
// leaves the reader to infer the cause. The inference is the expensive part: a
|
|
1736
|
+
// coordinator misdiagnosed a billing outage as a coordination failure for want
|
|
1737
|
+
// of exactly this distinction, and spent the incident chasing agents.
|
|
1738
|
+
//
|
|
1739
|
+
// UNIFORM every agent stale by roughly the same interval. Agents do not
|
|
1740
|
+
// fail in lockstep — something they SHARE did: the machine slept,
|
|
1741
|
+
// the API key expired, billing lapsed, the host lost network. The
|
|
1742
|
+
// remedy is the environment, and touching the agents does nothing.
|
|
1743
|
+
// DIVERGENT some stale, some fresh, on the same bus at the same moment. The
|
|
1744
|
+
// shared substrate is demonstrably working, so the fault is the
|
|
1745
|
+
// stale agent's. The remedy is that agent.
|
|
1746
|
+
//
|
|
1747
|
+
// CLASSIFICATION IS THE DELIVERABLE, NOT DETECTION — the same finding as
|
|
1748
|
+
// `transport-build-split`: two states with different remedies reported as one
|
|
1749
|
+
// warning leave the reader to guess, and the guess is made under incident
|
|
1750
|
+
// pressure.
|
|
1751
|
+
//
|
|
1752
|
+
// WITH FEWER THAN TWO AGENTS THE QUESTION IS UNANSWERABLE and it says so. One
|
|
1753
|
+
// stale agent on a bus of one is uniform and divergent simultaneously; there is
|
|
1754
|
+
// no second observation to compare against. Naming a cause there would be the
|
|
1755
|
+
// confident-wrong-answer shape this whole class is about.
|
|
1756
|
+
{
|
|
1757
|
+
const live = new Set<string>();
|
|
1758
|
+
for (const fname of await listTransportFiles()) {
|
|
1759
|
+
const marker = await readJson<TransportMarker | null>(path.join(TRANSPORT_DIR, fname), null);
|
|
1760
|
+
if (marker && isMarkerLive(marker, reg, now)) live.add(marker.agentId);
|
|
1761
|
+
}
|
|
1762
|
+
const entries = Object.entries(reg).map(([id, a]) => ({
|
|
1763
|
+
id,
|
|
1764
|
+
live: live.has(id),
|
|
1765
|
+
ageMs: now - a.lastHeartbeat,
|
|
1766
|
+
stale: !live.has(id) && now - a.lastHeartbeat > STALE_MS,
|
|
1767
|
+
}));
|
|
1768
|
+
const stale = entries.filter((e) => e.stale);
|
|
1769
|
+
const fresh = entries.filter((e) => !e.stale);
|
|
1770
|
+
const mins = (ms: number) => Math.round(ms / 60000);
|
|
1771
|
+
|
|
1772
|
+
let level: "ok" | "warn" = "ok";
|
|
1773
|
+
let detail: string;
|
|
1774
|
+
let items: string[] | undefined;
|
|
1775
|
+
|
|
1776
|
+
if (entries.length === 0) {
|
|
1777
|
+
detail = "no registered agents — nothing to classify";
|
|
1778
|
+
} else if (stale.length === 0) {
|
|
1779
|
+
detail = `${entries.length} agent(s), none stale (threshold ${mins(STALE_MS)}m)`;
|
|
1780
|
+
} else if (entries.length < 2) {
|
|
1781
|
+
level = "warn";
|
|
1782
|
+
detail =
|
|
1783
|
+
`1 agent and it is stale (${mins(stale[0]!.ageMs)}m) — UNCLASSIFIABLE. Uniform and divergent are the same ` +
|
|
1784
|
+
`picture with one observation, so the cause is not named here rather than guessed.`;
|
|
1785
|
+
items = [`${stale[0]!.id} (${mins(stale[0]!.ageMs)}m)`];
|
|
1786
|
+
} else if (fresh.length === 0) {
|
|
1787
|
+
// Spread across the stale set decides it: a shared cause stops everything at
|
|
1788
|
+
// once, so the ages cluster. Independent failures do not.
|
|
1789
|
+
const ages = stale.map((e) => e.ageMs).sort((a, b) => a - b);
|
|
1790
|
+
const spread = ages[ages.length - 1]! - ages[0]!;
|
|
1791
|
+
const tight = spread <= Math.max(2 * 60 * 1000, ages[ages.length - 1]! * 0.1);
|
|
1792
|
+
level = "warn";
|
|
1793
|
+
detail = tight
|
|
1794
|
+
? `UNIFORM: all ${stale.length} agent(s) stale within ${mins(spread)}m of each other (${mins(ages[0]!)}–${mins(ages[ages.length - 1]!)}m). ` +
|
|
1795
|
+
`Agents do not fail in lockstep — suspect something they SHARE (machine asleep, credentials, billing, network). ` +
|
|
1796
|
+
`Restarting agents will not help.`
|
|
1797
|
+
: `ALL ${stale.length} agent(s) are stale but their ages span ${mins(spread)}m — NOT uniform, so a single shared cause does not explain it. ` +
|
|
1798
|
+
`Treat as ${stale.length} independent failures until something ties them together.`;
|
|
1799
|
+
items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m)`);
|
|
1800
|
+
} else {
|
|
1801
|
+
level = "warn";
|
|
1802
|
+
detail =
|
|
1803
|
+
`DIVERGENT: ${stale.length} stale, ${fresh.length} fresh on the same bus at the same moment. ` +
|
|
1804
|
+
`The shared substrate is demonstrably working — the fault is the stale agent's, not the environment's.`;
|
|
1805
|
+
items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m stale)`);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
findings.push({ check: "staleness-class", level, detail, fixable: false, ...(items ? { items } : {}) });
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1636
1811
|
// 7. Oversized JSONL files. Report only (suggest prune).
|
|
1637
1812
|
{
|
|
1638
1813
|
const big: string[] = [];
|