agent-coord-mcp 0.26.13 → 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 +4 -2
- 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 +11 -6
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +23 -2
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/transport.js +26 -56
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +37 -1
- package/dist/tools/work.js.map +1 -1
- package/dist/tools/worktrees.js +46 -1
- package/dist/tools/worktrees.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 +10 -2
- package/src/tools/events.ts +89 -9
- package/src/tools/record-events.ts +11 -6
- package/src/tools/records.ts +23 -1
- package/src/tools/transport.ts +26 -57
- package/src/tools/work.ts +54 -2
- package/src/tools/worktrees.ts +45 -1
|
@@ -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,
|
|
@@ -539,7 +540,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
539
540
|
|
|
540
541
|
addTool(
|
|
541
542
|
"list_work",
|
|
542
|
-
"
|
|
543
|
+
"A re-parsing VIEW over a project's work state, not an index: it re-reads the documents (or the last import_work snapshot, reported as source:\"store\" vs \"markdown\") rather than answering from a maintained store, so treat repeated calls as costing what a fresh parse costs. Queue items come back as IDENTITY ONLY — id, priority, a bounded headline (truncated:true when cut, never silently), blockedBy — never the full body; pass id back on a second call to fetch one item's or one DONE entry's record whole. Done entries, board lane rows, and facts are returned in full. Filter queue by priority; falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
|
|
543
544
|
listWorkSchema,
|
|
544
545
|
gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
545
546
|
);
|
|
@@ -581,7 +582,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
581
582
|
|
|
582
583
|
addTool(
|
|
583
584
|
"next_unblocked",
|
|
584
|
-
"The next queue item
|
|
585
|
+
"The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
|
|
585
586
|
nextUnblockedSchema,
|
|
586
587
|
gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
587
588
|
);
|
|
@@ -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
|
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* notification for work that then vanishes is worse than a late one.
|
|
26
26
|
*/
|
|
27
27
|
import { execFileSync } from "node:child_process";
|
|
28
|
-
import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
|
|
28
|
+
import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf, hasCommitRef } from "@davidbalzan/groundwork-seam";
|
|
29
29
|
import { EVENT_KINDS, type RecordEvent, type SubKind } from "./event-kinds.js";
|
|
30
30
|
export { EVENT_KINDS, EVENT_KIND_IDS } from "./event-kinds.js";
|
|
31
31
|
export type { RecordEvent, SubKind } from "./event-kinds.js";
|
|
@@ -110,8 +110,6 @@ export function prRefsIn(field: string | undefined): string[] {
|
|
|
110
110
|
return out;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** A citation that closes an item without naming a PR — `owner/repo@sha`. */
|
|
114
|
-
const COMMIT_REF = /@[0-9a-f]{7,40}\b/;
|
|
115
113
|
/** Queue and done text, compared for the pairing below. */
|
|
116
114
|
const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
117
115
|
|
|
@@ -167,7 +165,7 @@ export function eventsFromCommittedChange(
|
|
|
167
165
|
// or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
|
|
168
166
|
// record actually says, and a commit-cited entry is still the record stating
|
|
169
167
|
// that the item closed.
|
|
170
|
-
const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 ||
|
|
168
|
+
const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? ""));
|
|
171
169
|
const unattributed: string[] = [];
|
|
172
170
|
|
|
173
171
|
for (const item of removedItems) {
|
|
@@ -259,7 +257,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
259
257
|
import path from "node:path";
|
|
260
258
|
import { z } from "zod";
|
|
261
259
|
import { ROOT } from "../store.js";
|
|
262
|
-
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
260
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
|
|
263
261
|
|
|
264
262
|
/**
|
|
265
263
|
* The watermark: the last commit whose record change has been turned into
|
|
@@ -351,8 +349,15 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
351
349
|
|
|
352
350
|
const deliveries: unknown[] = [];
|
|
353
351
|
if (args.write) {
|
|
354
|
-
|
|
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.
|
|
355
359
|
const now = Date.now();
|
|
360
|
+
let subs = markScanned(readSubs(), now);
|
|
356
361
|
for (const ev of emitted) {
|
|
357
362
|
const r = evaluate(subs, ev, now);
|
|
358
363
|
subs = r.subs;
|
package/src/tools/records.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type WorkDoc,
|
|
25
25
|
phaseCitationsIn,
|
|
26
26
|
newlyTickedInDiff,
|
|
27
|
+
sweepTagOf,
|
|
27
28
|
} from "@davidbalzan/groundwork-seam";
|
|
28
29
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
29
30
|
import { haltState } from "./stall.js";
|
|
@@ -151,8 +152,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
151
152
|
.sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
|
|
152
153
|
|
|
153
154
|
const skipped: { item: string; blockedBy: string }[] = [];
|
|
155
|
+
// NOT WORKER-CLAIMABLE (Task 15.4). Measured: 13-14 of the open queue is
|
|
156
|
+
// `[SWEEP:canon]`/`[SWEEP:canon.N]` — canon prose the aide and coordinator
|
|
157
|
+
// author directly into the playbook, never assigned as code work. Handing
|
|
158
|
+
// one out here is the exact defect: `next_unblocked` offered one outside
|
|
159
|
+
// the caller's lane while the rest of what remained was this same family.
|
|
160
|
+
// Skipped VISIBLY, same discipline as a blocked top item — never silently
|
|
161
|
+
// dropped, so a caller can tell "nothing left for me" from "nothing left".
|
|
162
|
+
const notClaimable: { item: string; sweepTag: string }[] = [];
|
|
154
163
|
let pick: QueueItem | null = null;
|
|
155
164
|
for (const { i } of ranked) {
|
|
165
|
+
const tag = sweepTagOf(i);
|
|
166
|
+
if (tag && /^canon(\.\d+)?$/.test(tag)) {
|
|
167
|
+
notClaimable.push({ item: keyOf(i), sweepTag: tag });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
156
170
|
const b = blockedBy(i);
|
|
157
171
|
// NEVER STALL THE LANE waiting on a reorder: a blocked top item is skipped,
|
|
158
172
|
// visibly, and the next unblocked one is taken.
|
|
@@ -176,7 +190,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
176
190
|
open: open.length,
|
|
177
191
|
next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
|
|
178
192
|
skipped,
|
|
179
|
-
|
|
193
|
+
// A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
|
|
194
|
+
// to take" rather than "blocked on something else". Collapsing the two
|
|
195
|
+
// would read a claimability gap as a dependency, which is a different
|
|
196
|
+
// remedy (route it, don't wait for it).
|
|
197
|
+
notClaimable,
|
|
198
|
+
boardHunks: [
|
|
199
|
+
...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
|
|
200
|
+
...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
|
|
201
|
+
],
|
|
180
202
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
181
203
|
noDownstream: undiscriminating
|
|
182
204
|
? {
|