@bongos/core 1.19.836 → 1.19.838
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/.bongos-core.json +59 -24
- package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +74 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/migrations/agents_003_output_ref_idx.sql +41 -0
- package/modules/agents/module.json +1 -1
- package/modules/agents/pollers/event-router.js +737 -0
- package/modules/discord/module.json +1 -1
- package/modules/discord/routes/discord.js +25 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +5 -1
- package/scripts/gds/doctor.js +28 -1
- package/scripts/gds/plain-cards.js +2 -0
- package/scripts/gds/provision.js +4 -1
- package/scripts/gds/runner-drift.js +151 -0
- package/src/module-api.js +1 -1
- package/tests/agents_event_router.mjs +523 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/provision.mjs +18 -0
- package/tests/runner_drift.mjs +173 -0
- package/tests/stuck_detector.mjs +233 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// tests/agents_event_router.mjs — the event-router poller (task 1002494).
|
|
2
|
+
//
|
|
3
|
+
// Everything is dependency-injected — a fake pool, a fake matcher, a fake sink,
|
|
4
|
+
// a fake runner. No database, no model call, no server. What is proven here are
|
|
5
|
+
// the four properties the router's header claims, and each test is written so it
|
|
6
|
+
// REDS if the property is removed:
|
|
7
|
+
//
|
|
8
|
+
// 1. the touches[] pre-filter runs BEFORE any spawn, and a miss costs nothing
|
|
9
|
+
// 2. the depth guard refuses a ship attributed to an agent run, fail-closed
|
|
10
|
+
// 3. an unwired/unavailable sink refuses BEFORE the spawn, not after
|
|
11
|
+
// 4. nothing the router does can block, slow or fail the ship it reacted to
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/agents_event_router.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const router = require('../modules/agents/pollers/event-router.js');
|
|
20
|
+
const {
|
|
21
|
+
fanOut, onShipped, matchesTouches, selectFiring, depthRefsFor,
|
|
22
|
+
sinkNameFor, resolveSink, declaredGlobs, EVENT, SINK_CATALOG, DEFAULT_SINK,
|
|
23
|
+
} = router;
|
|
24
|
+
|
|
25
|
+
let passed = 0;
|
|
26
|
+
let failed = 0;
|
|
27
|
+
async function test(name, fn) {
|
|
28
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
29
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.stack || err.message}`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A stand-in for permissionPathCheck.compileGlobMatcher. It splits on `**`
|
|
33
|
+
// rather than substituting a sentinel character: the sentinel this originally
|
|
34
|
+
// used was a literal NUL, which made the whole test file a binary blob git
|
|
35
|
+
// would not render as a diff — invisible locally, and caught only in review.
|
|
36
|
+
const compile = (globs) => (file) => globs.some((g) => {
|
|
37
|
+
if (g.endsWith('/')) return file.startsWith(g);
|
|
38
|
+
const src = g.split('**')
|
|
39
|
+
.map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'))
|
|
40
|
+
.join('.*');
|
|
41
|
+
return new RegExp('^' + src + '$').test(file);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function def(over = {}) {
|
|
45
|
+
return {
|
|
46
|
+
id: 1, name: 'review-lens', title: null, persona: 'observe and report',
|
|
47
|
+
trigger_type: 'event', trigger_spec: { event: EVENT }, model_tier: 'default',
|
|
48
|
+
scope_violation: null, enabled: true, ...over,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A pool that answers the router's three reads by SHAPE and records every
|
|
53
|
+
// statement, so the ledger can be asserted on directly (tests/agents_spawn.mjs
|
|
54
|
+
// idiom). `defs`, `task` and `attributedRuns` are what the reads return.
|
|
55
|
+
function fakePool({ defs = [], task = null, attributedRuns = [], spent = '0', throwOn = null } = {}) {
|
|
56
|
+
const calls = [];
|
|
57
|
+
let nextRunId = 100;
|
|
58
|
+
return {
|
|
59
|
+
calls,
|
|
60
|
+
async query(sql, params) {
|
|
61
|
+
calls.push({ sql, params });
|
|
62
|
+
if (throwOn && throwOn.test(sql)) throw new Error('boom');
|
|
63
|
+
if (/FROM agents_definitions/.test(sql)) return { rows: defs };
|
|
64
|
+
if (/FROM tasks/.test(sql)) return { rows: task ? [task] : [] };
|
|
65
|
+
if (/FROM agents_runs\n\s*WHERE output_ref/.test(sql) || /output_ref = ANY/.test(sql)) return { rows: attributedRuns };
|
|
66
|
+
if (/SUM\(cost_usd\)/.test(sql)) return { rows: [{ spent }] };
|
|
67
|
+
if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: nextRunId++ }] };
|
|
68
|
+
return { rows: [] };
|
|
69
|
+
},
|
|
70
|
+
ledgered() {
|
|
71
|
+
return calls.filter((c) => /^INSERT INTO agents_runs/.test(c.sql)).map((c) => {
|
|
72
|
+
const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
|
|
73
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SINK_OK = { resolveOptional: (port) => (port === 'ideas.capture' ? async () => ({ id: 777 }) : null) };
|
|
80
|
+
|
|
81
|
+
// ===========================================================================
|
|
82
|
+
// PROPERTY 1 — the cheap filter, first
|
|
83
|
+
// ===========================================================================
|
|
84
|
+
|
|
85
|
+
await test('NO declared filter fires on every ship — an absent filter is not an empty one', () => {
|
|
86
|
+
assert.deepEqual(declaredGlobs(def()), []);
|
|
87
|
+
assert.equal(matchesTouches(def(), ['anything/at/all.js'], compile).match, true);
|
|
88
|
+
// ...and a task with no touches[] at all still matches an unfiltered agent.
|
|
89
|
+
assert.equal(matchesTouches(def(), [], compile).match, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('a declared filter matches only an overlapping touches[]', () => {
|
|
93
|
+
const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**', 'src/bongos/'] } });
|
|
94
|
+
assert.equal(matchesTouches(d, ['docs/adr/0001.md'], compile).match, true, '** crosses separators');
|
|
95
|
+
assert.equal(matchesTouches(d, ['src/bongos/db.js'], compile).match, true, 'a trailing / is a subtree');
|
|
96
|
+
assert.equal(matchesTouches(d, ['modules/agents/spawn.js'], compile).match, false);
|
|
97
|
+
// One hit anywhere in touches[] is enough — a ship is relevant if ANY of what
|
|
98
|
+
// it touched is in scope.
|
|
99
|
+
assert.equal(matchesTouches(d, ['modules/x.js', 'docs/readme.md'], compile).match, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('a DECLARED filter against an EMPTY touches[] does NOT fire — no evidence must not read as go', () => {
|
|
103
|
+
const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } });
|
|
104
|
+
const v = matchesTouches(d, [], compile);
|
|
105
|
+
assert.equal(v.match, false);
|
|
106
|
+
assert.equal(v.reason, 'task_declared_no_touches');
|
|
107
|
+
assert.equal(matchesTouches(d, null, compile).match, false, 'a null touches[] is the same absence');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('a MALFORMED filter widens to "no filter", never narrows to "matches nothing"', () => {
|
|
111
|
+
// Narrowing would disarm an armed agent with nothing recording why.
|
|
112
|
+
for (const bad of ['docs/**', 42, {}, null, [], ['', ' ']]) {
|
|
113
|
+
assert.equal(matchesTouches(def({ trigger_spec: { event: EVENT, touches: bad } }), ['x.js'], compile).match,
|
|
114
|
+
true, `touches: ${JSON.stringify(bad)} must not silently disarm the agent`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('an UNCOMPILABLE filter refuses the fire rather than matching everything', () => {
|
|
119
|
+
const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } });
|
|
120
|
+
const v = matchesTouches(d, ['docs/x.md'], () => { throw new Error('bad glob'); });
|
|
121
|
+
assert.equal(v.match, false);
|
|
122
|
+
assert.equal(v.reason, 'filter_uncompilable');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('selectFiring splits the roster and never loses a definition', () => {
|
|
126
|
+
const defs = [
|
|
127
|
+
def({ id: 1, name: 'a', trigger_spec: { event: EVENT, touches: ['docs/**'] } }),
|
|
128
|
+
def({ id: 2, name: 'b', trigger_spec: { event: EVENT, touches: ['infra/**'] } }),
|
|
129
|
+
def({ id: 3, name: 'c' }),
|
|
130
|
+
];
|
|
131
|
+
const { firing, filtered } = selectFiring(defs, ['docs/a.md'], compile);
|
|
132
|
+
assert.deepEqual(firing.map((f) => f.definition.name), ['a', 'c']);
|
|
133
|
+
assert.deepEqual(filtered.map((f) => f.definition.name), ['b']);
|
|
134
|
+
assert.equal(firing.length + filtered.length, defs.length);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('a PRE-FILTER MISS spawns nothing AND ledgers nothing — the fire never happened', async () => {
|
|
138
|
+
let spawned = 0;
|
|
139
|
+
const pool = fakePool({
|
|
140
|
+
defs: [def({ trigger_spec: { event: EVENT, touches: ['infra/**'] } })],
|
|
141
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
|
|
142
|
+
});
|
|
143
|
+
const report = await fanOut({ id: '9' }, {
|
|
144
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
|
|
145
|
+
runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
|
|
146
|
+
});
|
|
147
|
+
assert.equal(spawned, 0, 'the cheap filter must run BEFORE any spawn');
|
|
148
|
+
assert.equal(report.fired, 0);
|
|
149
|
+
assert.equal(report.filtered, 1);
|
|
150
|
+
assert.equal(report.reason, 'no_agent_matched_touches');
|
|
151
|
+
assert.deepEqual(pool.ledgered(), [], 'selection is not refusal — a miss writes no ledger row');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('with NOTHING armed the router reads no task row and writes nothing', async () => {
|
|
155
|
+
const pool = fakePool({ defs: [] });
|
|
156
|
+
const report = await fanOut({ id: '9' }, { pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK });
|
|
157
|
+
assert.equal(report.reason, 'no_armed_event_agents');
|
|
158
|
+
assert.equal(pool.calls.filter((c) => /FROM tasks/.test(c.sql)).length, 0,
|
|
159
|
+
'the definition read is first and cheapest; a quiet instance must not pay for a task read');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ===========================================================================
|
|
163
|
+
// PROPERTY 2 — the depth guard
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
|
|
166
|
+
await test('depthRefsFor covers BOTH hops: a direct task pointer and a promoted idea', () => {
|
|
167
|
+
assert.deepEqual(depthRefsFor({ id: '55', source: null }), ['task:55']);
|
|
168
|
+
assert.deepEqual(depthRefsFor({ id: '55', source: 'idea-1000527' }), ['task:55', 'idea:1000527']);
|
|
169
|
+
// A source that is not an idea promotion contributes no idea ref.
|
|
170
|
+
assert.deepEqual(depthRefsFor({ id: '55', source: 'discord' }), ['task:55']);
|
|
171
|
+
assert.deepEqual(depthRefsFor({}), [], 'an unidentifiable task yields no refs');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('a ship ATTRIBUTED to an agent run fires nothing, and the refusal IS ledgered', async () => {
|
|
175
|
+
let spawned = 0;
|
|
176
|
+
const pool = fakePool({
|
|
177
|
+
defs: [def()],
|
|
178
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: 'idea-1000527' },
|
|
179
|
+
attributedRuns: [{ id: '42', agent_name: 'review-lens' }],
|
|
180
|
+
});
|
|
181
|
+
const report = await fanOut({ id: '9' }, {
|
|
182
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
|
|
183
|
+
runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(spawned, 0, 'agents must not trigger each other forever');
|
|
186
|
+
assert.equal(report.reason, 'depth_guard');
|
|
187
|
+
const rows = pool.ledgered();
|
|
188
|
+
assert.equal(rows.length, 1, 'a loop guard that leaves no trace is invisible');
|
|
189
|
+
assert.equal(rows[0].gate_decision, 'no-go');
|
|
190
|
+
assert.equal(rows[0].status, 'skipped');
|
|
191
|
+
assert.match(String(rows[0].gate_reason), /attributed to agent run 42/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('the depth guard FAILS CLOSED on an unreadable ledger', async () => {
|
|
195
|
+
const thrower = { query: async () => { throw new Error('db down'); } };
|
|
196
|
+
const v = await router.isAgentAttributed(thrower, { id: '9' });
|
|
197
|
+
assert.equal(v.attributed, true, '"I could not check for a loop" must never read as "no loop"');
|
|
198
|
+
assert.match(v.reason, /threw/);
|
|
199
|
+
|
|
200
|
+
assert.equal((await router.isAgentAttributed(null, { id: '9' })).attributed, true, 'no pool ⇒ no fire');
|
|
201
|
+
assert.equal((await router.isAgentAttributed({ query: async () => ({ rows: [] }) }, {})).attributed, true,
|
|
202
|
+
'an unidentifiable task ⇒ no fire');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('an UNREADABLE task row refuses every armed agent rather than firing blind', async () => {
|
|
206
|
+
let spawned = 0;
|
|
207
|
+
const pool = fakePool({ defs: [def({ id: 1, name: 'a' }), def({ id: 2, name: 'b' })], task: null });
|
|
208
|
+
const report = await fanOut({ id: '9' }, {
|
|
209
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
|
|
210
|
+
runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
|
|
211
|
+
});
|
|
212
|
+
assert.equal(spawned, 0, 'with no touches[] the pre-filter cannot run, so nothing may fire');
|
|
213
|
+
assert.equal(report.reason, 'task_unreadable');
|
|
214
|
+
assert.equal(pool.ledgered().length, 2, 'these fires WOULD have happened — refusing them is a refusal');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ===========================================================================
|
|
218
|
+
// PROPERTY 3 — the sink, checked before the spawn
|
|
219
|
+
// ===========================================================================
|
|
220
|
+
|
|
221
|
+
await test('the sink catalog is the WIRED set — every entry has a port behind it', () => {
|
|
222
|
+
assert.deepEqual(Object.keys(SINK_CATALOG).sort(), ['discord', 'ideas.capture']);
|
|
223
|
+
assert.equal(DEFAULT_SINK, 'ideas.capture');
|
|
224
|
+
assert.equal(sinkNameFor(def()), 'ideas.capture', 'an absent sink defaults rather than leaving a finding homeless');
|
|
225
|
+
assert.equal(sinkNameFor(def({ trigger_spec: { event: EVENT, sink: 'discord' } })), 'discord');
|
|
226
|
+
// A name nothing delivers to is refused, not stored — the EVENT_CATALOG rule.
|
|
227
|
+
const v = resolveSink(def({ trigger_spec: { event: EVENT, sink: 'carrier-pigeon' } }), SINK_OK);
|
|
228
|
+
assert.equal(v.ok, false);
|
|
229
|
+
assert.match(v.reason, /not wired/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await test('an UNAVAILABLE port refuses too — wired is not the same as present', () => {
|
|
233
|
+
const v = resolveSink(def(), { resolveOptional: () => null });
|
|
234
|
+
assert.equal(v.ok, false);
|
|
235
|
+
assert.match(v.reason, /port is not available/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await test('an unwired sink refuses BEFORE the model runs, and is ledgered', async () => {
|
|
239
|
+
let spawned = 0;
|
|
240
|
+
const pool = fakePool({
|
|
241
|
+
defs: [def({ trigger_spec: { event: EVENT, sink: 'carrier-pigeon' } })],
|
|
242
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
|
|
243
|
+
});
|
|
244
|
+
const report = await fanOut({ id: '9' }, {
|
|
245
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
|
|
246
|
+
runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
|
|
247
|
+
});
|
|
248
|
+
assert.equal(spawned, 0, 'a finding nobody receives is money spent for nothing');
|
|
249
|
+
assert.equal(report.reason, 'no_deliverable_sink');
|
|
250
|
+
assert.match(String(pool.ledgered()[0].gate_reason), /not wired/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ===========================================================================
|
|
254
|
+
// THE HAPPY PATH, AND WHAT IT WRITES
|
|
255
|
+
// ===========================================================================
|
|
256
|
+
|
|
257
|
+
await test('a matching ship fires, files the finding, and records the POINTER on the run', async () => {
|
|
258
|
+
const filed = [];
|
|
259
|
+
const pool = fakePool({
|
|
260
|
+
defs: [def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } })],
|
|
261
|
+
task: { id: '9', title: 'Ship the docs', touches: ['docs/a.md'], source: null },
|
|
262
|
+
});
|
|
263
|
+
const report = await fanOut({ id: '9' }, {
|
|
264
|
+
pool,
|
|
265
|
+
compileGlobMatcher: compile,
|
|
266
|
+
spendPayer: null,
|
|
267
|
+
resolveOptional: (port) => (port === 'ideas.capture'
|
|
268
|
+
? async (arg) => { filed.push(arg); return { id: 777 }; }
|
|
269
|
+
: null),
|
|
270
|
+
runSubagentCached: async () => ({ stdout: 'the copy on line 3 reads oddly', cost_usd: 0.9 }),
|
|
271
|
+
});
|
|
272
|
+
assert.equal(report.fired, 1);
|
|
273
|
+
assert.equal(filed.length, 1, 'the finding reaches its declared sink');
|
|
274
|
+
assert.match(filed[0].title, /^review-lens: Ship the docs/);
|
|
275
|
+
assert.match(filed[0].bodyMd, /the copy on line 3 reads oddly/);
|
|
276
|
+
assert.equal(filed[0].capturedBy, null);
|
|
277
|
+
assert.equal(filed[0].vector, undefined,
|
|
278
|
+
'NO vector: an agent may report, and may not manufacture claimable work');
|
|
279
|
+
|
|
280
|
+
const insert = pool.ledgered()[0];
|
|
281
|
+
assert.equal(insert.gate_decision, 'go');
|
|
282
|
+
assert.equal(insert.trigger_type, 'event');
|
|
283
|
+
assert.equal(insert.trigger_ref, `${EVENT}:9`);
|
|
284
|
+
assert.equal(insert.requested_by_builder_id, null);
|
|
285
|
+
assert.equal(insert.payer_kind, 'project', 'an event fire has no caller, so the project pays');
|
|
286
|
+
|
|
287
|
+
const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
|
|
288
|
+
assert.ok(update, 'the run is finished, not left pending');
|
|
289
|
+
assert.ok(update.params.includes('idea:777'),
|
|
290
|
+
'the output_ref pointer must land on the SAME update that records the cost');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('a fire with NOTHING to say is a success with no pointer, not a failure', async () => {
|
|
294
|
+
let filedCount = 0;
|
|
295
|
+
const pool = fakePool({
|
|
296
|
+
defs: [def()],
|
|
297
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
|
|
298
|
+
});
|
|
299
|
+
const report = await fanOut({ id: '9' }, {
|
|
300
|
+
pool,
|
|
301
|
+
compileGlobMatcher: compile,
|
|
302
|
+
spendPayer: null,
|
|
303
|
+
resolveOptional: (port) => (port === 'ideas.capture' ? async () => { filedCount++; return { id: 1 }; } : null),
|
|
304
|
+
runSubagentCached: async () => ({ stdout: ' \n ' }),
|
|
305
|
+
});
|
|
306
|
+
assert.equal(report.fired, 1);
|
|
307
|
+
assert.equal(filedCount, 0, 'an empty finding must not become an empty inbox row');
|
|
308
|
+
const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
|
|
309
|
+
assert.ok(update.params.includes('ok'), 'reporting "nothing here" is the outcome most fires should have');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await test('a fire whose finding could NOT be delivered is an ERROR row, never a quiet ok', async () => {
|
|
313
|
+
const pool = fakePool({
|
|
314
|
+
defs: [def()],
|
|
315
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
|
|
316
|
+
});
|
|
317
|
+
await fanOut({ id: '9' }, {
|
|
318
|
+
pool,
|
|
319
|
+
compileGlobMatcher: compile,
|
|
320
|
+
spendPayer: null,
|
|
321
|
+
resolveOptional: (port) => (port === 'ideas.capture' ? async () => { throw new Error('inbox down'); } : null),
|
|
322
|
+
runSubagentCached: async () => ({ stdout: 'a real finding', cost_usd: 0.9 }),
|
|
323
|
+
});
|
|
324
|
+
const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
|
|
325
|
+
assert.ok(update.params.includes('error'),
|
|
326
|
+
'a finding that evaporated must never look like an agent that found nothing');
|
|
327
|
+
assert.ok(update.params.includes('sink_delivery_failed'));
|
|
328
|
+
assert.ok(update.params.includes(0.9), 'the fire still cost money and the ledger still says so');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ===========================================================================
|
|
332
|
+
// THE SPEND BOUNDS
|
|
333
|
+
// ===========================================================================
|
|
334
|
+
|
|
335
|
+
await test('the FAN-OUT CAP bounds one event, because no per-builder ceiling can', async () => {
|
|
336
|
+
const many = Array.from({ length: router.FANOUT_MAX + 2 }, (_, i) => def({ id: i + 1, name: `a${i}` }));
|
|
337
|
+
const pool = fakePool({ defs: many, task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null } });
|
|
338
|
+
let spawned = 0;
|
|
339
|
+
const report = await fanOut({ id: '9' }, {
|
|
340
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
|
|
341
|
+
runSubagentCached: async () => { spawned++; return { stdout: '' }; },
|
|
342
|
+
});
|
|
343
|
+
assert.equal(spawned, router.FANOUT_MAX, 'an event fire has no builder, so fire-budget cannot bound it');
|
|
344
|
+
assert.equal(report.fired, router.FANOUT_MAX);
|
|
345
|
+
assert.equal(report.skipped, 2);
|
|
346
|
+
const overflow = pool.ledgered().filter((r) => /at most/.test(String(r.gate_reason)));
|
|
347
|
+
assert.equal(overflow.length, 2, 'the agents the cap dropped are on the record');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await test('an EXHAUSTED or UNREADABLE spend ceiling refuses every fire, ledgered', async () => {
|
|
351
|
+
for (const [label, ceiling] of [
|
|
352
|
+
['exhausted', { check: async () => ({ ok: false, reason: 'spend_cap_reached', capUsd: 25, spentUsd: 25 }) }],
|
|
353
|
+
['unreadable', { check: async () => ({ ok: false, reason: 'ledger_unreadable', capUsd: 25, spentUsd: null }) }],
|
|
354
|
+
]) {
|
|
355
|
+
let spawned = 0;
|
|
356
|
+
const pool = fakePool({ defs: [def()], task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null } });
|
|
357
|
+
const report = await fanOut({ id: '9' }, {
|
|
358
|
+
pool, compileGlobMatcher: compile, spendPayer: null, spendCeiling: ceiling, ...SINK_OK,
|
|
359
|
+
runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
|
|
360
|
+
});
|
|
361
|
+
assert.equal(spawned, 0, `${label}: a ceiling that cannot be read must not be assumed`);
|
|
362
|
+
assert.equal(report.reason, 'spend_ceiling');
|
|
363
|
+
assert.equal(pool.ledgered().length, 1, `${label}: the refusal is on the record`);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ===========================================================================
|
|
368
|
+
// PROPERTY 4 — it can never block, slow or fail a ship
|
|
369
|
+
// ===========================================================================
|
|
370
|
+
|
|
371
|
+
await test('the subscribed handler is SYNCHRONOUS — emitAsync must not await a model call', () => {
|
|
372
|
+
// emitAsync awaits what a listener returns and the ship route awaits the emit,
|
|
373
|
+
// so a returned promise would hold a shipping builder's request open for the
|
|
374
|
+
// length of a fire. This is the assertion that keeps that from regressing.
|
|
375
|
+
const started = Date.now();
|
|
376
|
+
const r = onShipped({ id: 'no-such-task' });
|
|
377
|
+
assert.equal(r, undefined, 'the handler must return undefined, never a promise');
|
|
378
|
+
assert.ok(Date.now() - started < 50, 'the handler must return immediately');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('a throwing fan-out never reaches the emitter', async () => {
|
|
382
|
+
// Every read path is broken at once: the pool throws on everything.
|
|
383
|
+
const pool = { query: async () => { throw new Error('everything is down'); } };
|
|
384
|
+
const report = await fanOut({ id: '9' }, { pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK });
|
|
385
|
+
assert.equal(report.reason, 'definitions_unreadable', 'a reactor must never break the actor');
|
|
386
|
+
assert.equal(report.fired, 0);
|
|
387
|
+
// ...and the synchronous entry point swallows it too.
|
|
388
|
+
assert.equal(onShipped({ id: '9' }), undefined);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await test('a payload with no id is a no-op, not a crash', async () => {
|
|
392
|
+
for (const bad of [null, undefined, {}, { id: null }]) {
|
|
393
|
+
const report = await fanOut(bad, { pool: fakePool(), compileGlobMatcher: compile, spendPayer: null });
|
|
394
|
+
assert.equal(report.reason, 'no_task_id');
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await test('start()/stop() subscribe exactly once and unsubscribe cleanly', () => {
|
|
399
|
+
const seams = require('../src/module-seams.js');
|
|
400
|
+
const before = seams.listEvents().includes(EVENT);
|
|
401
|
+
router.start();
|
|
402
|
+
router.start(); // idempotent — a duplicate listener would double every bill
|
|
403
|
+
assert.ok(seams.listEvents().includes(EVENT));
|
|
404
|
+
router.stop();
|
|
405
|
+
assert.equal(seams.listEvents().includes(EVENT), before,
|
|
406
|
+
'stop() must leave the seam registry exactly as it found it');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ===========================================================================
|
|
410
|
+
// THE DISCORD SINK — announce, never file
|
|
411
|
+
// ===========================================================================
|
|
412
|
+
|
|
413
|
+
const DISCORD_OK = (posts) => ({
|
|
414
|
+
resolveOptional: (port) => (port === 'discord.post'
|
|
415
|
+
? async (arg) => { posts.push(arg); return { posted: true, channel: arg.channel }; }
|
|
416
|
+
: null),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await test('a discord-sinked finding is ANNOUNCED on #ship-feed and files nothing', async () => {
|
|
420
|
+
const posts = [];
|
|
421
|
+
const pool = fakePool({
|
|
422
|
+
defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
|
|
423
|
+
task: { id: '9', title: 'Ship the docs', touches: ['docs/a.md'], source: null, security_sensitive: false },
|
|
424
|
+
});
|
|
425
|
+
const report = await fanOut({ id: '9' }, {
|
|
426
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
|
|
427
|
+
runSubagentCached: async () => ({ stdout: 'the copy on line 3 reads oddly', cost_usd: 0.9 }),
|
|
428
|
+
});
|
|
429
|
+
assert.equal(report.fired, 1);
|
|
430
|
+
assert.equal(posts.length, 1);
|
|
431
|
+
assert.equal(posts[0].channel, 'ship_feed');
|
|
432
|
+
assert.match(posts[0].text, /review-lens: Ship the docs/);
|
|
433
|
+
assert.match(posts[0].text, /the copy on line 3 reads oddly/);
|
|
434
|
+
const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
|
|
435
|
+
assert.ok(update.params.includes('discord:ship_feed:9'),
|
|
436
|
+
'the pointer names where it landed, so the ledger can be walked back');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await test('a finding about a SECURITY-SENSITIVE ship never reaches a public channel', async () => {
|
|
440
|
+
const posts = [];
|
|
441
|
+
const pool = fakePool({
|
|
442
|
+
defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
|
|
443
|
+
task: { id: '9', title: 'Patch the hole', touches: ['docs/a.md'], source: null, security_sensitive: true },
|
|
444
|
+
});
|
|
445
|
+
await fanOut({ id: '9' }, {
|
|
446
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
|
|
447
|
+
runSubagentCached: async () => ({ stdout: 'here is how the hole worked' }),
|
|
448
|
+
});
|
|
449
|
+
assert.equal(posts[0].channel, 'security_ship_feed',
|
|
450
|
+
'advertising the shape of an exploit on a channel the guild reads is the one thing this must not do');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await test('an INDETERMINATE security flag SUPPRESSES the post rather than guessing', async () => {
|
|
454
|
+
for (const flag of [null, undefined, 'yes', 1]) {
|
|
455
|
+
const posts = [];
|
|
456
|
+
const pool = fakePool({
|
|
457
|
+
defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
|
|
458
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null, security_sensitive: flag },
|
|
459
|
+
});
|
|
460
|
+
await fanOut({ id: '9' }, {
|
|
461
|
+
pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
|
|
462
|
+
runSubagentCached: async () => ({ stdout: 'a finding' }),
|
|
463
|
+
});
|
|
464
|
+
assert.equal(posts.length, 0, `security_sensitive=${flag}: "we could not tell" must not resolve to "post it publicly"`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await test('a post the operator never configured is NOT recorded as a pointer', async () => {
|
|
469
|
+
const pool = fakePool({
|
|
470
|
+
defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
|
|
471
|
+
task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null, security_sensitive: false },
|
|
472
|
+
});
|
|
473
|
+
await fanOut({ id: '9' }, {
|
|
474
|
+
pool,
|
|
475
|
+
compileGlobMatcher: compile,
|
|
476
|
+
spendPayer: null,
|
|
477
|
+
resolveOptional: (port) => (port === 'discord.post'
|
|
478
|
+
? async () => ({ posted: false, reason: 'no_webhook_for_channel' })
|
|
479
|
+
: null),
|
|
480
|
+
runSubagentCached: async () => ({ stdout: 'a finding' }),
|
|
481
|
+
});
|
|
482
|
+
const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
|
|
483
|
+
assert.ok(!update.params.some((x) => typeof x === 'string' && x.startsWith('discord:')),
|
|
484
|
+
'a pointer to a message that does not exist is a lie the ledger cannot be talked out of later');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ===========================================================================
|
|
488
|
+
// WHAT REACHES A SINK IS UNTRUSTED TEXT
|
|
489
|
+
// ===========================================================================
|
|
490
|
+
|
|
491
|
+
await test('control characters are stripped and the body is bounded before any sink sees it', async () => {
|
|
492
|
+
const filed = [];
|
|
493
|
+
const hostile = `a finding${String.fromCharCode(0)}with${String.fromCharCode(27)}[31m escapes`;
|
|
494
|
+
const pool = fakePool({
|
|
495
|
+
defs: [def()],
|
|
496
|
+
task: { id: '9', title: `T${String.fromCharCode(7)}itle`, touches: ['docs/a.md'], source: null },
|
|
497
|
+
});
|
|
498
|
+
await fanOut({ id: '9' }, {
|
|
499
|
+
pool,
|
|
500
|
+
compileGlobMatcher: compile,
|
|
501
|
+
spendPayer: null,
|
|
502
|
+
resolveOptional: (port) => (port === 'ideas.capture'
|
|
503
|
+
? async (arg) => { filed.push(arg); return { id: 1 }; }
|
|
504
|
+
: null),
|
|
505
|
+
runSubagentCached: async () => ({ stdout: hostile + 'x'.repeat(20000) }),
|
|
506
|
+
});
|
|
507
|
+
const sent = JSON.stringify(filed[0]);
|
|
508
|
+
assert.ok(!sent.includes('\\u0000'), 'a NUL must never reach a rendering surface');
|
|
509
|
+
assert.ok(!sent.includes('\\u001b'), 'nor an ANSI escape');
|
|
510
|
+
assert.ok(filed[0].bodyMd.length < 9000, `the body is bounded; got ${filed[0].bodyMd.length}`);
|
|
511
|
+
assert.ok(filed[0].title.length <= 180, 'and so is the title');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await test('this test file and the router are TEXT — git must be able to diff them', () => {
|
|
515
|
+
// A literal control byte in the source makes git call the file binary, and a
|
|
516
|
+
// binary file is one nobody can review. Both files shipped that way once.
|
|
517
|
+
const fs = require('node:fs');
|
|
518
|
+
for (const f of ['tests/agents_event_router.mjs', 'modules/agents/pollers/event-router.js']) {
|
|
519
|
+
assert.equal(fs.readFileSync(f).includes(0), false, `${f} contains a NUL byte`);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
console.log(`\nagents_event_router: ${passed} passed, ${failed} failed`);
|
|
523
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/hooks_drain.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
|
|
22
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
-
const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
|
|
23
|
+
const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints', 'stuck-detector'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
|
|
24
24
|
// The SessionStart/SessionEnd scripts that did network work and then process.exit()ed — 59 recorded
|
|
25
25
|
// UV_HANDLE_CLOSING aborts in three days (task 1003548). Same contract: exit 0 on their own, no abort.
|
|
26
26
|
// session-digest requires `pg` at load (via db-kernel), so it can only be exercised where node_modules
|
|
@@ -49,6 +49,7 @@ const ALLOWED = {
|
|
|
49
49
|
'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
|
|
50
50
|
session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
|
|
51
51
|
stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',
|
|
52
|
+
stuck_detector: '.claude/hooks/stuck-strikes.js (package-excluded)',
|
|
52
53
|
worktree_guard: '.claude/hooks/worktree-guard.js (package-excluded)',
|
|
53
54
|
};
|
|
54
55
|
|
package/tests/provision.mjs
CHANGED
|
@@ -1219,6 +1219,21 @@ await ta('cmdRunIntents (dry-run) lists the pending queue, runs nothing', async
|
|
|
1219
1219
|
assert.equal(r.ran, 0);
|
|
1220
1220
|
});
|
|
1221
1221
|
|
|
1222
|
+
await ta('cmdRunIntents opens with the runner core version + path, so a journal grep shows a stale runner (task 1002722)', async () => {
|
|
1223
|
+
for (const apply of [false, true]) {
|
|
1224
|
+
const lines = [];
|
|
1225
|
+
const deps = {
|
|
1226
|
+
apply, log: (l) => lines.push(l), instanceRoot: '/no/such/instance',
|
|
1227
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1228
|
+
provisioning: { claimNextIntent: async () => null, claimNextOAuthManifest: async () => null },
|
|
1229
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }),
|
|
1230
|
+
};
|
|
1231
|
+
await P.cmdRunIntents(deps);
|
|
1232
|
+
const { REPO_ROOT } = require('../scripts/gds/provision-config.js');
|
|
1233
|
+
assert.equal(lines[0], `runner core ${P.coreVersionSafe()} from ${REPO_ROOT} (no installed core at /no/such/instance)`, `apply=${apply}`);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1222
1237
|
await ta('cmdRunIntents (--apply) claims → provisions → resolves the intent done', async () => {
|
|
1223
1238
|
let claims = 0; const resolved = [];
|
|
1224
1239
|
const deps = {
|
|
@@ -2240,6 +2255,9 @@ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through
|
|
|
2240
2255
|
assert.equal(resolved.length, 1);
|
|
2241
2256
|
assert.equal(resolved[0].state, 'error');
|
|
2242
2257
|
assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
|
|
2258
|
+
// task 1002722 (the refusal itself landed in task 1002692): the error names the action AND
|
|
2259
|
+
// the runner's core, so a stale runner is diagnosable from the intent row alone.
|
|
2260
|
+
assert.equal(resolved[0].err, `unknown_action 'oauth-manifest' — runner core ${P.coreVersionSafe()} does not know it (runner too old?); refusing to guess`);
|
|
2243
2261
|
});
|
|
2244
2262
|
|
|
2245
2263
|
console.log('\nprovisioning truthfulness (task 1002720 — QA-walk F15/F16/F19):');
|