agent-coord-mcp 0.26.14 → 0.26.15
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/capabilities.js +158 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/server-identity.js +82 -0
- package/dist/server-identity.js.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/events.js +64 -7
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/record-events.js +9 -2
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/transport.js +26 -56
- package/dist/tools/transport.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +14 -11
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/capabilities.ts +188 -0
- package/src/server-identity.ts +83 -0
- package/src/server.ts +8 -0
- package/src/tools/events.ts +89 -9
- package/src/tools/record-events.ts +9 -2
- package/src/tools/transport.ts +26 -57
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Phase 5.1 Task 11 — CLOSE THE LOOP: answer a capability question by
|
|
3
|
+
* EXERCISING the capability, in the process that would serve it.
|
|
4
|
+
*
|
|
5
|
+
* 11.1: NO TIMESTAMP ON THIS BOX CAN ANSWER A CAPABILITY QUESTION. Every
|
|
6
|
+
* artefact people reach for is a proxy that fails differently:
|
|
7
|
+
*
|
|
8
|
+
* an mtime tracks WRITES. A reinstall of byte-identical code moves
|
|
9
|
+
* it, and an edit that never got built does not.
|
|
10
|
+
* serverBuildMtime is stamped at ATTACH, so it tells you when a transport
|
|
11
|
+
* started, not what the running process contains.
|
|
12
|
+
* a version string is a LABEL someone typed into package.json. Measured
|
|
13
|
+
* today: a tarball published as 0.5.23 contained a change
|
|
14
|
+
* the version numbering said it could not, and the number
|
|
15
|
+
* was believed over the artefact for an hour.
|
|
16
|
+
* `npm view` answers from a cache, and told this fleet the wrong
|
|
17
|
+
* published version twice in one morning.
|
|
18
|
+
*
|
|
19
|
+
* Each is honest about something and dishonest about capability, and the
|
|
20
|
+
* failure is always the same shape: an ANSWER ABOUT A LABEL read as an answer
|
|
21
|
+
* about behaviour.
|
|
22
|
+
*
|
|
23
|
+
* 11.2: A RELEASE IS NOT DELIVERED UNTIL A SERVER THAT RESTARTED ANSWERS.
|
|
24
|
+
* `merged · published · installed · restarted · observed` — five states, and
|
|
25
|
+
* the last two are the ones that keep being skipped. A probe run INSIDE the
|
|
26
|
+
* server process is the only artefact that speaks for the loaded code: it
|
|
27
|
+
* cannot be satisfied by a file that exists, a version that matches, or a
|
|
28
|
+
* package that installed, because it calls the code and reports what happened.
|
|
29
|
+
*
|
|
30
|
+
* WHY A PROBE MAY NEVER READ A VERSION: if a probe branched on a version
|
|
31
|
+
* string it would inherit that string's dishonesty, and a fleet would then
|
|
32
|
+
* have a capability check that passes on a restarted-but-not-upgraded server.
|
|
33
|
+
* Probes call behaviour. The version travels beside the answer as CONTEXT and
|
|
34
|
+
* is labelled as such.
|
|
35
|
+
*/
|
|
36
|
+
import { prRefsIn } from "./tools/record-events.js";
|
|
37
|
+
import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
|
|
38
|
+
import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
39
|
+
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
|
+
import { recordAuthorityFor } from "./roles.js";
|
|
41
|
+
import { subscriptionHealth } from "./tools/events.js";
|
|
42
|
+
|
|
43
|
+
export type ProbeResult = {
|
|
44
|
+
id: string;
|
|
45
|
+
/** The release the behaviour arrived in — reported, never TESTED against. */
|
|
46
|
+
since: string;
|
|
47
|
+
present: boolean;
|
|
48
|
+
/** What was actually called and what came back. The evidence, not a claim. */
|
|
49
|
+
evidence: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Probe = { id: string; since: string; run: () => { present: boolean; evidence: string } };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Each probe calls a behaviour that did not exist before its release and
|
|
56
|
+
* reports what it observed. A probe that could pass without the code being
|
|
57
|
+
* loaded is not a probe — it is a restatement of the version.
|
|
58
|
+
*/
|
|
59
|
+
const PROBES: Probe[] = [
|
|
60
|
+
{
|
|
61
|
+
id: "typed-records-obligatory",
|
|
62
|
+
since: "0.26.10",
|
|
63
|
+
run: () => {
|
|
64
|
+
const s = suggestRecordType("DONE: shipped it", []);
|
|
65
|
+
const mode = typedRecordMode();
|
|
66
|
+
return {
|
|
67
|
+
present: s.type === "done" && (mode === "warn" || mode === "refuse"),
|
|
68
|
+
evidence: `suggestRecordType("DONE: …") -> '${s.type}', policy mode '${mode}'`,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "record-events-all-kinds",
|
|
74
|
+
since: "0.26.12",
|
|
75
|
+
run: () => {
|
|
76
|
+
const kinds = [...EVENT_KIND_IDS].sort();
|
|
77
|
+
const present = ["item", "phase", "pr", "task"].every((k) => kinds.includes(k as never));
|
|
78
|
+
return { present, evidence: `subscribable kinds: ${kinds.join(", ")}` };
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "multi-pr-citation",
|
|
83
|
+
since: "0.26.14",
|
|
84
|
+
run: () => {
|
|
85
|
+
const refs = prRefsIn("owner/repo#170, #173");
|
|
86
|
+
return {
|
|
87
|
+
present: refs.length === 2 && refs[1] === "owner/repo#173",
|
|
88
|
+
evidence: `prRefsIn("owner/repo#170, #173") -> [${refs.join(", ")}]`,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "away-means-david-away",
|
|
94
|
+
since: "0.26.13",
|
|
95
|
+
run: () => {
|
|
96
|
+
const present = "merge" in LEAD_REFUSED && PARKED_CATEGORIES.includes("licence" as never);
|
|
97
|
+
return {
|
|
98
|
+
present,
|
|
99
|
+
evidence: `coord_away refuses [${Object.keys(LEAD_REFUSED).join(", ")}] for the lead; parks ${PARKED_CATEGORIES.length} categories`,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "subscription-scanned-vs-evaluated",
|
|
105
|
+
since: "0.26.15",
|
|
106
|
+
run: () => {
|
|
107
|
+
// The capability is that "the machinery ran" and "your kind fired" are
|
|
108
|
+
// separable. Probed by asking for the state that used to be reported as
|
|
109
|
+
// broken: scanned, never evaluated — a healthy idle watch.
|
|
110
|
+
const base = { id: "p", agentId: "p", kind: "item", target: "t", createdAt: 0, lastEvaluatedAt: null, lastEventAt: null, delivered: [] } as never;
|
|
111
|
+
const quiet = subscriptionHealth({ ...(base as object), lastScannedAt: Date.now() } as never);
|
|
112
|
+
const unscanned = subscriptionHealth({ ...(base as object), lastScannedAt: null } as never);
|
|
113
|
+
return {
|
|
114
|
+
present: quiet.level === "ok" && unscanned.level === "error",
|
|
115
|
+
evidence: `scanned+quiet -> '${quiet.level}', never-scanned -> '${unscanned.level}'`,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "record-authority",
|
|
121
|
+
since: "0.24.0",
|
|
122
|
+
run: () => {
|
|
123
|
+
const worker = recordAuthorityFor({ roleId: "worker" });
|
|
124
|
+
return {
|
|
125
|
+
present: worker.mayNotEmit.includes("verdict"),
|
|
126
|
+
evidence: `a worker mayNotEmit: [${worker.mayNotEmit.join(", ")}]`,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export type CapabilityReport = {
|
|
133
|
+
/**
|
|
134
|
+
* WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
|
|
135
|
+
* tell two servers apart when their answers disagree. Deliberately beside the
|
|
136
|
+
* probe results rather than above them: the temptation this whole verb exists
|
|
137
|
+
* to remove is reading the identity INSTEAD of the answers.
|
|
138
|
+
*/
|
|
139
|
+
answeredBy: { pid: number; startedAtIso: string; module: string; versionLabel: string };
|
|
140
|
+
probes: ProbeResult[];
|
|
141
|
+
missing: string[];
|
|
142
|
+
ok: boolean;
|
|
143
|
+
note: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function probeCapabilities(context: { module: string; versionLabel: string }): CapabilityReport {
|
|
147
|
+
const probes: ProbeResult[] = PROBES.map((p) => {
|
|
148
|
+
try {
|
|
149
|
+
const r = p.run();
|
|
150
|
+
return { id: p.id, since: p.since, present: r.present, evidence: r.evidence };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// A THROWN PROBE IS AN ABSENT CAPABILITY, NOT A BROKEN CHECK. Older code
|
|
153
|
+
// that lacks the symbol throws exactly here, and reporting that as an
|
|
154
|
+
// error rather than an absence would make the common case look like a
|
|
155
|
+
// malfunction.
|
|
156
|
+
return { id: p.id, since: p.since, present: false, evidence: `probe threw: ${(e as Error).message}` };
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
const missing = probes.filter((p) => !p.present).map((p) => p.id);
|
|
160
|
+
return {
|
|
161
|
+
answeredBy: {
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
startedAtIso: new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString(),
|
|
164
|
+
module: context.module,
|
|
165
|
+
versionLabel: context.versionLabel,
|
|
166
|
+
},
|
|
167
|
+
probes,
|
|
168
|
+
missing,
|
|
169
|
+
ok: missing.length === 0,
|
|
170
|
+
note:
|
|
171
|
+
"Every line above was produced by CALLING the code in this process. `versionLabel` is a label someone typed " +
|
|
172
|
+
"into package.json and is context, never evidence — a published tarball has already been observed carrying a " +
|
|
173
|
+
"change its version said it could not. THIS ANSWER IS ABOUT ONE PROCESS: a release is delivered when every " +
|
|
174
|
+
"live agent's OWN server answers, which is the fifth state (merged · published · installed · restarted · observed). " +
|
|
175
|
+
"A server that has not restarted answers honestly about the old code it is still running.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── the verb ──────────────────────────────────────────────────────────────── */
|
|
180
|
+
|
|
181
|
+
import { resolveServerIdentity } from "./server-identity.js";
|
|
182
|
+
|
|
183
|
+
export const capabilitiesSchema = {} as const;
|
|
184
|
+
|
|
185
|
+
export async function capabilitiesTool() {
|
|
186
|
+
const id = resolveServerIdentity();
|
|
187
|
+
return probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
188
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHICH SERVER PACKAGE IS RUNNING — a LEAF module, importing nothing from
|
|
3
|
+
* `tools/`.
|
|
4
|
+
*
|
|
5
|
+
* It lived in `transport.ts`, and `capabilities.ts` imported it from there. The
|
|
6
|
+
* moment `status` needs to REPORT capabilities, that becomes
|
|
7
|
+
* transport -> capabilities -> transport: a cycle, and the same one Task 9
|
|
8
|
+
* produced when the kind registry sat inside `record-events.ts`. That one only
|
|
9
|
+
* worked because `tools/index.ts` happened to import in the surviving order —
|
|
10
|
+
* a load-ORDER dependency, invisible until something imported a module
|
|
11
|
+
* directly, which is what a consumer or a test does.
|
|
12
|
+
*
|
|
13
|
+
* A cycle that works by luck is not a working cycle, so the shared fact moves
|
|
14
|
+
* out rather than the edge being added on top of it.
|
|
15
|
+
*
|
|
16
|
+
* NOTE WHAT THIS REPORTS AND WHAT IT DOES NOT. `version` is the label in the
|
|
17
|
+
* package.json this process loaded from — CONTEXT for a reader, never evidence
|
|
18
|
+
* of a capability. A published tarball has already been observed carrying a
|
|
19
|
+
* change its version said it could not. Capability questions are answered by
|
|
20
|
+
* `capabilities`, which CALLS the code.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { isGitRepo } from "./tools/scopes.js";
|
|
27
|
+
|
|
28
|
+
/** Running server package + optional git identity. `fromDir` is a test seam. */
|
|
29
|
+
export function resolveServerIdentity(fromDir?: string): {
|
|
30
|
+
path: string;
|
|
31
|
+
version: string;
|
|
32
|
+
branch?: string;
|
|
33
|
+
sha?: string;
|
|
34
|
+
} {
|
|
35
|
+
const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
let dir = start;
|
|
37
|
+
let pkgFile: string | undefined;
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
const candidate = path.join(dir, "package.json");
|
|
40
|
+
if (existsSync(candidate)) {
|
|
41
|
+
pkgFile = candidate;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const parent = path.dirname(dir);
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
|
|
49
|
+
const pkgDir = path.dirname(pkgPath);
|
|
50
|
+
let version = "unknown";
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
53
|
+
if (typeof raw.version === "string" && raw.version) version = raw.version;
|
|
54
|
+
} catch {
|
|
55
|
+
/* leave unknown — never invent a version */
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out: { path: string; version: string; branch?: string; sha?: string } = {
|
|
59
|
+
path: pkgDir,
|
|
60
|
+
version,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
dir = pkgDir;
|
|
64
|
+
let gitRoot: string | undefined;
|
|
65
|
+
for (let i = 0; i < 10; i++) {
|
|
66
|
+
if (existsSync(path.join(dir, ".git"))) {
|
|
67
|
+
gitRoot = dir;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const parent = path.dirname(dir);
|
|
71
|
+
if (parent === dir) break;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
if (gitRoot && isGitRepo(gitRoot)) {
|
|
75
|
+
const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
76
|
+
const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
77
|
+
const b = branch.status === 0 ? String(branch.stdout).trim() : "";
|
|
78
|
+
const s = sha.status === 0 ? String(sha.stdout).trim() : "";
|
|
79
|
+
if (b) out.branch = b;
|
|
80
|
+
if (s) out.sha = s;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinato
|
|
|
10
10
|
import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
|
|
11
11
|
import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
|
|
12
12
|
import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
|
|
13
|
+
import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
|
|
13
14
|
import {
|
|
14
15
|
ensureDirs,
|
|
15
16
|
getTokenMap,
|
|
@@ -635,6 +636,13 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
635
636
|
gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
636
637
|
);
|
|
637
638
|
|
|
639
|
+
addTool(
|
|
640
|
+
"capabilities",
|
|
641
|
+
"What THIS SERVER PROCESS can actually do, answered by CALLING each capability rather than by reading a version, an mtime, or a file. No timestamp on the box can answer a capability question: an mtime tracks writes (a reinstall of identical bytes moves it), `serverBuildMtime` is stamped at attach, and a version is a label someone typed — a published tarball has been observed carrying a change its version said it could not. Each probe reports the call it made and what came back, so an absent capability reads as an absence rather than an error. THE ANSWER IS ABOUT ONE PROCESS: a release is delivered only when every live agent's OWN server answers, which is the fifth state (merged \u00b7 published \u00b7 installed \u00b7 restarted \u00b7 observed). A server that has not restarted answers honestly about the old code it still runs.",
|
|
642
|
+
capabilitiesSchema,
|
|
643
|
+
gate(null, capabilitiesTool as () => Promise<unknown>),
|
|
644
|
+
);
|
|
645
|
+
|
|
638
646
|
addTool(
|
|
639
647
|
"scan_record_events",
|
|
640
648
|
"Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.",
|
package/src/tools/events.ts
CHANGED
|
@@ -29,8 +29,26 @@ export type Subscription = {
|
|
|
29
29
|
kind: SubKind;
|
|
30
30
|
target: string;
|
|
31
31
|
createdAt: number;
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* null until an event OF THIS KIND was evaluated against this subscription.
|
|
34
|
+
*
|
|
35
|
+
* NOT "the machinery ran" — see lastScannedAt. Those were one field, and the
|
|
36
|
+
* collapse is the defect: `evaluate()` only touches subscriptions whose kind
|
|
37
|
+
* matches the event, so an `item` subscriber sat at `never evaluated` for a
|
|
38
|
+
* week while `pr` events flowed past it. Its health said the wiring was dead;
|
|
39
|
+
* the wiring was fine and nothing of its kind had happened.
|
|
40
|
+
*/
|
|
33
41
|
lastEvaluatedAt: number | null;
|
|
42
|
+
/**
|
|
43
|
+
* null until a scan RAN with this subscription registered — regardless of
|
|
44
|
+
* kind. A scan that ran is evidence the wiring works; it says nothing about
|
|
45
|
+
* whether your kind fired, which is the other field.
|
|
46
|
+
*
|
|
47
|
+
* Optional on disk: subscriptions written before this existed have no value,
|
|
48
|
+
* and absent must read as UNKNOWN rather than as "never scanned". Fabricating
|
|
49
|
+
* a scan nobody observed is the failure this whole phase is about.
|
|
50
|
+
*/
|
|
51
|
+
lastScannedAt?: number | null;
|
|
34
52
|
lastEventAt: number | null;
|
|
35
53
|
/** Idempotency keys already delivered, for 6.4. */
|
|
36
54
|
delivered: string[];
|
|
@@ -60,17 +78,53 @@ function writeSubs(subs: Subscription[]): void {
|
|
|
60
78
|
* subscription with no last-evaluated mark has produced no evidence of
|
|
61
79
|
* anything, and "no events" is the same output a broken subscription gives.
|
|
62
80
|
*/
|
|
63
|
-
export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
|
|
64
|
-
|
|
81
|
+
export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
|
|
82
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
83
|
+
|
|
84
|
+
// THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
|
|
85
|
+
// collapse cost a week: an `item` subscriber read `error — never evaluated`
|
|
86
|
+
// the whole time, because `evaluate()` only touches subscriptions whose kind
|
|
87
|
+
// matches the event and no `item` event had fired. The wiring was fine. The
|
|
88
|
+
// health field said it was dead, and the agent holding it believed the field.
|
|
89
|
+
//
|
|
90
|
+
// Same shape as `alive` swallowing `heartbeatFresh`, and as
|
|
91
|
+
// `stall_clock_status` reporting green while covering nothing: one field
|
|
92
|
+
// answering two questions always answers the easier one.
|
|
93
|
+
if (s.lastScannedAt === undefined && s.lastEvaluatedAt === null) {
|
|
94
|
+
// Written before this field existed AND never evaluated. Absent is UNKNOWN,
|
|
95
|
+
// never "never scanned" — inventing a scan nobody observed is the failure
|
|
96
|
+
// this phase is about, and asserting one never happened is the same error
|
|
97
|
+
// pointed the other way.
|
|
98
|
+
return {
|
|
99
|
+
level: "unknown",
|
|
100
|
+
detail:
|
|
101
|
+
"predates scan tracking and has never been evaluated — whether the machinery has run for it CANNOT be determined from this record. " +
|
|
102
|
+
"It will resolve to ok or error on the next scan; until then this is an absence of evidence, not evidence of absence.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!s.lastScannedAt) {
|
|
65
106
|
return {
|
|
66
107
|
level: "error",
|
|
67
|
-
detail:
|
|
108
|
+
detail:
|
|
109
|
+
"NEVER SCANNED — no scan has run with this subscription registered, so it has produced no evidence of being wired to anything. " +
|
|
110
|
+
"This is the state that means broken.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (s.lastEvaluatedAt === null) {
|
|
114
|
+
// THE STATE THAT USED TO READ AS BROKEN. The machinery ran and nothing of
|
|
115
|
+
// this kind happened, which is a healthy idle watch.
|
|
116
|
+
return {
|
|
117
|
+
level: "ok",
|
|
118
|
+
detail:
|
|
119
|
+
`scanned ${iso(s.lastScannedAt)}, never evaluated — the machinery RAN and no '${s.kind}' event has occurred yet. ` +
|
|
120
|
+
"Scanned-and-quiet is healthy; it is not the same as never run, and those shared a field until now.",
|
|
68
121
|
};
|
|
122
|
+
}
|
|
69
123
|
return {
|
|
70
124
|
level: "ok",
|
|
71
125
|
detail: s.lastEventAt
|
|
72
|
-
? `last evaluated ${
|
|
73
|
-
: `last evaluated ${
|
|
126
|
+
? `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, last event ${iso(s.lastEventAt)}`
|
|
127
|
+
: `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, no events yet`,
|
|
74
128
|
};
|
|
75
129
|
}
|
|
76
130
|
|
|
@@ -122,12 +176,26 @@ export type Delivery = { subscriptionId: string; agentId: string; key: string; s
|
|
|
122
176
|
* A subscription only learns it is alive by being evaluated, so the mark is
|
|
123
177
|
* written for every subscription of that kind, not only the ones that fired.
|
|
124
178
|
*/
|
|
179
|
+
/**
|
|
180
|
+
* Record that a scan RAN, for every live subscription regardless of kind.
|
|
181
|
+
*
|
|
182
|
+
* Called once per scan — including a scan that produced NO events, which is
|
|
183
|
+
* exactly the case that starved the old field: no events of your kind means
|
|
184
|
+
* `evaluate` never touches you, and a subscription that is never touched cannot
|
|
185
|
+
* be told from one that is not wired to anything.
|
|
186
|
+
*/
|
|
187
|
+
export function markScanned(subs: Subscription[], now: number): Subscription[] {
|
|
188
|
+
return subs.map((s) => ({ ...s, lastScannedAt: now }));
|
|
189
|
+
}
|
|
190
|
+
|
|
125
191
|
export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
|
|
126
192
|
const key = eventKey(ev.kind, ev.target, ev.ref);
|
|
127
193
|
const deliveries: Delivery[] = [];
|
|
128
194
|
const next = subs.map((s) => {
|
|
129
|
-
|
|
130
|
-
const
|
|
195
|
+
// Every subscription observed this scan, whatever its kind.
|
|
196
|
+
const scanned = { ...s, lastScannedAt: now };
|
|
197
|
+
if (s.kind !== ev.kind) return scanned;
|
|
198
|
+
const evaluated = { ...scanned, lastEvaluatedAt: now };
|
|
131
199
|
if (s.target !== ev.target) return evaluated;
|
|
132
200
|
if (s.delivered.includes(key)) {
|
|
133
201
|
deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
|
|
@@ -157,6 +225,12 @@ export async function subscribeTool(args: { agentId: string; kind: SubKind; targ
|
|
|
157
225
|
kind: args.kind,
|
|
158
226
|
target: args.target,
|
|
159
227
|
createdAt: Date.now(),
|
|
228
|
+
// EXPLICIT null, never left absent. `null` means "we know no scan has run
|
|
229
|
+
// for this"; `undefined` means "this record predates the field and we
|
|
230
|
+
// cannot say". A subscription created now is the first case, and writing it
|
|
231
|
+
// explicitly is what keeps a brand-new registration from being reported as
|
|
232
|
+
// unknowable.
|
|
233
|
+
lastScannedAt: null,
|
|
160
234
|
lastEvaluatedAt: null,
|
|
161
235
|
lastEventAt: null,
|
|
162
236
|
delivered: [],
|
|
@@ -185,6 +259,7 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
|
|
|
185
259
|
const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
|
|
186
260
|
const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
|
|
187
261
|
const neverEvaluated = rows.filter((r) => r.health.level === "error");
|
|
262
|
+
const undetermined = rows.filter((r) => r.health.level === "unknown");
|
|
188
263
|
return {
|
|
189
264
|
ok: neverEvaluated.length === 0,
|
|
190
265
|
// Population beside the verdict, always: "no subscriptions" and "none
|
|
@@ -192,7 +267,12 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
|
|
|
192
267
|
population: { listed: rows.length, total: all.length },
|
|
193
268
|
subscriptions: rows,
|
|
194
269
|
...(neverEvaluated.length
|
|
195
|
-
? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER
|
|
270
|
+
? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER BEEN SCANNED — no scan has run with them registered, so they have produced no evidence of being wired to anything.` }
|
|
271
|
+
: {}),
|
|
272
|
+
...(undetermined.length
|
|
273
|
+
? {
|
|
274
|
+
undetermined: `${undetermined.length} of ${rows.length} subscription(s) predate scan tracking and cannot be judged yet — they resolve on the next scan. Reported rather than counted as either healthy or broken.`,
|
|
275
|
+
}
|
|
196
276
|
: {}),
|
|
197
277
|
};
|
|
198
278
|
}
|
|
@@ -257,7 +257,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
257
257
|
import path from "node:path";
|
|
258
258
|
import { z } from "zod";
|
|
259
259
|
import { ROOT } from "../store.js";
|
|
260
|
-
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
260
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
263
|
* The watermark: the last commit whose record change has been turned into
|
|
@@ -349,8 +349,15 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
349
349
|
|
|
350
350
|
const deliveries: unknown[] = [];
|
|
351
351
|
if (args.write) {
|
|
352
|
-
|
|
352
|
+
// MARK THE SCAN BEFORE THE EVENTS, and unconditionally.
|
|
353
|
+
//
|
|
354
|
+
// A scan that produced NO events is exactly the case that starved the old
|
|
355
|
+
// health field: `evaluate` is per-event, so a quiet scan touched nothing
|
|
356
|
+
// and every subscription kept reading "never evaluated" — indistinguishable
|
|
357
|
+
// from unwired. The scan RAN; that is a fact about the machinery and it is
|
|
358
|
+
// recorded whether or not anything fired.
|
|
353
359
|
const now = Date.now();
|
|
360
|
+
let subs = markScanned(readSubs(), now);
|
|
354
361
|
for (const ev of emitted) {
|
|
355
362
|
const r = evaluate(subs, ev, now);
|
|
356
363
|
subs = r.subs;
|
package/src/tools/transport.ts
CHANGED
|
@@ -5,6 +5,15 @@ import { execFileSync } from "node:child_process";
|
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { roleInputSchema, type RoleArg } from "../roles.js";
|
|
7
7
|
import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
|
|
8
|
+
// Moved to a LEAF so `status` can report capabilities without a cycle — see
|
|
9
|
+
// src/server-identity.ts. Re-exported so every existing importer is untouched.
|
|
10
|
+
export { resolveServerIdentity } from "../server-identity.js";
|
|
11
|
+
import { resolveServerIdentity } from "../server-identity.js";
|
|
12
|
+
// ONE-WAY EDGE, and the direction is the point: `status` CALLS the capabilities
|
|
13
|
+
// verb rather than re-deriving its probes. Two probes of the same thing is the
|
|
14
|
+
// four-matchers defect committed deliberately, which is why 17.4 was deferred
|
|
15
|
+
// behind 13.7 instead of built beside it.
|
|
16
|
+
import { capabilitiesTool } from "../capabilities.js";
|
|
8
17
|
import { sendMessageTool, readMessagesTool } from "./messaging.js";
|
|
9
18
|
import { randomUUID } from "node:crypto";
|
|
10
19
|
import { existsSync, openSync, readFileSync, watch } from "node:fs";
|
|
@@ -835,63 +844,6 @@ function resolvePusherPath(): string {
|
|
|
835
844
|
return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
|
|
836
845
|
}
|
|
837
846
|
|
|
838
|
-
/** Running server package + optional git identity. `fromDir` is a test seam. */
|
|
839
|
-
export function resolveServerIdentity(fromDir?: string): {
|
|
840
|
-
path: string;
|
|
841
|
-
version: string;
|
|
842
|
-
branch?: string;
|
|
843
|
-
sha?: string;
|
|
844
|
-
} {
|
|
845
|
-
const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
846
|
-
let dir = start;
|
|
847
|
-
let pkgFile: string | undefined;
|
|
848
|
-
for (let i = 0; i < 8; i++) {
|
|
849
|
-
const candidate = path.join(dir, "package.json");
|
|
850
|
-
if (existsSync(candidate)) {
|
|
851
|
-
pkgFile = candidate;
|
|
852
|
-
break;
|
|
853
|
-
}
|
|
854
|
-
const parent = path.dirname(dir);
|
|
855
|
-
if (parent === dir) break;
|
|
856
|
-
dir = parent;
|
|
857
|
-
}
|
|
858
|
-
const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
|
|
859
|
-
const pkgDir = path.dirname(pkgPath);
|
|
860
|
-
let version = "unknown";
|
|
861
|
-
try {
|
|
862
|
-
const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
863
|
-
if (typeof raw.version === "string" && raw.version) version = raw.version;
|
|
864
|
-
} catch {
|
|
865
|
-
/* leave unknown — never invent a version */
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const out: { path: string; version: string; branch?: string; sha?: string } = {
|
|
869
|
-
path: pkgDir,
|
|
870
|
-
version,
|
|
871
|
-
};
|
|
872
|
-
|
|
873
|
-
dir = pkgDir;
|
|
874
|
-
let gitRoot: string | undefined;
|
|
875
|
-
for (let i = 0; i < 10; i++) {
|
|
876
|
-
if (existsSync(path.join(dir, ".git"))) {
|
|
877
|
-
gitRoot = dir;
|
|
878
|
-
break;
|
|
879
|
-
}
|
|
880
|
-
const parent = path.dirname(dir);
|
|
881
|
-
if (parent === dir) break;
|
|
882
|
-
dir = parent;
|
|
883
|
-
}
|
|
884
|
-
if (gitRoot && isGitRepo(gitRoot)) {
|
|
885
|
-
const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
886
|
-
const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
887
|
-
const b = branch.status === 0 ? String(branch.stdout).trim() : "";
|
|
888
|
-
const s = sha.status === 0 ? String(sha.stdout).trim() : "";
|
|
889
|
-
if (b) out.branch = b;
|
|
890
|
-
if (s) out.sha = s;
|
|
891
|
-
}
|
|
892
|
-
return out;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
847
|
/** An epoch-ms stamp as ISO-8601 UTC, or an explicit absence — never a blank. */
|
|
896
848
|
function stamp(ms: number | undefined): string {
|
|
897
849
|
return ms === undefined ? "(no stamp)" : new Date(ms).toISOString();
|
|
@@ -925,6 +877,22 @@ export async function statusTool(args: { agentId: string }) {
|
|
|
925
877
|
const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
|
|
926
878
|
const inboxOffset = cursor.inboxOffset ?? 0;
|
|
927
879
|
const unread = Math.max(0, inbox.length - inboxOffset);
|
|
880
|
+
// WHAT THIS PROCESS CAN ACTUALLY DO, embedded WHOLE.
|
|
881
|
+
//
|
|
882
|
+
// A new tool otherwise lands silently: an agent reading an old listing
|
|
883
|
+
// asserts the tool does not exist, and it is right about its own listing and
|
|
884
|
+
// wrong about the world. Three wrong conclusions in two days came from
|
|
885
|
+
// exactly that, each about which build a pane was running.
|
|
886
|
+
//
|
|
887
|
+
// The full report travels, not a summary. Flattening it to a boolean would
|
|
888
|
+
// hand the caller a tidier answer and a worse one: `versionLabel` stays
|
|
889
|
+
// CONTEXT rather than evidence, a THROWN probe stays an absent capability
|
|
890
|
+
// rather than a broken check, and `answeredBy` keeps naming which pid
|
|
891
|
+
// answered — two servers disagreeing mid-rollout is the normal case, and it
|
|
892
|
+
// is the case every other instrument here reports as if the fleet were one
|
|
893
|
+
// thing.
|
|
894
|
+
const capabilities = await capabilitiesTool();
|
|
895
|
+
|
|
928
896
|
return {
|
|
929
897
|
agentId: args.agentId,
|
|
930
898
|
registered: !!entry,
|
|
@@ -935,6 +903,7 @@ export async function statusTool(args: { agentId: string }) {
|
|
|
935
903
|
inboxUnread: unread,
|
|
936
904
|
inTmux: !!process.env.TMUX_PANE,
|
|
937
905
|
tmuxPane: process.env.TMUX_PANE,
|
|
906
|
+
capabilities,
|
|
938
907
|
};
|
|
939
908
|
}
|
|
940
909
|
|