@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.
@@ -0,0 +1,737 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/pollers/event-router.js — the EVENT half of the agents module
4
+ // (task 1002494, goal 1000095 / BONGOS-V2).
5
+ //
6
+ // `POST /agents/:name/invoke` fires an agent because a builder asked. This fires
7
+ // one because something HAPPENED: the lifecycle emits `task.shipped`, and every
8
+ // enabled definition whose `trigger_spec` names that event and whose declared
9
+ // `touches[]` filter covers what shipped gets one fire. It is the counterpart of
10
+ // gate.js — that file answers "may this be asked?", this one answers "should
11
+ // this be told?" — and it is the thing task 1002495's roster (review-lens,
12
+ // doc-link-keeper, dep-security-watch) needs in order to exist at all.
13
+ //
14
+ // It is filed under pollers/ because that is the loader's only boot hook for a
15
+ // module (src/module-loader/loader.js startModulePollers → `contributes.pollers`,
16
+ // declared in module.json). It owns no timer: `start()` subscribes and `stop()`
17
+ // unsubscribes. A "poller" here means "something the module runs in the web
18
+ // process", not "something on a clock".
19
+ //
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // FOUR PROPERTIES, EACH FROM THE TASK'S OWN BRIEF, AND EACH LOAD-BEARING.
22
+ //
23
+ // 1. THE CHEAP FILTER RUNS FIRST — NEVER PAY FOR A SPAWN TO DISCOVER
24
+ // IRRELEVANCE. Nothing spawns until the definition's declared `touches[]`
25
+ // globs have been matched against what the shipped task actually touched. A
26
+ // fire is ~$0.90 and 60-120 seconds; a glob match is microseconds and no I/O.
27
+ // The order below is strictly cheapest-first for that reason: the indexed
28
+ // definition read, then the glob filter, then the loop guard, then the sink
29
+ // check, then the fan-out cap, then the spend ceiling, and only then a model.
30
+ //
31
+ // A PRE-FILTER MISS IS NOT LEDGERED, and that is a deliberate departure from
32
+ // the module's "every refusal is a row" rule. A miss is SELECTION, not
33
+ // refusal — the same category as the partial dispatch index skipping a
34
+ // disabled row, which also writes nothing. Ledgering it would put one row per
35
+ // non-matching agent on every ship forever and bury the refusals that matter.
36
+ // Everything downstream of the filter — the loop guard, an unwired sink, the
37
+ // fan-out cap, the spend ceiling — IS ledgered as a no-go, because each of
38
+ // those stops a fire that was otherwise going to happen.
39
+ //
40
+ // 2. THE DEPTH GUARD, OR AGENTS TRIGGER EACH OTHER FOREVER. An agent's finding
41
+ // is filed through `ideas.capture`; an idea becomes a task; a task ships; a
42
+ // ship is `task.shipped`. That is a closed loop, and without a guard it is an
43
+ // unbounded one that spends real money going round. So: a ship that is itself
44
+ // ATTRIBUTED to an `agents_runs` row fires nothing. Attribution is read off
45
+ // the ledger's own `output_ref` — the pointer a fire writes when its finding
46
+ // lands — never off a heuristic about the task's title or author.
47
+ //
48
+ // It FAILS CLOSED: an unreadable ledger, a missing task row, a throwing
49
+ // query all read as "attributed", i.e. do not fire. "I could not check
50
+ // whether this is a loop" must never resolve to "go ahead"; that is the exact
51
+ // failure lib/spend-ceiling.js's header dissects at length (task 1001393).
52
+ //
53
+ // 3. THE SINK IS CHECKED BEFORE THE SPAWN, NOT AFTER. A finding delivered
54
+ // nowhere is a fire nobody can read and money nobody got anything for, so an
55
+ // unwired or unavailable sink refuses BEFORE the model runs — property 1
56
+ // applied to the other end of the pipe. SINK_CATALOG is the WIRED set, not
57
+ // the imaginable set, exactly as validate.js's EVENT_CATALOG is — every
58
+ // entry is backed by a port that exists, and one that is wired but absent on
59
+ // this instance refuses by name rather than silently.
60
+ //
61
+ // 4. IT CAN NEVER BLOCK, SLOW, OR FAIL A SHIP. `seams.emitAsync` AWAITS each
62
+ // listener (src/module-seams.js), and the HTTP ship handler awaits the emit —
63
+ // so a listener that awaited a 120-second fire would hold the shipping
64
+ // builder's request open for two minutes. The subscribed handler is therefore
65
+ // SYNCHRONOUS and returns undefined: it kicks the fan-out off detached and
66
+ // returns instantly. Every error inside is caught and logged. Only the
67
+ // DB-gated Grader gates landing; agents are observe-only, and this file must
68
+ // not become a second gate by accident or by latency.
69
+
70
+ const api = require('../../../src/module-api');
71
+ const { spawnAgent } = require('../spawn');
72
+ const { createSpendCeiling, payerFor } = require('../lib/spend-ceiling');
73
+
74
+ const log = api.logger('agents');
75
+
76
+ // The event this router dispatches. One constant rather than a literal in three
77
+ // places, and a member of validate.js's EVENT_CATALOG by construction — a
78
+ // definition naming an event nothing dispatches is refused at validation, so
79
+ // this string and that catalog entry are two halves of one wiring.
80
+ const EVENT = 'task.shipped';
81
+
82
+ // ONE instance for the module, for the reason lib/fire-budget.js's header gives:
83
+ // a ceiling that splits when a second caller builds its own is not a ceiling.
84
+ // The routes file holds its own for the on-demand path; this is the event path's,
85
+ // and they read the same ledger, so the CAP is one number either way.
86
+ const spendCeiling = createSpendCeiling();
87
+
88
+ // How many agents ONE event may fire. The on-demand path is bounded by
89
+ // lib/fire-budget.js — a sliding window per BUILDER — and an event fire has no
90
+ // builder, so that ceiling cannot apply to it (lib/spend-ceiling.js's payerFor
91
+ // says so in as many words: "an event fire has no caller"). That leaves the
92
+ // monthly spend ceiling as the only bound, and its header names its own blind
93
+ // spot: fires already in flight are invisible to it, so the overshoot is
94
+ // "concurrent-fires × ~$0.90" and "what bounds the concurrent count is
95
+ // fire-budget". On this path nothing did. This is that missing half — a blast
96
+ // radius per event, not an accounting, and the same shape for the same reason.
97
+ const DEFAULT_FANOUT_MAX = 5;
98
+
99
+ function envInt(name, fallback) {
100
+ const raw = process.env[name];
101
+ if (!raw) return fallback;
102
+ const n = Number(raw);
103
+ return Number.isInteger(n) && n > 0 ? n : fallback;
104
+ }
105
+
106
+ const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
107
+
108
+ // The columns one fire needs. Spelled out rather than SELECT *, for the reason
109
+ // routes/agents.js gives about its own COLUMNS list: a column added later must
110
+ // not reach a model prompt just because it exists. Only what a fire reads: `id` + `name` for the ledger, `persona` for
111
+ // the prompt, `trigger_spec` for the filter and the sink, `model_tier` for the
112
+ // spawn. Deliberately NOT `scope_violation` — a flagged definition is forced
113
+ // `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
114
+ // clause below already excludes it and a second check here would only be a
115
+ // second place to get it wrong.
116
+ const DEF_COLUMNS = `
117
+ id, name, persona, trigger_type, trigger_spec, model_tier
118
+ `;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // THE PRE-FILTER (pure)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ // The declared filter, read off `trigger_spec`. `touches` is the documented
125
+ // spelling; `touches_globs` is accepted as the same thing because the field is
126
+ // named after the task column it matches against and both spellings have been
127
+ // written down in the goal's own prose. Anything that is not an array of
128
+ // non-empty strings is treated as ABSENT rather than as an error: a malformed
129
+ // filter must not silently narrow to "matches nothing", which would disarm an
130
+ // armed agent with no trace. It widens to "no filter declared", which is visible
131
+ // in the registry and in this file's log line.
132
+ function declaredGlobs(definition) {
133
+ const spec = definition && definition.trigger_spec;
134
+ if (!spec || typeof spec !== 'object') return [];
135
+ const raw = Array.isArray(spec.touches) ? spec.touches
136
+ : Array.isArray(spec.touches_globs) ? spec.touches_globs
137
+ : [];
138
+ return raw.filter((g) => typeof g === 'string' && g.trim() !== '');
139
+ }
140
+
141
+ /**
142
+ * Does this definition's declared filter cover what the task touched? PURE.
143
+ *
144
+ * NO DECLARED FILTER ⇒ FIRES ON EVERY SHIP. An absent filter is a definition
145
+ * that declared no narrowing, and inventing one for it would make an armed
146
+ * agent inert for reasons nothing records. Arming a universal agent is a
147
+ * deliberate act by a Metic+ author (the `agent.arm` atom), it is visible in
148
+ * `GET /agents`, and its cost is bounded by the fan-out cap and the spend
149
+ * ceiling — which is the honest place for that bound to live.
150
+ *
151
+ * A DECLARED FILTER AGAINST AN EMPTY `touches[]` ⇒ NO FIRE. A task that declared
152
+ * no blast radius offers no evidence that it is in the agent's scope, and this
153
+ * is the path where "no evidence" must read as "do not spend" — the cheap-filter
154
+ * rule of property 1. `touches[]` is advisory on a task (ADR 0049), so this is
155
+ * the right direction of error: a missed fire costs nothing and shows up as an
156
+ * agent that stayed quiet; a paid fire on an unrelated ship costs ~$0.90 and
157
+ * shows up as noise in the inbox.
158
+ *
159
+ * @param {object} definition the agents_definitions row.
160
+ * @param {string[]} taskTouches the shipped task's touches[].
161
+ * @param {(globs: string[]) => (file: string) => boolean} compile the matcher
162
+ * factory, injected so this stays pure and testable with no core require.
163
+ * @returns {{match: boolean, reason: string}}
164
+ */
165
+ function matchesTouches(definition, taskTouches, compile) {
166
+ const globs = declaredGlobs(definition);
167
+ if (globs.length === 0) return { match: true, reason: 'no_filter_declared' };
168
+ const paths = (Array.isArray(taskTouches) ? taskTouches : []).filter(
169
+ (p) => typeof p === 'string' && p.trim() !== '',
170
+ );
171
+ if (paths.length === 0) return { match: false, reason: 'task_declared_no_touches' };
172
+ let matcher;
173
+ try {
174
+ matcher = compile(globs);
175
+ } catch (_) {
176
+ // A filter that cannot be compiled is a filter nobody can trust. Refuse the
177
+ // fire rather than fall back to matching everything, which would turn a typo
178
+ // in one definition into a paid fire on every ship.
179
+ return { match: false, reason: 'filter_uncompilable' };
180
+ }
181
+ for (const p of paths) {
182
+ let hit = false;
183
+ try { hit = matcher(p) === true; } catch (_) { hit = false; }
184
+ if (hit) return { match: true, reason: 'touches_match' };
185
+ }
186
+ return { match: false, reason: 'no_touches_overlap' };
187
+ }
188
+
189
+ /**
190
+ * Split the candidate definitions into the ones that fire and the ones the
191
+ * pre-filter dropped. PURE — the whole selection decision, testable with no
192
+ * database, no model and no server.
193
+ */
194
+ function selectFiring(definitions, taskTouches, compile) {
195
+ const firing = [];
196
+ const filtered = [];
197
+ for (const d of definitions || []) {
198
+ const verdict = matchesTouches(d, taskTouches, compile);
199
+ (verdict.match ? firing : filtered).push({ definition: d, reason: verdict.reason });
200
+ }
201
+ return { firing, filtered };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // THE DEPTH GUARD
206
+ // ---------------------------------------------------------------------------
207
+
208
+ // The `output_ref` spellings that would mean "an agent produced this". PURE.
209
+ //
210
+ // Two refs, because a finding reaches a task by two hops and the guard has to
211
+ // cover both ends:
212
+ // `task:<id>` — a fire whose output landed as this task directly.
213
+ // `idea:<id>` — a fire whose finding landed as an idea; `/idea-triage` later
214
+ // promoted it, and the promotion stamps `tasks.source` as
215
+ // 'idea-<id>' (that is how every promoted task on this instance
216
+ // is spelled). So the idea id is recoverable from the task row
217
+ // with no cross-module table read — this module may not go
218
+ // reading `idea_inbox`, and does not have to.
219
+ function depthRefsFor(taskRow) {
220
+ const refs = [];
221
+ const id = taskRow && taskRow.id;
222
+ if (id !== null && id !== undefined && String(id) !== '') refs.push(`task:${id}`);
223
+ const source = taskRow && typeof taskRow.source === 'string' ? taskRow.source : '';
224
+ const m = source.match(/^idea-(\d+)$/);
225
+ if (m) refs.push(`idea:${m[1]}`);
226
+ return refs;
227
+ }
228
+
229
+ /**
230
+ * Is this ship attributable to an agent fire? FAILS CLOSED — an unanswerable
231
+ * question returns `true`, i.e. "do not fire".
232
+ *
233
+ * @returns {{attributed: boolean, reason: string|null}}
234
+ */
235
+ async function isAgentAttributed(pool, taskRow) {
236
+ const refs = depthRefsFor(taskRow);
237
+ if (refs.length === 0) {
238
+ // No id to ask about. Not knowing which task shipped is not a reason to
239
+ // spend; it is a reason to stop.
240
+ return { attributed: true, reason: 'the shipped task could not be identified, so the loop guard could not be evaluated' };
241
+ }
242
+ if (!pool || typeof pool.query !== 'function') {
243
+ return { attributed: true, reason: 'the agents_runs ledger was unreadable, so the loop guard could not be evaluated' };
244
+ }
245
+ try {
246
+ const { rows } = await pool.query(
247
+ `SELECT id, agent_name FROM agents_runs
248
+ WHERE output_ref = ANY($1::text[])
249
+ LIMIT 1`,
250
+ [refs],
251
+ );
252
+ if (rows && rows.length) {
253
+ return {
254
+ attributed: true,
255
+ reason: `this ship is attributed to agent run ${rows[0].id} (${rows[0].agent_name}) — firing on an agent's own output is the loop this guard exists to stop`,
256
+ };
257
+ }
258
+ return { attributed: false, reason: null };
259
+ } catch (_) {
260
+ return { attributed: true, reason: 'the agents_runs ledger read threw, so the loop guard could not be evaluated' };
261
+ }
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // THE SINKS
266
+ // ---------------------------------------------------------------------------
267
+
268
+ // WIRED, not imaginable — the same rule and the same reason as validate.js's
269
+ // EVENT_CATALOG: a definition naming a sink nothing delivers to looks armed and
270
+ // can never report, which is the worst of both readings. Both entries below are
271
+ // backed by a port that exists; a name with no port behind it does not belong
272
+ // here, and `resolveSink` refuses one that is wired but absent on this instance.
273
+ //
274
+ // The two differ in KIND, not just destination, and a definition picks by what
275
+ // it wants done with the finding:
276
+ //
277
+ // `ideas.capture` the finding enters the BACKLOG as an inbox row a human
278
+ // triages. Filed with no `vector`, so routing.js leaves it an
279
+ // idea and never a claimable task.
280
+ // `discord` the finding is ANNOUNCED and nothing more. Discord is a
281
+ // one-way mirror and never authority (ADR 0033), which is
282
+ // also why this sink cannot feed the loop the depth guard
283
+ // watches: a message becomes no record here, so there is
284
+ // nothing for a later ship to be attributed to.
285
+ const SINK_CATALOG = Object.freeze({
286
+ 'ideas.capture': Object.freeze({ port: 'ideas.capture', deliver: deliverToInbox }),
287
+ discord: Object.freeze({ port: 'discord.post', deliver: deliverToDiscord }),
288
+ });
289
+
290
+ const DEFAULT_SINK = 'ideas.capture';
291
+
292
+ // Which sink did this definition declare? PURE. Absent ⇒ the default, because a
293
+ // finding with nowhere to go is the one outcome worth ruling out by construction.
294
+ function sinkNameFor(definition) {
295
+ const spec = definition && definition.trigger_spec;
296
+ const raw = spec && typeof spec === 'object' ? spec.sink : null;
297
+ return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : DEFAULT_SINK;
298
+ }
299
+
300
+ /**
301
+ * Resolve the declared sink to something that can actually take a finding, or
302
+ * say why not. Called BEFORE the spawn — property 3.
303
+ *
304
+ * @returns {{ok: true, name: string, deliver: Function} | {ok: false, name: string, reason: string}}
305
+ */
306
+ function resolveSink(definition, deps = {}) {
307
+ const name = sinkNameFor(definition);
308
+ const entry = SINK_CATALOG[name];
309
+ if (!entry) {
310
+ return {
311
+ ok: false,
312
+ name,
313
+ reason: `sink '${name}' is not wired — this instance delivers findings to: ${Object.keys(SINK_CATALOG).join(', ')}. Refused before spawning rather than after: a finding nobody receives is money spent for nothing.`,
314
+ };
315
+ }
316
+ const resolveOptional = deps.resolveOptional
317
+ || (typeof api.resolveOptional === 'function' ? api.resolveOptional.bind(api) : null);
318
+ const port = resolveOptional ? resolveOptional(entry.port, null) : null;
319
+ if (typeof port !== 'function') {
320
+ return {
321
+ ok: false,
322
+ name,
323
+ reason: `sink '${name}' is wired but the '${entry.port}' port is not available on this instance (the module providing it is disabled) — refused before spawning.`,
324
+ };
325
+ }
326
+ return { ok: true, name, deliver: port, entry };
327
+ }
328
+
329
+ // Everything a sink is handed is UNTRUSTED TEXT, from two directions at once:
330
+ // `task.title` is builder-authored and can arrive from the idea inbox, and the
331
+ // finding is raw model output generated from a repo the agent read. Both are
332
+ // bounded and stripped of control characters HERE, once, so neither sink has to
333
+ // remember to — and so nothing that reaches a rendering surface can carry an
334
+ // ANSI escape, a NUL, or an unbounded blob. This is a floor, not an escaping
335
+ // layer: HTML escaping belongs to whatever renders the row, and this file makes
336
+ // no claim about it.
337
+ const TITLE_MAX = 180;
338
+ const BODY_MAX = 8000;
339
+
340
+ function clean(value, max) {
341
+ // Scanned by CODE POINT rather than matched by a regex character class: the
342
+ // class would have to spell control characters as escape sequences, and a
343
+ // source file that spells them is one editor away from CONTAINING them —
344
+ // which is how this file first shipped as a binary blob git would not diff.
345
+ // Tab and newline survive; everything else below 0x20, plus DEL, becomes a
346
+ // space.
347
+ const s = String(value == null ? '' : value);
348
+ let out = '';
349
+ for (const ch of s) {
350
+ const c = ch.codePointAt(0);
351
+ const control = (c < 0x20 && c !== 0x0a && c !== 0x09) || c === 0x7f;
352
+ out += control ? ' ' : ch;
353
+ }
354
+ return out.trim().slice(0, max);
355
+ }
356
+
357
+ // The two lines every finding carries, whichever sink takes it: WHO said it,
358
+ // WHAT it was looking at, and WHEN. Composed once so the two deliveries cannot
359
+ // describe the same fire differently.
360
+ function findingParts({ definition, task, text }) {
361
+ return {
362
+ title: clean(`${definition.name}: ${task.title || `task ${task.id}`}`, TITLE_MAX),
363
+ provenance: `Reported by the \`${definition.name}\` agent on \`${EVENT}\` for task ${task.id}.`,
364
+ body: clean(text, BODY_MAX),
365
+ };
366
+ }
367
+
368
+ // The BACKLOG sink. NO `vector` IS PASSED, and that is the observe-only property
369
+ // in one argument: modules/ideas/routing.js routes a capture into a real task
370
+ // only for a filing vector on its own allowlist, so an absent vector is
371
+ // UNTRUSTED and the finding stays an inbox row for a human to triage. An agent
372
+ // may report, and may not manufacture claimable work — the same posture as the
373
+ // read-only spawn, enforced at the other end of the pipe.
374
+ //
375
+ // Returns the POINTER, never the text: `agents_runs.output_ref` is a pointer by
376
+ // schema contract (see the migration's header), and `idea:<id>` is the spelling
377
+ // the depth guard reads back.
378
+ async function deliverToInbox(deliver, parts) {
379
+ const idea = await deliver({
380
+ title: parts.title,
381
+ bodyMd: `${parts.provenance}\n\n${parts.body}`,
382
+ capturedBy: null,
383
+ });
384
+ const id = idea && idea.id;
385
+ return id !== null && id !== undefined ? `idea:${id}` : null;
386
+ }
387
+
388
+ // The ANNOUNCE sink. Discord is a one-way mirror and never authority (ADR 0033).
389
+ //
390
+ // THE CHANNEL IS DECIDED HERE, NEVER BY THE DEFINITION. An agent that could name
391
+ // its own channel could choose where it is heard, and the one choice that
392
+ // actually matters is the security one: a finding about a SECURITY-SENSITIVE
393
+ // ship must not advertise the shape of an unpatched exploit on a channel the
394
+ // whole guild reads. So this mirrors ship-broadcast.chooseBroadcastTargets' rule
395
+ // verbatim, including its fail-closed arm — a `security_sensitive` that is not a
396
+ // definite boolean SUPPRESSES the post rather than guessing, because "we could
397
+ // not tell whether this was a security fix" must never resolve to "post it
398
+ // publicly".
399
+ function discordChannelFor(task) {
400
+ const sec = task && task.security_sensitive;
401
+ if (sec === true) return 'security_ship_feed';
402
+ if (sec === false) return 'ship_feed';
403
+ return null;
404
+ }
405
+
406
+ async function deliverToDiscord(deliver, parts, { task }) {
407
+ const channel = discordChannelFor(task);
408
+ if (!channel) return null;
409
+ const res = await deliver({
410
+ channel,
411
+ text: `${parts.title}\n${parts.provenance}\n${parts.body}`,
412
+ });
413
+ // A post that did not happen is not a pointer. `discord.post` answers
414
+ // `{posted:false}` when the operator configured no webhook for the channel,
415
+ // and recording a pointer to a message that does not exist would be a lie the
416
+ // ledger cannot be talked out of later.
417
+ return res && res.posted === true ? `discord:${channel}:${task.id}` : null;
418
+ }
419
+
420
+ // Deliver one finding through whichever sink resolved, and return the POINTER to
421
+ // where it landed.
422
+ async function deliverFinding(sink, { definition, task, text }) {
423
+ const parts = findingParts({ definition, task, text });
424
+ const deliver = sink.entry && typeof sink.entry.deliver === 'function' ? sink.entry.deliver : deliverToInbox;
425
+ return deliver(sink.deliver, parts, { task, definition });
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // THE FAN-OUT
430
+ // ---------------------------------------------------------------------------
431
+
432
+ // The deps spawnAgent is handed. The POOL is threaded explicitly rather than
433
+ // left to spawnAgent's `api.pool` fallback, so a caller that injected a fake pool
434
+ // for the reads above does not silently get the real one for the ledger writes —
435
+ // a test that half-injects is a test that writes rows into a live database.
436
+ function spawnDepsFrom(deps = {}) {
437
+ const extra = deps.spawnDeps || {};
438
+ return deps.pool && !extra.pool ? { ...extra, pool: deps.pool } : { ...extra };
439
+ }
440
+
441
+ // Record a fire that is NOT going to happen, using the same spawn seam the real
442
+ // ones use. spawnAgent writes the no-go row and returns without spawning, so
443
+ // this is a database round trip and never a model call — and routing it through
444
+ // spawnAgent rather than an INSERT here is what keeps ONE ledger contract in
445
+ // ONE place (the second copy is how two ledgers start disagreeing).
446
+ async function ledgerNoGo(deps, { definition, taskId, reason, spendPayer }) {
447
+ try {
448
+ return await spawnAgent({
449
+ definition,
450
+ trigger: { type: 'event', ref: `${EVENT}:${taskId}` },
451
+ gate: { decision: 'no-go', reason },
452
+ requestedByBuilderId: null,
453
+ spendPayer,
454
+ }, spawnDepsFrom(deps));
455
+ } catch (err) {
456
+ // spawnAgent does not throw, but this whole file's contract is that nothing
457
+ // reaches the emitter. A ledger write that failed is logged, not raised.
458
+ log.error({ err, agent: definition && definition.name }, 'agents/event-router: no-go ledger write failed');
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * The whole reaction to one shipped task. Exported and awaitable so a test can
465
+ * drive it end to end; the subscribed handler never awaits it (property 4).
466
+ *
467
+ * Returns a REPORT rather than throwing — the same shape the cascade runner
468
+ * returns for the same reason (ADR 0242): a reaction that could fail the thing
469
+ * it reacted to would smuggle a gate in through the back door.
470
+ */
471
+ async function fanOut(payload, deps = {}) {
472
+ const report = {
473
+ task_id: null, considered: 0, filtered: 0, fired: 0, skipped: 0, reason: null,
474
+ };
475
+ const pool = deps.pool || api.pool;
476
+ const compile = deps.compileGlobMatcher
477
+ || ((globs) => api.permissionPathCheck.compileGlobMatcher(globs));
478
+ const spendPayer = deps.spendPayer !== undefined ? deps.spendPayer : projectSpendPayer();
479
+
480
+ const taskId = payload && payload.id !== undefined && payload.id !== null ? String(payload.id) : null;
481
+ report.task_id = taskId;
482
+ if (!taskId) { report.reason = 'no_task_id'; return report; }
483
+
484
+ // (a) THE DEFINITION READ, FIRST AND CHEAPEST. It rides the partial dispatch
485
+ // index agents_001 built for exactly this query (`WHERE enabled`), and on the
486
+ // overwhelmingly common instance — agents shipped, nothing armed — it returns
487
+ // zero rows and the whole reaction ends here, having read no task, spent
488
+ // nothing and written nothing.
489
+ let definitions = [];
490
+ try {
491
+ const { rows } = await pool.query(
492
+ `SELECT ${DEF_COLUMNS} FROM agents_definitions
493
+ WHERE enabled AND trigger_type = 'event' AND trigger_spec ->> 'event' = $1
494
+ ORDER BY id`,
495
+ [EVENT],
496
+ );
497
+ definitions = rows || [];
498
+ } catch (err) {
499
+ // Nothing is armed as far as we can tell, so nothing fires and nothing is
500
+ // ledgered — there is no definition to attribute a row to.
501
+ log.error({ err, task_id: taskId }, 'agents/event-router: definition read failed; no agent fired');
502
+ report.reason = 'definitions_unreadable';
503
+ return report;
504
+ }
505
+ report.considered = definitions.length;
506
+ if (definitions.length === 0) { report.reason = 'no_armed_event_agents'; return report; }
507
+
508
+ // (b) THE TASK ROW. The `task.shipped` payload is db-ship.js's RETURNING
509
+ // projection — id, title, status, shipped_at, shipped_by, security_sensitive,
510
+ // value_summary, credits_reward, kind — and it carries NEITHER `touches` nor
511
+ // `source`, which are exactly the two fields the pre-filter and the loop guard
512
+ // read. So this re-reads them rather than trusting a projection that was never
513
+ // built for this consumer. Widening the emit instead would have coupled the
514
+ // lifecycle module's ship shape to this module's needs.
515
+ let task = null;
516
+ try {
517
+ const { rows } = await pool.query(
518
+ `SELECT id, title, touches, source, security_sensitive FROM tasks WHERE id = $1`, [taskId],
519
+ );
520
+ task = rows && rows[0] ? rows[0] : null;
521
+ } catch (_) { task = null; }
522
+ if (!task) {
523
+ // FAIL CLOSED. With no touches[] the pre-filter cannot run, and firing every
524
+ // armed agent on an unreadable task is the expensive direction of that
525
+ // error. Ledgered, unlike a filter miss: these fires WOULD have happened.
526
+ const reason = 'the shipped task row could not be read, so the touches[] pre-filter could not run — refused rather than firing every armed agent blind';
527
+ for (const d of definitions) await ledgerNoGo(deps, { definition: d, taskId, reason, spendPayer });
528
+ report.skipped = definitions.length;
529
+ report.reason = 'task_unreadable';
530
+ return report;
531
+ }
532
+
533
+ // (c) THE CHEAP FILTER — property 1. No I/O, no ledger rows for the misses.
534
+ const { firing, filtered } = selectFiring(definitions, task.touches, compile);
535
+ report.filtered = filtered.length;
536
+ if (firing.length === 0) { report.reason = 'no_agent_matched_touches'; return report; }
537
+
538
+ // (d) THE DEPTH GUARD — property 2.
539
+ const depth = await isAgentAttributed(pool, task);
540
+ if (depth.attributed) {
541
+ for (const f of firing) {
542
+ await ledgerNoGo(deps, { definition: f.definition, taskId, reason: depth.reason, spendPayer });
543
+ }
544
+ report.skipped = firing.length;
545
+ report.reason = 'depth_guard';
546
+ return report;
547
+ }
548
+
549
+ // (e) THE SINK CHECK — property 3, before any spawn.
550
+ const deliverable = [];
551
+ for (const f of firing) {
552
+ const sink = resolveSink(f.definition, deps);
553
+ if (!sink.ok) {
554
+ await ledgerNoGo(deps, { definition: f.definition, taskId, reason: sink.reason, spendPayer });
555
+ report.skipped += 1;
556
+ continue;
557
+ }
558
+ deliverable.push({ definition: f.definition, sink });
559
+ }
560
+ if (deliverable.length === 0) { report.reason = 'no_deliverable_sink'; return report; }
561
+
562
+ // (f) THE FAN-OUT CAP. Ordered by id, so which agents survive a cap is stable
563
+ // and reviewable rather than whatever the planner happened to return first.
564
+ const admitted = deliverable.slice(0, FANOUT_MAX);
565
+ for (const over of deliverable.slice(FANOUT_MAX)) {
566
+ await ledgerNoGo(deps, {
567
+ definition: over.definition,
568
+ taskId,
569
+ reason: `one event may fire at most ${FANOUT_MAX} agents (BONGOS_AGENTS_EVENT_FANOUT_MAX) and ${deliverable.length} matched — a blast radius per event, since an event fire has no builder for the per-builder rate ceiling to bound`,
570
+ spendPayer,
571
+ });
572
+ report.skipped += 1;
573
+ }
574
+
575
+ // (g) THE SPEND CEILING, once for the whole fan-out. Per PAYER, and an event
576
+ // fire's payer is always the project (lib/spend-ceiling.js payerFor). It fails
577
+ // closed, including on an unreadable ledger.
578
+ const payer = payerFor(spendPayer, null);
579
+ const ceiling = deps.spendCeiling || spendCeiling;
580
+ const room = await ceiling.check(pool, payer);
581
+ if (!room.ok) {
582
+ const reason = room.reason === 'spend_cap_reached'
583
+ ? `agent spend for this ${payer.payer_kind} has reached its $${room.capUsd} monthly cap`
584
+ : `the agent spend ceiling could not be established (${room.reason}) — a ceiling that cannot be read must not be assumed`;
585
+ for (const a of admitted) {
586
+ await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
587
+ }
588
+ report.skipped += admitted.length;
589
+ report.reason = 'spend_ceiling';
590
+ return report;
591
+ }
592
+
593
+ // (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
594
+ // rather than N at once — the spend ceiling's own blind spot is fires already
595
+ // in flight, and serialising is what keeps that blind spot one fire wide.
596
+ for (const a of admitted) {
597
+ const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
598
+ if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
599
+ }
600
+ report.reason = 'fired';
601
+ return report;
602
+ }
603
+
604
+ // One fire, plus the delivery of whatever it said.
605
+ //
606
+ // The delivery happens INSIDE the wrapped runner, so the pointer it produces is
607
+ // written by the SAME `finishRun` update that records the run's cost and
608
+ // duration. Doing it after `spawnAgent` returned would mean a second UPDATE and
609
+ // a window in which the ledger says a fire succeeded and names nowhere its
610
+ // finding went — which is the window the depth guard reads across.
611
+ async function fireOne({ definition, sink }, { task, taskId, spendPayer }, deps = {}) {
612
+ let outputRef = null;
613
+ let deliveryError = null;
614
+
615
+ const runner = async (args) => {
616
+ const resolveOptional = deps.resolveOptional
617
+ || (typeof api.resolveOptional === 'function' ? api.resolveOptional.bind(api) : null);
618
+ const grade = resolveOptional ? resolveOptional('grade', null) : null;
619
+ const run = deps.runSubagentCached
620
+ || (grade && typeof grade.runSubagentCached === 'function' ? grade.runSubagentCached.bind(grade) : null);
621
+ if (!run) throw Object.assign(new Error('grade port unavailable'), { code: 'grade_port_unavailable' });
622
+ const out = await run(args);
623
+ if (out && out.ok === false) return out;
624
+ const text = out && typeof out.stdout === 'string' ? out.stdout.trim() : '';
625
+ // A fire that found nothing to say is a SUCCESS with no pointer, not a
626
+ // failure. An observe-and-report agent reporting "nothing here" is the
627
+ // outcome most of its fires should have, and filing an empty idea for each
628
+ // one would make the inbox the noise this module is supposed to reduce.
629
+ if (!text) return out;
630
+ try {
631
+ outputRef = await deliverFinding(sink, { definition, task, text });
632
+ } catch (err) {
633
+ // The fire ran and cost money; the finding reached nobody. That is an
634
+ // ERROR row, not an ok one — a finding that silently evaporated is
635
+ // indistinguishable from an agent that found nothing, and those two must
636
+ // never look the same. The cost is still recorded either way.
637
+ deliveryError = err;
638
+ log.error({ err, agent: definition.name, task_id: taskId }, 'agents/event-router: sink delivery failed');
639
+ return { ...out, ok: false, error_code: 'sink_delivery_failed' };
640
+ }
641
+ return out;
642
+ };
643
+
644
+ const result = await spawnAgent({
645
+ definition,
646
+ trigger: { type: 'event', ref: `${EVENT}:${taskId}` },
647
+ gate: { decision: 'go' },
648
+ requestedByBuilderId: null,
649
+ spendPayer,
650
+ }, {
651
+ ...spawnDepsFrom(deps),
652
+ runSubagentCached: runner,
653
+ // Read by spawnAgent AFTER the runner resolved, so the pointer the delivery
654
+ // above produced is the one that lands on the row. A getter rather than a
655
+ // value for exactly that reason: the value does not exist yet at call time.
656
+ get outputRef() { return outputRef; },
657
+ });
658
+
659
+ if (deliveryError) log.warn({ agent: definition.name, run_id: result && result.runId }, 'agents/event-router: fire recorded as error because its finding could not be delivered');
660
+ return result;
661
+ }
662
+
663
+ // The project's `spend_payer` policy, read the way routes/agents.js reads it and
664
+ // for the same reason — branding already memoizes for the process, so a second
665
+ // cache here would only be a way for the two to disagree.
666
+ function projectSpendPayer() {
667
+ try {
668
+ const pack = api.branding ? api.branding() : null;
669
+ return (pack && pack.project && pack.project.spendPayer) || null;
670
+ } catch (_) {
671
+ return null;
672
+ }
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // THE SUBSCRIPTION
677
+ // ---------------------------------------------------------------------------
678
+
679
+ let _off = null;
680
+
681
+ // THE HANDLER IS SYNCHRONOUS ON PURPOSE — property 4. `emitAsync` awaits what a
682
+ // listener returns, and the ship route awaits the emit, so returning a promise
683
+ // here would hold a shipping builder's HTTP request open for the length of a
684
+ // model call. It returns undefined, and the fan-out runs detached behind it.
685
+ function onShipped(task) {
686
+ try {
687
+ const p = fanOut(task);
688
+ if (p && typeof p.catch === 'function') {
689
+ p.catch((err) => log.error({ err }, 'agents/event-router: fan-out rejected'));
690
+ }
691
+ } catch (err) {
692
+ // fanOut is written not to throw synchronously; this is the belt to that
693
+ // brace. A reactor must never break the actor.
694
+ log.error({ err }, 'agents/event-router: fan-out threw');
695
+ }
696
+ return undefined;
697
+ }
698
+
699
+ // off→on replacement rather than an unconditional subscribe, the same idiom
700
+ // modules/discord/routes/discord.js uses: a test (or a re-boot of the loader in
701
+ // one process) must not accumulate duplicate listeners, which would double every
702
+ // fire and every bill.
703
+ function start() {
704
+ if (_off) return;
705
+ _off = api.on(EVENT, onShipped);
706
+ log.info(
707
+ `[agents/event-router] listening on ${EVENT} — up to ${FANOUT_MAX} agents per event, sinks: ${Object.keys(SINK_CATALOG).join(', ')}`,
708
+ );
709
+ }
710
+
711
+ function stop() {
712
+ if (_off) { try { _off(); } catch (_) { /* already gone */ } }
713
+ _off = null;
714
+ }
715
+
716
+ module.exports = {
717
+ start,
718
+ stop,
719
+ // Exported for tests + introspection. The pure half (declaredGlobs,
720
+ // matchesTouches, selectFiring, depthRefsFor, sinkNameFor) is the whole
721
+ // selection decision and needs no database, no model and no server to prove.
722
+ fanOut,
723
+ onShipped,
724
+ declaredGlobs,
725
+ matchesTouches,
726
+ selectFiring,
727
+ depthRefsFor,
728
+ isAgentAttributed,
729
+ sinkNameFor,
730
+ resolveSink,
731
+ deliverFinding,
732
+ EVENT,
733
+ SINK_CATALOG,
734
+ DEFAULT_SINK,
735
+ FANOUT_MAX,
736
+ DEFAULT_FANOUT_MAX,
737
+ };