agent-coord-mcp 0.26.36 → 0.26.40
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/auth-faults.js +145 -0
- package/dist/auth-faults.js.map +1 -0
- package/dist/capabilities.js +113 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/launch-unit.js +282 -0
- package/dist/launch-unit.js.map +1 -0
- package/dist/server.js +46 -7
- package/dist/server.js.map +1 -1
- package/dist/store.js +139 -1
- package/dist/store.js.map +1 -1
- package/dist/test-count-key.js +22 -0
- package/dist/test-count-key.js.map +1 -0
- package/dist/tools/away.js +80 -17
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/events.js +77 -3
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +51 -36
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +99 -77
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/registry.js +50 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/transports/herdr.js +92 -2
- package/dist/transports/herdr.js.map +1 -1
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +80 -6
- package/scripts/coord-attention-clock.mjs +19 -21
- package/scripts/coord-stall-clock.mjs +23 -24
- package/scripts/lib/clock-unit-cli.mjs +134 -0
- package/src/auth-faults.ts +166 -0
- package/src/capabilities.ts +115 -2
- package/src/launch-unit.ts +350 -0
- package/src/server.ts +47 -6
- package/src/store.ts +146 -1
- package/src/test-count-key.ts +21 -0
- package/src/tools/away.ts +113 -28
- package/src/tools/events.ts +89 -4
- package/src/tools/herdr-delivery.ts +46 -31
- package/src/tools/herdr-tail.ts +84 -65
- package/src/tools/registry.ts +55 -3
- package/src/transports/herdr.ts +93 -2
package/src/tools/away.ts
CHANGED
|
@@ -112,8 +112,26 @@ export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { age
|
|
|
112
112
|
const stale = ageMs > COVERAGE_STALE_MINUTES * 60_000;
|
|
113
113
|
return { ageMs, stale, label: `measured ${fmtAge(ageMs)} ago${stale ? ` — STALE (older than ${COVERAGE_STALE_MINUTES}m; re-arm or run stall_check for a current reading)` : ""}` };
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
/*
|
|
116
|
+
* ⛔⛆ ⟨q-b0d4e45a⟩ THE DENOMINATOR IS THE IN-FLIGHT POPULATION, NOT THE SCORED SUBSET. This armed a
|
|
117
|
+
* fleet on `checked: 0, measurable: 0, blind: []` while its own population said `rows: 4, inFlight: 2`:
|
|
118
|
+
* both in-flight lanes carried a PROSE cell, so each one hit `deliberate` and `continue`d out of the
|
|
119
|
+
* scoring loop before `scored.push`, and `checked` counted only what survived that filter. `0 >= 0` armed.
|
|
120
|
+
*
|
|
121
|
+
* ⛔ SO THE GUARD INVERTED AT ITS OWN LIMIT: `2 of 5` REFUSED while `0 of 2` ARMED SILENTLY — the
|
|
122
|
+
* partially blind fleet was stopped and the totally blind one waved through. An EMPTY blind-list and a
|
|
123
|
+
* blind-list THAT WAS NEVER POPULATED are indistinguishable, and this read the second as the first.
|
|
124
|
+
*
|
|
125
|
+
* ⭐ `deliberate` STAYS a category — a deliberately unwatched lane is real. The bug was that it was
|
|
126
|
+
* subtracted from the POPULATION rather than reported within it: a classification is a REASON a lane is
|
|
127
|
+
* unscored, never an exemption from being counted.
|
|
128
|
+
*
|
|
129
|
+
* ⚠ `inFlight === 0` is the ONE case that still arms, and it is a different sentence: nothing is in
|
|
130
|
+
* flight, so there is nothing to see — idle, not blind. That distinction is why the old `checked > 0`
|
|
131
|
+
* reasoning existed; it was right about idle and wrong about what `checked` measured.
|
|
132
|
+
*/
|
|
133
|
+
export const meetsCoverageThreshold = (c: { measurable: number; inFlight: number }): boolean =>
|
|
134
|
+
c.inFlight === 0 ? true : c.measurable >= c.inFlight;
|
|
117
135
|
|
|
118
136
|
/**
|
|
119
137
|
* ⟨q-1c95f7d4⟩ Task 5.4 — WHAT THE EXTERNAL TICK CONTRIBUTES TO THE AWAY PRECONDITION.
|
|
@@ -139,8 +157,19 @@ export type AwayCoverage = {
|
|
|
139
157
|
tick?: AwayTick;
|
|
140
158
|
/** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
|
|
141
159
|
roles?: number;
|
|
142
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* ⛔ ⟨q-b0d4e45a⟩ THE IN-FLIGHT POPULATION — the denominator. `checked` is the SCORED SUBSET and is
|
|
162
|
+
* reported beside it, never as the divisor: a lane filtered out before scoring still has to be counted,
|
|
163
|
+
* or the reading congratulates itself for the rows it dropped.
|
|
164
|
+
*/
|
|
165
|
+
inFlight: number;
|
|
166
|
+
/**
|
|
167
|
+
* Lanes in flight that declare no per-agent branch. COUNTED IN `inFlight`, unscored, and named with the
|
|
168
|
+
* offending cell — a reason, not an exemption ⟨q-b0d4e45a⟩.
|
|
169
|
+
*/
|
|
143
170
|
deliberate?: number;
|
|
171
|
+
/** The `deliberate` lanes themselves, so a refusal can name the ROW and the CELL rather than a count. */
|
|
172
|
+
deliberateRows?: { agentId: string; stream: string; cell: string }[];
|
|
144
173
|
/** ⟨q-3d82f1a9⟩ — the board could not be read: rows present vs parsed, as stall_check stated it. */
|
|
145
174
|
unreadable?: { rowsPresent: number; rowsParsed: number; why: string };
|
|
146
175
|
at: string;
|
|
@@ -245,11 +274,20 @@ export const coordAwaySchema = {
|
|
|
245
274
|
agentId: z.string().optional(),
|
|
246
275
|
};
|
|
247
276
|
|
|
248
|
-
|
|
277
|
+
/**
|
|
278
|
+
* ⛔ THE SEAM EXISTS TO FEED THE GUARD BELOW, NOT TO BYPASS IT ⟨q-b0d4e45a⟩. `checkStall` replaces only
|
|
279
|
+
* the CLOCK'S REPLY; every line of the arithmetic and every refusal still runs on it. A seam that stood
|
|
280
|
+
* in for `measureCoverage` itself could not test the missing-population guard, because the guard is the
|
|
281
|
+
* thing under test — so the injection point is deliberately UPSTREAM of it.
|
|
282
|
+
*/
|
|
283
|
+
export type StallReplyForCoverage = { ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[]; population?: { rows?: number; inFlight?: number }; tick?: unknown; boardParse?: unknown };
|
|
284
|
+
async function measureCoverage(repo?: string, checkStall: (a: { repo: string }) => Promise<unknown> = stallCheckTool): Promise<AwayCoverage | null> {
|
|
249
285
|
if (!repo) return null;
|
|
250
286
|
try {
|
|
251
|
-
const r = (await
|
|
252
|
-
ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[];
|
|
287
|
+
const r = (await checkStall({ repo })) as unknown as {
|
|
288
|
+
ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[];
|
|
289
|
+
deliberate?: { agentId?: string; stream?: string; cell?: string }[];
|
|
290
|
+
population?: { rows?: number; inFlight?: number };
|
|
253
291
|
tick?: { seats?: unknown[]; readable?: number; states?: Record<string, number>; note?: string };
|
|
254
292
|
boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
|
|
255
293
|
};
|
|
@@ -258,21 +296,42 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
258
296
|
// promise a watched fleet over a table nobody can read.
|
|
259
297
|
if (r?.boardParse && !r.boardParse.readable) {
|
|
260
298
|
return {
|
|
261
|
-
checked: 0, measurable: 0, blind: [], roles: 0, deliberate: 0,
|
|
299
|
+
checked: 0, measurable: 0, inFlight: 0, blind: [], roles: 0, deliberate: 0,
|
|
262
300
|
unreadable: { rowsPresent: r.boardParse.rowsPresent, rowsParsed: r.boardParse.rowsParsed, why: r.boardParse.why },
|
|
263
301
|
at: new Date().toISOString(),
|
|
264
302
|
};
|
|
265
303
|
}
|
|
266
304
|
if (!r?.ok) return null;
|
|
305
|
+
// ⛔⛆ ⟨q-b0d4e45a⟩ A MISSING POPULATION IS NOT A POPULATION OF ZERO, and `?? 0` here would rebuild the
|
|
306
|
+
// defect one layer up: `inFlight: 0` is the one value that passes the threshold unconditionally. If the
|
|
307
|
+
// clock cannot state its own denominator the fleet is UNMEASURED, and `null` sends it to the
|
|
308
|
+
// "cannot measure" refusal.
|
|
309
|
+
// ⭐ MEASURED, by removing this line in the COMPILED dist (tsc refuses the mutation, so the matrix runs
|
|
310
|
+
// against dist — qa's technique) — and it is worth stating exactly, because the danger is NOT uniform:
|
|
311
|
+
// • plain call → refuses, but INCOHERENTLY: "covers 0 of undefined … (NaN short)"
|
|
312
|
+
// • acknowledgeBlindFleet → ⛔ ARMS, recording `coverage.inFlight: undefined` and announcing
|
|
313
|
+
// "Stall coverage 0/undefined — ACKNOWLEDGED BLIND"
|
|
314
|
+
// ⚠ So the acknowledged path is the one that actually goes away on an unmeasured fleet, and an earlier
|
|
315
|
+
// draft of this comment said "would ARM" without that split. Both outcomes are wrong; only one ships a
|
|
316
|
+
// fleet unwatched. The test covers BOTH, including that the flag cannot buy past it.
|
|
317
|
+
const inFlight = r.population?.inFlight;
|
|
318
|
+
if (typeof inFlight !== "number") return null;
|
|
319
|
+
const deliberateRows = (r.deliberate ?? []).map((d) => ({
|
|
320
|
+
agentId: String(d?.agentId ?? "-"),
|
|
321
|
+
stream: String(d?.stream ?? "-"),
|
|
322
|
+
cell: String(d?.cell ?? "").trim(),
|
|
323
|
+
}));
|
|
267
324
|
return {
|
|
268
325
|
checked: r.checked ?? 0,
|
|
269
326
|
measurable: r.measurable ?? 0,
|
|
327
|
+
inFlight,
|
|
270
328
|
blind: r.blind ?? [],
|
|
271
329
|
tick: r.tick
|
|
272
330
|
? { seats: r.tick.seats?.length ?? 0, readable: r.tick.readable ?? 0, states: (r.tick.states ?? {}) as Record<string, number>, note: r.tick.note ?? "" }
|
|
273
331
|
: undefined,
|
|
274
332
|
roles: r.roles?.length ?? 0,
|
|
275
|
-
deliberate:
|
|
333
|
+
deliberate: deliberateRows.length,
|
|
334
|
+
deliberateRows,
|
|
276
335
|
at: new Date().toISOString(),
|
|
277
336
|
};
|
|
278
337
|
} catch {
|
|
@@ -280,16 +339,23 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
280
339
|
}
|
|
281
340
|
}
|
|
282
341
|
|
|
283
|
-
export async function coordAwayTool(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
342
|
+
export async function coordAwayTool(
|
|
343
|
+
args: {
|
|
344
|
+
project: string;
|
|
345
|
+
on?: boolean;
|
|
346
|
+
leadId?: string;
|
|
347
|
+
decisionLog?: string;
|
|
348
|
+
until?: string;
|
|
349
|
+
repo?: string;
|
|
350
|
+
acknowledgeBlindFleet?: boolean;
|
|
351
|
+
agentId?: string;
|
|
352
|
+
},
|
|
353
|
+
/**
|
|
354
|
+
* ⚠ A SECOND PARAMETER RATHER THAN A FIELD ON `args`, so nothing here can reach the MCP tool schema:
|
|
355
|
+
* a caller on the bus cannot supply its own clock. Tests only.
|
|
356
|
+
*/
|
|
357
|
+
deps: { checkStall?: (a: { repo: string }) => Promise<unknown> } = {},
|
|
358
|
+
) {
|
|
293
359
|
const state = readAway();
|
|
294
360
|
const prior = state[args.project];
|
|
295
361
|
|
|
@@ -320,7 +386,10 @@ export async function coordAwayTool(args: {
|
|
|
320
386
|
summary:
|
|
321
387
|
`'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
|
|
322
388
|
`${prior.until ? ` until ${prior.until}` : ""}. ` +
|
|
323
|
-
|
|
389
|
+
// ⚠ A RECORD WRITTEN BEFORE ⟨q-b0d4e45a⟩ HAS NO POPULATION, and a query must not invent one.
|
|
390
|
+
// Printing `/${checked}` for it would quote the scored subset as the denominator — the very
|
|
391
|
+
// reading this row fixed — so an unrecorded population says so in words.
|
|
392
|
+
`Coverage ${prior.coverage.measurable}/${typeof prior.coverage.inFlight === "number" ? prior.coverage.inFlight : `?? (population not recorded at arm time; ${prior.coverage.checked} scored)`} lane(s) ${coverageAgeOf(prior.coverage).label}` +
|
|
324
393
|
`${prior.coverage.roles ? ` · ${prior.coverage.roles} role(s) present, not scored` : ""}` +
|
|
325
394
|
`${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
|
|
326
395
|
`Decisions logged to ${prior.decisionLog}.`,
|
|
@@ -347,7 +416,7 @@ export async function coordAwayTool(args: {
|
|
|
347
416
|
}
|
|
348
417
|
|
|
349
418
|
// THE PRECONDITION: what could the clock actually SEE?
|
|
350
|
-
const coverage = await measureCoverage(args.repo);
|
|
419
|
+
const coverage = await measureCoverage(args.repo, deps.checkStall);
|
|
351
420
|
if (!coverage) {
|
|
352
421
|
return {
|
|
353
422
|
ok: false as const,
|
|
@@ -370,18 +439,29 @@ export async function coordAwayTool(args: {
|
|
|
370
439
|
`Away mode promises a watched fleet; a board the clock cannot read is not a quiet fleet, it is an unwatched one. Fix the row and re-arm.`,
|
|
371
440
|
};
|
|
372
441
|
}
|
|
373
|
-
// `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
|
|
374
|
-
// Without it, arming while nothing is in flight refuses with "the clock
|
|
375
|
-
// covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
|
|
376
|
-
// fact there is nothing to see, and the only way past it would be to
|
|
377
|
-
// acknowledge a blindness that does not exist.
|
|
378
442
|
// ⟨q-5a71fc3b⟩ — below the threshold, not only at zero: "2 of 5" refuses.
|
|
443
|
+
// ⛔⛆ ⟨q-b0d4e45a⟩ — AND "0 of 2" REFUSES THE SAME WAY. The denominator is the in-flight
|
|
444
|
+
// population, so a lane filtered out of `checked` before scoring cannot buy silence. An empty
|
|
445
|
+
// board still arms, because `inFlight === 0` is idle rather than blind — the one thing the old
|
|
446
|
+
// `checked > 0` reasoning got right, now expressed against the population instead of against
|
|
447
|
+
// the scored subset it was mistaken for.
|
|
379
448
|
if (!meetsCoverageThreshold(coverage) && !args.acknowledgeBlindFleet) {
|
|
449
|
+
// ⭐ NAME THE ROW AND THE CELL, not a count. "1 deliberate" tells a reader nothing they can act
|
|
450
|
+
// on; the stream and the offending cell text tell them which line to fix — or that the lane is
|
|
451
|
+
// legitimately unscoreable because nothing has been pushed yet.
|
|
452
|
+
const unscored = coverage.deliberateRows ?? [];
|
|
453
|
+
const unscoredSentence = unscored.length
|
|
454
|
+
? ` UNSCORED BUT COUNTED — ${unscored.length} in-flight lane(s) declare no branch ref, and each is counted in the ${coverage.inFlight}: ` +
|
|
455
|
+
unscored.map((d) => `${d.agentId} (${d.stream}) cell reads ${JSON.stringify(d.cell)}`).join(" · ") +
|
|
456
|
+
`. A cell like this is a REASON a lane is unscored, never an exemption from being counted: if the lane has genuinely pushed nothing there is no ref to write, and that is UNSCOREABLE-AND-COUNTED, not covered.`
|
|
457
|
+
: "";
|
|
380
458
|
return {
|
|
381
459
|
ok: false as const,
|
|
382
460
|
error:
|
|
383
|
-
`REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.
|
|
384
|
-
`(${coverage.checked
|
|
461
|
+
`REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.inFlight} in-flight row(s) ` +
|
|
462
|
+
`(${coverage.checked} scored); the threshold is that ${COVERAGE_THRESHOLD} ` +
|
|
463
|
+
`(${coverage.inFlight - coverage.measurable} short)${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}.` +
|
|
464
|
+
unscoredSentence + " " +
|
|
385
465
|
`${coverage.measurable === 0 ? "It would RUN and see nothing. " : "The seats it cannot see are exactly the ones an absence leaves unattended. "}` +
|
|
386
466
|
`"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
|
|
387
467
|
`The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
|
|
@@ -417,7 +497,12 @@ export async function coordAwayTool(args: {
|
|
|
417
497
|
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
418
498
|
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
419
499
|
`Decisions logged to ${args.decisionLog}. ` +
|
|
420
|
-
|
|
500
|
+
// ⭐ ⟨q-b0d4e45a⟩ TWO DIFFERENT SENTENCES, AND ONLY ONE OF THEM IS REASSURING. An idle fleet says
|
|
501
|
+
// "0 in flight"; it must never render as "0/0 covered", which reads as a measured fleet and is
|
|
502
|
+
// exactly how a never-populated reading passed for a clean one.
|
|
503
|
+
(coverage.inFlight === 0
|
|
504
|
+
? `Stall coverage: 0 in flight — nothing to watch, so nothing was measured (idle, not blind; ${coverage.checked} scored).`
|
|
505
|
+
: `Stall coverage ${coverage.measurable}/${coverage.inFlight}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.`) +
|
|
421
506
|
tickSentence(coverage.tick),
|
|
422
507
|
};
|
|
423
508
|
}
|
package/src/tools/events.ts
CHANGED
|
@@ -23,11 +23,31 @@ const subsFile = () => path.join(ROOT, "subscriptions.json");
|
|
|
23
23
|
// only if the enum cannot be widened without an emitter.
|
|
24
24
|
export { EVENT_KINDS, EVENT_KIND_IDS };
|
|
25
25
|
export type { RecordEvent, SubKind };
|
|
26
|
+
/**
|
|
27
|
+
* ⟨q-8e00b367⟩(a) — HOW WIDELY A SUBSCRIPTION IS ASKING, as an EXPLICIT FIELD.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ THE ALTERNATIVE WAS AN IN-BAND SENTINEL AND IT IS FORBIDDEN BY DESIGN. `target: "*"` would make
|
|
30
|
+
* one string mean two things — a literal target that happens to be an asterisk, and "everything" —
|
|
31
|
+
* and nothing in the wire shape would say which was meant. A separate field cannot be ambiguous.
|
|
32
|
+
*
|
|
33
|
+
* ⚠ `prefix` IS A PREFIX OF WHATEVER `target` HOLDS FOR THAT KIND, which differs per kind and is not
|
|
34
|
+
* guessable from the name: `pr` → the full ref (`owner/repo#402`), `item` → the queue id, `task` →
|
|
35
|
+
* the KEY (its `ref` is the ticked LINE, so a prefix here is NOT a prefix of the line), `phase` →
|
|
36
|
+
* the phase. Measured from the emitters in record-events.ts, not from documentation.
|
|
37
|
+
*/
|
|
38
|
+
export const SUB_SCOPES = ["exact", "kind", "prefix"] as const;
|
|
39
|
+
export type SubScope = (typeof SUB_SCOPES)[number];
|
|
40
|
+
|
|
26
41
|
export type Subscription = {
|
|
27
42
|
id: string;
|
|
28
43
|
agentId: string;
|
|
29
44
|
kind: SubKind;
|
|
30
45
|
target: string;
|
|
46
|
+
/**
|
|
47
|
+
* Absent ⇒ `"exact"`, which is what every subscription stored before this field meant. The
|
|
48
|
+
* widening is ADDITIVE: no migration, and no stored registration changes meaning.
|
|
49
|
+
*/
|
|
50
|
+
scope?: SubScope;
|
|
31
51
|
createdAt: number;
|
|
32
52
|
/**
|
|
33
53
|
* null until an event OF THIS KIND was evaluated against this subscription.
|
|
@@ -195,9 +215,33 @@ export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true
|
|
|
195
215
|
return { ok: true };
|
|
196
216
|
}
|
|
197
217
|
|
|
198
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Subscriptions matching an event, by the scope each one asked for.
|
|
220
|
+
*
|
|
221
|
+
* ⛔ THE GAP THIS CLOSES IS A DELIVERY OF NOTHING REPORTED AS A DELIVERY. With exact-only matching,
|
|
222
|
+
* "tell me when a new PR arrives" is inexpressible — a `pr` event's `target` IS its ref, so you would
|
|
223
|
+
* have to name `owner/repo#402` at subscribe time, and the entire point is that you do not know it
|
|
224
|
+
* yet. A poller could then scan correctly, emit a well-formed event, and match ZERO subscriptions
|
|
225
|
+
* while every instrument reported success: the tick exits 0, the scan finds a change, the emit
|
|
226
|
+
* succeeds, `list_subscriptions` shows healthy watches. Nothing anywhere reads as wrong.
|
|
227
|
+
*
|
|
228
|
+
* ⚠ `exact` REMAINS THE DEFAULT, so a widened match is always something a subscriber opted into.
|
|
229
|
+
*/
|
|
199
230
|
export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
|
|
200
|
-
subs.filter((s) =>
|
|
231
|
+
subs.filter((s) => {
|
|
232
|
+
if (s.kind !== ev.kind) return false;
|
|
233
|
+
switch (s.scope ?? "exact") {
|
|
234
|
+
case "kind":
|
|
235
|
+
return true;
|
|
236
|
+
// `startsWith` on a non-empty prefix only: an EMPTY prefix matches every target, which is the
|
|
237
|
+
// `"*"` sentinel this design rejected, arriving through the field that replaced it. `subscribe`
|
|
238
|
+
// refuses to create one; this guard means a hand-edited store cannot smuggle one in either.
|
|
239
|
+
case "prefix":
|
|
240
|
+
return s.target !== "" && ev.target.startsWith(s.target);
|
|
241
|
+
default:
|
|
242
|
+
return s.target === ev.target;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
201
245
|
|
|
202
246
|
export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
|
|
203
247
|
|
|
@@ -244,18 +288,59 @@ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): {
|
|
|
244
288
|
export const subscribeSchema = {
|
|
245
289
|
agentId: z.string().min(1),
|
|
246
290
|
kind: z.enum(EVENT_KIND_IDS),
|
|
291
|
+
// ⚠ STILL REQUIRED AND STILL `min(1)`. `scope:"kind"` does not relax it — it REFUSES it (below), so
|
|
292
|
+
// there is no shape where an empty or absent target is accepted. Relaxing the schema instead would
|
|
293
|
+
// have made `target:""` representable, which is the wildcard this design exists to keep out.
|
|
247
294
|
target: z.string().min(1),
|
|
295
|
+
// A CLOSED SET, never a free string. The registry generates the kind enum so that a subscription
|
|
296
|
+
// which no emitter can satisfy cannot be registered; a free-form scope would reopen exactly that
|
|
297
|
+
// hole one field over — an unsatisfiable scope is an unsatisfiable subscription.
|
|
298
|
+
scope: z.enum(SUB_SCOPES).optional(),
|
|
248
299
|
};
|
|
249
300
|
|
|
250
|
-
export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
|
|
301
|
+
export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string; scope?: SubScope }) {
|
|
302
|
+
const scope: SubScope = args.scope ?? "exact";
|
|
303
|
+
|
|
304
|
+
// ⛔ `kind` + A TARGET IS REFUSED, NOT IGNORED. An accepted-and-ignored target leaves a
|
|
305
|
+
// subscription that READS NARROWER THAN IT BEHAVES: an inspector sees one specific target and
|
|
306
|
+
// concludes the watch is narrow, while it fires on everything of that kind.
|
|
307
|
+
//
|
|
308
|
+
// ⭐ AND THE ASYMMETRY IS WHAT DECIDES IT RATHER THAN TASTE: refusing is relaxable later, whereas
|
|
309
|
+
// silently-ignoring is NOT tightenable later — the moment such subscriptions exist on disk,
|
|
310
|
+
// tightening changes what an already-stored registration means.
|
|
311
|
+
if (args.scope === "kind" && args.target)
|
|
312
|
+
return {
|
|
313
|
+
ok: false as const,
|
|
314
|
+
error:
|
|
315
|
+
`scope "kind" watches EVERY ${args.kind} event, so the target '${args.target}' would be ignored — refusing rather than ` +
|
|
316
|
+
`accepting a subscription that reads narrower than it behaves. Omit the target for kind-wide, ` +
|
|
317
|
+
`or use scope "prefix" with '${args.target}' to watch the ${args.kind} events whose target starts with it.`,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// ⛔ AN EMPTY PREFIX IS THE `"*"` WILDCARD WEARING THE FIELD THAT REPLACED IT. `"".startsWith()` is
|
|
321
|
+
// true of every string, so `scope:"prefix"` with an empty target subscribes to everything of the
|
|
322
|
+
// kind while LOOKING like a narrow, targeted watch — the same back door as `target:"*"`, one layer
|
|
323
|
+
// down and through an approved field. `kind` is how you ask for everything, and it says so.
|
|
324
|
+
if (scope === "prefix" && args.target.trim() === "")
|
|
325
|
+
return {
|
|
326
|
+
ok: false as const,
|
|
327
|
+
error:
|
|
328
|
+
`scope "prefix" with an empty target matches EVERY ${args.kind} event — that is the wildcard this ` +
|
|
329
|
+
`field exists to avoid spelling in-band. Use scope "kind" to watch all of them explicitly, or ` +
|
|
330
|
+
`give a non-empty prefix (e.g. 'davidbalzan/groundwork-kit#' for one repo's PRs).`,
|
|
331
|
+
};
|
|
332
|
+
|
|
251
333
|
const subs = readSubs();
|
|
252
|
-
|
|
334
|
+
// Identity includes the SCOPE: `exact` on 'q-1' and `prefix` on 'q-1' are different watches, and
|
|
335
|
+
// collapsing them would silently hand back the narrower one.
|
|
336
|
+
const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target && (s.scope ?? "exact") === scope);
|
|
253
337
|
if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
|
|
254
338
|
const sub: Subscription = {
|
|
255
339
|
id: randomUUID(),
|
|
256
340
|
agentId: args.agentId,
|
|
257
341
|
kind: args.kind,
|
|
258
342
|
target: args.target,
|
|
343
|
+
scope,
|
|
259
344
|
createdAt: Date.now(),
|
|
260
345
|
// EXPLICIT null, never left absent. `null` means "we know no scan has run
|
|
261
346
|
// for this"; `undefined` means "this record predates the field and we
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
import { mkdirSync, statSync } from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { pathToFileURL } from "node:url";
|
|
26
|
-
import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
|
|
27
|
-
import { ROOT, inboxFile, roomFile } from "../store.js";
|
|
26
|
+
import { activeTransport, HERDR, targetOf, type TransportMarker } from "../transports/index.js";
|
|
27
|
+
import { ROOT, inboxFile, roomFile, withDeliveryLock } from "../store.js";
|
|
28
28
|
import { loadLiveTransports } from "./registry.js";
|
|
29
29
|
import { newMessagesIn } from "./jsonl-offsets.js";
|
|
30
30
|
import type { Message } from "./shared.js";
|
|
@@ -46,6 +46,8 @@ const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
export type Where = { kind: "dm" } | { kind: "room"; chan: string };
|
|
49
|
+
/** The (seat, source) key the delivery lock is taken on — ONE spelling, shared by the send-time path and the tail. */
|
|
50
|
+
export const sourceKey = (where: Where) => (where.kind === "dm" ? "dm" : `room:${where.chan}`);
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
|
|
@@ -102,9 +104,15 @@ export async function readPushCursorFor(agentId: string): Promise<Record<string,
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
|
|
107
|
+
/** Every push, whichever path made it, is logged NAMING THE PATH — so a duplicate is separable from a retried write without transcript archaeology (⟨q-524305dd⟩). */
|
|
108
|
+
export const pushLogLine = (path: "send-time" | "tail", id: unknown, target: string | undefined, outcome: string, error?: string) =>
|
|
109
|
+
`[herdr-${path === "tail" ? "tail" : "delivery"}] push id=${String(id ?? "?")} target=${target ?? "?"} outcome=${outcome}` +
|
|
110
|
+
(error ? ` reason=${JSON.stringify(error)}` : "") +
|
|
111
|
+
` path=${path}`;
|
|
105
112
|
|
|
106
113
|
/** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
|
|
107
|
-
export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
|
|
114
|
+
export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }, opts: { log?: (line: string) => void } = {}): Promise<HerdrDeliveryOutcome[]> {
|
|
115
|
+
const log = opts.log ?? ((l: string) => console.error(l));
|
|
108
116
|
const t = activeTransport();
|
|
109
117
|
if (!t || t.kind !== HERDR) return [];
|
|
110
118
|
let markers: Map<string, TransportMarker>;
|
|
@@ -122,34 +130,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
|
|
|
122
130
|
// The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
|
|
123
131
|
// it did exactly that. So: find THIS message after the persisted cursor; if any earlier
|
|
124
132
|
// message to someone else precedes it, defer to the recipient's tail, which delivers in order.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
133
|
+
// ⛔ ⟨q-524305dd⟩ read → type → verify → advance as the ONLY deliverer to this seat's source, across
|
|
134
|
+
// processes: the seat's own tail takes the same lock, so it cannot read this cursor mid-verify and
|
|
135
|
+
// type the message a second time. WAIT, never skip: the seat may have no tail at all (⟨q-cdb5b007⟩).
|
|
136
|
+
const locked = await withDeliveryLock(agentId, sourceKey(where), async () => {
|
|
137
|
+
const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
|
|
138
|
+
const c = await readPushCursorFor(agentId);
|
|
139
|
+
const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
|
|
140
|
+
const after = newMessagesIn(file, from);
|
|
141
|
+
const at = after.findIndex((m) => m.msg.id === msg.id);
|
|
142
|
+
const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
|
|
143
|
+
if (at === -1 || owed.length > 0) {
|
|
144
|
+
out.push({
|
|
145
|
+
agentId,
|
|
146
|
+
delivered: false,
|
|
147
|
+
cursorAdvanced: false,
|
|
148
|
+
deferred: at === -1
|
|
149
|
+
? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
|
|
150
|
+
: `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const rendered = await renderForPane(msg, agentId, where);
|
|
155
|
+
// A control never comes through here: send_command hands a herdr seat's control to the
|
|
156
|
+
// transport's sendControl directly (a raw-vs-rendered branch here was dead and its
|
|
157
|
+
// mutation survived, so it is gone). Everything delivered here is a rendered message.
|
|
158
|
+
let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
|
|
159
|
+
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
|
|
160
|
+
// ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
|
|
161
|
+
const verified = r.delivered && r.verified === true;
|
|
162
|
+
log(pushLogLine("send-time", msg.id, targetOf(marker), verified ? "delivered" : r.delivered ? "typed-unconfirmed" : "not-delivered", r.error));
|
|
163
|
+
// Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
|
|
164
|
+
const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
|
|
165
|
+
out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
|
|
166
|
+
}, { wait: true, log });
|
|
167
|
+
if (!locked.ran) out.push({ agentId, delivered: false, cursorAdvanced: false, deferred: `${locked.why} — left to that deliverer, which types in order` });
|
|
153
168
|
}
|
|
154
169
|
return out;
|
|
155
170
|
}
|