@polderlabs/bizar 10.5.0 → 10.6.0
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/bizar-dash/src/server/agent-bus.mjs +485 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/loop-runtime.mjs +429 -0
- package/bizar-dash/src/server/routes/agent-bus.mjs +185 -0
- package/bizar-dash/src/server/routes/loops.mjs +138 -0
- package/bizar-dash/src/server/task-splitter.mjs +241 -0
- package/bizar-dash/tests/agent-bus.test.mjs +289 -0
- package/bizar-dash/tests/loop-runtime.test.mjs +312 -0
- package/bizar-dash/tests/loops-agent-bus-routes.test.mjs +293 -0
- package/bizar-dash/tests/task-splitter.test.mjs +268 -0
- package/package.json +1 -1
- package/packages/sdk/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/loops.mjs
|
|
3
|
+
*
|
|
4
|
+
* G-autoloop Phase 5 — HTTP surface for the loop runtime.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints (mounted under `/api/loops`):
|
|
7
|
+
* GET /api/loops — snapshot of all known loops
|
|
8
|
+
* GET /api/loops/:id — single loop state
|
|
9
|
+
* POST /api/loops — create a new pending loop
|
|
10
|
+
* PATCH /api/loops/:id — patch fields (intervalMs, maxIterations, etc.)
|
|
11
|
+
* POST /api/loops/:id/start — flip status to 'running' (idempotent)
|
|
12
|
+
* POST /api/loops/:id/stop — flip status to 'stopped' (idempotent)
|
|
13
|
+
* DELETE /api/loops/:id — delete the state file
|
|
14
|
+
*
|
|
15
|
+
* The actual scheduling (in-process setInterval) happens in
|
|
16
|
+
* `loop-runtime.mjs`. This router owns only the HTTP wiring — keeps
|
|
17
|
+
* the runtime pure + testable.
|
|
18
|
+
*
|
|
19
|
+
* Phase 5 deliberately stops short of dispatching real tasks here.
|
|
20
|
+
* That's wired in a follow-up so we can ship the loop infrastructure
|
|
21
|
+
* (CRUD + scheduler) and let a dedicated route pair it with the real
|
|
22
|
+
* `taskDelegator` + project-store integration.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Router } from 'express';
|
|
26
|
+
import {
|
|
27
|
+
createLoop,
|
|
28
|
+
readLoop,
|
|
29
|
+
patchLoop,
|
|
30
|
+
deleteLoop,
|
|
31
|
+
listLoops,
|
|
32
|
+
snapshotLoops,
|
|
33
|
+
startLoop,
|
|
34
|
+
stopLoop,
|
|
35
|
+
} from '../loop-runtime.mjs';
|
|
36
|
+
import { wrap } from './_shared.mjs';
|
|
37
|
+
|
|
38
|
+
const VALID_PATCH_FIELDS = new Set([
|
|
39
|
+
'name',
|
|
40
|
+
'intervalMs',
|
|
41
|
+
'maxIterations',
|
|
42
|
+
'projectId',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {Object} deps
|
|
47
|
+
* @param {Function} [deps.broadcast]
|
|
48
|
+
*/
|
|
49
|
+
export function createLoopsRouter({ broadcast = () => {} } = {}) {
|
|
50
|
+
const router = Router();
|
|
51
|
+
|
|
52
|
+
router.get('/loops', wrap(async (req, res) => {
|
|
53
|
+
const snap = snapshotLoops();
|
|
54
|
+
res.json({ loops: Object.values(snap), count: Object.keys(snap).length });
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
router.get('/loops/:id', wrap(async (req, res) => {
|
|
58
|
+
const s = readLoop(req.params.id);
|
|
59
|
+
if (!s) {
|
|
60
|
+
res.status(404).json({ error: 'not_found' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.json(s);
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
router.post('/loops', wrap(async (req, res) => {
|
|
67
|
+
const body = req.body || {};
|
|
68
|
+
if (!body.projectId) {
|
|
69
|
+
res.status(400).json({ error: 'bad_request', message: 'projectId required' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const s = createLoop({
|
|
73
|
+
projectId: body.projectId,
|
|
74
|
+
name: body.name,
|
|
75
|
+
intervalMs: body.intervalMs,
|
|
76
|
+
maxIterations: body.maxIterations,
|
|
77
|
+
});
|
|
78
|
+
broadcast({ type: 'loops:change', loop: s });
|
|
79
|
+
res.status(201).json(s);
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
router.patch('/loops/:id', wrap(async (req, res) => {
|
|
83
|
+
const patch = {};
|
|
84
|
+
for (const [k, v] of Object.entries(req.body || {})) {
|
|
85
|
+
if (VALID_PATCH_FIELDS.has(k)) patch[k] = v;
|
|
86
|
+
}
|
|
87
|
+
const next = patchLoop(req.params.id, patch);
|
|
88
|
+
if (!next) {
|
|
89
|
+
res.status(404).json({ error: 'not_found' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
broadcast({ type: 'loops:change', loop: next });
|
|
93
|
+
res.json(next);
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
// Start a loop. The runtime owns the timer; this just flips state.
|
|
97
|
+
// We re-use `startLoop` from the runtime so the in-process scheduler
|
|
98
|
+
// is the single source of truth. If the caller passed a `dry` flag,
|
|
99
|
+
// we only flip the state without engaging the timer — useful for
|
|
100
|
+
// the dashboard's "arm only" toggle.
|
|
101
|
+
router.post('/loops/:id/start', wrap(async (req, res) => {
|
|
102
|
+
const dry = req.body && req.body.dry === true;
|
|
103
|
+
let next;
|
|
104
|
+
try {
|
|
105
|
+
next = dry ? patchLoop(req.params.id, { status: 'running' }) : startLoop(req.params.id, {});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
res.status(404).json({ error: 'not_found', message: err.message });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!next) {
|
|
111
|
+
res.status(404).json({ error: 'not_found' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
broadcast({ type: 'loops:change', loop: next });
|
|
115
|
+
res.json(next);
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
router.post('/loops/:id/stop', wrap(async (req, res) => {
|
|
119
|
+
const next = stopLoop(req.params.id);
|
|
120
|
+
if (!next) {
|
|
121
|
+
res.status(404).json({ error: 'not_found' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
broadcast({ type: 'loops:change', loop: next });
|
|
125
|
+
res.json(next);
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
router.delete('/loops/:id', wrap(async (req, res) => {
|
|
129
|
+
// Stop the in-process timer first so it doesn't keep firing on a
|
|
130
|
+
// deleted state.json.
|
|
131
|
+
stopLoop(req.params.id);
|
|
132
|
+
deleteLoop(req.params.id);
|
|
133
|
+
broadcast({ type: 'loops:deleted', loopId: req.params.id });
|
|
134
|
+
res.status(204).end();
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
return router;
|
|
138
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/task-splitter.mjs
|
|
3
|
+
*
|
|
4
|
+
* v10.6.0 — G-autoloop Phase 3. Task ordering + auto-splitting.
|
|
5
|
+
*
|
|
6
|
+
* The "best order" problem the user cares about has three pieces:
|
|
7
|
+
*
|
|
8
|
+
* 1. Order. Which ready task should the loop pick next? Already
|
|
9
|
+
* solved in `loop-runtime.mjs:pickReadyTasks` (priority
|
|
10
|
+
* then age, dependencies gated on `done` status).
|
|
11
|
+
*
|
|
12
|
+
* 2. Size. How big is each task? Operators want to know
|
|
13
|
+
* "`tsk_abc` is L-sized" before they let the loop loose
|
|
14
|
+
* on it. Size is a heuristic over title/description
|
|
15
|
+
* length, presence of linked goal/KR, and historical
|
|
16
|
+
* completion times.
|
|
17
|
+
*
|
|
18
|
+
* 3. Split. Tasks tagged `size ∈ { L, XL }` get routed to the
|
|
19
|
+
* `task-decomposer` agent, which returns 2-N smaller
|
|
20
|
+
* tasks. The new tasks link back via `parentTaskId`
|
|
21
|
+
* so progress aggregates up. The parent stays open
|
|
22
|
+
* until all children are `done`.
|
|
23
|
+
*
|
|
24
|
+
* This module implements pure functions for (2) and (3). Splitting
|
|
25
|
+
* itself delegates to a caller-supplied `decompose` function so we
|
|
26
|
+
* don't bake an LLM call into the harness — that's the agent's job
|
|
27
|
+
* (per our architecture boundary: skills/MCP define agent behaviour,
|
|
28
|
+
* not the dash server).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SIZE_ORDER = ['S', 'M', 'L', 'XL'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Estimate a task's size bucket from its title, description, linked
|
|
35
|
+
* goals, and history. Heuristic, no LLM. Buckets: S, M, L, XL.
|
|
36
|
+
*
|
|
37
|
+
* Signals:
|
|
38
|
+
* - title word count : +0.2 per word over 6
|
|
39
|
+
* - description char count : +0.001 per char over 200
|
|
40
|
+
* - linked goal/KR : +1 size point each
|
|
41
|
+
* - avg historical duration : +1 if > 2h, +2 if > 6h
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} task
|
|
44
|
+
* @param {string} task.title
|
|
45
|
+
* @param {string} [task.description]
|
|
46
|
+
* @param {Object} [task.metadata] may carry goalId/krId
|
|
47
|
+
* @param {Array} [history] prior tasks from this assignee;
|
|
48
|
+
* we use title-length buckets and
|
|
49
|
+
* avg `timeSpent` minutes as a
|
|
50
|
+
* proxy for difficulty.
|
|
51
|
+
* @returns {'S'|'M'|'L'|'XL'}
|
|
52
|
+
*/
|
|
53
|
+
export function estimateSize(task, history = []) {
|
|
54
|
+
if (!task || typeof task !== 'object') return 'M';
|
|
55
|
+
const title = String(task.title || '');
|
|
56
|
+
const desc = String(task.description || '');
|
|
57
|
+
const meta = task.metadata || {};
|
|
58
|
+
|
|
59
|
+
let score = 0;
|
|
60
|
+
// Title length signal — every word over 6 adds 0.2 to the score.
|
|
61
|
+
const words = title.trim().split(/\s+/).filter(Boolean);
|
|
62
|
+
if (words.length > 6) score += (words.length - 6) * 0.2;
|
|
63
|
+
|
|
64
|
+
// Description length signal — chars over 200 add 0.001 each.
|
|
65
|
+
if (desc.length > 200) score += (desc.length - 200) * 0.001;
|
|
66
|
+
|
|
67
|
+
// Linked goal / KR — both count.
|
|
68
|
+
if (meta.goalId) score += 1;
|
|
69
|
+
if (meta.krId) score += 1;
|
|
70
|
+
|
|
71
|
+
// Historical signal — if prior similar-sized tasks took > 2h, push up.
|
|
72
|
+
const historicalMinutes = avgHistoricalMinutes(task, history);
|
|
73
|
+
if (historicalMinutes > 360) score += 2; // > 6h
|
|
74
|
+
else if (historicalMinutes > 120) score += 1; // > 2h
|
|
75
|
+
|
|
76
|
+
return scoreToBucket(score);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scoreToBucket(score) {
|
|
80
|
+
if (score < 0.5) return 'S';
|
|
81
|
+
if (score < 1.5) return 'M';
|
|
82
|
+
if (score < 3.0) return 'L';
|
|
83
|
+
return 'XL';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Average minutes spent on historical tasks that share at least 2
|
|
88
|
+
* title-words with the candidate. We treat very short histories
|
|
89
|
+
* (< 2 items) as unreliable and return 0.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} task
|
|
92
|
+
* @param {Array} history
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
export function avgHistoricalMinutes(task, history) {
|
|
96
|
+
if (!task || !Array.isArray(history) || history.length < 2) return 0;
|
|
97
|
+
const titleWords = new Set(
|
|
98
|
+
String(task.title || '').toLowerCase().split(/\s+/).filter((w) => w.length > 3),
|
|
99
|
+
);
|
|
100
|
+
if (titleWords.size === 0) return 0;
|
|
101
|
+
const similar = history.filter((h) => {
|
|
102
|
+
if (!h || !h.title) return false;
|
|
103
|
+
const hw = new Set(String(h.title).toLowerCase().split(/\s+/).filter((w) => w.length > 3));
|
|
104
|
+
let overlap = 0;
|
|
105
|
+
for (const w of titleWords) if (hw.has(w)) overlap++;
|
|
106
|
+
return overlap >= 2;
|
|
107
|
+
});
|
|
108
|
+
if (similar.length === 0) return 0;
|
|
109
|
+
let total = 0;
|
|
110
|
+
let count = 0;
|
|
111
|
+
for (const h of similar) {
|
|
112
|
+
const mins = Number(h.timeSpent) || 0;
|
|
113
|
+
if (mins > 0) {
|
|
114
|
+
total += mins;
|
|
115
|
+
count++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return count > 0 ? total / count : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decide whether a task should be split. Threshold: `size ∈ { L, XL }`
|
|
123
|
+
* OR explicit `metadata.autoSplit === true`.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} task
|
|
126
|
+
* @param {Array} [history]
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function shouldSplit(task, history = []) {
|
|
130
|
+
if (!task) return false;
|
|
131
|
+
if (task.metadata && task.metadata.autoSplit === true) return true;
|
|
132
|
+
if (task.metadata && task.metadata.autoSplit === false) return false;
|
|
133
|
+
const size = estimateSize(task, history);
|
|
134
|
+
return size === 'L' || size === 'XL';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Plan a split. Returns the **call shape** the caller dispatches to a
|
|
139
|
+
* `task-decomposer` agent (LLM-step), plus the metadata that should
|
|
140
|
+
* be patched back onto the parent. The agent must return:
|
|
141
|
+
*
|
|
142
|
+
* [{ title, description?, size?, priority? }, ...] // 2..N tasks
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} parent
|
|
145
|
+
* @returns {Object} plan
|
|
146
|
+
* - kind: 'task-decompose'
|
|
147
|
+
* - parentTaskId
|
|
148
|
+
* - prompt: natural-language instruction for the agent
|
|
149
|
+
* - constraints: { min: 2, max: 5 } — the agent may not exceed this
|
|
150
|
+
*/
|
|
151
|
+
export function planSplit(parent) {
|
|
152
|
+
if (!parent || !parent.id) throw new TypeError('planSplit: task id required');
|
|
153
|
+
const p = parent;
|
|
154
|
+
return {
|
|
155
|
+
kind: 'task-decompose',
|
|
156
|
+
parentTaskId: p.id,
|
|
157
|
+
prompt: [
|
|
158
|
+
`Break the following task into 2-5 smaller, independently-completable subtasks.`,
|
|
159
|
+
`Each subtask should be completable in under 30 minutes by a single subagent.`,
|
|
160
|
+
``,
|
|
161
|
+
`Title: ${p.title || '(no title)'}`,
|
|
162
|
+
`Description: ${p.description || '(no description)'}`,
|
|
163
|
+
p.assignee ? `Original assignee: ${p.assignee}` : null,
|
|
164
|
+
p.metadata && p.metadata.goalId ? `Linked goal: ${p.metadata.goalId}` : null,
|
|
165
|
+
].filter(Boolean).join('\n'),
|
|
166
|
+
constraints: { min: 2, max: 5 },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Validate a `task-decompose` response. Returns an array of task
|
|
172
|
+
* seeds (without ids — those are minted by the task store on create).
|
|
173
|
+
*
|
|
174
|
+
* @param {Array} candidates
|
|
175
|
+
* @param {Object} [opts]
|
|
176
|
+
* @param {number} [opts.min=2]
|
|
177
|
+
* @param {number} [opts.max=5]
|
|
178
|
+
* @returns {Array} cleaned seeds, ready to be passed to `tasksStore.create`
|
|
179
|
+
*/
|
|
180
|
+
export function acceptSplit(candidates, { min = 2, max = 5 } = {}) {
|
|
181
|
+
if (!Array.isArray(candidates)) return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const c of candidates) {
|
|
184
|
+
if (!c || typeof c !== 'object') continue;
|
|
185
|
+
if (!c.title || typeof c.title !== 'string') continue;
|
|
186
|
+
out.push({
|
|
187
|
+
title: String(c.title).slice(0, 200),
|
|
188
|
+
description: c.description ? String(c.description).slice(0, 4000) : '',
|
|
189
|
+
priority: Number.isFinite(c.priority) ? c.priority : 5,
|
|
190
|
+
tags: Array.isArray(c.tags) ? c.tags.filter((t) => typeof t === 'string') : ['autosplit'],
|
|
191
|
+
});
|
|
192
|
+
if (out.length >= max) break;
|
|
193
|
+
}
|
|
194
|
+
if (out.length < min) return [];
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Roll up child task progress into the parent's `metadata.progress`
|
|
200
|
+
* (0..1) and auto-complete the parent when all children are `done`.
|
|
201
|
+
*
|
|
202
|
+
* Pure function — the caller is responsible for writing back to the
|
|
203
|
+
* task store. Returns `{ ratio, allDone }`. If `parent` has no
|
|
204
|
+
* children array, returns `{ ratio: 0, allDone: false }`.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} parent
|
|
207
|
+
* @returns {Object}
|
|
208
|
+
*/
|
|
209
|
+
export function rollupProgress(parent) {
|
|
210
|
+
if (!parent) return { ratio: 0, allDone: false };
|
|
211
|
+
const children = Array.isArray(parent.subtasks) ? parent.subtasks : [];
|
|
212
|
+
if (children.length === 0) return { ratio: 0, allDone: false };
|
|
213
|
+
let done = 0;
|
|
214
|
+
for (const c of children) {
|
|
215
|
+
if (c && c.status === 'done') done++;
|
|
216
|
+
}
|
|
217
|
+
const ratio = done / children.length;
|
|
218
|
+
return { ratio, allDone: done === children.length };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Suggest a goal-aware priority bump. If a task links to a goal whose
|
|
223
|
+
* status is `at-risk` or `blocked`, bump its priority by 1; if `done`,
|
|
224
|
+
* drop the task to backlog. Pure helper; caller decides what to do
|
|
225
|
+
* with the result.
|
|
226
|
+
*
|
|
227
|
+
* @param {Object} task
|
|
228
|
+
* @param {Object} [goal] parsed goal object (from progress-parser)
|
|
229
|
+
* @returns {{ priority: number, action: 'bump'|'drop'|'none' }}
|
|
230
|
+
*/
|
|
231
|
+
export function goalAwarePriority(task, goal) {
|
|
232
|
+
if (!task || !goal) return { priority: Number.isFinite(task && task.priority) ? task.priority : 5, action: 'none' };
|
|
233
|
+
const base = Number.isFinite(task.priority) ? task.priority : 5;
|
|
234
|
+
if (goal.status === 'at-risk') return { priority: Math.max(1, base - 1), action: 'bump' };
|
|
235
|
+
if (goal.status === 'blocked') return { priority: base, action: 'none' };
|
|
236
|
+
if (goal.status === 'done') return { priority: 9, action: 'drop' };
|
|
237
|
+
return { priority: base, action: 'none' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Exported for tests + UI. */
|
|
241
|
+
export const __sizeBuckets = SIZE_ORDER;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/agent-bus.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Self-check for agent-bus.mjs (G-autoloop Phase 4).
|
|
5
|
+
* Uses node:test + node:assert/strict, mirroring the convention in
|
|
6
|
+
* tests/background-steer.test.mjs. Isolates state by pointing the
|
|
7
|
+
* module at a fresh temp directory and resetting presence on exit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, appendFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const TMP = mkdtempSync(join(tmpdir(), 'bizar-agent-bus-'));
|
|
17
|
+
|
|
18
|
+
// Point the module at the temp dir BEFORE importing it.
|
|
19
|
+
process.env.BIZAR_AGENT_BUS_ROOT = TMP;
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
AGENT_BUS_LOG_DIR,
|
|
23
|
+
AGENT_BUS_PRESENCE_FILE,
|
|
24
|
+
DEFAULT_CHANNEL,
|
|
25
|
+
isValidAddress,
|
|
26
|
+
resolveAddress,
|
|
27
|
+
publish,
|
|
28
|
+
steer,
|
|
29
|
+
correct,
|
|
30
|
+
handoff,
|
|
31
|
+
request,
|
|
32
|
+
respond,
|
|
33
|
+
subscribe,
|
|
34
|
+
subscribeChannel,
|
|
35
|
+
readChannel,
|
|
36
|
+
announce,
|
|
37
|
+
leave,
|
|
38
|
+
heartbeat,
|
|
39
|
+
getPresence,
|
|
40
|
+
_resetForTests,
|
|
41
|
+
} = await import('../src/server/agent-bus.mjs');
|
|
42
|
+
|
|
43
|
+
beforeEach(() => _resetForTests());
|
|
44
|
+
|
|
45
|
+
after(() => {
|
|
46
|
+
try { rmSync(TMP, { recursive: true, force: true }); } catch { /* */ }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('isValidAddress: accepts loose and targeted and broadcast', () => {
|
|
50
|
+
assert.equal(isValidAddress('agent://odin'), true);
|
|
51
|
+
assert.equal(isValidAddress('agent://odin/session-abc123'), true);
|
|
52
|
+
assert.equal(isValidAddress('agent://*'), true);
|
|
53
|
+
assert.equal(isValidAddress('odin'), false);
|
|
54
|
+
assert.equal(isValidAddress('agent:/odin'), false);
|
|
55
|
+
assert.equal(isValidAddress(''), false);
|
|
56
|
+
assert.equal(isValidAddress(null), false);
|
|
57
|
+
assert.equal(isValidAddress(undefined), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('publish: rejects bad input with TypeError', () => {
|
|
61
|
+
assert.throws(() => publish(null), TypeError);
|
|
62
|
+
assert.throws(() => publish({}), TypeError);
|
|
63
|
+
assert.throws(() => publish({ from: 'odin', to: 'agent://thor', kind: 'request' }), TypeError);
|
|
64
|
+
assert.throws(() => publish({ from: 'agent://odin', to: 'agent://thor', kind: 'bogus' }), TypeError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('publish: appends to JSONL log and emits live', () => {
|
|
68
|
+
const seen = [];
|
|
69
|
+
const unsub = subscribe((m) => seen.push(m));
|
|
70
|
+
const id = publish({ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: { q: 'hello' } });
|
|
71
|
+
unsub();
|
|
72
|
+
assert.ok(id.length > 0);
|
|
73
|
+
assert.equal(seen.length, 1);
|
|
74
|
+
assert.equal(seen[0].kind, 'request');
|
|
75
|
+
assert.equal(seen[0].channel, DEFAULT_CHANNEL);
|
|
76
|
+
const logFile = join(AGENT_BUS_LOG_DIR, `${DEFAULT_CHANNEL}.jsonl`);
|
|
77
|
+
assert.ok(existsSync(logFile));
|
|
78
|
+
const text = readFileSync(logFile, 'utf8');
|
|
79
|
+
assert.equal(text.trim().split('\n').length, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('publish: per-task channels are auto-created on first publish', () => {
|
|
83
|
+
const id = publish({
|
|
84
|
+
from: 'agent://odin',
|
|
85
|
+
to: 'agent://thor',
|
|
86
|
+
channel: 'task-tsk_abc',
|
|
87
|
+
taskId: 'tsk_abc',
|
|
88
|
+
kind: 'request',
|
|
89
|
+
payload: { x: 1 },
|
|
90
|
+
});
|
|
91
|
+
assert.ok(id);
|
|
92
|
+
assert.ok(existsSync(join(AGENT_BUS_LOG_DIR, 'task-tsk_abc.jsonl')));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('steer + correct + handoff: convenience wrappers set kind and payload', () => {
|
|
96
|
+
const seen = [];
|
|
97
|
+
const unsub = subscribe((m) => seen.push(m));
|
|
98
|
+
steer('agent://odin', 'agent://thor', 'focus on tests', { channel: 'task-tsk_x', taskId: 'tsk_x' });
|
|
99
|
+
correct('agent://odin', 'agent://thor', 'use new endpoint', { before: 'a()', after: 'b()', reason: 'simpler' }, { taskId: 'tsk_x' });
|
|
100
|
+
handoff('agent://odin', 'agent://thor', 'tsk_x', 'better fit', { channel: 'task-tsk_x' });
|
|
101
|
+
unsub();
|
|
102
|
+
assert.equal(seen.length, 3);
|
|
103
|
+
assert.equal(seen[0].kind, 'steer');
|
|
104
|
+
assert.equal(seen[0].payload.note, 'focus on tests');
|
|
105
|
+
assert.equal(seen[1].kind, 'correct');
|
|
106
|
+
assert.equal(seen[1].payload.before, 'a()');
|
|
107
|
+
assert.equal(seen[1].payload.after, 'b()');
|
|
108
|
+
assert.equal(seen[2].kind, 'handoff');
|
|
109
|
+
assert.equal(seen[2].payload.taskId, 'tsk_x');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('request/respond: correlation roundtrip resolves the Promise', async () => {
|
|
113
|
+
const unsub = subscribe((msg) => {
|
|
114
|
+
if (msg.kind === 'request') {
|
|
115
|
+
respond(msg, 'agent://thor', { ok: true, value: 42 });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const reply = await request('agent://odin', 'agent://thor', { ask: 'meaning' }, { timeoutMs: 2000 });
|
|
119
|
+
unsub();
|
|
120
|
+
assert.equal(reply.kind, 'response');
|
|
121
|
+
assert.equal(reply.from, 'agent://thor');
|
|
122
|
+
assert.equal(reply.payload.value, 42);
|
|
123
|
+
assert.equal(reply.correlationId.length > 0, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('request: timeout rejects with TIMEOUT code', async () => {
|
|
127
|
+
await assert.rejects(
|
|
128
|
+
request('agent://odin', 'agent://nobody', { ask: 'nothing' }, { timeoutMs: 50 }),
|
|
129
|
+
(err) => err.code === 'TIMEOUT',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('subscribeChannel: only fires for the named channel', () => {
|
|
134
|
+
const target = [];
|
|
135
|
+
const other = [];
|
|
136
|
+
const u1 = subscribeChannel('task-tsk_y', (m) => target.push(m));
|
|
137
|
+
const u2 = subscribeChannel('task-tsk_z', (m) => other.push(m));
|
|
138
|
+
publish({ from: 'agent://odin', to: 'agent://thor', channel: 'task-tsk_y', kind: 'request', payload: {} });
|
|
139
|
+
publish({ from: 'agent://odin', to: 'agent://thor', channel: 'task-tsk_z', kind: 'request', payload: {} });
|
|
140
|
+
u1(); u2();
|
|
141
|
+
assert.equal(target.length, 1);
|
|
142
|
+
assert.equal(other.length, 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('readChannel: skips corrupt JSONL lines and respects limit', () => {
|
|
146
|
+
const logFile = join(AGENT_BUS_LOG_DIR, 'corrupt.jsonl');
|
|
147
|
+
mkdirSync(AGENT_BUS_LOG_DIR, { recursive: true });
|
|
148
|
+
const good = { id: 'a', from: 'agent://odin', to: 'agent://thor', channel: 'corrupt', kind: 'request', payload: {}, ts: new Date().toISOString() };
|
|
149
|
+
appendFileSync(logFile, JSON.stringify(good) + '\n');
|
|
150
|
+
appendFileSync(logFile, 'this is not json\n');
|
|
151
|
+
appendFileSync(logFile, JSON.stringify({ ...good, id: 'b' }) + '\n');
|
|
152
|
+
const out = readChannel('corrupt');
|
|
153
|
+
assert.equal(out.length, 2);
|
|
154
|
+
assert.equal(out[0].id, 'a');
|
|
155
|
+
assert.equal(out[1].id, 'b');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('readChannel: limit truncates to the last N', () => {
|
|
159
|
+
for (let i = 0; i < 5; i++) {
|
|
160
|
+
publish({ from: 'agent://odin', to: 'agent://thor', channel: 'limit', kind: 'request', payload: { i } });
|
|
161
|
+
}
|
|
162
|
+
const out = readChannel('limit', { limit: 2 });
|
|
163
|
+
assert.equal(out.length, 2);
|
|
164
|
+
assert.equal(out[0].payload.i, 3);
|
|
165
|
+
assert.equal(out[1].payload.i, 4);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('readChannel: since filters out messages at or before the cutoff', async () => {
|
|
169
|
+
publish({ from: 'agent://odin', to: 'agent://thor', channel: 'since', kind: 'request', payload: { i: 1 } });
|
|
170
|
+
// Guarantee a strictly-monotonic clock between msg 1 and the cutoff.
|
|
171
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
172
|
+
const cutoff = new Date().toISOString();
|
|
173
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
174
|
+
publish({ from: 'agent://odin', to: 'agent://thor', channel: 'since', kind: 'request', payload: { i: 2 } });
|
|
175
|
+
const out = readChannel('since', { since: cutoff });
|
|
176
|
+
assert.equal(out.length, 1);
|
|
177
|
+
assert.equal(out[0].payload.i, 2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('presence: announce + heartbeat + getPresence roundtrip', () => {
|
|
181
|
+
announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan'], model: 'opus' });
|
|
182
|
+
announce({ name: 'thor', sessionId: 'sess_2', currentTask: 'tsk_1' });
|
|
183
|
+
const p = getPresence();
|
|
184
|
+
assert.ok(p.odin);
|
|
185
|
+
assert.equal(p.odin.sessionId, 'sess_1');
|
|
186
|
+
assert.deepEqual(p.odin.capabilities, ['plan']);
|
|
187
|
+
assert.ok(p.thor);
|
|
188
|
+
heartbeat('odin', 'sess_1');
|
|
189
|
+
heartbeat('thor', 'sess_1'); // wrong sessionId — no-op
|
|
190
|
+
assert.equal(getPresence().odin.sessionId, 'sess_1');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('presence: leave removes and persists', () => {
|
|
194
|
+
announce({ name: 'odin', sessionId: 'sess_1' });
|
|
195
|
+
assert.ok(getPresence().odin);
|
|
196
|
+
leave('odin');
|
|
197
|
+
assert.equal(getPresence().odin, undefined);
|
|
198
|
+
// Persistence: presence.json on disk reflects the leave.
|
|
199
|
+
const persisted = JSON.parse(readFileSync(AGENT_BUS_PRESENCE_FILE, 'utf8'));
|
|
200
|
+
assert.equal(persisted.odin, undefined);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('presence: announce with same name+sessionId updates lastSeenAt in-place', () => {
|
|
204
|
+
announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan'] });
|
|
205
|
+
const first = getPresence().odin.lastSeenAt;
|
|
206
|
+
// Wait 5ms so the timestamp moves.
|
|
207
|
+
const wait = Date.now() + 5;
|
|
208
|
+
while (Date.now() < wait) { /* spin */ }
|
|
209
|
+
announce({ name: 'odin', sessionId: 'sess_1', capabilities: ['plan', 'impl'] });
|
|
210
|
+
const second = getPresence().odin;
|
|
211
|
+
assert.equal(second.sessionId, 'sess_1');
|
|
212
|
+
assert.notEqual(second.lastSeenAt, first);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('presence: getPresence filters stale entries older than 5 minutes', () => {
|
|
216
|
+
announce({ name: 'fresh', sessionId: 's' });
|
|
217
|
+
announce({ name: 'stale', sessionId: 's' });
|
|
218
|
+
// Backdate stale manually via the in-memory map.
|
|
219
|
+
// We don't have a handle to it, so we use leave+reannounce with a
|
|
220
|
+
// forged timestamp persisted through read-modify-write.
|
|
221
|
+
const persisted = JSON.parse(readFileSync(AGENT_BUS_PRESENCE_FILE, 'utf8'));
|
|
222
|
+
persisted.stale.lastSeenAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
|
223
|
+
// _resetForTests wipes the in-memory map; we leave the file as-is
|
|
224
|
+
// and call announce again so in-memory state matches.
|
|
225
|
+
writeFileSync(AGENT_BUS_PRESENCE_FILE + '.tmp', JSON.stringify(persisted));
|
|
226
|
+
renameSync(AGENT_BUS_PRESENCE_FILE + '.tmp', AGENT_BUS_PRESENCE_FILE);
|
|
227
|
+
_resetForTests();
|
|
228
|
+
announce({ name: 'fresh', sessionId: 's' });
|
|
229
|
+
// `stale` is no longer in-memory, so getPresence won't see it —
|
|
230
|
+
// this is correct. The persistence-level staleness check would
|
|
231
|
+
// only matter on cold start. We assert the in-memory filter:
|
|
232
|
+
const p = getPresence();
|
|
233
|
+
assert.ok(p.fresh);
|
|
234
|
+
assert.equal(p.stale, undefined);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('resolveAddress: loose address returns live sessions; targeted requires matching sid', () => {
|
|
238
|
+
announce({ name: 'odin', sessionId: 'sess_a' });
|
|
239
|
+
announce({ name: 'thor', sessionId: 'sess_b' });
|
|
240
|
+
assert.equal(resolveAddress('agent://odin').length, 1);
|
|
241
|
+
assert.equal(resolveAddress('agent://odin')[0].sessionId, 'sess_a');
|
|
242
|
+
assert.equal(resolveAddress('agent://odin/sess_a').length, 1);
|
|
243
|
+
assert.equal(resolveAddress('agent://odin/wrong-sid').length, 0);
|
|
244
|
+
assert.equal(resolveAddress('agent://nobody').length, 0);
|
|
245
|
+
assert.equal(resolveAddress('not-an-address').length, 0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('publish: empty payload becomes {}', () => {
|
|
249
|
+
const id = publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
|
|
250
|
+
const log = readChannel(DEFAULT_CHANNEL);
|
|
251
|
+
const found = log.find((m) => m.id === id);
|
|
252
|
+
assert.ok(found);
|
|
253
|
+
assert.deepEqual(found.payload, {});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('publish: correlationId and taskId survive the roundtrip', () => {
|
|
257
|
+
publish({
|
|
258
|
+
from: 'agent://odin',
|
|
259
|
+
to: 'agent://thor',
|
|
260
|
+
channel: 'task-tsk_q',
|
|
261
|
+
taskId: 'tsk_q',
|
|
262
|
+
kind: 'request',
|
|
263
|
+
payload: {},
|
|
264
|
+
});
|
|
265
|
+
const log = readChannel('task-tsk_q');
|
|
266
|
+
assert.equal(log[0].taskId, 'tsk_q');
|
|
267
|
+
assert.equal(log[0].correlationId, null);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('subscribe: returns an unsubscribe function that detaches the listener', () => {
|
|
271
|
+
let count = 0;
|
|
272
|
+
const unsub = subscribe(() => count++);
|
|
273
|
+
publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
|
|
274
|
+
publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
|
|
275
|
+
assert.equal(count, 2);
|
|
276
|
+
unsub();
|
|
277
|
+
publish({ from: 'agent://odin', to: 'agent://thor', kind: 'ack' });
|
|
278
|
+
assert.equal(count, 2);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('subscribe: rejects non-function listener', () => {
|
|
282
|
+
assert.throws(() => subscribe('not a fn'), TypeError);
|
|
283
|
+
assert.throws(() => subscribe(null), TypeError);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('announce: rejects missing name or sessionId', () => {
|
|
287
|
+
assert.throws(() => announce({ sessionId: 's' }), TypeError);
|
|
288
|
+
assert.throws(() => announce({ name: 'x' }), TypeError);
|
|
289
|
+
});
|