chapterhouse 0.1.1 → 0.2.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/README.md +79 -12
- package/dist/api/errors.js +5 -3
- package/dist/api/errors.test.js +12 -21
- package/dist/api/server.js +67 -17
- package/dist/cli.js +111 -18
- package/dist/copilot/agents.js +9 -7
- package/dist/copilot/classifier.js +3 -1
- package/dist/copilot/orchestrator.js +64 -31
- package/dist/copilot/orchestrator.test.js +107 -1
- package/dist/copilot/router.js +4 -2
- package/dist/copilot/tools.js +7 -5
- package/dist/daemon-install.js +368 -0
- package/dist/daemon-install.test.js +98 -0
- package/dist/daemon.js +35 -33
- package/dist/squad/discovery.test.js +61 -0
- package/dist/store/db.js +42 -0
- package/dist/store/db.test.js +88 -0
- package/dist/update.js +162 -28
- package/dist/update.test.js +84 -5
- package/dist/util/logger.js +41 -0
- package/dist/util/logger.test.js +53 -0
- package/dist/wiki/migrate.js +4 -2
- package/dist/wiki/seed-team-wiki.js +4 -2
- package/package.json +3 -2
- package/web/dist/assets/{index-DAg9IrpO.js → index-Bgs6Mze7.js} +59 -59
- package/web/dist/assets/index-Bgs6Mze7.js.map +1 -0
- package/web/dist/assets/index-CxeGtVlE.css +10 -0
- package/web/dist/chapterhouse-icon.svg +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D-e7K-fT.css +0 -10
- package/web/dist/assets/index-DAg9IrpO.js.map +0 -1
|
@@ -5,7 +5,7 @@ import { config, DEFAULT_MODEL } from "../config.js";
|
|
|
5
5
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
6
6
|
import { getSkillDirectories } from "./skills.js";
|
|
7
7
|
import { resetClient } from "./client.js";
|
|
8
|
-
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey } from "../store/db.js";
|
|
8
|
+
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed } from "../store/db.js";
|
|
9
9
|
import { maybeWriteEpisode } from "./episode-writer.js";
|
|
10
10
|
import { getWikiSummary } from "../wiki/context.js";
|
|
11
11
|
import { SESSIONS_DIR } from "../paths.js";
|
|
@@ -13,6 +13,8 @@ import { resolveModel } from "./router.js";
|
|
|
13
13
|
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
|
|
14
14
|
import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
|
|
15
15
|
import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
|
|
16
|
+
import { childLogger } from "../util/logger.js";
|
|
17
|
+
const log = childLogger("orchestrator");
|
|
16
18
|
/**
|
|
17
19
|
* Permission handler for the orchestrator session.
|
|
18
20
|
* Approves all tool requests so @chapterhouse has full access to all tools.
|
|
@@ -160,9 +162,9 @@ async function ensureClient() {
|
|
|
160
162
|
return copilotClient;
|
|
161
163
|
}
|
|
162
164
|
if (!resetPromise) {
|
|
163
|
-
|
|
165
|
+
log.info({ state: copilotClient?.getState() ?? "null" }, "Client not connected, resetting");
|
|
164
166
|
resetPromise = resetClient().then((c) => {
|
|
165
|
-
|
|
167
|
+
log.info({ state: c.getState() }, "Client reset successful");
|
|
166
168
|
copilotClient = c;
|
|
167
169
|
return c;
|
|
168
170
|
}).finally(() => { resetPromise = undefined; });
|
|
@@ -179,7 +181,7 @@ function startHealthCheck() {
|
|
|
179
181
|
try {
|
|
180
182
|
const state = copilotClient.getState();
|
|
181
183
|
if (state !== "connected") {
|
|
182
|
-
|
|
184
|
+
log.info({ state }, "Health check: client not connected, resetting");
|
|
183
185
|
await ensureClient();
|
|
184
186
|
// Session may need recovery after client reset
|
|
185
187
|
sessionMap.clear();
|
|
@@ -187,7 +189,7 @@ function startHealthCheck() {
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
catch (err) {
|
|
190
|
-
|
|
192
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Health check error");
|
|
191
193
|
}
|
|
192
194
|
}, HEALTH_CHECK_INTERVAL_MS);
|
|
193
195
|
}
|
|
@@ -237,7 +239,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
237
239
|
const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
|
|
238
240
|
if (savedSessionId) {
|
|
239
241
|
try {
|
|
240
|
-
|
|
242
|
+
log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
|
|
241
243
|
const session = await client.resumeSession(savedSessionId, {
|
|
242
244
|
model: config.copilotModel,
|
|
243
245
|
configDir: SESSIONS_DIR,
|
|
@@ -249,19 +251,19 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
249
251
|
onPermissionRequest: orchestratorPermissionHandler,
|
|
250
252
|
infiniteSessions,
|
|
251
253
|
});
|
|
252
|
-
|
|
254
|
+
log.info({ sessionKey }, "Session resumed successfully");
|
|
253
255
|
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
254
256
|
sessionModelMap.set(sessionKey, config.copilotModel);
|
|
255
257
|
return session;
|
|
256
258
|
}
|
|
257
259
|
catch (err) {
|
|
258
|
-
|
|
260
|
+
log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "Could not resume session, creating new");
|
|
259
261
|
if (sessionKey === "default")
|
|
260
262
|
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
261
263
|
}
|
|
262
264
|
}
|
|
263
265
|
// Create a fresh session
|
|
264
|
-
|
|
266
|
+
log.info({ sessionKey }, "Creating new session");
|
|
265
267
|
const session = await client.createSession({
|
|
266
268
|
model: config.copilotModel,
|
|
267
269
|
configDir: SESSIONS_DIR,
|
|
@@ -273,7 +275,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
273
275
|
onPermissionRequest: orchestratorPermissionHandler,
|
|
274
276
|
infiniteSessions,
|
|
275
277
|
});
|
|
276
|
-
|
|
278
|
+
log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
|
|
277
279
|
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
278
280
|
// Backward compat: also persist the default session to the legacy state key
|
|
279
281
|
if (sessionKey === "default")
|
|
@@ -287,30 +289,30 @@ export async function initOrchestrator(client) {
|
|
|
287
289
|
// Initialize agent system
|
|
288
290
|
ensureDefaultAgents();
|
|
289
291
|
const agents = loadAgents();
|
|
290
|
-
|
|
292
|
+
log.info({ count: agents.length, agents: agents.map((a) => `@${a.slug}`) }, "Agents loaded");
|
|
291
293
|
// Validate configured model against available models
|
|
292
294
|
try {
|
|
293
295
|
const models = await client.listModels();
|
|
294
296
|
const configured = config.copilotModel;
|
|
295
297
|
const isAvailable = models.some((m) => m.id === configured);
|
|
296
298
|
if (!isAvailable) {
|
|
297
|
-
|
|
299
|
+
log.warn({ configured, fallback: DEFAULT_MODEL }, "Configured model not available, falling back");
|
|
298
300
|
config.copilotModel = DEFAULT_MODEL;
|
|
299
301
|
}
|
|
300
302
|
}
|
|
301
303
|
catch (err) {
|
|
302
|
-
|
|
304
|
+
log.warn({ model: config.copilotModel, err: err instanceof Error ? err.message : err }, "Could not validate model, using as-is");
|
|
303
305
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
log.info({ mcpServerCount: Object.keys(mcpServers).length, mcpServers: Object.keys(mcpServers) }, "Loading MCP servers");
|
|
307
|
+
log.info({ skillDirectories }, "Skill directories");
|
|
308
|
+
log.info("Persistent session mode — conversation history maintained by SDK");
|
|
307
309
|
startHealthCheck();
|
|
308
310
|
// Eagerly create/resume the default orchestrator session
|
|
309
311
|
try {
|
|
310
312
|
await ensureOrchestratorSession("default");
|
|
311
313
|
}
|
|
312
314
|
catch (err) {
|
|
313
|
-
|
|
315
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
|
|
314
316
|
}
|
|
315
317
|
}
|
|
316
318
|
/** How long to wait for the orchestrator to finish a turn (10 min). */
|
|
@@ -403,6 +405,32 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
|
|
|
403
405
|
});
|
|
404
406
|
})
|
|
405
407
|
: () => { };
|
|
408
|
+
// Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
|
|
409
|
+
// These fire when the built-in `task` tool (Squad coordinator) routes work to a
|
|
410
|
+
// specialist — separate from `delegate_to_agent` which handles CH-registry agents.
|
|
411
|
+
const db = getDb();
|
|
412
|
+
const unsubSubStartDb = session.on("subagent.started", (event) => {
|
|
413
|
+
try {
|
|
414
|
+
const data = event.data;
|
|
415
|
+
const agentSlug = (data.agentName || "unknown").toLowerCase().replace(/\s+/g, "-");
|
|
416
|
+
const description = (data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
|
|
417
|
+
db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, currentSourceChannel || null, sessionKey);
|
|
418
|
+
}
|
|
419
|
+
catch { /* non-fatal */ }
|
|
420
|
+
});
|
|
421
|
+
const unsubSubDoneDb = session.on("subagent.completed", (event) => {
|
|
422
|
+
try {
|
|
423
|
+
db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
|
|
424
|
+
}
|
|
425
|
+
catch { /* non-fatal */ }
|
|
426
|
+
});
|
|
427
|
+
const unsubSubFailDb = session.on("subagent.failed", (event) => {
|
|
428
|
+
try {
|
|
429
|
+
const data = event.data;
|
|
430
|
+
db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
|
|
431
|
+
}
|
|
432
|
+
catch { /* non-fatal */ }
|
|
433
|
+
});
|
|
406
434
|
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
407
435
|
// After a tool call completes, ensure a line break separates the text blocks
|
|
408
436
|
// so they don't visually run together in the rendered chat.
|
|
@@ -424,22 +452,22 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
|
|
|
424
452
|
// session and may have been (partially) processed. Return what we have.
|
|
425
453
|
if (/timeout/i.test(msg)) {
|
|
426
454
|
if (accumulated.length > 0) {
|
|
427
|
-
|
|
455
|
+
log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
|
|
428
456
|
return accumulated;
|
|
429
457
|
}
|
|
430
458
|
// No text yet but tool calls ran — the session is working in the background
|
|
431
459
|
// (e.g. delegate_to_agent dispatched). Don't error out.
|
|
432
460
|
if (toolCallCount > 0) {
|
|
433
|
-
|
|
461
|
+
log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, toolCallCount }, "Timeout — tool calls ran but no text yet, session still working");
|
|
434
462
|
return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
|
|
435
463
|
}
|
|
436
464
|
// No text, no tool calls — the session is truly stuck
|
|
437
|
-
|
|
465
|
+
log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity — session may be stuck");
|
|
438
466
|
return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
|
|
439
467
|
}
|
|
440
468
|
// If the session is broken, invalidate it so it's recreated on next attempt
|
|
441
469
|
if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
|
|
442
|
-
|
|
470
|
+
log.warn({ sessionKey, msg }, "Session appears dead, will recreate");
|
|
443
471
|
sessionMap.delete(sessionKey);
|
|
444
472
|
sessionModelMap.delete(sessionKey);
|
|
445
473
|
if (sessionKey === "default")
|
|
@@ -455,6 +483,9 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
|
|
|
455
483
|
unsubSubStart();
|
|
456
484
|
unsubSubDone();
|
|
457
485
|
unsubSubFail();
|
|
486
|
+
unsubSubStartDb();
|
|
487
|
+
unsubSubDoneDb();
|
|
488
|
+
unsubSubFailDb();
|
|
458
489
|
currentCallback = undefined;
|
|
459
490
|
currentActivityCallback = undefined;
|
|
460
491
|
currentProcessingSessionKey = undefined;
|
|
@@ -464,7 +495,7 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
|
|
|
464
495
|
async function processQueue() {
|
|
465
496
|
if (processing) {
|
|
466
497
|
if (messageQueue.length > 0) {
|
|
467
|
-
|
|
498
|
+
log.debug({ queueLength: messageQueue.length }, "Message queued, orchestrator is busy");
|
|
468
499
|
}
|
|
469
500
|
return;
|
|
470
501
|
}
|
|
@@ -486,17 +517,17 @@ async function processQueue() {
|
|
|
486
517
|
const currentModel = sessionModelMap.get(sessionKey) ?? config.copilotModel;
|
|
487
518
|
const routeResult = await resolveModel(item.prompt, currentModel, recentTiers);
|
|
488
519
|
if (routeResult.switched) {
|
|
489
|
-
|
|
520
|
+
log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
|
|
490
521
|
config.copilotModel = routeResult.model;
|
|
491
522
|
const existingSession = sessionMap.get(sessionKey);
|
|
492
523
|
if (existingSession) {
|
|
493
524
|
try {
|
|
494
525
|
await existingSession.setModel(routeResult.model);
|
|
495
526
|
sessionModelMap.set(sessionKey, routeResult.model);
|
|
496
|
-
|
|
527
|
+
log.info({ sessionKey }, "Model switched in-place");
|
|
497
528
|
}
|
|
498
529
|
catch (err) {
|
|
499
|
-
|
|
530
|
+
log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
|
|
500
531
|
sessionMap.delete(sessionKey);
|
|
501
532
|
if (sessionKey === "default")
|
|
502
533
|
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
@@ -541,6 +572,8 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
541
572
|
sessionKey = "project:" + normalizeProjectPath(source.projectPath);
|
|
542
573
|
// Keep the legacy channel-project map in sync for tools that read it
|
|
543
574
|
setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
|
|
575
|
+
// Bump last-used timestamp so sidebar can sort by real activity
|
|
576
|
+
bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
|
|
544
577
|
}
|
|
545
578
|
else if (source.type === "background" && source.sessionKey) {
|
|
546
579
|
sessionKey = source.sessionKey;
|
|
@@ -592,7 +625,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
592
625
|
// the user reply path.
|
|
593
626
|
if (copilotClient) {
|
|
594
627
|
maybeWriteEpisode(copilotClient).catch((err) => {
|
|
595
|
-
|
|
628
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
|
|
596
629
|
});
|
|
597
630
|
}
|
|
598
631
|
return;
|
|
@@ -605,7 +638,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
605
638
|
}
|
|
606
639
|
if (isRecoverableError(err) && attempt < MAX_RETRIES) {
|
|
607
640
|
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
608
|
-
|
|
641
|
+
log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error, retrying");
|
|
609
642
|
await sleep(delay);
|
|
610
643
|
// Reset client before retry in case the connection is stale
|
|
611
644
|
try {
|
|
@@ -614,7 +647,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
614
647
|
catch { /* will fail again on next attempt */ }
|
|
615
648
|
continue;
|
|
616
649
|
}
|
|
617
|
-
|
|
650
|
+
log.error({ msg }, "Error processing message");
|
|
618
651
|
callback(`Error: ${msg}`, true);
|
|
619
652
|
return;
|
|
620
653
|
}
|
|
@@ -634,11 +667,11 @@ export async function cancelCurrentMessage() {
|
|
|
634
667
|
if (activeSession && currentCallback) {
|
|
635
668
|
try {
|
|
636
669
|
await activeSession.abort();
|
|
637
|
-
|
|
670
|
+
log.info({ sessionKey: currentProcessingSessionKey }, "Aborted in-flight request");
|
|
638
671
|
return true;
|
|
639
672
|
}
|
|
640
673
|
catch (err) {
|
|
641
|
-
|
|
674
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Abort failed");
|
|
642
675
|
}
|
|
643
676
|
}
|
|
644
677
|
return drained > 0;
|
|
@@ -675,7 +708,7 @@ export async function shutdownAgents() {
|
|
|
675
708
|
await session.disconnect();
|
|
676
709
|
}
|
|
677
710
|
catch (err) {
|
|
678
|
-
|
|
711
|
+
log.error({ sessionKey: key, err: err instanceof Error ? err.message : err }, "Error disconnecting session during shutdown");
|
|
679
712
|
}
|
|
680
713
|
}
|
|
681
714
|
sessionMap.clear();
|
|
@@ -13,6 +13,11 @@ function createFakeClient(state) {
|
|
|
13
13
|
this.listeners.set(eventName, next);
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
emit(eventName, data) {
|
|
17
|
+
for (const handler of this.listeners.get(eventName) || []) {
|
|
18
|
+
handler({ data });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
16
21
|
async sendAndWait(request, _timeoutMs) {
|
|
17
22
|
state.sessionPrompts.push(request);
|
|
18
23
|
if (state.sendResult === "__PENDING__") {
|
|
@@ -45,7 +50,11 @@ function createFakeClient(state) {
|
|
|
45
50
|
if (state.createSessionError) {
|
|
46
51
|
throw new Error(state.createSessionError);
|
|
47
52
|
}
|
|
48
|
-
|
|
53
|
+
const session = new FakeSession();
|
|
54
|
+
state.lastSession = {
|
|
55
|
+
emit: (eventName, data) => session.emit(eventName, data),
|
|
56
|
+
};
|
|
57
|
+
return session;
|
|
49
58
|
},
|
|
50
59
|
async resumeSession(savedId, options) {
|
|
51
60
|
state.resumeSessionCalls.push({ savedId, options });
|
|
@@ -71,6 +80,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
71
80
|
disconnectCalls: 0,
|
|
72
81
|
conversationLogs: [],
|
|
73
82
|
dbLogs: [],
|
|
83
|
+
dbWrites: [],
|
|
74
84
|
episodeWrites: 0,
|
|
75
85
|
ensureDefaultAgentsCalls: 0,
|
|
76
86
|
loadAgentsCalls: 0,
|
|
@@ -162,6 +172,17 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
162
172
|
getCopilotSession: (_sessionKey) => undefined,
|
|
163
173
|
upsertCopilotSession: (_sessionKey, _mode, _copilotSessionId, _projectRoot, _model) => { },
|
|
164
174
|
getTaskSessionKey: (taskId) => state.taskSessionKeys?.get(taskId) ?? "default",
|
|
175
|
+
getDb: () => ({
|
|
176
|
+
prepare: (sql) => ({
|
|
177
|
+
run: (...args) => {
|
|
178
|
+
state.dbWrites.push({ sql, args });
|
|
179
|
+
return {};
|
|
180
|
+
},
|
|
181
|
+
get: () => undefined,
|
|
182
|
+
all: () => [],
|
|
183
|
+
}),
|
|
184
|
+
}),
|
|
185
|
+
bumpProjectLastUsed: (_projectRoot) => { },
|
|
165
186
|
},
|
|
166
187
|
});
|
|
167
188
|
t.mock.module("./episode-writer.js", {
|
|
@@ -520,4 +541,89 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
|
|
|
520
541
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
521
542
|
assert.ok(state.createSessionCalls.length > countAfterFirst, "a second message must trigger a new createSession attempt, proving sessionCreatePromises was cleaned up");
|
|
522
543
|
});
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// S5-01: SDK subagent events persist to agent_tasks (squad dispatch tracking)
|
|
546
|
+
// Root cause: executeOnSession subscribed to subagent.* events for the UI
|
|
547
|
+
// activity feed but never wrote rows to agent_tasks. Workers tab only reads
|
|
548
|
+
// agent_tasks, so squad coordinator dispatches were invisible.
|
|
549
|
+
// Fix: unconditional DB subscriptions in executeOnSession write/update rows.
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
test("S5-01: subagent.started event inserts a squad row into agent_tasks", async (t) => {
|
|
552
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
553
|
+
sendResult: "__PENDING__",
|
|
554
|
+
});
|
|
555
|
+
await orchestrator.initOrchestrator(client);
|
|
556
|
+
// Start a message so executeOnSession runs and registers event handlers
|
|
557
|
+
const callbackResults = [];
|
|
558
|
+
orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, (text, done) => {
|
|
559
|
+
callbackResults.push({ text, done });
|
|
560
|
+
});
|
|
561
|
+
// Yield so the async IIFE reaches sendAndWait (which is pending)
|
|
562
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
563
|
+
assert.ok(state.lastSession, "FakeSession should have been created");
|
|
564
|
+
// Fire a subagent.started event — simulates the SDK's task tool starting a squad dispatch
|
|
565
|
+
state.lastSession.emit("subagent.started", {
|
|
566
|
+
toolCallId: "subagent-call-001",
|
|
567
|
+
agentName: "Kaylee",
|
|
568
|
+
agentDisplayName: "Kaylee — Backend Dev",
|
|
569
|
+
agentDescription: "Implement S5-01 backend fix",
|
|
570
|
+
});
|
|
571
|
+
// The handler is synchronous — DB write should be in state.dbWrites immediately
|
|
572
|
+
const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
|
|
573
|
+
assert.ok(insertWrite, "subagent.started must INSERT a row into agent_tasks");
|
|
574
|
+
assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("squad"), "inserted row must carry source='squad'");
|
|
575
|
+
assert.ok(JSON.stringify(insertWrite.args).includes("subagent-call-001"), "task_id must equal the toolCallId from the event");
|
|
576
|
+
// Resolve the pending sendAndWait so the test can clean up
|
|
577
|
+
state.pendingReject?.(new Error("test teardown"));
|
|
578
|
+
});
|
|
579
|
+
test("S5-01: subagent.completed event updates agent_tasks status to completed", async (t) => {
|
|
580
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
581
|
+
sendResult: "__PENDING__",
|
|
582
|
+
});
|
|
583
|
+
await orchestrator.initOrchestrator(client);
|
|
584
|
+
orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, () => { });
|
|
585
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
586
|
+
// First, start the subagent
|
|
587
|
+
state.lastSession.emit("subagent.started", {
|
|
588
|
+
toolCallId: "subagent-call-002",
|
|
589
|
+
agentName: "Wash",
|
|
590
|
+
agentDisplayName: "Wash — Frontend Dev",
|
|
591
|
+
agentDescription: "Fix Workers tab UI",
|
|
592
|
+
});
|
|
593
|
+
// Then complete it
|
|
594
|
+
state.lastSession.emit("subagent.completed", {
|
|
595
|
+
toolCallId: "subagent-call-002",
|
|
596
|
+
agentName: "Wash",
|
|
597
|
+
agentDisplayName: "Wash — Frontend Dev",
|
|
598
|
+
durationMs: 1234,
|
|
599
|
+
});
|
|
600
|
+
const updateWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("completed"));
|
|
601
|
+
assert.ok(updateWrite, "subagent.completed must UPDATE agent_tasks to completed");
|
|
602
|
+
assert.ok(JSON.stringify(updateWrite.args).includes("subagent-call-002"), "UPDATE must target the correct task_id");
|
|
603
|
+
state.pendingReject?.(new Error("test teardown"));
|
|
604
|
+
});
|
|
605
|
+
test("S5-01: subagent.failed event updates agent_tasks status to error", async (t) => {
|
|
606
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
607
|
+
sendResult: "__PENDING__",
|
|
608
|
+
});
|
|
609
|
+
await orchestrator.initOrchestrator(client);
|
|
610
|
+
orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, () => { });
|
|
611
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
612
|
+
state.lastSession.emit("subagent.started", {
|
|
613
|
+
toolCallId: "subagent-call-003",
|
|
614
|
+
agentName: "Zoe",
|
|
615
|
+
agentDisplayName: "Zoe — QA",
|
|
616
|
+
agentDescription: "Run test suite",
|
|
617
|
+
});
|
|
618
|
+
state.lastSession.emit("subagent.failed", {
|
|
619
|
+
toolCallId: "subagent-call-003",
|
|
620
|
+
agentName: "Zoe",
|
|
621
|
+
agentDisplayName: "Zoe — QA",
|
|
622
|
+
error: "Timeout after 600s",
|
|
623
|
+
});
|
|
624
|
+
const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
|
|
625
|
+
assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
|
|
626
|
+
assert.ok(JSON.stringify(errorWrite.args).includes("subagent-call-003"), "UPDATE must target the correct task_id");
|
|
627
|
+
state.pendingReject?.(new Error("test teardown"));
|
|
628
|
+
});
|
|
523
629
|
//# sourceMappingURL=orchestrator.test.js.map
|
package/dist/copilot/router.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getState, setState } from "../store/db.js";
|
|
2
2
|
import { classifyWithLLM } from "./classifier.js";
|
|
3
|
+
import { childLogger } from "../util/logger.js";
|
|
4
|
+
const log = childLogger("router");
|
|
3
5
|
// ---------------------------------------------------------------------------
|
|
4
6
|
// Default configuration
|
|
5
7
|
// ---------------------------------------------------------------------------
|
|
@@ -96,12 +98,12 @@ async function classifyMessage(prompt, recentTiers, client) {
|
|
|
96
98
|
if (client) {
|
|
97
99
|
const tier = await classifyWithLLM(client, text);
|
|
98
100
|
if (tier) {
|
|
99
|
-
|
|
101
|
+
log.debug({ tier }, "LLM classifier result");
|
|
100
102
|
return tier;
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
// Fallback — standard is always safe
|
|
104
|
-
|
|
106
|
+
log.debug({ tier: "standard" }, "Classifier fallback: using standard tier");
|
|
105
107
|
return "standard";
|
|
106
108
|
}
|
|
107
109
|
// ---------------------------------------------------------------------------
|
package/dist/copilot/tools.js
CHANGED
|
@@ -22,6 +22,8 @@ import { getChannelProject } from "../squad/context.js";
|
|
|
22
22
|
import { findSquadAgent } from "../squad/registry.js";
|
|
23
23
|
import { buildSquadSystemPrefix } from "../squad/charter.js";
|
|
24
24
|
import { mirrorDecisionToWiki, syncDecisionsFileToWiki } from "../squad/mirror.js";
|
|
25
|
+
import { childLogger } from "../util/logger.js";
|
|
26
|
+
const log = childLogger("tools");
|
|
25
27
|
function getCategoryDir(category) {
|
|
26
28
|
const map = {
|
|
27
29
|
person: "people",
|
|
@@ -156,7 +158,7 @@ export function createTools(deps) {
|
|
|
156
158
|
const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel());
|
|
157
159
|
// Persist task to DB
|
|
158
160
|
const db = getDb();
|
|
159
|
-
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key) VALUES (?, ?, ?, 'running', ?,
|
|
161
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, task.originChannel || null, getCurrentSessionKey());
|
|
160
162
|
// Capture the parent's activity callback so the child session can stream
|
|
161
163
|
// its events back to the originating SSE connection. This survives past
|
|
162
164
|
// the parent assistant turn — the child runs long after the parent's
|
|
@@ -223,12 +225,12 @@ export function createTools(deps) {
|
|
|
223
225
|
// all 89+ entries, not just this task-completion entry.
|
|
224
226
|
syncDecisionsFileToWiki(projectRoot).then(syncResult => {
|
|
225
227
|
if (syncResult) {
|
|
226
|
-
|
|
228
|
+
log.info({ entriesSynced: syncResult.entriesSynced, wikiPath: syncResult.wikiPath }, "Post-task squad decisions synced to wiki");
|
|
227
229
|
}
|
|
228
230
|
}).catch(() => { });
|
|
229
231
|
}
|
|
230
232
|
catch (mirrorErr) {
|
|
231
|
-
|
|
233
|
+
log.error({ err: mirrorErr instanceof Error ? mirrorErr.message : mirrorErr }, "Failed to mirror squad decision to wiki (non-fatal)");
|
|
232
234
|
}
|
|
233
235
|
}
|
|
234
236
|
}
|
|
@@ -689,7 +691,7 @@ export function createTools(deps) {
|
|
|
689
691
|
await switchSessionModel(args.model_id);
|
|
690
692
|
}
|
|
691
693
|
catch (err) {
|
|
692
|
-
|
|
694
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "setModel() failed during switch_model, will apply on next session");
|
|
693
695
|
}
|
|
694
696
|
// Disable router when manually switching — user has explicit preference
|
|
695
697
|
if (getRouterConfig().enabled) {
|
|
@@ -1169,7 +1171,7 @@ export function createTools(deps) {
|
|
|
1169
1171
|
// Schedule restart after returning the response
|
|
1170
1172
|
setTimeout(() => {
|
|
1171
1173
|
restartDaemon().catch((err) => {
|
|
1172
|
-
|
|
1174
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Restart failed");
|
|
1173
1175
|
});
|
|
1174
1176
|
}, 1000);
|
|
1175
1177
|
return `Restarting Chapterhouse${reason}. I'll be back in a few seconds.`;
|