@dotdrelle/wiki-manager 0.15.97 → 0.15.99
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/package.json +2 -2
- package/src/agent/graph.js +45 -7
- package/src/agent/graph.test.js +45 -0
- package/src/cli/wiki-manager.js +47 -33
- package/src/commands/slash.js +7 -2
- package/src/contracts/schemas.js +8 -17
- package/src/contracts/schemas.test.js +15 -0
- package/src/core/agentEvents.js +45 -7
- package/src/core/agentEvents.test.js +65 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +99 -1
- package/src/core/runtimeEventAdapter.test.js +92 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/testGate.test.js +33 -0
- package/src/core/toolLoop.js +14 -2
- package/src/core/toolLoop.test.js +28 -0
- package/src/orchestrator/dispatcher.js +19 -0
- package/src/orchestrator/knowledgeSignals.js +260 -0
- package/src/orchestrator/knowledgeSignals.test.js +193 -0
- package/src/orchestrator/proactiveReviewScheduler.js +240 -0
- package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
- package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
- package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
- package/src/orchestrator/resultAggregator.js +115 -1
- package/src/orchestrator/resultAggregator.test.js +138 -0
- package/src/runtime/controlClassify.test.js +31 -0
- package/src/runtime/runner.js +13 -4
- package/src/runtime/runner.test.js +20 -0
- package/src/runtime/server.js +256 -4
- package/src/runtime/server.test.js +13 -1
- package/src/runtime/store.js +1 -1
- package/src/runtime/store.test.js +5 -1
- package/src/shell/openExternal.js +43 -0
- package/src/shell/repl.js +1 -1
- package/wiki-workspace +0 -1
|
@@ -4,6 +4,21 @@ import {
|
|
|
4
4
|
normalizeRuntimeEvent,
|
|
5
5
|
} from './runtimeProvider.js';
|
|
6
6
|
|
|
7
|
+
// The gateway closes the stream when one of these arrives; the client stops
|
|
8
|
+
// reconnecting on it too, so a finished run never leaves a retry loop behind.
|
|
9
|
+
const TERMINAL_RUNTIME_EVENT_TYPES = new Set(['run_completed', 'run_failed', 'run_cancelled']);
|
|
10
|
+
|
|
11
|
+
function sleep(ms, signal) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
if (signal?.aborted) {
|
|
14
|
+
resolve();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const timer = setTimeout(resolve, ms);
|
|
18
|
+
signal?.addEventListener?.('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/**
|
|
8
23
|
* DeepAgentsProvider — client HTTP vers un runtime Deep Agents externe
|
|
9
24
|
* (RFC § 11, option A). Implémente le contrat RuntimeProvider :
|
|
@@ -28,6 +43,8 @@ export function createDeepAgentsProvider({
|
|
|
28
43
|
headers = {},
|
|
29
44
|
version = null,
|
|
30
45
|
timeoutMs = 10_000,
|
|
46
|
+
streamRetries = Number(process.env.WIKI_MANAGER_RUNTIME_STREAM_RETRIES ?? 5),
|
|
47
|
+
streamBackoffMs = Number(process.env.WIKI_MANAGER_RUNTIME_STREAM_BACKOFF_MS ?? 250),
|
|
31
48
|
} = {}) {
|
|
32
49
|
const base = String(endpoint).replace(/\/+$/, '');
|
|
33
50
|
|
|
@@ -117,6 +134,11 @@ export function createDeepAgentsProvider({
|
|
|
117
134
|
capability: request.capability ?? null,
|
|
118
135
|
arguments: request.arguments ?? {},
|
|
119
136
|
workspace: request.workspace ?? null,
|
|
137
|
+
// The body is rebuilt field by field here, so a value the dispatcher
|
|
138
|
+
// adds only at the runtime layer would be dropped in transit. The
|
|
139
|
+
// memory scope decides which past conversation the run resumes: it
|
|
140
|
+
// has to be named in this list to exist at all.
|
|
141
|
+
memoryScope: request.memoryScope ?? null,
|
|
120
142
|
model: request.model ?? null,
|
|
121
143
|
language: request.language ?? null,
|
|
122
144
|
mcp: Array.isArray(request.mcp) ? request.mcp : [],
|
|
@@ -152,43 +174,126 @@ export function createDeepAgentsProvider({
|
|
|
152
174
|
},
|
|
153
175
|
});
|
|
154
176
|
},
|
|
177
|
+
// A broken stream must not mean "silence forever", and a naive reconnect
|
|
178
|
+
// must not replay what was already seen. `cursor` is the last `sequence`
|
|
179
|
+
// delivered, `epoch` the gateway instance that produced it; both travel
|
|
180
|
+
// back on the next connection. Bounded retries then announce the give-up:
|
|
181
|
+
// a dead subscription that never says so is the defect this closes.
|
|
155
182
|
subscribe(runId, listener) {
|
|
156
183
|
const controller = new AbortController();
|
|
157
|
-
const
|
|
158
|
-
|
|
184
|
+
const target = `${base}/runs/${encodeURIComponent(String(runId))}/events`;
|
|
185
|
+
let cursor = null;
|
|
186
|
+
let epoch = null;
|
|
187
|
+
let attempts = 0;
|
|
188
|
+
let stopped = false;
|
|
189
|
+
|
|
190
|
+
const emit = (event) => {
|
|
159
191
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
listener(normalizeRuntimeEvent(event));
|
|
193
|
+
} catch {
|
|
194
|
+
// The contract refused the frame: journal it instead of vanishing.
|
|
195
|
+
try {
|
|
196
|
+
listener(normalizeRuntimeEvent({
|
|
197
|
+
type: 'degraded',
|
|
198
|
+
capability: 'stream',
|
|
199
|
+
cause: 'an out-of-contract frame arrived from the runtime',
|
|
200
|
+
fallback: 'the frame was skipped',
|
|
201
|
+
}));
|
|
202
|
+
} catch { /* nothing left to report with */ }
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const announce = (cause, fallback) => emit({ type: 'degraded', capability: 'stream', cause, fallback });
|
|
206
|
+
|
|
207
|
+
void (async () => {
|
|
208
|
+
while (!stopped && attempts < streamRetries) {
|
|
209
|
+
attempts += 1;
|
|
210
|
+
try {
|
|
211
|
+
const query = new URLSearchParams();
|
|
212
|
+
if (cursor != null) query.set('after', String(cursor));
|
|
213
|
+
if (epoch) query.set('epoch', epoch);
|
|
214
|
+
const suffix = query.toString();
|
|
215
|
+
const response = await fetchImpl(suffix ? `${target}?${suffix}` : target, {
|
|
216
|
+
headers: { accept: 'text/event-stream', ...headers },
|
|
217
|
+
signal: controller.signal,
|
|
218
|
+
});
|
|
219
|
+
if (response.status === 404) {
|
|
220
|
+
announce(`run ${runId} is not known to the runtime`, 'it was purged or never existed; no replay is possible');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
224
|
+
// The budget resets on PROGRESS, not on a mere HTTP 200: a gateway
|
|
225
|
+
// that accepts a connection and immediately closes it (or replays
|
|
226
|
+
// only what was already seen) would otherwise reset the budget
|
|
227
|
+
// forever and reconnect in a loop that never gives up.
|
|
228
|
+
let progressed = false;
|
|
229
|
+
const reader = response.body.getReader();
|
|
230
|
+
const decoder = new TextDecoder();
|
|
231
|
+
let buffer = '';
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await reader.read();
|
|
234
|
+
if (done) break;
|
|
235
|
+
buffer += decoder.decode(value, { stream: true });
|
|
236
|
+
const blocks = buffer.split('\n\n');
|
|
237
|
+
buffer = blocks.pop() ?? '';
|
|
238
|
+
for (const block of blocks) {
|
|
239
|
+
const data = block
|
|
240
|
+
.split('\n')
|
|
241
|
+
.filter((line) => line.startsWith('data: '))
|
|
242
|
+
.map((line) => line.slice(6))
|
|
243
|
+
.join('');
|
|
244
|
+
if (!data) continue;
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(data);
|
|
248
|
+
} catch {
|
|
249
|
+
announce('a malformed frame arrived from the runtime', 'the frame was skipped');
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (parsed?.type === 'stream_epoch') {
|
|
253
|
+
const next = String(parsed.epoch ?? '');
|
|
254
|
+
if (epoch && next && next !== epoch) {
|
|
255
|
+
announce('the runtime restarted (stream epoch changed)', 'reconnect from a fresh subscription; no events were replayed');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
epoch = next || epoch;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (Number.isFinite(Number(parsed?.sequence))) {
|
|
262
|
+
const sequence = Number(parsed.sequence);
|
|
263
|
+
if (sequence > (cursor ?? -1)) progressed = true;
|
|
264
|
+
cursor = Math.max(cursor ?? 0, sequence);
|
|
265
|
+
}
|
|
266
|
+
emit(parsed);
|
|
267
|
+
if (TERMINAL_RUNTIME_EVENT_TYPES.has(String(parsed?.type))) return;
|
|
184
268
|
}
|
|
185
269
|
}
|
|
270
|
+
if (progressed) attempts = 0;
|
|
271
|
+
// The stream ended without a terminal event: reconnect from the cursor.
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (controller.signal.aborted || stopped) return;
|
|
274
|
+
if (attempts >= streamRetries) {
|
|
275
|
+
announce(
|
|
276
|
+
'gave up reconnecting to the runtime stream',
|
|
277
|
+
`after ${attempts} attempt(s): ${error instanceof Error ? error.message : String(error)}`,
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
186
281
|
}
|
|
187
|
-
|
|
188
|
-
|
|
282
|
+
if (stopped || controller.signal.aborted) return;
|
|
283
|
+
if (attempts >= streamRetries) break;
|
|
284
|
+
await sleep(streamBackoffMs * 2 ** (attempts - 1), controller.signal);
|
|
285
|
+
}
|
|
286
|
+
// The loop can also end because the budget ran out on a stream that
|
|
287
|
+
// kept closing cleanly — that is a give-up too, and it must be said.
|
|
288
|
+
if (!stopped && !controller.signal.aborted) {
|
|
289
|
+
announce('gave up reconnecting to the runtime stream', `after ${attempts} attempt(s) without progress`);
|
|
189
290
|
}
|
|
190
291
|
})();
|
|
191
|
-
|
|
292
|
+
|
|
293
|
+
return () => {
|
|
294
|
+
stopped = true;
|
|
295
|
+
controller.abort();
|
|
296
|
+
};
|
|
192
297
|
},
|
|
193
298
|
};
|
|
194
299
|
return provider;
|
|
@@ -170,13 +170,18 @@ test('subscribe parses the SSE stream and forwards normalized events', async ()
|
|
|
170
170
|
]),
|
|
171
171
|
}),
|
|
172
172
|
});
|
|
173
|
-
|
|
173
|
+
// A single attempt: this test is about parsing, not reconnection. The mock
|
|
174
|
+
// stream carries no terminal event, so the provider would otherwise reconnect
|
|
175
|
+
// (and the mock would re-deliver) until its budget ran out.
|
|
176
|
+
const provider = createDeepAgentsProvider({
|
|
177
|
+
endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 1, streamBackoffMs: 1,
|
|
178
|
+
});
|
|
174
179
|
|
|
175
180
|
const events = [];
|
|
176
181
|
provider.subscribe('run-1', (event) => events.push(event));
|
|
177
182
|
|
|
178
|
-
await waitFor(() => events.length
|
|
179
|
-
assert.deepEqual(events.map((event) => event.type), ['tool_started', 'tool_finished']);
|
|
183
|
+
await waitFor(() => events.length >= 2);
|
|
184
|
+
assert.deepEqual(events.slice(0, 2).map((event) => event.type), ['tool_started', 'tool_finished']);
|
|
180
185
|
assert.equal(events[0].tool, 'wiki_search');
|
|
181
186
|
});
|
|
182
187
|
|
|
@@ -204,3 +209,133 @@ test('the deepagents factory is reachable from the agentRuntimes config', () =>
|
|
|
204
209
|
assert.equal(providers[0].type, 'deepagents');
|
|
205
210
|
assertRuntimeProvider(providers[0].provider);
|
|
206
211
|
});
|
|
212
|
+
|
|
213
|
+
// ── Lot 0: a broken stream must not mean "silence forever" ──────────────────
|
|
214
|
+
|
|
215
|
+
test('an event type this manager does not know still reaches the listener', async () => {
|
|
216
|
+
const fetchImpl = mockFetch({
|
|
217
|
+
'GET /runs/run-6/events': () => ({
|
|
218
|
+
ok: true,
|
|
219
|
+
status: 200,
|
|
220
|
+
body: sseBody([
|
|
221
|
+
{ type: 'phase_started', phase: 'discover', sequence: 1 },
|
|
222
|
+
{ type: 'run_completed', sequence: 2 },
|
|
223
|
+
]),
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
227
|
+
const events = [];
|
|
228
|
+
provider.subscribe('run-6', (event) => events.push(event));
|
|
229
|
+
|
|
230
|
+
await waitFor(() => events.some((event) => event.type === 'phase_started'));
|
|
231
|
+
assert.equal(events.find((event) => event.type === 'phase_started').phase, 'discover');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a dropped stream reconnects from the cursor and never repeats an event', async () => {
|
|
235
|
+
let connections = 0;
|
|
236
|
+
const fetchImpl = mockFetch({
|
|
237
|
+
'GET /runs/run-1/events': () => {
|
|
238
|
+
connections += 1;
|
|
239
|
+
if (connections === 1) {
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
status: 200,
|
|
243
|
+
body: sseBody([
|
|
244
|
+
{ type: 'stream_epoch', epoch: 'epoch-a' },
|
|
245
|
+
{ type: 'tool_started', tool: 'wiki_search', sequence: 5 },
|
|
246
|
+
]),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 200,
|
|
252
|
+
body: sseBody([
|
|
253
|
+
{ type: 'stream_epoch', epoch: 'epoch-a' },
|
|
254
|
+
{ type: 'run_completed', sequence: 6 },
|
|
255
|
+
]),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const provider = createDeepAgentsProvider({
|
|
260
|
+
endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 3, streamBackoffMs: 1,
|
|
261
|
+
});
|
|
262
|
+
const events = [];
|
|
263
|
+
const unsubscribe = provider.subscribe('run-1', (event) => events.push(event));
|
|
264
|
+
|
|
265
|
+
await waitFor(() => events.some((event) => event.type === 'run_completed'));
|
|
266
|
+
unsubscribe();
|
|
267
|
+
|
|
268
|
+
assert.equal(connections, 2, 'the stream was reconnected once');
|
|
269
|
+
assert.match(fetchImpl.calls[1].url, /after=5/, 'the cursor travels back');
|
|
270
|
+
assert.match(fetchImpl.calls[1].url, /epoch=epoch-a/, 'the epoch travels back');
|
|
271
|
+
assert.equal(events.filter((event) => event.type === 'tool_started').length, 1, 'no duplicate delivery');
|
|
272
|
+
assert.equal(events.filter((event) => event.type === 'stream_epoch').length, 0, 'the epoch frame is consumed, never forwarded');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('a stream epoch change stops the subscription with an announced reason', async () => {
|
|
276
|
+
let connections = 0;
|
|
277
|
+
const fetchImpl = mockFetch({
|
|
278
|
+
'GET /runs/run-2/events': () => {
|
|
279
|
+
connections += 1;
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
status: 200,
|
|
283
|
+
body: sseBody([{ type: 'stream_epoch', epoch: connections === 1 ? 'epoch-a' : 'epoch-b' }]),
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
const provider = createDeepAgentsProvider({
|
|
288
|
+
endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 5, streamBackoffMs: 1,
|
|
289
|
+
});
|
|
290
|
+
const events = [];
|
|
291
|
+
provider.subscribe('run-2', (event) => events.push(event));
|
|
292
|
+
|
|
293
|
+
await waitFor(() => events.some((event) => event.type === 'degraded'));
|
|
294
|
+
assert.equal(connections, 2, 'it stopped after the mismatch');
|
|
295
|
+
assert.match(events.find((event) => event.type === 'degraded').cause, /epoch changed/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('a purged run ends the subscription with an announced reason', async () => {
|
|
299
|
+
const fetchImpl = mockFetch({}); // 404 on every route
|
|
300
|
+
const provider = createDeepAgentsProvider({
|
|
301
|
+
endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 5, streamBackoffMs: 1,
|
|
302
|
+
});
|
|
303
|
+
const events = [];
|
|
304
|
+
provider.subscribe('run-gone', (event) => events.push(event));
|
|
305
|
+
|
|
306
|
+
await waitFor(() => events.some((event) => event.type === 'degraded'));
|
|
307
|
+
assert.match(events.find((event) => event.type === 'degraded').cause, /not known to the runtime/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a stream that keeps failing announces the give-up', async () => {
|
|
311
|
+
const fetchImpl = mockFetch({
|
|
312
|
+
'GET /runs/run-4/events': () => ({ ok: false, status: 500, json: async () => ({}) }),
|
|
313
|
+
});
|
|
314
|
+
const provider = createDeepAgentsProvider({
|
|
315
|
+
endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 2, streamBackoffMs: 1,
|
|
316
|
+
});
|
|
317
|
+
const events = [];
|
|
318
|
+
provider.subscribe('run-4', (event) => events.push(event));
|
|
319
|
+
|
|
320
|
+
await waitFor(() => events.some((event) => event.type === 'degraded' && /gave up/.test(event.cause)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a malformed frame is journalled instead of silently skipped', async () => {
|
|
324
|
+
const encoder = new TextEncoder();
|
|
325
|
+
const body = new ReadableStream({
|
|
326
|
+
start(controller) {
|
|
327
|
+
controller.enqueue(encoder.encode('data: {not json\n\n'));
|
|
328
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'run_completed' })}\n\n`));
|
|
329
|
+
controller.close();
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const fetchImpl = mockFetch({
|
|
333
|
+
'GET /runs/run-5/events': () => ({ ok: true, status: 200, body }),
|
|
334
|
+
});
|
|
335
|
+
const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
|
|
336
|
+
const events = [];
|
|
337
|
+
provider.subscribe('run-5', (event) => events.push(event));
|
|
338
|
+
|
|
339
|
+
await waitFor(() => events.some((event) => event.type === 'degraded' && /malformed/.test(event.cause)));
|
|
340
|
+
assert.ok(events.some((event) => event.type === 'run_completed'), 'the good frame still arrives');
|
|
341
|
+
});
|
|
@@ -9,6 +9,7 @@ import { resolve as resolveCapability } from './capabilityResolver.js';
|
|
|
9
9
|
import { integrate } from './planIntegrator.js';
|
|
10
10
|
import { validateFragment } from './planValidator.js';
|
|
11
11
|
import { isSuccessful } from './taskStatuses.js';
|
|
12
|
+
import { PROACTIVE_REVIEW_CAPABILITY, triggerForTask } from './proactiveReviewScheduler.js';
|
|
12
13
|
|
|
13
14
|
export function createResultAggregator({
|
|
14
15
|
session = null,
|
|
@@ -102,6 +103,52 @@ export async function accept(result, {
|
|
|
102
103
|
taskId,
|
|
103
104
|
payload,
|
|
104
105
|
})));
|
|
106
|
+
// A successful ingest/rebuild is a business FACT the rest of the manager can
|
|
107
|
+
// act on. We publish it as a stable event and hand it to a trigger hook; the
|
|
108
|
+
// runtime decides whether it is worth a read-only review. Nothing here
|
|
109
|
+
// mutates anything — a trigger only ever proposes.
|
|
110
|
+
if (ok) {
|
|
111
|
+
const trigger = triggerForTask({
|
|
112
|
+
capability: task?.requiredCapability,
|
|
113
|
+
operation: task?.operation,
|
|
114
|
+
});
|
|
115
|
+
if (trigger) {
|
|
116
|
+
const workspace = session.workspace ?? session._currentRunIdentity?.workspace ?? null;
|
|
117
|
+
// The RUN is the fact, not the task: a parallel ingest of N sources is N
|
|
118
|
+
// tasks with N idempotency keys, and keying on the task would fire on the
|
|
119
|
+
// first source applied — an audit of a half-written corpus. One run, one
|
|
120
|
+
// version, one review; the queued review itself starts only after the
|
|
121
|
+
// run's control-lane drain.
|
|
122
|
+
const sourceVersion = runId ?? result?.idempotencyKey ?? task?.idempotencyKey ?? null;
|
|
123
|
+
const triggerPayload = { workspace, trigger, sourceVersion, taskId, runId };
|
|
124
|
+
persistDispatch(store, dispatchAgentEvent(session, createAgentEvent(trigger, {
|
|
125
|
+
origin: 'result_aggregator',
|
|
126
|
+
runId,
|
|
127
|
+
taskId,
|
|
128
|
+
workspace,
|
|
129
|
+
payload: triggerPayload,
|
|
130
|
+
})));
|
|
131
|
+
session._onKnowledgeTrigger?.(triggerPayload);
|
|
132
|
+
}
|
|
133
|
+
// A proactive review's result is a NOTE, not a mutation: file it in the
|
|
134
|
+
// workspace's review queue (never in agent-proposals, which is for merges)
|
|
135
|
+
// and announce where it waits. The run is read-only by capability, so
|
|
136
|
+
// nothing here can have changed the wiki.
|
|
137
|
+
if (task?.requiredCapability === PROACTIVE_REVIEW_CAPABILITY && session._proactiveReview) {
|
|
138
|
+
const persisted = persistProactiveReview(session, result, session._proactiveReview, taskId);
|
|
139
|
+
persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
140
|
+
origin: 'result_aggregator',
|
|
141
|
+
runId,
|
|
142
|
+
taskId,
|
|
143
|
+
payload: {
|
|
144
|
+
message: persisted.error
|
|
145
|
+
? `proactive-review: could not file the review — ${persisted.error}`
|
|
146
|
+
: `proactive-review: ${persisted.id} is waiting — ${persisted.path} (${persisted.findings} finding(s))`,
|
|
147
|
+
},
|
|
148
|
+
})));
|
|
149
|
+
session._proactiveReview = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
105
152
|
const expansion = await maybeExpandPlan(result, {
|
|
106
153
|
session,
|
|
107
154
|
runId,
|
|
@@ -258,7 +305,14 @@ function persistDispatch(store, event) {
|
|
|
258
305
|
* write machinery — this function only records, it never touches wiki content.
|
|
259
306
|
*/
|
|
260
307
|
function persistWorktreeProposal(session, result, { runId, taskId }) {
|
|
261
|
-
|
|
308
|
+
// The dispatcher wraps the agent's status payload under `rawStatus`
|
|
309
|
+
// (`taskResultFromStatus`), so the gateway's proposal lives at
|
|
310
|
+
// `rawStatus.result.worktreeProposal`. Reading only `result.result`/
|
|
311
|
+
// `result.worktreeProposal` matched the unit-test fixture but never the real
|
|
312
|
+
// run: the proposal was silently dropped and no review item ever appeared.
|
|
313
|
+
const proposal = result?.rawStatus?.result?.worktreeProposal
|
|
314
|
+
?? result?.result?.worktreeProposal
|
|
315
|
+
?? result?.worktreeProposal;
|
|
262
316
|
if (!proposal || typeof proposal !== 'object') return { path: null };
|
|
263
317
|
const changes = Array.isArray(proposal.changes) ? proposal.changes : [];
|
|
264
318
|
if (changes.length === 0) return { path: null };
|
|
@@ -294,6 +348,66 @@ function persistWorktreeProposal(session, result, { runId, taskId }) {
|
|
|
294
348
|
}
|
|
295
349
|
}
|
|
296
350
|
|
|
351
|
+
/*
|
|
352
|
+
A proactive review is a read-only audit the workspace asked for. Its result is
|
|
353
|
+
FILED, never merged: `.wiki/agent-reviews/<id>.json` is a queue of notes a
|
|
354
|
+
human reads, distinct from `.wiki/agent-proposals/` (which exists to be
|
|
355
|
+
merged). No worktree, no mutation, no external message — the capability is
|
|
356
|
+
read-only by construction; this is only where its conclusion is kept.
|
|
357
|
+
*/
|
|
358
|
+
function persistProactiveReview(session, result, pending, taskId) {
|
|
359
|
+
const workspacePath = session?.workspacePath;
|
|
360
|
+
if (!workspacePath || typeof workspacePath !== 'string') {
|
|
361
|
+
return { error: 'no workspace path on the session — the review stays in the run result only' };
|
|
362
|
+
}
|
|
363
|
+
// Same wrapping as the worktree proposal above: the real dispatcher result
|
|
364
|
+
// carries the gateway's content under `rawStatus.result.content`.
|
|
365
|
+
const content = String(result?.rawStatus?.result?.content ?? result?.result?.content ?? result?.content ?? '');
|
|
366
|
+
const findings = extractReviewFindings(content);
|
|
367
|
+
const id = String(pending?.id ?? `review-${taskId}`).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
368
|
+
const record = {
|
|
369
|
+
id,
|
|
370
|
+
workspace: String(pending?.workspace ?? session.workspace ?? ''),
|
|
371
|
+
trigger: pending?.trigger ?? null,
|
|
372
|
+
createdAt: pending?.createdAt ?? new Date().toISOString(),
|
|
373
|
+
status: 'proposed',
|
|
374
|
+
summary: content.replace(/\s+/g, ' ').trim().slice(0, 1000),
|
|
375
|
+
findings,
|
|
376
|
+
sourceVersion: pending?.sourceVersion ?? null,
|
|
377
|
+
budget: pending?.budget ?? null,
|
|
378
|
+
// What the deterministic scan knew when it queued this review.
|
|
379
|
+
evidence: pending?.evidence ?? null,
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
const dir = join(workspacePath, '.wiki', 'agent-reviews');
|
|
383
|
+
mkdirSync(dir, { recursive: true });
|
|
384
|
+
const path = join(dir, `${id}.json`);
|
|
385
|
+
writeFileSync(path, JSON.stringify(record, null, 2));
|
|
386
|
+
return { path, id, findings: findings.length };
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// The `[objection] severity: … — path — statement` lines the gateway's Critique
|
|
393
|
+
// emits, lifted so a review's findings are structured, not a prose blob.
|
|
394
|
+
function extractReviewFindings(content, { max = 50 } = {}) {
|
|
395
|
+
const findings = [];
|
|
396
|
+
for (const line of String(content ?? '').split('\n')) {
|
|
397
|
+
const match = /^\s*\[objection\]\s*severity:\s*(blocking|non-blocking)\s*[-—]\s*(.+)$/i.exec(line.trim());
|
|
398
|
+
if (!match) continue;
|
|
399
|
+
const tail = match[2].trim();
|
|
400
|
+
const separator = tail.indexOf(' — ');
|
|
401
|
+
findings.push({
|
|
402
|
+
severity: match[1].toLowerCase(),
|
|
403
|
+
path: separator === -1 ? null : tail.slice(0, separator).trim() || null,
|
|
404
|
+
statement: (separator === -1 ? tail : tail.slice(separator + 3).trim()).slice(0, 300),
|
|
405
|
+
});
|
|
406
|
+
if (findings.length >= max) break;
|
|
407
|
+
}
|
|
408
|
+
return findings;
|
|
409
|
+
}
|
|
410
|
+
|
|
297
411
|
function agentPlanRequest(request, session) {
|
|
298
412
|
return {
|
|
299
413
|
capability: request.capability,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
3
6
|
|
|
4
7
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
5
8
|
import { accept } from './resultAggregator.js';
|
|
@@ -271,3 +274,138 @@ test('resultAggregator honours an explicit opt-out from the proposal constraints
|
|
|
271
274
|
assert.equal(calls[0].constraints.requireApprovalForMutations, false);
|
|
272
275
|
assert.equal(calls[0].constraints.maxTasks, 2);
|
|
273
276
|
});
|
|
277
|
+
|
|
278
|
+
test('a completed ingest publishes knowledge.ingested and hands the trigger to the hook', async () => {
|
|
279
|
+
const session = { agentEvents: [], activities: {}, workspace: 'docs', headlessPlan: [] };
|
|
280
|
+
const triggers = [];
|
|
281
|
+
session._onKnowledgeTrigger = (payload) => triggers.push(payload);
|
|
282
|
+
const task = { id: 't1', requiredCapability: 'knowledge.update', operation: 'ingest_apply', idempotencyKey: 'sha-1' };
|
|
283
|
+
|
|
284
|
+
await accept({ ok: true, taskId: 't1', status: 'succeeded', idempotencyKey: 'sha-1' }, {
|
|
285
|
+
session,
|
|
286
|
+
runId: 'run-1',
|
|
287
|
+
task,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
assert.ok(session.agentEvents.some((event) => event.type === 'knowledge.ingested'));
|
|
291
|
+
assert.equal(triggers.length, 1);
|
|
292
|
+
assert.deepEqual(triggers[0], {
|
|
293
|
+
workspace: 'docs',
|
|
294
|
+
trigger: 'knowledge.ingested',
|
|
295
|
+
// The RUN is the fact: the task's own idempotency key is neither. A
|
|
296
|
+
// parallel ingest's other tasks must land on the same version.
|
|
297
|
+
sourceVersion: 'run-1',
|
|
298
|
+
taskId: 't1',
|
|
299
|
+
runId: 'run-1',
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('a completed build publishes no knowledge trigger', async () => {
|
|
304
|
+
const session = { agentEvents: [], activities: {}, workspace: 'docs', headlessPlan: [] };
|
|
305
|
+
const triggers = [];
|
|
306
|
+
session._onKnowledgeTrigger = (payload) => triggers.push(payload);
|
|
307
|
+
|
|
308
|
+
await accept({ ok: true, taskId: 't2', status: 'succeeded' }, {
|
|
309
|
+
session,
|
|
310
|
+
runId: 'run-1',
|
|
311
|
+
task: { id: 't2', requiredCapability: 'document.build', operation: 'build' },
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
assert.equal(triggers.length, 0);
|
|
315
|
+
assert.ok(!session.agentEvents.some((event) => event.type.startsWith('knowledge.')));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a completed proactive review is filed under .wiki/agent-reviews, never merged', async () => {
|
|
319
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'proactive-review-'));
|
|
320
|
+
const session = { agentEvents: [], activities: {}, workspace: 'docs', workspacePath, headlessPlan: [] };
|
|
321
|
+
session._proactiveReview = {
|
|
322
|
+
id: 'review-1',
|
|
323
|
+
workspace: 'docs',
|
|
324
|
+
trigger: 'knowledge.ingested',
|
|
325
|
+
sourceVersion: 'sha-9',
|
|
326
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
327
|
+
budget: { runsToday: 1, inFlight: 1 },
|
|
328
|
+
evidence: { kind: 'stale', counts: { aged: 0, vanishedArchive: 1, vanishedPage: 0 }, items: [{ kind: 'vanished-archive', path: 'raw/ingested/gone.md' }] },
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
const content = [
|
|
332
|
+
'The workspace is missing a cost concept.',
|
|
333
|
+
'[objection] severity: blocking — wiki/concepts/cout/a.md — unsourced claim',
|
|
334
|
+
].join('\n');
|
|
335
|
+
await accept(
|
|
336
|
+
{ ok: true, taskId: 't-review', status: 'succeeded', result: { content } },
|
|
337
|
+
{ session, runId: 'run-review', task: { id: 't-review', requiredCapability: 'agent.review' } },
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const file = join(workspacePath, '.wiki', 'agent-reviews', 'review-1.json');
|
|
341
|
+
assert.ok(existsSync(file), 'the review is filed');
|
|
342
|
+
const record = JSON.parse(readFileSync(file, 'utf8'));
|
|
343
|
+
assert.equal(record.id, 'review-1');
|
|
344
|
+
assert.equal(record.trigger, 'knowledge.ingested');
|
|
345
|
+
assert.equal(record.sourceVersion, 'sha-9');
|
|
346
|
+
assert.equal(record.status, 'proposed');
|
|
347
|
+
assert.equal(record.findings.length, 1);
|
|
348
|
+
assert.equal(record.findings[0].severity, 'blocking');
|
|
349
|
+
assert.equal(record.findings[0].path, 'wiki/concepts/cout/a.md');
|
|
350
|
+
// The deterministic evidence travels with the note: "a review happened"
|
|
351
|
+
// vs "the review knew what to look at".
|
|
352
|
+
assert.deepEqual(record.evidence, {
|
|
353
|
+
kind: 'stale',
|
|
354
|
+
counts: { aged: 0, vanishedArchive: 1, vanishedPage: 0 },
|
|
355
|
+
items: [{ kind: 'vanished-archive', path: 'raw/ingested/gone.md' }],
|
|
356
|
+
});
|
|
357
|
+
// It is a note, not a proposal to merge.
|
|
358
|
+
assert.ok(!existsSync(join(workspacePath, '.wiki', 'agent-proposals')));
|
|
359
|
+
assert.equal(session._proactiveReview, null, 'the marker is consumed');
|
|
360
|
+
} finally {
|
|
361
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('a gateway worktree proposal (rawStatus shape) is persisted for review', async () => {
|
|
366
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'worktree-proposal-'));
|
|
367
|
+
const session = { agentEvents: [], activities: {}, workspace: 'docs', workspacePath, headlessPlan: [] };
|
|
368
|
+
try {
|
|
369
|
+
const proposal = {
|
|
370
|
+
workspace: 'docs',
|
|
371
|
+
branch: 'agent/gateway-1',
|
|
372
|
+
worktreePath: '/workspaces/docs/.wiki/agent-worktrees/gateway-1',
|
|
373
|
+
worktreeRelativePath: '.wiki/agent-worktrees/gateway-1',
|
|
374
|
+
justification: 'dedup',
|
|
375
|
+
changedFiles: [{ status: 'M', path: 'wiki/a.md' }],
|
|
376
|
+
changes: [{ path: 'wiki/a.md', status: 'M', content: '# A' }],
|
|
377
|
+
diff: 'diff --git a/wiki/a.md b/wiki/a.md',
|
|
378
|
+
};
|
|
379
|
+
// The dispatcher wraps the agent's status payload under `rawStatus`
|
|
380
|
+
// (`taskResultFromStatus`). The fixture used to hand `{ result: {...} }`
|
|
381
|
+
// straight to accept — a shape the real gateway run never produces — so
|
|
382
|
+
// the proposal was silently dropped and no review item ever appeared.
|
|
383
|
+
const result = await accept({
|
|
384
|
+
ok: true,
|
|
385
|
+
taskId: 't-curate',
|
|
386
|
+
status: 'completed',
|
|
387
|
+
outputRefs: [],
|
|
388
|
+
rawStatus: {
|
|
389
|
+
runId: 'gateway-1',
|
|
390
|
+
status: 'completed',
|
|
391
|
+
result: { status: 'completed', content: 'report', worktreeProposal: proposal },
|
|
392
|
+
},
|
|
393
|
+
}, {
|
|
394
|
+
session,
|
|
395
|
+
runId: 'run-curate',
|
|
396
|
+
task: { id: 't-curate', requiredCapability: 'agent.curate', operation: 'run' },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
assert.equal(result.ok, true);
|
|
400
|
+
const dir = join(workspacePath, '.wiki', 'agent-proposals');
|
|
401
|
+
assert.ok(existsSync(dir), 'the proposal directory is created');
|
|
402
|
+
const record = JSON.parse(readFileSync(join(dir, 't-curate.json'), 'utf8'));
|
|
403
|
+
assert.equal(record.branch, 'agent/gateway-1');
|
|
404
|
+
assert.equal(record.changes.length, 1);
|
|
405
|
+
assert.equal(record.changes[0].path, 'wiki/a.md');
|
|
406
|
+
// A proposal nobody is told about is a proposal nobody merges.
|
|
407
|
+
assert.ok(session.agentEvents.some((event) => String(event.payload?.message ?? '').includes('agent-proposal')));
|
|
408
|
+
} finally {
|
|
409
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
});
|