agent-coord-mcp 0.26.28 → 0.26.30
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 +78 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/tools/herdr-tail.js +21 -1
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/stall.js +42 -0
- package/dist/tools/stall.js.map +1 -1
- package/dist/transports/herdr.js +77 -0
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +1 -1
- package/dist/transports/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +33 -7
- package/src/capabilities.ts +87 -2
- package/src/tools/herdr-tail.ts +21 -1
- package/src/tools/stall.ts +51 -0
- package/src/transports/herdr.ts +92 -0
- package/src/transports/index.ts +1 -1
|
@@ -93,18 +93,25 @@ function rel(file) {
|
|
|
93
93
|
return file ? `test/${path.basename(file)}` : "(unattributed)";
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
// ⟨q-4b2362a8⟩ — A FAILING TEST MUST NOT MASK STRUCTURAL DRIFT. This used to report the failures and
|
|
97
|
+
// exit, so an unrecorded test file stayed invisible behind any red: #393's `bind-list.test.mjs` was
|
|
98
|
+
// missing from the record for days, hidden behind the quiet-fleet failure of merge-window. The
|
|
99
|
+
// failures are still the headline — a count over a failing suite means little — but the drift that
|
|
100
|
+
// does NOT depend on the counts (a file nobody recorded, a record entry with no file) is reported in
|
|
101
|
+
// the same run, because one red hiding another is how both survive.
|
|
102
|
+
const failed = failures.length > 0;
|
|
103
|
+
if (failed) {
|
|
97
104
|
console.error(`[check-test-count] ${failures.length} test(s) FAILED — the count is not the story here:`);
|
|
98
105
|
for (const f of failures) console.error(` ${f}`);
|
|
99
|
-
process.exit(1);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
// `=0` disables; any other override asserts the old single global total.
|
|
103
|
-
|
|
109
|
+
// Both early exits are skipped on a failing suite: a green there would report a pass over a red run.
|
|
110
|
+
if (globalOverride === 0 && !failed) {
|
|
104
111
|
console.log(`[check-test-count] disabled by AGENT_COORD_EXPECTED_TESTS=0 — ${total} test(s) passed, unchecked`);
|
|
105
112
|
process.exit(0);
|
|
106
113
|
}
|
|
107
|
-
if (globalOverride !== null && Number.isFinite(globalOverride)) {
|
|
114
|
+
if (globalOverride !== null && Number.isFinite(globalOverride) && !failed) {
|
|
108
115
|
if (total !== globalOverride) {
|
|
109
116
|
console.error(`[check-test-count] FAIL — ${total} tests passed, override expected ${globalOverride}.`);
|
|
110
117
|
process.exit(1);
|
|
@@ -115,11 +122,15 @@ if (globalOverride !== null && Number.isFinite(globalOverride)) {
|
|
|
115
122
|
|
|
116
123
|
// ── the real comparison, per file ──────────────────────────────────────────
|
|
117
124
|
const drifted = [];
|
|
125
|
+
// STRUCTURAL drift does not depend on the counts — which file is recorded at all, which record entry
|
|
126
|
+
// has no file. It is the half that still means something when the suite is red (⟨q-4b2362a8⟩).
|
|
127
|
+
const structural = [];
|
|
118
128
|
for (const f of files) {
|
|
119
129
|
const want = expected[f];
|
|
120
130
|
const got = actual.get(f) ?? 0;
|
|
121
131
|
if (want === undefined) {
|
|
122
|
-
|
|
132
|
+
const line = `${f}: ran ${got} test(s) and is NOT in the record — a new test file must be recorded in the same commit`;
|
|
133
|
+
drifted.push(line); structural.push(line);
|
|
123
134
|
} else if (got !== want) {
|
|
124
135
|
drifted.push(`${f}: ran ${got}, record says ${want}`);
|
|
125
136
|
}
|
|
@@ -134,15 +145,30 @@ for (const f of files) {
|
|
|
134
145
|
// exists to exercise — the guard's own test caught it.
|
|
135
146
|
for (const f of Object.keys(expected)) {
|
|
136
147
|
if (!existsSync(path.join(pkgRoot, f))) {
|
|
137
|
-
|
|
148
|
+
const line = `${f}: in the record but no such file exists — deleted, or renamed and its coverage lost`;
|
|
149
|
+
drifted.push(line); structural.push(line);
|
|
138
150
|
} else if (files.includes(f) === false && GLOB === "test/*.test.mjs") {
|
|
139
|
-
|
|
151
|
+
const line = `${f}: exists but did not run — the glob no longer selects it`;
|
|
152
|
+
drifted.push(line); structural.push(line);
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
155
|
// An unattributed pass would make every per-file number meaningless.
|
|
143
156
|
const unattributed = actual.get("(unattributed)") ?? 0;
|
|
144
157
|
if (unattributed) drifted.push(`${unattributed} test(s) passed with no file attribution — the per-file record cannot be verified against this run`);
|
|
145
158
|
|
|
159
|
+
// On a red suite: report the structural half (never the counts, which a failing test legitimately
|
|
160
|
+
// changes) and exit on the failures.
|
|
161
|
+
if (failed) {
|
|
162
|
+
if (structural.length) {
|
|
163
|
+
console.error(`[check-test-count] AND, not masked by those failures — ${structural.length} structural drift(s) in the record:`);
|
|
164
|
+
for (const d of structural) console.error(` ${d}`);
|
|
165
|
+
console.error(` Record them with \`node scripts/check-test-count.mjs --write\` once the suite is green.`);
|
|
166
|
+
} else {
|
|
167
|
+
console.error(`[check-test-count] no structural record drift besides the failures above (per-file counts are not judged over a red suite)`);
|
|
168
|
+
}
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
if (!drifted.length) {
|
|
147
173
|
console.log(
|
|
148
174
|
`[check-test-count] ${total} test(s) across ${files.length} file(s), each matching its recorded count — ` +
|
package/src/capabilities.ts
CHANGED
|
@@ -64,7 +64,7 @@ import { queueWriteSchema } from "./tools/queue-write.js";
|
|
|
64
64
|
import { treeProvenance } from "./tools/tree-provenance.js";
|
|
65
65
|
import { detectSpread } from "./server-spread.js";
|
|
66
66
|
import { answeringServerIdentity } from "./tools/registry.js";
|
|
67
|
-
import { HerdrTransport, herdrKeyName } from "./transports/herdr.js";
|
|
67
|
+
import { HerdrTransport, herdrKeyName, interpretHerdrReply, submitModeOf, SUBMIT_MODE_ENV_VAR } from "./transports/herdr.js";
|
|
68
68
|
import { tickVerdict } from "./tools/tick.js";
|
|
69
69
|
import { ensureHerdrTail, herdrTailState, newContext, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
70
70
|
import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
|
|
@@ -461,6 +461,62 @@ const PROBES: Probe[] = [
|
|
|
461
461
|
};
|
|
462
462
|
},
|
|
463
463
|
},
|
|
464
|
+
{
|
|
465
|
+
// ⟨q-f7e3c701⟩ 0.26.29 — ATOMIC SUBMIT. Two properties, and BOTH are here because each one
|
|
466
|
+
// alone would have shipped a defect that was actually shipped:
|
|
467
|
+
//
|
|
468
|
+
// 1. THE PARSE. #397 first read `json?.status ?? json?.result?.status`. The real herdr 0.9.1
|
|
469
|
+
// reply carries `result.type`, and there is no `status` anywhere — so on real hardware the
|
|
470
|
+
// success check NEVER fired and every genuine delivery fell through to unverified /
|
|
471
|
+
// safeToRetry:false, which is the DROP path. It would have reported 100% failure while
|
|
472
|
+
// succeeding, and the canary would have binned a working fix on false evidence.
|
|
473
|
+
// ⛔ THE UNIT TESTS COULD NOT CATCH IT: they mocked `{status:"agent_prompted"}` — the code's
|
|
474
|
+
// own assumption — so they asserted the belief rather than the world. The payloads below are
|
|
475
|
+
// VERBATIM CAPTURES from a real binary, which is the only fixture shape that can fail.
|
|
476
|
+
//
|
|
477
|
+
// 2. THE SAFE DEFAULT. Blast radius is both fleets and the failure mode is silence, so the
|
|
478
|
+
// canary must be opt-in by EXACT literal. A near-miss must NOT opt in.
|
|
479
|
+
//
|
|
480
|
+
// Both are pure predicates handed the REJECTED input, per this file's mutation-matrix rule:
|
|
481
|
+
// a probe that cannot be given the case it must refuse is decorative.
|
|
482
|
+
id: "atomic-submit-parses-real-herdr-replies",
|
|
483
|
+
since: "0.26.29",
|
|
484
|
+
run: () => {
|
|
485
|
+
// VERBATIM from `herdr agent prompt` on herdr 0.9.1 — success carries result.type.
|
|
486
|
+
const REAL_PROMPTED = '{"id":"cli:agent:prompt","result":{"agent":{"agent":"claude","agent_status":"idle","pane_id":"w16:pD"},"type":"agent_prompted"}}';
|
|
487
|
+
// VERBATIM from the same binary against a blocked agent — a TOP-LEVEL error.code, no result.
|
|
488
|
+
const REAL_BLOCKED = '{"error":{"code":"agent_blocked","message":"agent w16:pD is blocked and requires interactive input"},"id":"cli:agent:prompt"}';
|
|
489
|
+
|
|
490
|
+
const okReply = interpretHerdrReply({ status: 0, stdout: REAL_PROMPTED, stderr: "" });
|
|
491
|
+
const blockedReply = interpretHerdrReply({ status: 1, stdout: REAL_BLOCKED, stderr: "" });
|
|
492
|
+
|
|
493
|
+
const prompted = (okReply.json as { result?: { type?: string } } | undefined)?.result?.type === "agent_prompted";
|
|
494
|
+
// The two live in DIFFERENT PLACES — one field cannot serve both, which was the bug.
|
|
495
|
+
const blockedCode = blockedReply.ok === false && blockedReply.error?.code === "agent_blocked";
|
|
496
|
+
// ⛔ THE NEGATIVE CONTROL FOR THE PARSE: the old expression must NOT find anything, or this
|
|
497
|
+
// probe would pass on the broken code it exists to refuse.
|
|
498
|
+
const oldPathFindsNothing =
|
|
499
|
+
(okReply.json as { status?: string; result?: { status?: string } } | undefined)?.status === undefined &&
|
|
500
|
+
(okReply.json as { status?: string; result?: { status?: string } } | undefined)?.result?.status === undefined;
|
|
501
|
+
|
|
502
|
+
const defaultsSafe = submitModeOf({}) === "send-keys";
|
|
503
|
+
const optsIn = submitModeOf({ [SUBMIT_MODE_ENV_VAR]: "agent-prompt" }) === "agent-prompt";
|
|
504
|
+
// ⛔ NEGATIVE CONTROLS FOR THE FLAG: a trailing space and a near-miss must NOT flip a fleet.
|
|
505
|
+
const typoStaysSafe =
|
|
506
|
+
submitModeOf({ [SUBMIT_MODE_ENV_VAR]: "agent-prompt " }) === "send-keys" &&
|
|
507
|
+
submitModeOf({ [SUBMIT_MODE_ENV_VAR]: "AGENT-PROMPT" }) === "send-keys" &&
|
|
508
|
+
submitModeOf({ [SUBMIT_MODE_ENV_VAR]: "true" }) === "send-keys";
|
|
509
|
+
|
|
510
|
+
const present = prompted && blockedCode && oldPathFindsNothing && defaultsSafe && optsIn && typoStaysSafe;
|
|
511
|
+
return {
|
|
512
|
+
present,
|
|
513
|
+
evidence:
|
|
514
|
+
`real agent_prompted -> result.type read=${prompted} · real agent_blocked -> error.code read=${blockedCode} · ` +
|
|
515
|
+
`old \`status\` path finds nothing on the real payload=${oldPathFindsNothing} · ` +
|
|
516
|
+
`default=${JSON.stringify(submitModeOf({}))} safe=${defaultsSafe} · exact opt-in=${optsIn} · near-misses stay send-keys=${typoStaysSafe} -> present=${present}`,
|
|
517
|
+
};
|
|
518
|
+
},
|
|
519
|
+
},
|
|
464
520
|
{
|
|
465
521
|
// ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
|
|
466
522
|
// decides what a reading MEANS (every probe here is synchronous, so the async read is
|
|
@@ -567,6 +623,35 @@ const PROBES: Probe[] = [
|
|
|
567
623
|
return { present, evidence: `module ${me.serverModule ?? "UNRESOLVED"} · dead stamp -> unknown: ${staleUnknown} · split + unstamped seat -> ${split?.state ?? "n/a"} -> present=${present}` };
|
|
568
624
|
},
|
|
569
625
|
},
|
|
626
|
+
{
|
|
627
|
+
// ⟨q-caa2959e⟩ (0.26.30) — the merge-window live control's APPLICABILITY is own landings in the
|
|
628
|
+
// horizon, never its own window count. A quiet fleet (record commits, no landing) is NOT
|
|
629
|
+
// APPLICABLE and says so with both counts; a landing whose PR announced no window is a problem,
|
|
630
|
+
// which is the violation the control exists to catch and the one a count-based rule would skip
|
|
631
|
+
// green. Asked of the running function, so a server on the pre-fix build answers false.
|
|
632
|
+
id: "merge-window-applies-on-own-landings",
|
|
633
|
+
since: "0.26.30",
|
|
634
|
+
run: () => {
|
|
635
|
+
const T = Date.parse("2026-09-20T12:00:00Z");
|
|
636
|
+
const landing = { sha: "a".repeat(40), at: T, author: "seat", subject: "docs(done): close it (#900)", mergeOf: 900 };
|
|
637
|
+
const plain = { sha: "b".repeat(40), at: T, author: "seat", subject: "docs(board): claim a row", mergeOf: null };
|
|
638
|
+
const opts = { now: T + 60_000, floorMs: T - 60_000 };
|
|
639
|
+
const unannounced = mergeWindowLivePopulation({ logText: "", writes: [landing], ...opts });
|
|
640
|
+
const quiet = mergeWindowLivePopulation({ logText: "", writes: [plain], ...opts });
|
|
641
|
+
const windowsOnly = mergeWindowLivePopulation({ logText: `{"ts":${T},"from":"qa","to":"#k","text":"QA GATE: PASS #900\nMERGE WINDOW: #900 — hold queue/board writes"}`, writes: [], ...opts });
|
|
642
|
+
const present =
|
|
643
|
+
unannounced.applicable === true && unannounced.missingWindow.length === 1 &&
|
|
644
|
+
quiet.applicable === false && /landed nothing in the horizon/.test(quiet.reason) && /0 own landing\(s\)/.test(quiet.reason) &&
|
|
645
|
+
windowsOnly.applicable === false;
|
|
646
|
+
return {
|
|
647
|
+
present,
|
|
648
|
+
evidence:
|
|
649
|
+
`landing with no window -> applicable=${unannounced.applicable} missingWindow=${unannounced.missingWindow.length} · ` +
|
|
650
|
+
`record commit, no landing -> applicable=${quiet.applicable} (names both counts: ${/0 own landing\(s\)/.test(quiet.reason)}) · ` +
|
|
651
|
+
`windows but no landing -> applicable=${windowsOnly.applicable} -> present=${present}`,
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
},
|
|
570
655
|
{
|
|
571
656
|
id: "record-authority",
|
|
572
657
|
since: "0.24.0",
|
|
@@ -750,7 +835,7 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
|
|
|
750
835
|
|
|
751
836
|
import { resolveServerIdentity } from "./server-identity.js";
|
|
752
837
|
import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
|
|
753
|
-
import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
|
|
838
|
+
import { isInFlightStatus, gateClaimsIn, mergeWindowLivePopulation } from "./tools/stall.js";
|
|
754
839
|
import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
|
|
755
840
|
|
|
756
841
|
export const capabilitiesSchema = {} as const;
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -127,6 +127,19 @@ export async function tailOnce(
|
|
|
127
127
|
// read its marker file on every tick while the stated idle cost said otherwise.
|
|
128
128
|
if (refresh) {
|
|
129
129
|
ctx.marker = (opts.markerOf ?? readOwnMarker)(agentId);
|
|
130
|
+
// ⟨q-d58ccdce⟩ ⛔⛆⛆ THIS BRANCH IS THE WHOLE DEFECT. `marker.rooms === false` makes
|
|
131
|
+
// `ctx.rooms` PERMANENTLY EMPTY — every room this agent belongs to is silently excluded
|
|
132
|
+
// from `sources` below, so the per-source loop never runs for a single one of them. That
|
|
133
|
+
// is a total, structural skip with no `continue` to log: #395's per-outcome push logging
|
|
134
|
+
// fires only for a source the loop actually reaches, and a room that is never a source
|
|
135
|
+
// produces zero log lines forever — indistinguishable from a room with nothing owed.
|
|
136
|
+
// Measured live 2026-09-19: every groundwork-kit-* seat's marker had `rooms:false` (a
|
|
137
|
+
// side effect of `attach_agent {..., includeRoom:false}`, called believing it only
|
|
138
|
+
// affected DM liveness), while every unaffected control seat had `rooms:true`. So THIS
|
|
139
|
+
// is the log line the class needed — not one more line inside a loop that never starts.
|
|
140
|
+
if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms === false) {
|
|
141
|
+
log(`[herdr-tail] rooms DISABLED for ${agentId} — marker.rooms=false, so no room is ever tailed regardless of membership (⟨q-d58ccdce⟩); re-attach with includeRoom:true (or omit it) to restore room delivery`);
|
|
142
|
+
}
|
|
130
143
|
if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms !== false) {
|
|
131
144
|
try {
|
|
132
145
|
const all = await (opts.roomsOf ?? getRooms)();
|
|
@@ -150,13 +163,20 @@ export async function tailOnce(
|
|
|
150
163
|
// ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
|
|
151
164
|
// the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
|
|
152
165
|
// cursor file that a restart may have reset. Per source, so a room joined later seeds on its
|
|
153
|
-
// own first sight instead of replaying its history.
|
|
166
|
+
// own first sight instead of replaying its history. Logged (low-frequency: once per source
|
|
167
|
+
// per process) so a seat that never types into a newly-joined room is distinguishable from
|
|
168
|
+
// one that was never told the room existed.
|
|
154
169
|
if (ctx.seedEof && !ctx.seeded.has(key)) {
|
|
155
170
|
ctx.seeded.add(key);
|
|
156
171
|
ctx.hw.set(key, size);
|
|
172
|
+
log(`[herdr-tail] seeded ${key} at EOF (${size} bytes) for ${agentId} — first sight this process, nothing predating the tail will be typed`);
|
|
157
173
|
continue;
|
|
158
174
|
}
|
|
159
175
|
const from = await offsetOf(agentId, src, ctx);
|
|
176
|
+
// ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
|
|
177
|
+
// `size === from` — a source with nothing owed must stay silent, or every idle room on
|
|
178
|
+
// every tick becomes a line and the log that is supposed to make a real stall visible
|
|
179
|
+
// drowns in ones that are not.
|
|
160
180
|
if (size <= from) continue;
|
|
161
181
|
|
|
162
182
|
for (const { msg, end } of newMessagesIn(src.file, from)) {
|
package/src/tools/stall.ts
CHANGED
|
@@ -512,6 +512,57 @@ export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[
|
|
|
512
512
|
return { hits: withAudience(hits), windows: out, unmeasurable };
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* ⟨q-caa2959e⟩ — WHETHER THE MERGE-WINDOW CONVENTION CAN BE JUDGED ON THIS BUS RIGHT NOW,
|
|
517
|
+
* decided from OWN LANDINGS IN THE HORIZON and never from the window count.
|
|
518
|
+
*
|
|
519
|
+
* The live check used to assert `windows.length > 0`. A stand-down left the fleet quiet: 0 window
|
|
520
|
+
* announcements inside the horizon while record commits remained, so a control pinned to a live
|
|
521
|
+
* baseline failed for everyone — it expired when the fleet went quiet, not when anything broke.
|
|
522
|
+
*
|
|
523
|
+
* ⛔ AND APPLICABILITY IS NEVER READ OFF THE PREDICATE'S OWN COUNT. Deciding it from `windows.length`
|
|
524
|
+
* is the by-mention trap ⟨q-552b9912⟩ taught us, inverted: a fleet that landed PRs and announced NO
|
|
525
|
+
* window — precisely the violation this check exists to catch — would skip itself green. The
|
|
526
|
+
* population is what the fleet DID (its landings); the windows are what it is judged ON.
|
|
527
|
+
*
|
|
528
|
+
* Applicable when the horizon holds at least one landing of this fleet's (a squash carrying the
|
|
529
|
+
* forge's `(#N)` marker). Then every one of them MUST carry a window announcement, and a missing one
|
|
530
|
+
* is a problem. With no landing there is nothing to judge: NOT APPLICABLE, named with both counts.
|
|
531
|
+
*/
|
|
532
|
+
export function mergeWindowLivePopulation(input: { logText: string; writes: RecordWrite[] | null; now: number; floorMs?: number; horizonMs?: number }): {
|
|
533
|
+
applicable: boolean;
|
|
534
|
+
reason: string;
|
|
535
|
+
population: string;
|
|
536
|
+
landings: RecordWrite[];
|
|
537
|
+
missingWindow: { sha: string; pr: number }[];
|
|
538
|
+
windows: ReturnType<typeof mergeWindowChecks>["windows"];
|
|
539
|
+
hits: ReturnType<typeof mergeWindowChecks>["hits"];
|
|
540
|
+
} {
|
|
541
|
+
const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
|
|
542
|
+
const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
|
|
543
|
+
const r = mergeWindowChecks(input);
|
|
544
|
+
const since = new Date(input.now - horizon).toISOString();
|
|
545
|
+
const head = `horizon ${since} → ${new Date(input.now).toISOString()}, floor ${new Date(floor).toISOString()}`;
|
|
546
|
+
if (input.writes === null) {
|
|
547
|
+
const reason = `NOT APPLICABLE: record-document writes could not be read from the shared branch — ${head}. UNMEASURED is not the same as kept.`;
|
|
548
|
+
return { applicable: false, reason, population: reason, landings: [], missingWindow: [], windows: r.windows, hits: r.hits };
|
|
549
|
+
}
|
|
550
|
+
const inHorizon = input.writes.filter((c) => Number.isFinite(c.at) && c.at >= input.now - horizon);
|
|
551
|
+
const landings = inHorizon.filter((c) => c.mergeOf !== null);
|
|
552
|
+
const population = `${head}: ${inHorizon.length} record commit(s), ${landings.length} own landing(s), ${r.windows.length} window(s), ${r.hits.length} hit(s)`;
|
|
553
|
+
if (landings.length === 0) {
|
|
554
|
+
return {
|
|
555
|
+
applicable: false,
|
|
556
|
+
reason: `NOT APPLICABLE: this fleet landed nothing in the horizon, so there is no window to require — ${population}. (A quiet fleet, not a broken convention; the window count is never what decides this.)`,
|
|
557
|
+
population, landings, missingWindow: [], windows: r.windows, hits: r.hits,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const missingWindow = landings
|
|
561
|
+
.filter((c) => !r.windows.some((w) => w.pr === c.mergeOf))
|
|
562
|
+
.map((c) => ({ sha: c.sha, pr: c.mergeOf as number }));
|
|
563
|
+
return { applicable: true, reason: `APPLICABLE — ${population}`, population, landings, missingWindow, windows: r.windows, hits: r.hits };
|
|
564
|
+
}
|
|
565
|
+
|
|
515
566
|
export function conventionChecks(input: {
|
|
516
567
|
recentMerges: RecentMerge[] | null | undefined;
|
|
517
568
|
/** PRs the caller knows merged express. Nothing populates this yet — see isDocsOnlyMerge. */
|
package/src/transports/herdr.ts
CHANGED
|
@@ -170,8 +170,28 @@ export type HerdrTransportOptions = {
|
|
|
170
170
|
* variable at a time, six others left in place — was what turned it green.
|
|
171
171
|
*/
|
|
172
172
|
env?: Record<string, string | undefined>;
|
|
173
|
+
/** Override the submit mode this instance uses; defaults to `submitModeOf(this.#env)`. Tests only. */
|
|
174
|
+
submitMode?: SubmitMode;
|
|
173
175
|
};
|
|
174
176
|
|
|
177
|
+
/**
|
|
178
|
+
* ⟨q-f7e3c701⟩ — WHICH SUBMIT PATH `push` USES. `agent-prompt` is the atomic fix
|
|
179
|
+
* (`herdr agent prompt <target> <text>`, one call, no type→read-back→Enter window). `send-keys`
|
|
180
|
+
* is today's path (type, verify it landed, Enter, verify it submitted).
|
|
181
|
+
*
|
|
182
|
+
* DEFAULT IS `send-keys`, DELIBERATELY. The blast radius of switching is both fleets and the
|
|
183
|
+
* failure mode is silence — a wrong default ships broken delivery to every seat with no signal
|
|
184
|
+
* until someone notices nothing arrived. `agent-prompt` is enabled per-seat as a canary via
|
|
185
|
+
* `AGENT_COORD_SUBMIT=agent-prompt`, compared against control seats by `outcome=` rate in
|
|
186
|
+
* bus-daemon.err.log, and reverted by unsetting the variable — no republish required, which
|
|
187
|
+
* matters because 0.26.28 took two days to travel merged→observed.
|
|
188
|
+
*/
|
|
189
|
+
export type SubmitMode = "agent-prompt" | "send-keys";
|
|
190
|
+
export const SUBMIT_MODE_ENV_VAR = "AGENT_COORD_SUBMIT";
|
|
191
|
+
export function submitModeOf(env: Record<string, string | undefined> = process.env): SubmitMode {
|
|
192
|
+
return env[SUBMIT_MODE_ENV_VAR] === "agent-prompt" ? "agent-prompt" : "send-keys";
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
/**
|
|
176
196
|
* ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched.
|
|
177
197
|
*
|
|
@@ -225,6 +245,7 @@ export class HerdrTransport implements Transport {
|
|
|
225
245
|
#readLines: number;
|
|
226
246
|
#markerPid: () => HerdrMarkerPid;
|
|
227
247
|
#env: Record<string, string | undefined>;
|
|
248
|
+
#submitMode: SubmitMode;
|
|
228
249
|
|
|
229
250
|
constructor(opts: HerdrTransportOptions = {}) {
|
|
230
251
|
this.#run = opts.run ?? defaultHerdrRunner;
|
|
@@ -237,6 +258,7 @@ export class HerdrTransport implements Transport {
|
|
|
237
258
|
this.#readLines = opts.readLines ?? 40;
|
|
238
259
|
this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid());
|
|
239
260
|
this.#env = opts.env ?? process.env;
|
|
261
|
+
this.#submitMode = opts.submitMode ?? submitModeOf(this.#env);
|
|
240
262
|
}
|
|
241
263
|
|
|
242
264
|
/** Is herdr on this host AND is its server running? Both, or the reason. */
|
|
@@ -330,6 +352,9 @@ export class HerdrTransport implements Transport {
|
|
|
330
352
|
if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason };
|
|
331
353
|
const target = targetOf(marker);
|
|
332
354
|
if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" };
|
|
355
|
+
// ⟨q-f7e3c701⟩ ATOMIC SUBMIT, canaried per seat via AGENT_COORD_SUBMIT. Everything below this
|
|
356
|
+
// branch is the send-keys path (today's default); #pushAgentPrompt is the opt-in replacement.
|
|
357
|
+
if (this.#submitMode === "agent-prompt") return this.#pushAgentPrompt(target, text);
|
|
333
358
|
const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`;
|
|
334
359
|
const enterKey = herdrKeyName("enter");
|
|
335
360
|
if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error };
|
|
@@ -363,6 +388,73 @@ export class HerdrTransport implements Transport {
|
|
|
363
388
|
return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
364
389
|
}
|
|
365
390
|
|
|
391
|
+
/**
|
|
392
|
+
* ⟨q-f7e3c701⟩ ATOMIC SUBMIT, behind `AGENT_COORD_SUBMIT=agent-prompt` (default `send-keys` —
|
|
393
|
+
* see `submitModeOf`). `herdr agent prompt <target> <text> --wait --until working` types and
|
|
394
|
+
* submits in ONE call: no type→read-back→Enter window for a decoration to land in, which is
|
|
395
|
+
* the class the send-keys path above exists to survive. MEASURED (joint cross-fleet design,
|
|
396
|
+
* 2026-09-21): 4/4 across two hosts, including a LIVE WEDGE the send-keys path had already
|
|
397
|
+
* abandoned in the same minute.
|
|
398
|
+
*
|
|
399
|
+
* ⛔ `refusalBeforeKeys` IS NOT OPTIONAL — MEASURED ON A THROWAWAY PANE: `agent prompt` into a
|
|
400
|
+
* DIRTY composer returned `agent_prompted` (success) and CONCATENATED the stale draft with the
|
|
401
|
+
* new text, no separator, then submitted both. Atomic submit alone is strictly WORSE than
|
|
402
|
+
* today: a recoverable `held` becomes unrecoverable corruption carrying a success receipt.
|
|
403
|
+
* `agent_blocked` is a STATUS axis and is BLIND to a dirty composer (reads
|
|
404
|
+
* `status: idle · interactive_ready: true` regardless), so it cannot substitute for reading
|
|
405
|
+
* the box first — this method still reads it, exactly as the send-keys path does.
|
|
406
|
+
*
|
|
407
|
+
* Receipt is `--until working`, an OBSERVED state transition — strictly stronger than "I typed
|
|
408
|
+
* it and could read it back", the only receipt class send-keys has ever had. A stall/timeout is
|
|
409
|
+
* treated as UNCONFIRMED, never as safe to retry: unlike send-keys's `typed-unconfirmed` (where
|
|
410
|
+
* nothing had actually been typed yet), a stalled atomic submit may already have gone through,
|
|
411
|
+
* and retrying risks a genuine double-send.
|
|
412
|
+
*
|
|
413
|
+
* ⛔⛔ THE FIELD PATH BELOW WAS WRONG ONCE, SHIPPED, AND CAUGHT BY QA'S OWN LIVE SMOKE TEST
|
|
414
|
+
* BEFORE MERGE — recorded because the wrong version passed every unit test, having been
|
|
415
|
+
* written against a GUESSED shape (`json.status ?? json.result.status`) that the mocks then
|
|
416
|
+
* reproduced instead of measuring. Against the real CLI that path is always `undefined`, so
|
|
417
|
+
* every genuine success fell through to the catch-all and the feature would have reported
|
|
418
|
+
* 100% failure on real hardware. VERIFIED PAYLOADS, captured against herdr 0.9.1 by the aide
|
|
419
|
+
* and independently reproduced here on a disposable scratch pane (`herdr workspace create` /
|
|
420
|
+
* `agent start --kind claude` / closed after):
|
|
421
|
+
* success: {"result":{"agent":{...},"type":"agent_prompted"}} — result.type, not result.status
|
|
422
|
+
* blocked: {"error":{"code":"agent_blocked","message":"..."}} — TOP-LEVEL error, no `result` at all
|
|
423
|
+
* timeout: {"error":{"code":"timeout","message":"..."}} — same top-level shape as blocked
|
|
424
|
+
* So this is TWO PATHS, not one field read: `result.type` for success, `error.code` for every
|
|
425
|
+
* refusal. A single-location parse mishandles one of them regardless of which is picked — the
|
|
426
|
+
* exact shape of the bug that shipped. `agent_prompt_stalled` was NOT reproduced against a real
|
|
427
|
+
* binary despite attempting it (the 5000ms stall window did not fire in either kit's or my own
|
|
428
|
+
* attempts) — it is handled via the same `error.code` path as `agent_blocked`/`timeout` BY
|
|
429
|
+
* ANALOGY with the CLI's consistent refuse-via-top-level-error convention, not by measurement.
|
|
430
|
+
* If that analogy is wrong, the failure mode is `unverified/safeToRetry:false` (never a
|
|
431
|
+
* double-send) rather than a false "delivered", so the untested case fails safe.
|
|
432
|
+
*/
|
|
433
|
+
async #pushAgentPrompt(target: string, text: string): Promise<HerdrPushResult> {
|
|
434
|
+
if (readyProfile().profile !== "none") {
|
|
435
|
+
const before = readReadyBox(this.#screen(target));
|
|
436
|
+
const refusal = refusalBeforeKeys(before);
|
|
437
|
+
if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
|
|
438
|
+
}
|
|
439
|
+
const r = this.#run(["agent", "prompt", target, text, "--wait", "--until", "working"]);
|
|
440
|
+
const resultType = (r.json as { result?: { type?: string } } | undefined)?.result?.type;
|
|
441
|
+
if (r.ok && resultType === "agent_prompted") {
|
|
442
|
+
return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false };
|
|
443
|
+
}
|
|
444
|
+
if (r.error?.code === "agent_blocked") {
|
|
445
|
+
// MEASURED: nothing is typed into the pane on agent_blocked — the agent itself refused the
|
|
446
|
+
// prompt before any input was sent. Safe to retry, same as a send-keys `held`.
|
|
447
|
+
return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `agent_blocked at ${target}: ${r.error.message}` };
|
|
448
|
+
}
|
|
449
|
+
if (r.error?.code === "agent_prompt_stalled" || r.error?.code === "timeout") {
|
|
450
|
+
return {
|
|
451
|
+
delivered: false, verified: false, outcome: "unverified", safeToRetry: false,
|
|
452
|
+
error: `${r.error.code} at ${target}: submission unconfirmed by --wait --until working — not retried, to avoid a double-send on an atomic path`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, error: `agent prompt to ${target} refused or unrecognised: ${r.error?.message ?? r.stderr ?? JSON.stringify(r.json)}` };
|
|
456
|
+
}
|
|
457
|
+
|
|
366
458
|
/** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */
|
|
367
459
|
#screen(target: string): string | null {
|
|
368
460
|
const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "ansi"]);
|
package/src/transports/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { configuredTransport } from "./config.js";
|
|
|
14
14
|
export * from "./types.js";
|
|
15
15
|
export * from "./config.js";
|
|
16
16
|
export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
|
|
17
|
-
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid } from "./herdr.js";
|
|
17
|
+
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, submitModeOf, SUBMIT_MODE_ENV_VAR, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid, type SubmitMode } from "./herdr.js";
|
|
18
18
|
|
|
19
19
|
let host: TmuxHost | undefined;
|
|
20
20
|
|