chapterhouse 0.3.22 → 0.3.23
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/dist/api/server.js +71 -29
- package/dist/api/worker-events-sse.integration.test.js +336 -0
- package/dist/copilot/orchestrator.js +10 -5
- package/dist/copilot/orchestrator.test.js +6 -1
- package/dist/copilot/task-event-log.js +6 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-Ch4AYrQP.js → index-BK-hInnO.js} +87 -84
- package/web/dist/assets/index-BK-hInnO.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-Ch4AYrQP.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -361,48 +361,90 @@ app.get("/api/workers/:taskId", (req, res) => {
|
|
|
361
361
|
completedAt: row.completed_at,
|
|
362
362
|
});
|
|
363
363
|
});
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
//
|
|
364
|
+
const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
|
|
365
|
+
// SSE stream for per-task tool-call activity.
|
|
366
|
+
// Replays buffered/persisted backlog on connect, then streams live events until
|
|
367
|
+
// the task reaches a terminal state.
|
|
367
368
|
app.get("/api/workers/:taskId/events", (req, res) => {
|
|
368
369
|
const taskId = req.params.taskId;
|
|
369
|
-
const afterSeqRaw = req.query.afterSeq;
|
|
370
|
-
const afterSeq = typeof afterSeqRaw === "string" && !isNaN(Number(afterSeqRaw)) ? Number(afterSeqRaw) : 0;
|
|
371
370
|
const taskRow = getDb()
|
|
372
371
|
.prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
|
|
373
372
|
.get(taskId);
|
|
374
373
|
if (!taskRow) {
|
|
375
374
|
throw new NotFoundError("Task not found");
|
|
376
375
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
res.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
376
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
377
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
378
|
+
res.setHeader("Connection", "keep-alive");
|
|
379
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
380
|
+
res.flushHeaders();
|
|
381
|
+
const rawLastId = req.headers["last-event-id"];
|
|
382
|
+
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
383
|
+
? parseInt(rawLastId.trim(), 10)
|
|
384
|
+
: undefined;
|
|
385
|
+
const sendEvent = (event) => {
|
|
386
|
+
const payload = {
|
|
387
|
+
taskId: event.taskId,
|
|
388
|
+
seq: event.seq,
|
|
389
|
+
ts: event.ts,
|
|
390
|
+
kind: event.kind,
|
|
391
|
+
toolName: event.toolName,
|
|
392
|
+
summary: event.summary,
|
|
393
|
+
};
|
|
394
|
+
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
395
|
+
};
|
|
396
|
+
let replayHighSeq = lastSeq;
|
|
397
|
+
if (lastSeq !== undefined) {
|
|
398
|
+
const bufferedEvents = getTaskLogEvents(taskId);
|
|
399
|
+
const oldestBufferedSeq = bufferedEvents[0]?.seq;
|
|
400
|
+
const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
|
|
401
|
+
if (bufferMissesRange) {
|
|
402
|
+
const dbEvents = getTaskEvents(taskId, lastSeq);
|
|
403
|
+
for (const event of dbEvents) {
|
|
404
|
+
sendEvent(event);
|
|
405
|
+
if (replayHighSeq === undefined || event.seq > replayHighSeq) {
|
|
406
|
+
replayHighSeq = event.seq;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
391
410
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
411
|
+
const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
|
|
412
|
+
const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
|
|
413
|
+
for (const event of backlog) {
|
|
414
|
+
sendEvent(event);
|
|
415
|
+
}
|
|
416
|
+
const isTerminal = () => {
|
|
417
|
+
const row = getDb()
|
|
418
|
+
.prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
|
|
419
|
+
.get(taskId);
|
|
420
|
+
return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
|
|
421
|
+
};
|
|
422
|
+
res.write(`: connected task=${taskId}\n\n`);
|
|
423
|
+
if (isTerminal()) {
|
|
424
|
+
res.end();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const heartbeat = setInterval(() => {
|
|
428
|
+
res.write(`: keep-alive\n\n`);
|
|
429
|
+
}, 15_000);
|
|
430
|
+
const unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
|
|
431
|
+
sendEvent(event);
|
|
396
432
|
});
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
433
|
+
const unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
|
|
434
|
+
if (event.sessionId === taskId && isTerminal()) {
|
|
435
|
+
res.end();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
const unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
|
|
439
|
+
if (event.sessionId === taskId && isTerminal()) {
|
|
440
|
+
res.end();
|
|
441
|
+
}
|
|
402
442
|
});
|
|
403
443
|
req.on("close", () => {
|
|
404
444
|
clearInterval(heartbeat);
|
|
405
|
-
|
|
445
|
+
unsubscribeTaskLog();
|
|
446
|
+
unsubscribeDestroyed();
|
|
447
|
+
unsubscribeError();
|
|
406
448
|
});
|
|
407
449
|
});
|
|
408
450
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
const repoRoot = process.cwd();
|
|
8
|
+
const testWorkRoot = join(repoRoot, ".test-work", `worker-events-sse-${process.pid}`);
|
|
9
|
+
let instanceCounter = 0;
|
|
10
|
+
const CONTROL_PREFIX = "__CH__";
|
|
11
|
+
const READY_PREFIX = "__CH_READY__";
|
|
12
|
+
const controlledServerScript = `
|
|
13
|
+
import readline from "node:readline";
|
|
14
|
+
import { startApiServer } from "./dist/api/server.js";
|
|
15
|
+
import { agentEventBus } from "./dist/copilot/agent-event-bus.js";
|
|
16
|
+
import { initTaskEventLog } from "./dist/copilot/task-event-log.js";
|
|
17
|
+
import { appendTaskEvent, getDb } from "./dist/store/db.js";
|
|
18
|
+
|
|
19
|
+
const db = getDb();
|
|
20
|
+
initTaskEventLog();
|
|
21
|
+
const reply = (payload) => process.stdout.write("${CONTROL_PREFIX}" + JSON.stringify(payload) + "\\n");
|
|
22
|
+
|
|
23
|
+
function emitTaskEvent(taskId, kind, toolName, summary) {
|
|
24
|
+
const event = appendTaskEvent(taskId, kind, toolName ?? null, summary ?? null);
|
|
25
|
+
if (!event) {
|
|
26
|
+
throw new Error("appendTaskEvent returned undefined");
|
|
27
|
+
}
|
|
28
|
+
agentEventBus.emit({
|
|
29
|
+
type: "session:tool_call",
|
|
30
|
+
sessionId: taskId,
|
|
31
|
+
payload: {
|
|
32
|
+
_kind: event.kind,
|
|
33
|
+
_seq: event.seq,
|
|
34
|
+
_ts: event.ts,
|
|
35
|
+
_summary: event.summary,
|
|
36
|
+
...(event.toolName ? { toolName: event.toolName } : {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return event;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await startApiServer();
|
|
43
|
+
process.stdout.write("${READY_PREFIX}\\n");
|
|
44
|
+
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
46
|
+
for await (const line of rl) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
|
|
49
|
+
const command = JSON.parse(line);
|
|
50
|
+
try {
|
|
51
|
+
switch (command.type) {
|
|
52
|
+
case "createTask":
|
|
53
|
+
db.prepare(
|
|
54
|
+
"INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)"
|
|
55
|
+
).run(command.taskId, command.agentSlug ?? "coder", command.description ?? "Test worker task", command.status ?? "running");
|
|
56
|
+
reply({ ok: true });
|
|
57
|
+
break;
|
|
58
|
+
case "appendEvent":
|
|
59
|
+
reply({
|
|
60
|
+
ok: true,
|
|
61
|
+
event: emitTaskEvent(command.taskId, command.kind, command.toolName ?? null, command.summary ?? null),
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
case "finishTask": {
|
|
65
|
+
db.prepare(
|
|
66
|
+
"UPDATE agent_tasks SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?"
|
|
67
|
+
).run(command.status, command.taskId);
|
|
68
|
+
agentEventBus.emit({
|
|
69
|
+
type: command.status === "failed" ? "session:error" : "session:destroyed",
|
|
70
|
+
sessionId: command.taskId,
|
|
71
|
+
payload: command.status === "failed"
|
|
72
|
+
? { agentName: command.agentName ?? "coder", error: command.reason ?? "Task failed" }
|
|
73
|
+
: { agentName: command.agentName ?? "coder", reason: command.reason ?? "complete" },
|
|
74
|
+
});
|
|
75
|
+
reply({ ok: true });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
throw new Error(\`Unknown command: \${command.type}\`);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
async function getFreePort() {
|
|
87
|
+
const server = createServer();
|
|
88
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
89
|
+
const address = server.address();
|
|
90
|
+
assert.ok(address && typeof address === "object");
|
|
91
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
92
|
+
return address.port;
|
|
93
|
+
}
|
|
94
|
+
async function stopChild(child) {
|
|
95
|
+
if (child.exitCode !== null)
|
|
96
|
+
return;
|
|
97
|
+
child.kill("SIGTERM");
|
|
98
|
+
await new Promise((resolve) => {
|
|
99
|
+
child.once("exit", () => resolve());
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (child.exitCode === null)
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}, 2_000);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function withControlledServer(run, timeoutMs = 30_000) {
|
|
107
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
108
|
+
const instanceRoot = `${testWorkRoot}-${Date.now()}-${++instanceCounter}`;
|
|
109
|
+
rmSync(instanceRoot, { recursive: true, force: true });
|
|
110
|
+
mkdirSync(instanceRoot, { recursive: true });
|
|
111
|
+
const port = await getFreePort();
|
|
112
|
+
const logs = [];
|
|
113
|
+
let stdoutBuffer = "";
|
|
114
|
+
let readyResolve;
|
|
115
|
+
let readyReject;
|
|
116
|
+
const ready = new Promise((resolve, reject) => {
|
|
117
|
+
readyResolve = resolve;
|
|
118
|
+
readyReject = reject;
|
|
119
|
+
});
|
|
120
|
+
const pendingReplies = [];
|
|
121
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", controlledServerScript], {
|
|
122
|
+
cwd: repoRoot,
|
|
123
|
+
env: {
|
|
124
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("COPILOT_") && !key.startsWith("NODE_TEST_"))),
|
|
125
|
+
CHAPTERHOUSE_DISABLE_DOTENV: "1",
|
|
126
|
+
CHAPTERHOUSE_HOME: instanceRoot,
|
|
127
|
+
API_HOST: "127.0.0.1",
|
|
128
|
+
API_PORT: String(port),
|
|
129
|
+
API_TOKEN: "test-token",
|
|
130
|
+
},
|
|
131
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
132
|
+
});
|
|
133
|
+
child.stdout?.on("data", (chunk) => {
|
|
134
|
+
stdoutBuffer += String(chunk);
|
|
135
|
+
const lines = stdoutBuffer.split("\n");
|
|
136
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (line === READY_PREFIX) {
|
|
139
|
+
readyResolve?.();
|
|
140
|
+
}
|
|
141
|
+
else if (line.startsWith(CONTROL_PREFIX)) {
|
|
142
|
+
const pending = pendingReplies.shift();
|
|
143
|
+
if (!pending)
|
|
144
|
+
continue;
|
|
145
|
+
clearTimeout(pending.timer);
|
|
146
|
+
pending.resolve(JSON.parse(line.slice(CONTROL_PREFIX.length)));
|
|
147
|
+
}
|
|
148
|
+
else if (line.length > 0) {
|
|
149
|
+
logs.push(`${line}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
|
|
154
|
+
const sendCommand = async (command) => {
|
|
155
|
+
assert.ok(child.stdin, "Controlled server stdin should be writable");
|
|
156
|
+
const reply = new Promise((resolve, reject) => {
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
reject(new Error(`Timed out waiting for control reply for ${command.type}`));
|
|
159
|
+
}, 2_000);
|
|
160
|
+
pendingReplies.push({ resolve, reject, timer });
|
|
161
|
+
});
|
|
162
|
+
child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
163
|
+
const payload = await reply;
|
|
164
|
+
if (!payload.ok) {
|
|
165
|
+
throw new Error(payload.error ?? `Control command ${command.type} failed`);
|
|
166
|
+
}
|
|
167
|
+
return payload;
|
|
168
|
+
};
|
|
169
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
170
|
+
try {
|
|
171
|
+
const timeout = setTimeout(() => {
|
|
172
|
+
readyReject?.(new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`));
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
try {
|
|
175
|
+
await ready;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(timeout);
|
|
179
|
+
}
|
|
180
|
+
if (child.exitCode !== null) {
|
|
181
|
+
throw new Error(`Server exited early:\n${logs.join("")}`);
|
|
182
|
+
}
|
|
183
|
+
await run({ baseUrl, authHeader: "Bearer test-token", sendCommand });
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
child.stdin?.end();
|
|
187
|
+
await stopChild(child);
|
|
188
|
+
rmSync(instanceRoot, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function createSseReader(body) {
|
|
192
|
+
const reader = body.getReader();
|
|
193
|
+
const decoder = new TextDecoder();
|
|
194
|
+
let leftover = "";
|
|
195
|
+
function drainFrames() {
|
|
196
|
+
const frames = [];
|
|
197
|
+
const parts = leftover.split("\n\n");
|
|
198
|
+
leftover = parts.pop() ?? "";
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
if (!part.trim())
|
|
201
|
+
continue;
|
|
202
|
+
const frame = { data: null };
|
|
203
|
+
for (const line of part.split("\n")) {
|
|
204
|
+
if (line.startsWith(":"))
|
|
205
|
+
continue;
|
|
206
|
+
if (line.startsWith("id: "))
|
|
207
|
+
frame.id = line.slice(4);
|
|
208
|
+
else if (line.startsWith("event: "))
|
|
209
|
+
frame.event = line.slice(7);
|
|
210
|
+
else if (line.startsWith("data: ")) {
|
|
211
|
+
try {
|
|
212
|
+
frame.data = JSON.parse(line.slice(6));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
frame.data = line.slice(6);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (frame.data !== null)
|
|
220
|
+
frames.push(frame);
|
|
221
|
+
}
|
|
222
|
+
return frames;
|
|
223
|
+
}
|
|
224
|
+
async function readFrames(count, timeoutMs = 3_000) {
|
|
225
|
+
const frames = [];
|
|
226
|
+
const deadline = Date.now() + timeoutMs;
|
|
227
|
+
while (frames.length < count && Date.now() < deadline) {
|
|
228
|
+
const { value, done } = await Promise.race([
|
|
229
|
+
reader.read(),
|
|
230
|
+
new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
|
|
231
|
+
]);
|
|
232
|
+
if (done)
|
|
233
|
+
break;
|
|
234
|
+
if (value !== undefined) {
|
|
235
|
+
leftover += decoder.decode(value, { stream: true });
|
|
236
|
+
frames.push(...drainFrames());
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return frames;
|
|
240
|
+
}
|
|
241
|
+
async function waitForClose(timeoutMs = 3_000) {
|
|
242
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
|
+
while (Date.now() < deadline) {
|
|
244
|
+
const { value, done } = await Promise.race([
|
|
245
|
+
reader.read(),
|
|
246
|
+
new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
|
|
247
|
+
]);
|
|
248
|
+
if (done)
|
|
249
|
+
return true;
|
|
250
|
+
if (value !== undefined) {
|
|
251
|
+
leftover += decoder.decode(value, { stream: true });
|
|
252
|
+
void drainFrames();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
async function cancel() {
|
|
258
|
+
try {
|
|
259
|
+
await reader.cancel();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Ignore already-closed streams.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { readFrames, waitForClose, cancel };
|
|
266
|
+
}
|
|
267
|
+
test("worker events SSE replays backlog, streams live events, and closes on completion", async () => {
|
|
268
|
+
await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
|
|
269
|
+
const taskId = "worker-events-sse-001";
|
|
270
|
+
await sendCommand({
|
|
271
|
+
type: "createTask",
|
|
272
|
+
taskId,
|
|
273
|
+
description: "Stream worker task events",
|
|
274
|
+
status: "running",
|
|
275
|
+
});
|
|
276
|
+
await sendCommand({
|
|
277
|
+
type: "appendEvent",
|
|
278
|
+
taskId,
|
|
279
|
+
kind: "tool_start",
|
|
280
|
+
toolName: "bash",
|
|
281
|
+
summary: "npm run build",
|
|
282
|
+
});
|
|
283
|
+
await sendCommand({
|
|
284
|
+
type: "appendEvent",
|
|
285
|
+
taskId,
|
|
286
|
+
kind: "tool_complete",
|
|
287
|
+
summary: "build complete",
|
|
288
|
+
});
|
|
289
|
+
const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: authHeader,
|
|
292
|
+
Accept: "text/event-stream",
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
assert.equal(response.status, 200);
|
|
296
|
+
assert.ok(response.headers.get("content-type")?.includes("text/event-stream"), `Content-Type should include text/event-stream, got ${response.headers.get("content-type")}`);
|
|
297
|
+
assert.ok(response.body, "SSE response should have a readable body");
|
|
298
|
+
const reader = createSseReader(response.body);
|
|
299
|
+
try {
|
|
300
|
+
const backlogFrames = await reader.readFrames(2, 2_000);
|
|
301
|
+
assert.equal(backlogFrames.length, 2, "Expected backlog replay frames");
|
|
302
|
+
assert.deepEqual(backlogFrames.map((frame) => frame.id), ["1", "2"], "Backlog frames should preserve task event sequence ids");
|
|
303
|
+
const backlogKinds = backlogFrames.map((frame) => frame.data.kind);
|
|
304
|
+
assert.deepEqual(backlogKinds, ["tool_start", "tool_complete"]);
|
|
305
|
+
await sendCommand({
|
|
306
|
+
type: "appendEvent",
|
|
307
|
+
taskId,
|
|
308
|
+
kind: "tool_start",
|
|
309
|
+
toolName: "view",
|
|
310
|
+
summary: "README.md",
|
|
311
|
+
});
|
|
312
|
+
const liveFrames = await reader.readFrames(1, 2_000);
|
|
313
|
+
assert.equal(liveFrames.length, 1, "Expected one live frame");
|
|
314
|
+
assert.equal(liveFrames[0]?.id, "3");
|
|
315
|
+
assert.deepEqual(liveFrames[0]?.data, {
|
|
316
|
+
taskId,
|
|
317
|
+
seq: 3,
|
|
318
|
+
ts: (liveFrames[0]?.data).ts,
|
|
319
|
+
kind: "tool_start",
|
|
320
|
+
toolName: "view",
|
|
321
|
+
summary: "README.md",
|
|
322
|
+
});
|
|
323
|
+
await sendCommand({
|
|
324
|
+
type: "finishTask",
|
|
325
|
+
taskId,
|
|
326
|
+
status: "completed",
|
|
327
|
+
reason: "complete",
|
|
328
|
+
});
|
|
329
|
+
assert.equal(await reader.waitForClose(2_000), true, "SSE stream should close when the task completes");
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await reader.cancel();
|
|
333
|
+
}
|
|
334
|
+
}, 30_000);
|
|
335
|
+
});
|
|
336
|
+
//# sourceMappingURL=worker-events-sse.integration.test.js.map
|
|
@@ -574,10 +574,16 @@ async function executeOnSession(manager, item) {
|
|
|
574
574
|
});
|
|
575
575
|
const unsubSubDoneDb = session.on("subagent.completed", (event) => {
|
|
576
576
|
try {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
577
|
+
const doneData = event.data;
|
|
578
|
+
const taskId = String(doneData.toolCallId ?? "");
|
|
579
|
+
const finalResult = typeof doneData.result?.detailedContent === "string"
|
|
580
|
+
? doneData.result.detailedContent
|
|
581
|
+
: typeof doneData.result?.content === "string"
|
|
582
|
+
? doneData.result.content
|
|
583
|
+
: null;
|
|
584
|
+
spawnArgsMap.delete(taskId);
|
|
585
|
+
activeSubagentTaskIds.delete(taskId);
|
|
586
|
+
db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
|
|
581
587
|
const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
|
|
582
588
|
void agentEventBus.emit({
|
|
583
589
|
type: "session:destroyed",
|
|
@@ -587,7 +593,6 @@ async function executeOnSession(manager, item) {
|
|
|
587
593
|
timestamp: new Date(),
|
|
588
594
|
});
|
|
589
595
|
// Emit turn:delta with subagent completed part (coexistence — #130)
|
|
590
|
-
const doneData = event.data;
|
|
591
596
|
const donePart = {
|
|
592
597
|
type: "subagent",
|
|
593
598
|
toolCallId: String(doneData.toolCallId ?? ""),
|
|
@@ -914,7 +914,7 @@ test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", asyn
|
|
|
914
914
|
// Resolve the pending sendAndWait so the test can clean up
|
|
915
915
|
state.pendingReject?.(new Error("test teardown"));
|
|
916
916
|
});
|
|
917
|
-
test("S5-01: subagent.completed event
|
|
917
|
+
test("S5-01: subagent.completed event persists final result text to agent_tasks", async (t) => {
|
|
918
918
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
919
919
|
sendResult: "__PENDING__",
|
|
920
920
|
});
|
|
@@ -934,10 +934,15 @@ test("S5-01: subagent.completed event updates agent_tasks status to completed",
|
|
|
934
934
|
agentName: "Wash",
|
|
935
935
|
agentDisplayName: "Wash — Frontend Dev",
|
|
936
936
|
durationMs: 1234,
|
|
937
|
+
result: {
|
|
938
|
+
detailedContent: "Workers tab refreshes with persisted final output",
|
|
939
|
+
},
|
|
937
940
|
});
|
|
938
941
|
const updateWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("completed"));
|
|
939
942
|
assert.ok(updateWrite, "subagent.completed must UPDATE agent_tasks to completed");
|
|
943
|
+
assert.ok(updateWrite.sql.includes("result = ?"), "completed subagent updates must persist the final result text");
|
|
940
944
|
assert.ok(JSON.stringify(updateWrite.args).includes("subagent-call-002"), "UPDATE must target the correct task_id");
|
|
945
|
+
assert.ok(JSON.stringify(updateWrite.args).includes("Workers tab refreshes with persisted final output"), "UPDATE must store the final result text in agent_tasks.result");
|
|
941
946
|
state.pendingReject?.(new Error("test teardown"));
|
|
942
947
|
});
|
|
943
948
|
test("S5-01: subagent.failed event updates agent_tasks status to error", async (t) => {
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* in sync without the caller needing to wire anything extra.
|
|
8
8
|
*
|
|
9
9
|
* Consumers:
|
|
10
|
-
* 1. `GET /api/workers/:taskId/events` —
|
|
11
|
-
* The ring buffer is checked first (fast path, in-memory);
|
|
12
|
-
* for completed tasks whose ring buffer has been
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* 1. `GET /api/workers/:taskId/events` — SSE backlog replay on connect and
|
|
11
|
+
* reconnect. The ring buffer is checked first (fast path, in-memory);
|
|
12
|
+
* SQLite is the fallback for completed tasks whose ring buffer has been
|
|
13
|
+
* cleared.
|
|
14
|
+
* 2. Per-task SSE subscribers — `subscribeTaskLog` delivers live events as
|
|
15
|
+
* they arrive so the SSE frame fires immediately (no SQLite round-trip).
|
|
15
16
|
*
|
|
16
17
|
* Lifecycle:
|
|
17
18
|
* - `initTaskEventLog()` must be called once from `initOrchestrator()`.
|
package/package.json
CHANGED