@bongos/core 1.19.356 → 1.19.358
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 +43 -13
- package/modules/agents/migrations/agents_001_definitions.sql +301 -0
- package/modules/agents/module.json +11 -0
- package/modules/agents/spawn.js +180 -0
- package/modules/memory/module.json +11 -2
- package/modules/memory/pollers/search-index.js +201 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/src/module-api.js +5 -1
- package/tests/agents_spawn.mjs +190 -0
- package/tests/publish_manifest.mjs +8 -10
- package/tests/search_index_poller.mjs +161 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.358",
|
|
6
|
+
"core_contract": "1.19.358",
|
|
7
|
+
"source_commit": "6bfaa149e1894ef5d78af75527e0158d951470c7",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-25T16:32:44.606Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 330,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1515,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1867,
|
|
20
|
+
"tree_sha256": "bac9c3fc06876d7ba5183bf4a6ace1bdda8e43a3e5f18037d3ebb4e006d7fa7a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2499,6 +2499,21 @@
|
|
|
2499
2499
|
"mode": "0000644",
|
|
2500
2500
|
"sha256": "a379e9f926274559d8c57c9127024463e38b99e3f73962022b9f5d0f4e156a57"
|
|
2501
2501
|
},
|
|
2502
|
+
{
|
|
2503
|
+
"path": "modules/agents/migrations/agents_001_definitions.sql",
|
|
2504
|
+
"mode": "0000644",
|
|
2505
|
+
"sha256": "a35cb06d31f05acdc9bee40a977346d258acda79174944361710f3733dd69a2f"
|
|
2506
|
+
},
|
|
2507
|
+
{
|
|
2508
|
+
"path": "modules/agents/module.json",
|
|
2509
|
+
"mode": "0000644",
|
|
2510
|
+
"sha256": "055d1a806c40d8153b45bac2518b183874d9a8df7924f00355c86ed50682dd24"
|
|
2511
|
+
},
|
|
2512
|
+
{
|
|
2513
|
+
"path": "modules/agents/spawn.js",
|
|
2514
|
+
"mode": "0000644",
|
|
2515
|
+
"sha256": "657e6a314e9daec8a7a3d17c3eee1ccfc35aaa1390d89fa0dd135d6365c103b1"
|
|
2516
|
+
},
|
|
2502
2517
|
{
|
|
2503
2518
|
"path": "modules/autonomy/CLAUDE.md",
|
|
2504
2519
|
"mode": "0000644",
|
|
@@ -4212,7 +4227,12 @@
|
|
|
4212
4227
|
{
|
|
4213
4228
|
"path": "modules/memory/module.json",
|
|
4214
4229
|
"mode": "0000644",
|
|
4215
|
-
"sha256": "
|
|
4230
|
+
"sha256": "c697548692393d65c6beb8092f70f8864acb53d568548f123213023ca10032f8"
|
|
4231
|
+
},
|
|
4232
|
+
{
|
|
4233
|
+
"path": "modules/memory/pollers/search-index.js",
|
|
4234
|
+
"mode": "0000644",
|
|
4235
|
+
"sha256": "4950c01fc2b895a7eb8f5fc929826c1bea44d9764f5ffa4c8378de73be1446e9"
|
|
4216
4236
|
},
|
|
4217
4237
|
{
|
|
4218
4238
|
"path": "modules/memory/routes/learnings.js",
|
|
@@ -4797,12 +4817,12 @@
|
|
|
4797
4817
|
{
|
|
4798
4818
|
"path": "package-lock.json",
|
|
4799
4819
|
"mode": "0000644",
|
|
4800
|
-
"sha256": "
|
|
4820
|
+
"sha256": "b155c2a0dda8e0bcfbd1efadbc4ef619a25be608f9e29714e7039f24267bcd25"
|
|
4801
4821
|
},
|
|
4802
4822
|
{
|
|
4803
4823
|
"path": "package.json",
|
|
4804
4824
|
"mode": "0000644",
|
|
4805
|
-
"sha256": "
|
|
4825
|
+
"sha256": "1f7444718fbae90aa6d3b0f6c33c6cc1052cf48917a3c1c030f7a9de7806ce53"
|
|
4806
4826
|
},
|
|
4807
4827
|
{
|
|
4808
4828
|
"path": "public-docs/index.html",
|
|
@@ -5827,7 +5847,7 @@
|
|
|
5827
5847
|
{
|
|
5828
5848
|
"path": "scripts/gds/publish-manifest.js",
|
|
5829
5849
|
"mode": "0000644",
|
|
5830
|
-
"sha256": "
|
|
5850
|
+
"sha256": "12b3b200c1f60cc8a287d9a3ca2dd28201fda3cc219e83dffb60913c084e0413"
|
|
5831
5851
|
},
|
|
5832
5852
|
{
|
|
5833
5853
|
"path": "scripts/gds/pulse.js",
|
|
@@ -6662,7 +6682,7 @@
|
|
|
6662
6682
|
{
|
|
6663
6683
|
"path": "src/module-api.js",
|
|
6664
6684
|
"mode": "0000644",
|
|
6665
|
-
"sha256": "
|
|
6685
|
+
"sha256": "fdd1d69409cf0ac75e9063d0ec0bf742c83aba0cdc9f15935839c9c6998e96af"
|
|
6666
6686
|
},
|
|
6667
6687
|
{
|
|
6668
6688
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6739,6 +6759,11 @@
|
|
|
6739
6759
|
"mode": "0000644",
|
|
6740
6760
|
"sha256": "125edcec8c7d7536365b9675daa1fceb9fe072a30e2a4a68ee2911cd258a8c10"
|
|
6741
6761
|
},
|
|
6762
|
+
{
|
|
6763
|
+
"path": "tests/agents_spawn.mjs",
|
|
6764
|
+
"mode": "0000644",
|
|
6765
|
+
"sha256": "8cc261471a36e3c447df57b46082997b6a50da3c8ff4b214787ae55ab27cb9ef"
|
|
6766
|
+
},
|
|
6742
6767
|
{
|
|
6743
6768
|
"path": "tests/api_cli_flags.mjs",
|
|
6744
6769
|
"mode": "0000644",
|
|
@@ -8652,7 +8677,7 @@
|
|
|
8652
8677
|
{
|
|
8653
8678
|
"path": "tests/publish_manifest.mjs",
|
|
8654
8679
|
"mode": "0000644",
|
|
8655
|
-
"sha256": "
|
|
8680
|
+
"sha256": "5eca5f50f200166ce0fd7e40a57e18fffb135139fe558d0dda34064d426814dc"
|
|
8656
8681
|
},
|
|
8657
8682
|
{
|
|
8658
8683
|
"path": "tests/publish_reconciler.mjs",
|
|
@@ -8804,6 +8829,11 @@
|
|
|
8804
8829
|
"mode": "0000644",
|
|
8805
8830
|
"sha256": "1034ab278e7eb005ba0286b8bcecd7fafdec390418ae94e3cbacf81ffb9fc88c"
|
|
8806
8831
|
},
|
|
8832
|
+
{
|
|
8833
|
+
"path": "tests/search_index_poller.mjs",
|
|
8834
|
+
"mode": "0000644",
|
|
8835
|
+
"sha256": "f665d4f0a9ccfb5b92564dfda39d13d6d8ed3146cde3fe2e05bb9f9aec029558"
|
|
8836
|
+
},
|
|
8807
8837
|
{
|
|
8808
8838
|
"path": "tests/search_ingest.mjs",
|
|
8809
8839
|
"mode": "0000644",
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
-- agents_001_definitions.sql — the agent registry + the fire ledger.
|
|
2
|
+
--
|
|
3
|
+
-- Two tables for the agents module (goal 1000038, Phase 1):
|
|
4
|
+
--
|
|
5
|
+
-- agents_definitions — the REGISTRY. One row per declarative agent definition,
|
|
6
|
+
-- whether it came from a committed `.claude/agents/*.md` file (source='file',
|
|
7
|
+
-- reconciled at deploy by agents-sync.js) or was authored in the instance
|
|
8
|
+
-- against the DB (source='db'). Files-are-source: the sync upserts file rows
|
|
9
|
+
-- and must never clobber a db row, so `source` is load-bearing, not a label.
|
|
10
|
+
--
|
|
11
|
+
-- agents_runs — the LEDGER. One row per fire: what triggered it, what the gate
|
|
12
|
+
-- decided, what the spawn cost, and where the output went. Every fire is
|
|
13
|
+
-- recorded — ESPECIALLY a refusal, for the same reason autonomy_runs records
|
|
14
|
+
-- a no-go: a gate that silently declines is indistinguishable from a gate
|
|
15
|
+
-- that never ran.
|
|
16
|
+
--
|
|
17
|
+
-- NAMESPACE NOTE (why `agents_definitions`, not `agent_definitions`). Module
|
|
18
|
+
-- objects must carry the module key's own prefix, and this module's key is
|
|
19
|
+
-- `agents` — so the wall is `agents_`, and a singular `agent_definitions` fails
|
|
20
|
+
-- the fitness gate (`scripts/gds/fitness.js` Check 11) rather than being a style
|
|
21
|
+
-- quibble. The plural prefix is the price of the namespace; downstream tasks
|
|
22
|
+
-- (1002488 read routes, 1002489 spawn adapter, 1002490 agents-sync) read
|
|
23
|
+
-- `agents_definitions` / `agents_runs`.
|
|
24
|
+
--
|
|
25
|
+
-- FAIL-CLOSED ENUMS. `trigger_type`, `model_tier`, `source`, `provenance` and the
|
|
26
|
+
-- run's `status` are text + CHECK rather than Postgres enum TYPEs: the constraint
|
|
27
|
+
-- pins today's WIRED vocabulary closed (an unwired trigger name is refused, never
|
|
28
|
+
-- silently accepted — the same rule task 1002487's EVENT_CATALOG enforces in JS),
|
|
29
|
+
-- and widening later is a one-line constraint change in a subsequent agents_*
|
|
30
|
+
-- migration instead of an enum-type migration.
|
|
31
|
+
--
|
|
32
|
+
-- A DEFINITION IS NEVER ARMED BY ACCIDENT. Two invariants below are the DB half
|
|
33
|
+
-- of rules the code also enforces, and they exist because "enabled" is the field
|
|
34
|
+
-- that turns a piece of text into something that spends money and reads the repo:
|
|
35
|
+
-- • a definition that tripped the scope wall CANNOT be enabled — the sync
|
|
36
|
+
-- imports it disabled + flagged (task 1002490), and this constraint means a
|
|
37
|
+
-- later stray UPDATE cannot quietly arm it;
|
|
38
|
+
-- • an enabled definition MUST carry an author_rank — authority comes from the
|
|
39
|
+
-- author's LIVE DB rank (ADR 0016), never from anything written in the file,
|
|
40
|
+
-- so a definition with no known author has no authority to run.
|
|
41
|
+
--
|
|
42
|
+
-- STRUCTURE ONLY, NEVER AGENT OUTPUT. `agents_runs.output_ref` is a POINTER to
|
|
43
|
+
-- where a run's output landed — never the output itself. An agent's output is
|
|
44
|
+
-- model-generated text that is later rendered to whoever reads the ledger, so
|
|
45
|
+
-- storing it inline would make this table a delivery path for whatever the agent
|
|
46
|
+
-- read. Same rule, same reason as `security_tool_scans.findings_summary`.
|
|
47
|
+
--
|
|
48
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
49
|
+
-- object is agents_-prefixed, there is no down-migration, and the filename is
|
|
50
|
+
-- agents_-prefixed (ADR 0108 §5) so its stem never collides across repos.
|
|
51
|
+
-- `scripts/migrate.sh` discovers + applies this only when the agents module is
|
|
52
|
+
-- enabled — and the module ships `default:false` (task 1002485), so on a vanilla
|
|
53
|
+
-- instance this file is correctly skipped. Idempotent: safe to re-run.
|
|
54
|
+
|
|
55
|
+
BEGIN;
|
|
56
|
+
|
|
57
|
+
-- ---------------------------------------------------------------------------
|
|
58
|
+
-- agents_definitions — the registry
|
|
59
|
+
-- ---------------------------------------------------------------------------
|
|
60
|
+
CREATE TABLE IF NOT EXISTS agents_definitions (
|
|
61
|
+
id bigserial PRIMARY KEY,
|
|
62
|
+
|
|
63
|
+
-- The agent key. For a file-sourced definition this is the `.claude/agents/`
|
|
64
|
+
-- filename stem, which is why the format check matches the module-key shape:
|
|
65
|
+
-- it has to be safe as a filename, a URL path segment, and a CLI argument.
|
|
66
|
+
name text NOT NULL,
|
|
67
|
+
title text,
|
|
68
|
+
-- The role/system prompt. REQUIRED — task 1002487's validator rejects a
|
|
69
|
+
-- definition with no persona, and a NOT NULL here means a row that skipped the
|
|
70
|
+
-- validator still cannot exist without one.
|
|
71
|
+
persona text NOT NULL,
|
|
72
|
+
|
|
73
|
+
-- What fires it. 'event' consumes the wired event catalog; 'on-demand' is the
|
|
74
|
+
-- historian-exemplar shape (a builder asks, the agent answers). trigger_spec
|
|
75
|
+
-- carries the type's parameters — for 'event', at minimum {"event": "..."}.
|
|
76
|
+
trigger_type text NOT NULL,
|
|
77
|
+
trigger_spec jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
78
|
+
|
|
79
|
+
-- Which subagent tier this spawns at. Resolved to a concrete model id at fire
|
|
80
|
+
-- time from the branding pack's `models` block (task 1002489) — the tier is
|
|
81
|
+
-- stored, never the model id, so an instance re-pointing its tiers moves every
|
|
82
|
+
-- agent with it instead of leaving a pinned model behind.
|
|
83
|
+
-- 'default' → models.subagentDefault 'routine' → models.subagentRoutine
|
|
84
|
+
-- Deliberately NO 'main' tier: an agent is a subagent, never the main session.
|
|
85
|
+
model_tier text NOT NULL DEFAULT 'default',
|
|
86
|
+
|
|
87
|
+
-- The declared scope wall. scope_modules names module keys the agent may work
|
|
88
|
+
-- within; scope_paths narrows further. The wall is re-evaluated SERVER-SIDE at
|
|
89
|
+
-- sync (task 1002490) — a definition scoped onto protected paths lands with
|
|
90
|
+
-- scope_violation set, which the constraint below makes un-armable.
|
|
91
|
+
scope_modules text[] NOT NULL DEFAULT '{}',
|
|
92
|
+
scope_paths text[] NOT NULL DEFAULT '{}',
|
|
93
|
+
scope_violation text,
|
|
94
|
+
|
|
95
|
+
-- Where this row came from, and whether the sync owns it. 'file' rows are
|
|
96
|
+
-- rewritten from the committed definition on every deploy; 'db' rows are
|
|
97
|
+
-- instance-authored and must SURVIVE a deploy untouched (task 1002490).
|
|
98
|
+
source text NOT NULL,
|
|
99
|
+
-- 'built-in' (ships with the core) vs 'instance' (authored by this instance).
|
|
100
|
+
-- Distinct from `source`: a built-in agent is file-sourced, but an instance can
|
|
101
|
+
-- also commit its own file. Provenance answers "whose agent is this", source
|
|
102
|
+
-- answers "who may overwrite the row".
|
|
103
|
+
provenance text NOT NULL,
|
|
104
|
+
|
|
105
|
+
-- The author's LIVE DB rank at the moment the row was written (ADR 0016).
|
|
106
|
+
-- Never read from the definition file — a file cannot grant authority.
|
|
107
|
+
author_rank text,
|
|
108
|
+
author_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
|
|
109
|
+
|
|
110
|
+
-- File provenance, for source='file'. source_sha256 is over the definition's
|
|
111
|
+
-- bytes so the sync can tell "unchanged" from "edited" without re-parsing, and
|
|
112
|
+
-- so a row can be traced back to the exact committed text that produced it.
|
|
113
|
+
source_path text,
|
|
114
|
+
source_sha256 text,
|
|
115
|
+
last_synced_at timestamptz,
|
|
116
|
+
|
|
117
|
+
-- Shipped != enabled, at the row level as well as the module level. A newly
|
|
118
|
+
-- synced definition lands OFF; arming it is a separate, deliberate act.
|
|
119
|
+
enabled boolean NOT NULL DEFAULT false,
|
|
120
|
+
|
|
121
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
122
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
123
|
+
|
|
124
|
+
-- One row per agent name. This is what makes the sync an UPSERT rather than an
|
|
125
|
+
-- append, and what stops two definitions from answering to the same name.
|
|
126
|
+
CONSTRAINT agents_definitions_name_uniq UNIQUE (name),
|
|
127
|
+
-- Filename/CLI/URL-safe, and stable: the same shape a module key must take.
|
|
128
|
+
CONSTRAINT agents_definitions_name_fmt_chk
|
|
129
|
+
CHECK (name ~ '^[a-z][a-z0-9-]*$' AND length(name) <= 64),
|
|
130
|
+
-- v1 wires exactly these two trigger types. Anything else is refused here, not
|
|
131
|
+
-- silently stored and quietly never fired.
|
|
132
|
+
CONSTRAINT agents_definitions_trigger_type_chk
|
|
133
|
+
CHECK (trigger_type IN ('event', 'on-demand')),
|
|
134
|
+
CONSTRAINT agents_definitions_trigger_spec_shape_chk
|
|
135
|
+
CHECK (jsonb_typeof(trigger_spec) = 'object'),
|
|
136
|
+
-- An event-triggered definition with no event name is a definition that can
|
|
137
|
+
-- never fire; the catalog membership check itself lives in the validator, which
|
|
138
|
+
-- can name the offending event in its error.
|
|
139
|
+
CONSTRAINT agents_definitions_event_needs_name_chk
|
|
140
|
+
CHECK (trigger_type <> 'event'
|
|
141
|
+
OR (trigger_spec ? 'event' AND length(trigger_spec ->> 'event') > 0)),
|
|
142
|
+
CONSTRAINT agents_definitions_model_tier_chk
|
|
143
|
+
CHECK (model_tier IN ('default', 'routine')),
|
|
144
|
+
CONSTRAINT agents_definitions_source_chk
|
|
145
|
+
CHECK (source IN ('file', 'db')),
|
|
146
|
+
CONSTRAINT agents_definitions_provenance_chk
|
|
147
|
+
CHECK (provenance IN ('built-in', 'instance')),
|
|
148
|
+
CONSTRAINT agents_definitions_author_rank_chk
|
|
149
|
+
CHECK (author_rank IS NULL OR author_rank IN ('xenos', 'thetes', 'metic', 'archon')),
|
|
150
|
+
-- A file-sourced row must say WHICH file, or the next sync cannot reconcile it.
|
|
151
|
+
CONSTRAINT agents_definitions_file_needs_path_chk
|
|
152
|
+
CHECK (source <> 'file' OR source_path IS NOT NULL),
|
|
153
|
+
-- The two arming invariants (see the header). A flagged definition stays
|
|
154
|
+
-- disabled; an anonymous definition has no rank to act with, so it stays off.
|
|
155
|
+
CONSTRAINT agents_definitions_flagged_not_armed_chk
|
|
156
|
+
CHECK (scope_violation IS NULL OR enabled = false),
|
|
157
|
+
CONSTRAINT agents_definitions_armed_needs_author_chk
|
|
158
|
+
CHECK (enabled = false OR author_rank IS NOT NULL),
|
|
159
|
+
-- Defense in depth with the validator's own caps: a persona is a prompt sent to
|
|
160
|
+
-- a paid model on every fire, so an unbounded one is both a cost and a blast
|
|
161
|
+
-- radius. trigger_spec is parameters, never payload.
|
|
162
|
+
CONSTRAINT agents_definitions_persona_size_chk
|
|
163
|
+
CHECK (length(persona) <= 32768),
|
|
164
|
+
CONSTRAINT agents_definitions_trigger_spec_size_chk
|
|
165
|
+
CHECK (length(trigger_spec::text) <= 8192)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
-- "Which definitions fire on event X?" — the dispatch lookup, and the only read
|
|
169
|
+
-- on the hot path. Partial: a disabled definition is never dispatched, so its
|
|
170
|
+
-- rows are dead weight in this index.
|
|
171
|
+
CREATE INDEX IF NOT EXISTS agents_definitions_enabled_trigger_idx
|
|
172
|
+
ON agents_definitions (trigger_type, (trigger_spec ->> 'event'))
|
|
173
|
+
WHERE enabled;
|
|
174
|
+
|
|
175
|
+
-- "What does the sync own?" — agents-sync.js walks file rows to reconcile them
|
|
176
|
+
-- and must never touch the db rows beside them (task 1002490).
|
|
177
|
+
CREATE INDEX IF NOT EXISTS agents_definitions_source_idx
|
|
178
|
+
ON agents_definitions (source, name);
|
|
179
|
+
|
|
180
|
+
-- "What tripped the scope wall?" — the review queue for flagged imports. Small
|
|
181
|
+
-- by construction, so partial.
|
|
182
|
+
CREATE INDEX IF NOT EXISTS agents_definitions_flagged_idx
|
|
183
|
+
ON agents_definitions (updated_at DESC)
|
|
184
|
+
WHERE scope_violation IS NOT NULL;
|
|
185
|
+
|
|
186
|
+
-- ---------------------------------------------------------------------------
|
|
187
|
+
-- agents_runs — one row per fire
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
CREATE TABLE IF NOT EXISTS agents_runs (
|
|
190
|
+
id bigserial PRIMARY KEY,
|
|
191
|
+
|
|
192
|
+
-- The definition that fired. ON DELETE SET NULL, not CASCADE: the ledger is the
|
|
193
|
+
-- record of what this instance actually spent and ran, and deleting a
|
|
194
|
+
-- definition must not erase its history. agent_name is denormalized for exactly
|
|
195
|
+
-- that case — the run stays attributable after the row it pointed at is gone.
|
|
196
|
+
definition_id bigint REFERENCES agents_definitions(id) ON DELETE SET NULL,
|
|
197
|
+
agent_name text NOT NULL,
|
|
198
|
+
|
|
199
|
+
-- What fired it, as a stable reference rather than prose: the event and its
|
|
200
|
+
-- subject ('task.shipped:1002486'), or the on-demand caller. Kept as text so a
|
|
201
|
+
-- trigger can reference a record type this module does not own.
|
|
202
|
+
trigger_ref text,
|
|
203
|
+
trigger_type text NOT NULL,
|
|
204
|
+
|
|
205
|
+
-- The gate's decision for this fire, recorded whether or not anything spawned.
|
|
206
|
+
-- Same vocabulary as autonomy_runs ('go' | 'no-go') so the two ledgers read the
|
|
207
|
+
-- same way.
|
|
208
|
+
gate_decision text NOT NULL,
|
|
209
|
+
gate_reason text,
|
|
210
|
+
|
|
211
|
+
-- What became of the run. 'skipped' is the terminal state of a no-go (nothing
|
|
212
|
+
-- spawned); 'pending' covers a run in flight so a crashed spawn is visible as
|
|
213
|
+
-- unfinished rather than vanishing.
|
|
214
|
+
status text NOT NULL DEFAULT 'pending',
|
|
215
|
+
error_code text,
|
|
216
|
+
|
|
217
|
+
-- The CONCRETE model the tier resolved to at fire time, so an old row is never
|
|
218
|
+
-- re-read as though it ran on today's tier mapping.
|
|
219
|
+
model text,
|
|
220
|
+
-- What the fire actually cost. Spawns route through the grade port, which bills
|
|
221
|
+
-- into the LLM cost cache (task 1002489); this column is the per-agent view of
|
|
222
|
+
-- that spend, which is what an instance's ~$/agent/month cap reads.
|
|
223
|
+
cost_usd numeric(10, 4),
|
|
224
|
+
duration_ms integer,
|
|
225
|
+
|
|
226
|
+
-- A POINTER to where the output landed — never the output. See the header.
|
|
227
|
+
output_ref text,
|
|
228
|
+
|
|
229
|
+
requested_by_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
|
|
230
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
231
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
232
|
+
|
|
233
|
+
CONSTRAINT agents_runs_gate_decision_chk
|
|
234
|
+
CHECK (gate_decision IN ('go', 'no-go')),
|
|
235
|
+
CONSTRAINT agents_runs_status_chk
|
|
236
|
+
CHECK (status IN ('pending', 'ok', 'error', 'skipped')),
|
|
237
|
+
CONSTRAINT agents_runs_trigger_type_chk
|
|
238
|
+
CHECK (trigger_type IN ('event', 'on-demand')),
|
|
239
|
+
-- A refusal must never be readable as a run that happened. A no-go spawned
|
|
240
|
+
-- nothing, so it is terminal-skipped and carries no output — the mirror of
|
|
241
|
+
-- security_tool_scans' verdict/completion invariant.
|
|
242
|
+
CONSTRAINT agents_runs_nogo_is_skipped_chk
|
|
243
|
+
CHECK (gate_decision = 'go' OR (status = 'skipped' AND output_ref IS NULL)),
|
|
244
|
+
-- ...and it cannot have cost anything, because nothing ran.
|
|
245
|
+
CONSTRAINT agents_runs_nogo_free_chk
|
|
246
|
+
CHECK (gate_decision = 'go' OR cost_usd IS NULL OR cost_usd = 0),
|
|
247
|
+
-- A failed run must say why. Silent failure is the state this ledger exists to
|
|
248
|
+
-- make impossible.
|
|
249
|
+
CONSTRAINT agents_runs_error_needs_code_chk
|
|
250
|
+
CHECK (status <> 'error' OR error_code IS NOT NULL),
|
|
251
|
+
CONSTRAINT agents_runs_cost_nonneg_chk
|
|
252
|
+
CHECK (cost_usd IS NULL OR cost_usd >= 0),
|
|
253
|
+
CONSTRAINT agents_runs_duration_nonneg_chk
|
|
254
|
+
CHECK (duration_ms IS NULL OR duration_ms >= 0),
|
|
255
|
+
CONSTRAINT agents_runs_output_ref_len_chk
|
|
256
|
+
CHECK (output_ref IS NULL OR length(output_ref) <= 2048)
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
-- "What has this agent done lately?" — the per-agent history, and the read behind
|
|
260
|
+
-- a per-agent spend rollup.
|
|
261
|
+
CREATE INDEX IF NOT EXISTS agents_runs_agent_idx
|
|
262
|
+
ON agents_runs (agent_name, created_at DESC);
|
|
263
|
+
|
|
264
|
+
-- "What did this trigger set off?" — walks back from a shipped task (or any other
|
|
265
|
+
-- subject) to the agents that fired on it.
|
|
266
|
+
CREATE INDEX IF NOT EXISTS agents_runs_trigger_ref_idx
|
|
267
|
+
ON agents_runs (trigger_ref, created_at DESC)
|
|
268
|
+
WHERE trigger_ref IS NOT NULL;
|
|
269
|
+
|
|
270
|
+
-- "What did agents cost this month?" — the spend-cap read. Partial: a no-go row
|
|
271
|
+
-- contributes nothing to spend and would only dilute the scan.
|
|
272
|
+
CREATE INDEX IF NOT EXISTS agents_runs_spend_idx
|
|
273
|
+
ON agents_runs (created_at DESC)
|
|
274
|
+
WHERE cost_usd IS NOT NULL;
|
|
275
|
+
|
|
276
|
+
-- ---------------------------------------------------------------------------
|
|
277
|
+
-- updated_at triggers
|
|
278
|
+
-- ---------------------------------------------------------------------------
|
|
279
|
+
-- Both tables ARE mutated after insert (the sync rewrites definitions; a run goes
|
|
280
|
+
-- pending → ok/error and gains its cost + output_ref), so updated_at needs a real
|
|
281
|
+
-- trigger — without one it silently equals created_at forever, and "when did this
|
|
282
|
+
-- definition last change" becomes unanswerable. Idempotent: OR REPLACE + DROP IF
|
|
283
|
+
-- EXISTS on the trigger.
|
|
284
|
+
CREATE OR REPLACE FUNCTION agents_touch_updated_at() RETURNS trigger AS $$
|
|
285
|
+
BEGIN
|
|
286
|
+
NEW.updated_at = now();
|
|
287
|
+
RETURN NEW;
|
|
288
|
+
END;
|
|
289
|
+
$$ LANGUAGE plpgsql;
|
|
290
|
+
|
|
291
|
+
DROP TRIGGER IF EXISTS agents_definitions_updated_at ON agents_definitions;
|
|
292
|
+
CREATE TRIGGER agents_definitions_updated_at
|
|
293
|
+
BEFORE UPDATE ON agents_definitions
|
|
294
|
+
FOR EACH ROW EXECUTE FUNCTION agents_touch_updated_at();
|
|
295
|
+
|
|
296
|
+
DROP TRIGGER IF EXISTS agents_runs_updated_at ON agents_runs;
|
|
297
|
+
CREATE TRIGGER agents_runs_updated_at
|
|
298
|
+
BEFORE UPDATE ON agents_runs
|
|
299
|
+
FOR EACH ROW EXECUTE FUNCTION agents_touch_updated_at();
|
|
300
|
+
|
|
301
|
+
COMMIT;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "agents",
|
|
3
|
+
"title": "Agents (declarative agent registry)",
|
|
4
|
+
"description": "Declarative agent definitions (.claude/agents/*.md) reconciled at deploy into a DB-backed registry (agents_definitions) plus a per-fire ledger (agents_runs) — BONGOS-V1 goal 1000038, Phase 1. Files-are-source: agents-sync upserts file-sourced definitions and never clobbers an instance-authored db row, and stamps author_rank from the author's LIVE DB rank because a committed file cannot grant authority (ADR 0016). Spawns consume the EXISTING `grade` port (runSubagentCached), which is fixed read-only, so v1 agents are observe-and-report only — no write principals — and every fire bills through the shared LLM cost cache. default:false: shipped is not enabled, and an instance opts in per ADR 0083.",
|
|
5
|
+
"coreVersion": "^1.19.0",
|
|
6
|
+
"default": false,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
8
|
+
"contributes": { "migrations": true },
|
|
9
|
+
"provides": [],
|
|
10
|
+
"consumes": ["grade"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/agents/spawn.js — the spawn seam (task 1002489, goal 1000038 Phase 1).
|
|
4
|
+
//
|
|
5
|
+
// One fire = resolve the tier to a concrete model, ask the `grade` port to run the
|
|
6
|
+
// subagent, and write exactly one agents_runs row saying what happened. Nothing
|
|
7
|
+
// else. The gate decides WHETHER to fire; this decides HOW and records THAT.
|
|
8
|
+
//
|
|
9
|
+
// THREE CONSTRAINTS THAT ARE NOT NEGOTIABLE HERE, each from the goal's own
|
|
10
|
+
// adversarial critique — a future reader will be tempted by all three:
|
|
11
|
+
//
|
|
12
|
+
// 1. CONSUME grade.runSubagentCached. Not a new `grade.runSubagent` port (the
|
|
13
|
+
// critique explicitly dropped it), and not a bespoke child_process.spawn. The
|
|
14
|
+
// port is also what bills every fire into the shared LLM cost cache, so an
|
|
15
|
+
// agent's spend lands in the same ledger as everything else instead of being
|
|
16
|
+
// invisible. A bespoke spawn would be free-looking and wrong.
|
|
17
|
+
//
|
|
18
|
+
// 2. REACH IT THROUGH THE DOORWAY, never `require('../grading/…')`. A
|
|
19
|
+
// module→module require is a hard fitness failure (ADR 0083), and the
|
|
20
|
+
// resolve-by-name seam is what lets an instance run `agents` with `grading`
|
|
21
|
+
// disabled — it degrades to a named refusal instead of a boot crash.
|
|
22
|
+
//
|
|
23
|
+
// 3. V1 AGENTS ARE READ-ONLY, and not by policy — by plumbing. grader.js's
|
|
24
|
+
// runSubagent hardcodes `--tools Read` + bypassPermissions and parametrizes
|
|
25
|
+
// only model/cwd/timeout. There is no allowlist to widen from here. A
|
|
26
|
+
// write-capable agent needs a parametrized-capability spawn path in `grading`
|
|
27
|
+
// first (a PROTECTED_GLOBS edit, Archon-gated), which is deferred to a later
|
|
28
|
+
// goal. Anything in this file that appears to grant a capability is a bug.
|
|
29
|
+
//
|
|
30
|
+
// THE LEDGER IS WRITTEN EVEN WHEN NOTHING RUNS. A no-go is a row: gate_decision
|
|
31
|
+
// 'no-go', status 'skipped', no output, no cost — the schema enforces all three.
|
|
32
|
+
// The reason is the same one behind every other loud-failure surface in this repo:
|
|
33
|
+
// a refusal that leaves no trace is indistinguishable from a fire that never
|
|
34
|
+
// happened, and "the agent didn't run" then has two causes nobody can tell apart.
|
|
35
|
+
|
|
36
|
+
const api = require('../../src/module-api');
|
|
37
|
+
|
|
38
|
+
// tier → concrete model id. The mapping is BRANDING, not a constant here: an
|
|
39
|
+
// instance retunes its own tiers, and the DB constraint already limits the
|
|
40
|
+
// vocabulary to these two.
|
|
41
|
+
//
|
|
42
|
+
// An unknown tier resolves to the DEFAULT tier, never to nothing and never to the
|
|
43
|
+
// cheap one. A definition whose tier is somehow outside the vocabulary is a bug in
|
|
44
|
+
// whatever wrote it; silently downgrading its model would make that bug present as
|
|
45
|
+
// mysteriously poor agent output much later, which is far harder to trace than an
|
|
46
|
+
// unexpected line in the cost ledger.
|
|
47
|
+
function resolveModel(tier, brandingModels) {
|
|
48
|
+
const m = brandingModels || {};
|
|
49
|
+
const fallback = m.subagentDefault || m.main || null;
|
|
50
|
+
if (tier === 'routine') return m.subagentRoutine || fallback;
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The insert shape for one fire, decided BEFORE anything spawns. Pure, so the
|
|
55
|
+
// schema's invariants can be tested without a database.
|
|
56
|
+
function plannedRow({ definition = {}, trigger = {}, gate = {}, model = null, requestedByBuilderId = null } = {}) {
|
|
57
|
+
const go = gate.decision === 'go';
|
|
58
|
+
return {
|
|
59
|
+
definition_id: definition.id ?? null,
|
|
60
|
+
agent_name: definition.name || null,
|
|
61
|
+
trigger_ref: trigger.ref ?? null,
|
|
62
|
+
trigger_type: trigger.type === 'on-demand' ? 'on-demand' : 'event',
|
|
63
|
+
gate_decision: go ? 'go' : 'no-go',
|
|
64
|
+
gate_reason: gate.reason ?? null,
|
|
65
|
+
// A no-go is TERMINAL. 'pending' would leave a refusal looking like a run
|
|
66
|
+
// still in flight, and the crash-visibility argument for 'pending' only
|
|
67
|
+
// applies to something that actually started.
|
|
68
|
+
status: go ? 'pending' : 'skipped',
|
|
69
|
+
// Recorded even on a no-go: it is what the fire WOULD have cost, and the
|
|
70
|
+
// schema permits it. It is the concrete id, never the tier — an old row must
|
|
71
|
+
// never be re-read through today's tier mapping.
|
|
72
|
+
model: go ? model : null,
|
|
73
|
+
requested_by_builder_id: requestedByBuilderId ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// How a finished run is recorded. Pure for the same reason.
|
|
78
|
+
function completedRow({ ok, errorCode = null, costUsd = null, durationMs = null, outputRef = null } = {}) {
|
|
79
|
+
if (ok) {
|
|
80
|
+
return { status: 'ok', error_code: null, cost_usd: costUsd, duration_ms: durationMs, output_ref: outputRef };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
status: 'error',
|
|
84
|
+
// The schema REFUSES an error row with no code, so this can never be null —
|
|
85
|
+
// 'agent_run_failed' is the honest fallback when the thrower named nothing.
|
|
86
|
+
error_code: errorCode || 'agent_run_failed',
|
|
87
|
+
cost_usd: costUsd,
|
|
88
|
+
duration_ms: durationMs,
|
|
89
|
+
// A failed run's partial output is not a result. The pointer stays null.
|
|
90
|
+
output_ref: null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function insertRun(pool, row) {
|
|
95
|
+
const cols = Object.keys(row);
|
|
96
|
+
const vals = cols.map((_, i) => `$${i + 1}`);
|
|
97
|
+
const r = await pool.query(
|
|
98
|
+
`INSERT INTO agents_runs (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING id`,
|
|
99
|
+
cols.map((c) => row[c]),
|
|
100
|
+
);
|
|
101
|
+
return r.rows[0].id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function finishRun(pool, id, patch) {
|
|
105
|
+
const cols = Object.keys(patch);
|
|
106
|
+
const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
|
|
107
|
+
await pool.query(
|
|
108
|
+
`UPDATE agents_runs SET ${set}, updated_at = now() WHERE id = $1`,
|
|
109
|
+
[id, ...cols.map((c) => patch[c])],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fire one agent. Never throws: a spawn failure is a ledger row, not an exception
|
|
114
|
+
// into whatever triggered it — an event fabric must not be taken down by one
|
|
115
|
+
// agent's bad day.
|
|
116
|
+
async function spawnAgent({
|
|
117
|
+
definition, trigger = {}, gate = { decision: 'go' }, requestedByBuilderId = null, cache = null,
|
|
118
|
+
} = {}, deps = {}) {
|
|
119
|
+
const pool = deps.pool || api.pool;
|
|
120
|
+
const now = deps.now || (() => Date.now());
|
|
121
|
+
const brandingModels = deps.brandingModels || (api.branding && api.branding().models) || {};
|
|
122
|
+
const model = resolveModel(definition && definition.model_tier, brandingModels);
|
|
123
|
+
|
|
124
|
+
const row = plannedRow({ definition, trigger, gate, model, requestedByBuilderId });
|
|
125
|
+
let runId = null;
|
|
126
|
+
try {
|
|
127
|
+
runId = await insertRun(pool, row);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// The ledger write itself failing is the one case with nowhere to record it.
|
|
130
|
+
// Say so and refuse to spawn: an unledgered fire is exactly what this table
|
|
131
|
+
// exists to prevent, and spending money we cannot account for is worse than
|
|
132
|
+
// not firing.
|
|
133
|
+
return { ok: false, ran: false, runId: null, error_code: 'ledger_write_failed', error: (e && e.message) || String(e) };
|
|
134
|
+
}
|
|
135
|
+
if (row.gate_decision !== 'go') {
|
|
136
|
+
return { ok: true, ran: false, runId, skipped: true, gate_reason: row.gate_reason || null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Resolved by NAME through the doorway — see constraint 2. resolveOptional so an
|
|
140
|
+
// instance running `agents` without `grading` gets a named ledger row rather than
|
|
141
|
+
// a crash at require time.
|
|
142
|
+
const grade = typeof api.resolveOptional === 'function' ? api.resolveOptional('grade', null) : null;
|
|
143
|
+
const runner = deps.runSubagentCached
|
|
144
|
+
|| (grade && typeof grade.runSubagentCached === 'function' ? grade.runSubagentCached : null);
|
|
145
|
+
if (!runner) {
|
|
146
|
+
await finishRun(pool, runId, completedRow({ ok: false, errorCode: 'grade_port_unavailable' }));
|
|
147
|
+
return { ok: false, ran: false, runId, error_code: 'grade_port_unavailable' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const started = now();
|
|
151
|
+
try {
|
|
152
|
+
const res = await runner({
|
|
153
|
+
prompt: (definition && definition.persona) || '',
|
|
154
|
+
// model/cwd/timeout are the ONLY knobs the port takes. No tools, no
|
|
155
|
+
// permissions — see constraint 3.
|
|
156
|
+
opts: { model, ...(deps.opts || {}) },
|
|
157
|
+
cache,
|
|
158
|
+
});
|
|
159
|
+
const durationMs = Math.max(0, now() - started);
|
|
160
|
+
const costUsd = (res && typeof res.cost_usd === 'number' && res.cost_usd >= 0) ? res.cost_usd : null;
|
|
161
|
+
const ok = !res || res.ok !== false;
|
|
162
|
+
const patch = completedRow({
|
|
163
|
+
ok,
|
|
164
|
+
errorCode: ok ? null : (res && res.error_code) || 'agent_run_failed',
|
|
165
|
+
costUsd,
|
|
166
|
+
durationMs,
|
|
167
|
+
outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
|
|
168
|
+
});
|
|
169
|
+
await finishRun(pool, runId, patch);
|
|
170
|
+
return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
|
|
171
|
+
} catch (e) {
|
|
172
|
+
const durationMs = Math.max(0, now() - started);
|
|
173
|
+
await finishRun(pool, runId, completedRow({
|
|
174
|
+
ok: false, errorCode: (e && e.code) || 'agent_spawn_threw', durationMs,
|
|
175
|
+
})).catch(() => { /* the throw below is the report of last resort */ });
|
|
176
|
+
return { ok: false, ran: true, runId, error_code: (e && e.code) || 'agent_spawn_threw', error: (e && e.message) || String(e) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { spawnAgent, resolveModel, plannedRow, completedRow };
|
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
"description": "Per-builder session memory, the lexical recall/search retrieval layer (ADR 0060), and durable learnings. A core-domain module carved out of the GDS core in BV1.R72 (ADR 0091); default-on because every instance needs the build system's memory.",
|
|
5
5
|
"coreVersion": "^1.5.0",
|
|
6
6
|
"default": true,
|
|
7
|
-
"maintenance": {
|
|
7
|
+
"maintenance": {
|
|
8
|
+
"status": "core-maintained"
|
|
9
|
+
},
|
|
8
10
|
"contributes": {
|
|
9
|
-
"routes": [
|
|
11
|
+
"routes": [
|
|
12
|
+
"memory",
|
|
13
|
+
"search",
|
|
14
|
+
"learnings"
|
|
15
|
+
],
|
|
16
|
+
"pollers": [
|
|
17
|
+
"search-index"
|
|
18
|
+
]
|
|
10
19
|
},
|
|
11
20
|
"provides": [],
|
|
12
21
|
"consumes": []
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/memory/pollers/search-index.js — the recall index keeps itself, and says
|
|
4
|
+
// so out loud (task 1003162, goal 1000027).
|
|
5
|
+
//
|
|
6
|
+
// THE INCIDENT. `/recall` on live returned the `index_empty` outage state for words
|
|
7
|
+
// that demonstrably exist in the corpus — an entire goal's worth of ADRs, session
|
|
8
|
+
// logs and task rows. The index genuinely held nothing, and had held nothing for as
|
|
9
|
+
// long as anyone could tell, because NOTHING RAN THE INGESTERS.
|
|
10
|
+
//
|
|
11
|
+
// ADR 0060 says repo markdown is ingested by "a deploy-hook step" and DB prose by
|
|
12
|
+
// "a nightly timer". Both are BOX-SIDE artifacts: `grep -rn search-ingest` across
|
|
13
|
+
// the core tree finds the two ingesters, some prose about them, and nothing that
|
|
14
|
+
// executes either. Whether the box's deploy.sh ever called them is unknowable from
|
|
15
|
+
// here — and that unknowability IS the bug. An index that depends on an unwatched
|
|
16
|
+
// host script fails exactly the way this one did: silently, indefinitely, and only
|
|
17
|
+
// noticed when a human happens to search for something they know is there.
|
|
18
|
+
//
|
|
19
|
+
// WHY IN-PROCESS, and not a cron or a CI workflow. The ingesters need the database.
|
|
20
|
+
// A GitHub Actions schedule cannot reach it; the box's own timer is the thing that
|
|
21
|
+
// stopped. The web process already holds a pool and already runs this exact shape
|
|
22
|
+
// of work (the publish reconciler, the board-expiry sweep), so putting the sweep
|
|
23
|
+
// here makes it start whenever the app starts and stop only when the app stops.
|
|
24
|
+
// There is no separate thing left to forget to arm.
|
|
25
|
+
//
|
|
26
|
+
// THE SHAPE follows the publish reconciler, the in-tree precedent:
|
|
27
|
+
// * a DISPOSITION LINE every sweep — the reconciler's lesson is that a sweep
|
|
28
|
+
// which only speaks on failure is indistinguishable from a sweep that is not
|
|
29
|
+
// running. "held 4,812 chunks, nothing to do" is the sentence whose ABSENCE is
|
|
30
|
+
// the alarm.
|
|
31
|
+
// * failure isolation — the md ingest failing must not skip the db ingest.
|
|
32
|
+
// * bounded and self-gating — SEARCH_INDEX_SWEEP_DISABLED turns it off, an unref'd
|
|
33
|
+
// timer never holds the process open, and a sweep already running is not
|
|
34
|
+
// re-entered.
|
|
35
|
+
//
|
|
36
|
+
// AND IT CHECKS EXIT CODES, which is the half the task named explicitly. Task
|
|
37
|
+
// 1002859 made a partial ingest exit non-zero and NAME every skipped source; that
|
|
38
|
+
// only helps if someone reads the code. Spawning the CLIs (rather than importing
|
|
39
|
+
// their main()) is what preserves it — the exit code is the contract, and running
|
|
40
|
+
// them the documented way keeps this poller honest about what an operator would see.
|
|
41
|
+
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const { execFile } = require('node:child_process');
|
|
44
|
+
|
|
45
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
46
|
+
const MD_INGESTER = path.join(REPO_ROOT, 'scripts', 'gds', 'search-ingest-md.js');
|
|
47
|
+
const DB_INGESTER = path.join(REPO_ROOT, 'scripts', 'gds', 'search-ingest-db.js');
|
|
48
|
+
|
|
49
|
+
// Nightly-ish. DB prose changes slowly and the md corpus only moves on deploy, so
|
|
50
|
+
// a long period is right; the value is that it recurs at all, not that it is tight.
|
|
51
|
+
const INTERVAL_MS = Number(process.env.SEARCH_INDEX_SWEEP_MS) || 6 * 60 * 60 * 1000;
|
|
52
|
+
// An EMPTY index is an outage, not a slow day: recall answers nothing at all until
|
|
53
|
+
// it is fixed. Sweep early after boot so a deploy that lands on an empty table
|
|
54
|
+
// repairs it in minutes rather than at the next six-hour tick.
|
|
55
|
+
const FIRST_SWEEP_DELAY_MS = Number(process.env.SEARCH_INDEX_FIRST_MS) || 90_000;
|
|
56
|
+
// The md ingest walks a few hundred files; the db ingest reads prose tables. Both
|
|
57
|
+
// are minutes at worst. The cap exists so a wedged child can never pin the sweep.
|
|
58
|
+
const INGEST_TIMEOUT_MS = Number(process.env.SEARCH_INDEX_INGEST_TIMEOUT_MS) || 10 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
let intervalTimer = null;
|
|
61
|
+
let firstTimer = null;
|
|
62
|
+
let sweeping = false;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Pure decisions — exported and unit-tested. No DB, no spawn, no clock.
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// What does a chunk count MEAN? Separated from the doing so the judgement can be
|
|
69
|
+
// tested without a database, and so the wording lives in one place.
|
|
70
|
+
//
|
|
71
|
+
// `empty` is deliberately its own state rather than "very stale": it is the only
|
|
72
|
+
// one where recall is not degraded but DEAD, it is rank-independent (so it can
|
|
73
|
+
// never be "nothing visible to YOU"), and it is the one that justifies spending an
|
|
74
|
+
// ingest immediately on boot.
|
|
75
|
+
function describeIndex({ chunks, lastIngestedAt = null, now = Date.now() } = {}) {
|
|
76
|
+
// typeof FIRST: Number(null) is 0 and Number('') is 0, so a missing column or an
|
|
77
|
+
// absent row would coerce into a confident "the index is empty" — a wrong
|
|
78
|
+
// diagnosis that also spends an ingest. Only an actual number can be a count.
|
|
79
|
+
const n = typeof chunks === 'number' ? chunks : NaN;
|
|
80
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
81
|
+
return { level: 'alarm', state: 'unreadable', chunks: null,
|
|
82
|
+
message: 'search-index: could not read search_chunks — recall health is UNKNOWN, which is not the same as healthy.' };
|
|
83
|
+
}
|
|
84
|
+
if (n === 0) {
|
|
85
|
+
return { level: 'alarm', state: 'empty', chunks: 0,
|
|
86
|
+
message: 'search-index: search_chunks is EMPTY — /recall can return nothing at all, for any query, at any rank. Ingesting now.' };
|
|
87
|
+
}
|
|
88
|
+
const ageMs = lastIngestedAt ? Math.max(0, now - new Date(lastIngestedAt).getTime()) : null;
|
|
89
|
+
const ageDays = ageMs == null ? null : Math.floor(ageMs / 86_400_000);
|
|
90
|
+
if (ageDays != null && ageDays >= 7) {
|
|
91
|
+
return { level: 'warn', state: 'stale', chunks: n, age_days: ageDays,
|
|
92
|
+
message: `search-index: ${n} chunk(s), but the newest is ${ageDays} day(s) old — the sweep may have stopped.` };
|
|
93
|
+
}
|
|
94
|
+
return { level: 'ok', state: 'ok', chunks: n, age_days: ageDays,
|
|
95
|
+
message: `search-index: ${n} chunk(s) indexed${ageDays == null ? '' : `, newest ${ageDays} day(s) old`}.` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One ingester's outcome, in the words an operator needs. A non-zero exit is the
|
|
99
|
+
// signal task 1002859 built and nothing was reading.
|
|
100
|
+
function describeIngest({ name, code, signal = null, stderr = '' } = {}) {
|
|
101
|
+
if (signal) {
|
|
102
|
+
return { level: 'alarm', ok: false,
|
|
103
|
+
message: `search-index: ${name} was KILLED by ${signal} (timeout or OOM) — the index is partially written at best.` };
|
|
104
|
+
}
|
|
105
|
+
if (code === 0) return { level: 'ok', ok: true, message: `search-index: ${name} ok.` };
|
|
106
|
+
// The tail, not the head: these CLIs name what they skipped at the END.
|
|
107
|
+
const tail = String(stderr || '').trim().split('\n').slice(-6).join('\n');
|
|
108
|
+
return { level: 'alarm', ok: false,
|
|
109
|
+
message: `search-index: ${name} exited ${code} — it names every source it skipped:\n${tail || '(no stderr captured)'}` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Doing
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function runIngester(file, { execFileImpl = execFile } = {}) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
execFileImpl(process.execPath, [file], {
|
|
119
|
+
cwd: REPO_ROOT, timeout: INGEST_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024,
|
|
120
|
+
env: process.env, shell: false,
|
|
121
|
+
}, (err, _stdout, stderr) => {
|
|
122
|
+
resolve({
|
|
123
|
+
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
124
|
+
signal: (err && err.signal) || null,
|
|
125
|
+
stderr: stderr || (err && err.message) || '',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readChunkStats(pool) {
|
|
132
|
+
const r = await pool.query(
|
|
133
|
+
'SELECT count(*)::int AS chunks, max(updated_at) AS last FROM search_chunks'
|
|
134
|
+
);
|
|
135
|
+
const row = (r && r.rows && r.rows[0]) || {};
|
|
136
|
+
return { chunks: row.chunks, lastIngestedAt: row.last || null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One sweep. Never throws — a poller that can take the process down is worse than
|
|
140
|
+
// the outage it watches.
|
|
141
|
+
async function sweep({ pool, log = console, execFileImpl = execFile, now = Date.now() } = {}) {
|
|
142
|
+
let health;
|
|
143
|
+
try {
|
|
144
|
+
health = describeIndex({ ...(await readChunkStats(pool)), now });
|
|
145
|
+
} catch (e) {
|
|
146
|
+
health = describeIndex({ chunks: NaN, now });
|
|
147
|
+
health.error = (e && e.message) || String(e);
|
|
148
|
+
}
|
|
149
|
+
(health.level === 'ok' ? log.log : log.error).call(log, health.message);
|
|
150
|
+
|
|
151
|
+
// Ingest when the index is empty or unreadable-but-recoverable; a populated
|
|
152
|
+
// index is refreshed on the ordinary tick, not re-walked because it is stale by
|
|
153
|
+
// an hour. `empty` is the case that cannot wait.
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const [name, file] of [['search-ingest-md', MD_INGESTER], ['search-ingest-db', DB_INGESTER]]) {
|
|
156
|
+
const r = await runIngester(file, { execFileImpl });
|
|
157
|
+
const d = describeIngest({ name, ...r });
|
|
158
|
+
results.push(d);
|
|
159
|
+
(d.ok ? log.log : log.error).call(log, d.message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The after-picture. Without it a run that exited 0 while writing nothing still
|
|
163
|
+
// reads as success — the exact shape of failure this task exists to end.
|
|
164
|
+
let after = null;
|
|
165
|
+
try {
|
|
166
|
+
after = describeIndex({ ...(await readChunkStats(pool)), now });
|
|
167
|
+
(after.level === 'ok' ? log.log : log.error).call(log, `search-index: after sweep — ${after.message}`);
|
|
168
|
+
} catch (_) { /* the before-picture already reported; never throw from a sweep */ }
|
|
169
|
+
|
|
170
|
+
return { health, results, after };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function start(deps = {}) {
|
|
174
|
+
// Same injectable-log shape as sweep() above (default console), so the two
|
|
175
|
+
// halves of this poller log through one seam and neither hard-codes console.
|
|
176
|
+
const log = deps.log || console;
|
|
177
|
+
if (process.env.SEARCH_INDEX_SWEEP_DISABLED === 'true') {
|
|
178
|
+
log.log('search-index: sweep disabled by SEARCH_INDEX_SWEEP_DISABLED.');
|
|
179
|
+
return { stop };
|
|
180
|
+
}
|
|
181
|
+
const pool = deps.pool || require('../../../src/module-api.js').pool;
|
|
182
|
+
const tick = async () => {
|
|
183
|
+
if (sweeping) return; // never re-enter; a long ingest must not stack
|
|
184
|
+
sweeping = true;
|
|
185
|
+
try { await sweep({ pool, log }); }
|
|
186
|
+
catch (e) { log.error('search-index: sweep failed:', (e && e.message) || e); }
|
|
187
|
+
finally { sweeping = false; }
|
|
188
|
+
};
|
|
189
|
+
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
190
|
+
intervalTimer = setInterval(tick, INTERVAL_MS);
|
|
191
|
+
if (firstTimer.unref) firstTimer.unref();
|
|
192
|
+
if (intervalTimer.unref) intervalTimer.unref();
|
|
193
|
+
return { stop };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stop() {
|
|
197
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
198
|
+
if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { start, stop, sweep, describeIndex, describeIngest, INTERVAL_MS, FIRST_SWEEP_DELAY_MS };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.358",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.358",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.358",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -68,6 +68,7 @@ const PUBLISH_ALLOWLIST = [
|
|
|
68
68
|
'modules/dev-box/',
|
|
69
69
|
'modules/discord/',
|
|
70
70
|
'modules/provisioning/', // instance-hosting control plane — CORE like its sibling dev-box (same do-api/cf-tunnel toolchain, all core). Reclassified IN so the core CLI scripts/gds/provision.js resolves it from the core package post-split, not a MODULE_NOT_FOUND (task 2135). default:false — shipped ≠ enabled.
|
|
71
|
+
'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
|
|
71
72
|
'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
|
|
72
73
|
// --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
|
|
73
74
|
'config/branding.neutral.json',
|
package/src/module-api.js
CHANGED
|
@@ -1142,8 +1142,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1142
1142
|
// landed since 1.19.354 with no explicit bump. run 32853063015. (task 1002620)
|
|
1143
1143
|
// 1.19.356 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1144
1144
|
// landed since 1.19.355 with no explicit bump. run 32861072779. (task 1002620)
|
|
1145
|
+
// 1.19.357 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1146
|
+
// landed since 1.19.356 with no explicit bump. run 32871335939. (task 1002620)
|
|
1147
|
+
// 1.19.358 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1148
|
+
// landed since 1.19.357 with no explicit bump. run 32872551850. (task 1002620)
|
|
1145
1149
|
// ---------------------------------------------------------------------------
|
|
1146
|
-
const CORE_VERSION = '1.19.
|
|
1150
|
+
const CORE_VERSION = '1.19.358'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1147
1151
|
|
|
1148
1152
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1149
1153
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// tests/agents_spawn.mjs — the spawn seam (task 1002489, goal 1000038).
|
|
2
|
+
//
|
|
3
|
+
// Gates two criteria: agents-spawn-via-grade-port and agents-model-tier-default.
|
|
4
|
+
//
|
|
5
|
+
// Everything is dependency-injected — a fake pool, a fake runner, a fake clock. No
|
|
6
|
+
// database, no subprocess, no model call. What is proven here is the DECISIONS: the
|
|
7
|
+
// tier mapping, the ledger's shape, and the refusals.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/agents_spawn.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { spawnAgent, resolveModel, plannedRow, completedRow } = require('../modules/agents/spawn.js');
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
let failed = 0;
|
|
19
|
+
async function test(name, fn) {
|
|
20
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
21
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODELS = { main: 'opus', subagentDefault: 'opus', subagentRoutine: 'haiku' };
|
|
25
|
+
const DEF = { id: 7, name: 'historian', model_tier: 'default', persona: 'observe and report' };
|
|
26
|
+
|
|
27
|
+
// A pool that records every statement, so the LEDGER can be asserted on directly.
|
|
28
|
+
function fakePool() {
|
|
29
|
+
const calls = [];
|
|
30
|
+
return {
|
|
31
|
+
calls,
|
|
32
|
+
async query(sql, params) {
|
|
33
|
+
calls.push({ sql, params });
|
|
34
|
+
if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: 42 }] };
|
|
35
|
+
return { rows: [] };
|
|
36
|
+
},
|
|
37
|
+
inserted() {
|
|
38
|
+
const c = calls.find((x) => /^INSERT INTO agents_runs/.test(x.sql));
|
|
39
|
+
if (!c) return null;
|
|
40
|
+
const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
|
|
41
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
|
|
42
|
+
},
|
|
43
|
+
updated() {
|
|
44
|
+
const c = calls.find((x) => /^UPDATE agents_runs/.test(x.sql));
|
|
45
|
+
if (!c) return null;
|
|
46
|
+
const cols = [...c.sql.matchAll(/(\w+) = \$\d+/g)].map((m) => m[1]).filter((k) => k !== 'updated_at');
|
|
47
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i + 1]]));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- THE TIER MAPPING (criterion agents-model-tier-default) -----------------
|
|
53
|
+
|
|
54
|
+
await test('tier resolves through BRANDING, not a constant in the module', () => {
|
|
55
|
+
assert.equal(resolveModel('default', MODELS), 'opus');
|
|
56
|
+
assert.equal(resolveModel('routine', MODELS), 'haiku');
|
|
57
|
+
// An instance that retunes its tiers must move the agents with it.
|
|
58
|
+
assert.equal(resolveModel('routine', { subagentDefault: 'sonnet', subagentRoutine: 'sonnet' }), 'sonnet');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('an UNKNOWN tier falls back to DEFAULT, never to the cheap tier', () => {
|
|
62
|
+
// Silently downgrading a malformed definition presents much later as
|
|
63
|
+
// mysteriously poor agent output — far harder to trace than an unexpected
|
|
64
|
+
// cost line. Fail toward capability.
|
|
65
|
+
for (const bad of ['cheap', '', null, undefined, 'ROUTINE']) {
|
|
66
|
+
assert.equal(resolveModel(bad, MODELS), 'opus', `${bad} must not resolve to the routine tier`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('a missing branding map yields null rather than inventing a model id', () => {
|
|
71
|
+
assert.equal(resolveModel('default', {}), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- THE LEDGER SHAPE -------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
await test('a no-go is TERMINAL: skipped, no model, no output, no cost', () => {
|
|
77
|
+
const row = plannedRow({ definition: DEF, gate: { decision: 'no-go', reason: 'autonomy off' }, model: 'opus' });
|
|
78
|
+
assert.equal(row.gate_decision, 'no-go');
|
|
79
|
+
assert.equal(row.status, 'skipped', "'pending' would read as a run still in flight");
|
|
80
|
+
assert.equal(row.model, null, 'nothing ran, so no model was used');
|
|
81
|
+
assert.equal(row.gate_reason, 'autonomy off');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await test('a go starts PENDING so a crashed spawn is visible as unfinished', () => {
|
|
85
|
+
const row = plannedRow({ definition: DEF, gate: { decision: 'go' }, model: 'opus' });
|
|
86
|
+
assert.equal(row.status, 'pending');
|
|
87
|
+
assert.equal(row.model, 'opus', 'the CONCRETE model, so an old row is never re-read through a new tier map');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('the run stays attributable when the definition is gone', () => {
|
|
91
|
+
const row = plannedRow({ definition: DEF, gate: { decision: 'go' } });
|
|
92
|
+
assert.equal(row.agent_name, 'historian', 'denormalized name survives ON DELETE SET NULL');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('an errored run ALWAYS carries a code — the schema refuses otherwise', () => {
|
|
96
|
+
const c = completedRow({ ok: false });
|
|
97
|
+
assert.equal(c.status, 'error');
|
|
98
|
+
assert.ok(c.error_code, 'never null, even when the thrower named nothing');
|
|
99
|
+
assert.equal(c.output_ref, null, "a failed run's partial output is not a result");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- SPAWNING (criterion agents-spawn-via-grade-port) -----------------------
|
|
103
|
+
|
|
104
|
+
await test('a go spawns through the injected grade port and completes the row', async () => {
|
|
105
|
+
const pool = fakePool();
|
|
106
|
+
let seen = null;
|
|
107
|
+
const r = await spawnAgent(
|
|
108
|
+
{ definition: DEF, trigger: { type: 'event', ref: 'task.shipped:1' }, gate: { decision: 'go' } },
|
|
109
|
+
{ pool, brandingModels: MODELS, now: (() => { let t = 1000; return () => (t += 250); })(),
|
|
110
|
+
runSubagentCached: async (a) => { seen = a; return { ok: true, cost_usd: 0.42, output_ref: 'grade:9' }; } },
|
|
111
|
+
);
|
|
112
|
+
assert.equal(r.ok, true);
|
|
113
|
+
assert.equal(r.ran, true);
|
|
114
|
+
assert.equal(seen.opts.model, 'opus', 'the resolved model reaches the port');
|
|
115
|
+
assert.equal(pool.updated().status, 'ok');
|
|
116
|
+
assert.equal(Number(pool.updated().cost_usd), 0.42, 'spend lands in the ledger');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('the port receives NO tool or permission knobs — v1 agents are read-only', async () => {
|
|
120
|
+
// Not policy: grader.js hardcodes `--tools Read`. Anything here that looked
|
|
121
|
+
// like granting a capability would be a bug, so assert the absence.
|
|
122
|
+
let seen = null;
|
|
123
|
+
await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
|
|
124
|
+
pool: fakePool(), brandingModels: MODELS,
|
|
125
|
+
runSubagentCached: async (a) => { seen = a; return { ok: true }; },
|
|
126
|
+
});
|
|
127
|
+
for (const forbidden of ['tools', 'allowedTools', 'permissions', 'permissionMode', 'bypassPermissions']) {
|
|
128
|
+
assert.ok(!(forbidden in seen.opts), `opts must not carry ${forbidden}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('a NO-GO spawns nothing at all', async () => {
|
|
133
|
+
const pool = fakePool();
|
|
134
|
+
let ran = false;
|
|
135
|
+
const r = await spawnAgent({ definition: DEF, gate: { decision: 'no-go', reason: 'capped' } }, {
|
|
136
|
+
pool, brandingModels: MODELS, runSubagentCached: async () => { ran = true; return { ok: true }; },
|
|
137
|
+
});
|
|
138
|
+
assert.equal(ran, false, 'a refusal must not reach the model');
|
|
139
|
+
assert.equal(r.ran, false);
|
|
140
|
+
assert.equal(r.skipped, true);
|
|
141
|
+
assert.equal(pool.updated(), null, 'terminal at insert — nothing to complete');
|
|
142
|
+
assert.equal(pool.inserted().status, 'skipped');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('a refusal is still LEDGERED — an untraced no-go is unexplainable later', async () => {
|
|
146
|
+
const pool = fakePool();
|
|
147
|
+
await spawnAgent({ definition: DEF, gate: { decision: 'no-go', reason: 'autonomy gate closed' } },
|
|
148
|
+
{ pool, brandingModels: MODELS });
|
|
149
|
+
const row = pool.inserted();
|
|
150
|
+
assert.ok(row, 'the row exists even though nothing ran');
|
|
151
|
+
assert.equal(row.gate_reason, 'autonomy gate closed', 'and says why');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('a missing grade port is a named ledger row, not a crash', async () => {
|
|
155
|
+
// An instance may run `agents` with `grading` disabled. resolveOptional is what
|
|
156
|
+
// makes that a refusal instead of a boot-time require failure.
|
|
157
|
+
const pool = fakePool();
|
|
158
|
+
const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } },
|
|
159
|
+
{ pool, brandingModels: MODELS, runSubagentCached: null });
|
|
160
|
+
assert.equal(r.ok, false);
|
|
161
|
+
assert.equal(r.error_code, 'grade_port_unavailable');
|
|
162
|
+
assert.equal(pool.updated().status, 'error');
|
|
163
|
+
assert.equal(pool.updated().error_code, 'grade_port_unavailable');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('a throwing subagent is recorded, and never propagates', async () => {
|
|
167
|
+
// An event fabric must not be taken down by one agent's bad day.
|
|
168
|
+
const pool = fakePool();
|
|
169
|
+
const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
|
|
170
|
+
pool, brandingModels: MODELS,
|
|
171
|
+
runSubagentCached: async () => { throw Object.assign(new Error('boom'), { code: 'timeout' }); },
|
|
172
|
+
});
|
|
173
|
+
assert.equal(r.ok, false);
|
|
174
|
+
assert.equal(r.error_code, 'timeout');
|
|
175
|
+
assert.equal(pool.updated().status, 'error');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('an unledgerable fire does NOT spawn — unaccounted spend is worse', async () => {
|
|
179
|
+
let ran = false;
|
|
180
|
+
const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
|
|
181
|
+
pool: { query: async () => { throw new Error('db down'); } },
|
|
182
|
+
brandingModels: MODELS,
|
|
183
|
+
runSubagentCached: async () => { ran = true; return { ok: true }; },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(ran, false, 'money must never be spent with nowhere to record it');
|
|
186
|
+
assert.equal(r.error_code, 'ledger_write_failed');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log(`\nagents_spawn: ${passed} passed, ${failed} failed`);
|
|
190
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -204,16 +204,14 @@ t('every PROTECTED_GLOB is either topology-excluded or publishable (the methodol
|
|
|
204
204
|
// Modules deliberately not published, on the record. An entry here is a decision
|
|
205
205
|
// someone made, not a gap someone forgot — and every entry needs the task or idea that
|
|
206
206
|
// records the decision, so this can never become a quiet parking lot.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
['agents', 'idea 1000797'],
|
|
216
|
-
]);
|
|
207
|
+
// Currently EMPTY, and that is the healthy state — every module on disk is either
|
|
208
|
+
// allowlisted or denied on the record. The one entry it ever held ('agents', idea
|
|
209
|
+
// 1000797) was retired by task 1002489: the copy-desk task that found the gap
|
|
210
|
+
// deliberately left the call to goal 1000038, and task 1002489 IS that goal, so it
|
|
211
|
+
// made it — agents is core-maintained, so it joined PUBLISH_ALLOWLIST rather than a
|
|
212
|
+
// deny list. Keep the map (and the two guards below): the next module added without
|
|
213
|
+
// a publish decision is caught the same way this one was.
|
|
214
|
+
const KNOWN_UNPUBLISHED_MODULES = new Map([]);
|
|
217
215
|
|
|
218
216
|
t('every module on disk is either publishable or deliberately unpublished (a new module cannot be silently invisible)', () => {
|
|
219
217
|
const modulesDir = path.join(ROOT, 'modules');
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tests/search_index_poller.mjs — the recall index keeps itself, and says so
|
|
2
|
+
// (task 1003162).
|
|
3
|
+
//
|
|
4
|
+
// The outage this guards: /recall returned `index_empty` on live for words that
|
|
5
|
+
// demonstrably existed in the corpus, because NOTHING ran the ingesters. ADR 0060
|
|
6
|
+
// claims a deploy hook and a nightly timer; both are box-side, and neither could be
|
|
7
|
+
// shown to have ever run.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is pure or dependency-injected — no database, no child process,
|
|
10
|
+
// no clock. The sweep is driven with a fake pool and a fake execFile so the
|
|
11
|
+
// DECISIONS are tested, not the plumbing.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/search_index_poller.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 poller = require('../modules/memory/pollers/search-index.js');
|
|
20
|
+
const { describeIndex, describeIngest, sweep } = poller;
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
async function test(name, fn) {
|
|
25
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
26
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOW = Date.parse('2026-08-25T12:00:00Z');
|
|
30
|
+
const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
|
|
31
|
+
const quietLog = { log() {}, error() {} };
|
|
32
|
+
const okExec = (_p, _a, _o, cb) => cb(null, '', '');
|
|
33
|
+
|
|
34
|
+
// --- READING THE INDEX ------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
await test('an EMPTY index is an alarm, and says recall returns nothing AT ANY RANK', async () => {
|
|
37
|
+
const d = describeIndex({ chunks: 0, now: NOW });
|
|
38
|
+
assert.equal(d.level, 'alarm');
|
|
39
|
+
assert.equal(d.state, 'empty');
|
|
40
|
+
// Rank-independence is the whole reason `index_empty` is diagnostic: it rules
|
|
41
|
+
// out "nothing visible to YOU", which is what made the live report trustworthy.
|
|
42
|
+
assert.match(d.message, /any rank/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await test('an UNREADABLE count is an alarm — unknown is not healthy', async () => {
|
|
46
|
+
for (const bad of [NaN, undefined, null, -1, 'lots']) {
|
|
47
|
+
const d = describeIndex({ chunks: bad, now: NOW });
|
|
48
|
+
assert.equal(d.level, 'alarm', `${bad} must not read as healthy`);
|
|
49
|
+
assert.equal(d.state, 'unreadable');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('a populated, fresh index is ok and says how much it holds', async () => {
|
|
54
|
+
const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(1), now: NOW });
|
|
55
|
+
assert.equal(d.level, 'ok');
|
|
56
|
+
assert.match(d.message, /4812/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('a populated but WEEK-OLD index warns — the sweep may have stopped', async () => {
|
|
60
|
+
const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(9), now: NOW });
|
|
61
|
+
assert.equal(d.level, 'warn');
|
|
62
|
+
assert.equal(d.state, 'stale');
|
|
63
|
+
assert.equal(d.age_days, 9);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('staleness is not guessed when nothing says when it last ran', async () => {
|
|
67
|
+
const d = describeIndex({ chunks: 10, lastIngestedAt: null, now: NOW });
|
|
68
|
+
assert.equal(d.level, 'ok', 'an unknown age must not be reported as stale');
|
|
69
|
+
assert.equal(d.age_days, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- READING THE INGESTERS' EXIT CODES --------------------------------------
|
|
73
|
+
|
|
74
|
+
await test('a NON-ZERO exit is an alarm carrying the TAIL of stderr', async () => {
|
|
75
|
+
// Task 1002859 made a partial ingest exit non-zero and NAME every skipped
|
|
76
|
+
// source — at the end of its output. A head-clip would drop exactly that.
|
|
77
|
+
const d = describeIngest({
|
|
78
|
+
name: 'search-ingest-md', code: 1,
|
|
79
|
+
stderr: ['noise', 'noise', 'noise', 'noise', 'noise', 'noise', 'skipped: docs/adr/0060.md (NUL byte)'].join('\n'),
|
|
80
|
+
});
|
|
81
|
+
assert.equal(d.ok, false);
|
|
82
|
+
assert.equal(d.level, 'alarm');
|
|
83
|
+
assert.match(d.message, /skipped: docs\/adr\/0060\.md/, 'the last line is the one that matters');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a KILLED ingester is reported as killed, not as a clean failure', async () => {
|
|
87
|
+
const d = describeIngest({ name: 'search-ingest-db', code: null, signal: 'SIGTERM' });
|
|
88
|
+
assert.equal(d.ok, false);
|
|
89
|
+
assert.match(d.message, /KILLED by SIGTERM/);
|
|
90
|
+
assert.match(d.message, /partially written/, 'a half-written index is the real risk');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await test('exit 0 is ok', async () => {
|
|
94
|
+
assert.equal(describeIngest({ name: 'x', code: 0 }).ok, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- THE SWEEP --------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function fakePool(seq) {
|
|
100
|
+
let i = 0;
|
|
101
|
+
return { query: async () => ({ rows: [seq[Math.min(i++, seq.length - 1)]] }) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
|
|
105
|
+
const called = [];
|
|
106
|
+
const exec = (_p, args, _o, cb) => {
|
|
107
|
+
called.push(args[0].split('/').pop());
|
|
108
|
+
cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
|
|
109
|
+
};
|
|
110
|
+
const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
|
|
111
|
+
assert.deepEqual(called, ['search-ingest-md.js', 'search-ingest-db.js']);
|
|
112
|
+
assert.equal(r.results.length, 2);
|
|
113
|
+
assert.equal(r.results[0].ok, false);
|
|
114
|
+
assert.equal(r.results[1].ok, true, 'the db ingest still ran after the md ingest failed');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('the sweep reports the AFTER count — exit 0 that wrote nothing is a failure shape', async () => {
|
|
118
|
+
// The precise trap: an ingester can exit 0 and leave the table empty. Without
|
|
119
|
+
// an after-picture that reads as success.
|
|
120
|
+
const r = await sweep({
|
|
121
|
+
pool: fakePool([{ chunks: 0, last: null }, { chunks: 0, last: null }]),
|
|
122
|
+
log: quietLog, execFileImpl: okExec, now: NOW,
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r.after.state, 'empty', 'still empty after a "successful" ingest');
|
|
125
|
+
assert.equal(r.after.level, 'alarm');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('a healthy sweep still SPEAKS — silence must not be the healthy state', async () => {
|
|
129
|
+
// The reconciler's lesson: a sweep that only logs on failure is
|
|
130
|
+
// indistinguishable from a sweep that is not running at all.
|
|
131
|
+
const lines = [];
|
|
132
|
+
await sweep({
|
|
133
|
+
pool: fakePool([{ chunks: 4812, last: daysAgo(1) }]),
|
|
134
|
+
log: { log: (m) => lines.push(m), error: (m) => lines.push(m) },
|
|
135
|
+
execFileImpl: okExec, now: NOW,
|
|
136
|
+
});
|
|
137
|
+
assert.ok(lines.length >= 2, 'a healthy sweep leaves a disposition line');
|
|
138
|
+
assert.ok(lines.some((l) => /4812/.test(l)), 'and says what it found');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('a DB read failure never throws out of the sweep', async () => {
|
|
142
|
+
const r = await sweep({
|
|
143
|
+
pool: { query: async () => { throw new Error('connection refused'); } },
|
|
144
|
+
log: quietLog, execFileImpl: okExec, now: NOW,
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r.health.state, 'unreadable');
|
|
147
|
+
assert.equal(r.health.level, 'alarm');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('the poller is declared in the memory manifest — a file nothing loads is dormant', async () => {
|
|
151
|
+
// The exact failure goal 1000076 hit five times: a surface that exists, is
|
|
152
|
+
// documented, and is never wired.
|
|
153
|
+
const manifest = require('../modules/memory/module.json');
|
|
154
|
+
assert.ok(
|
|
155
|
+
(manifest.contributes.pollers || []).includes('search-index'),
|
|
156
|
+
'modules/memory/module.json must declare the poller for the loader to start it',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log(`\nsearch_index_poller: ${passed} passed, ${failed} failed`);
|
|
161
|
+
process.exit(failed ? 1 : 0);
|