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.
@@ -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
- console.log(`[chapterhouse] Client not connected (state: ${copilotClient?.getState() ?? "null"}), resetting…`);
165
+ log.info({ state: copilotClient?.getState() ?? "null" }, "Client not connected, resetting");
164
166
  resetPromise = resetClient().then((c) => {
165
- console.log(`[chapterhouse] Client reset successful, state: ${c.getState()}`);
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
- console.log(`[chapterhouse] Health check: client state is '${state}', resetting…`);
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
- console.error(`[chapterhouse] Health check error:`, err instanceof Error ? err.message : err);
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
- console.log(`[chapterhouse] Resuming session [${sessionKey}] ${savedSessionId.slice(0, 8)}…`);
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
- console.log(`[chapterhouse] Resumed session [${sessionKey}] successfully`);
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
- console.log(`[chapterhouse] Could not resume session [${sessionKey}]: ${err instanceof Error ? err.message : err}. Creating new.`);
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
- console.log(`[chapterhouse] Creating new session [${sessionKey}]`);
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
- console.log(`[chapterhouse] Created session [${sessionKey}] ${session.sessionId.slice(0, 8)}…`);
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
- console.log(`[chapterhouse] Loaded ${agents.length} agent(s): ${agents.map((a) => `@${a.slug}`).join(", ") || "(none)"}`);
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
- console.log(`[chapterhouse] ⚠️ Configured model '${configured}' is not available. Falling back to '${DEFAULT_MODEL}'.`);
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
- console.log(`[chapterhouse] Could not validate model (will use '${config.copilotModel}' as-is): ${err instanceof Error ? err.message : err}`);
304
+ log.warn({ model: config.copilotModel, err: err instanceof Error ? err.message : err }, "Could not validate model, using as-is");
303
305
  }
304
- console.log(`[chapterhouse] Loading ${Object.keys(mcpServers).length} MCP server(s): ${Object.keys(mcpServers).join(", ") || "(none)"}`);
305
- console.log(`[chapterhouse] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
306
- console.log(`[chapterhouse] Persistent session mode — conversation history maintained by SDK`);
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
- console.error(`[chapterhouse] Failed to create initial session (will retry on first message):`, err instanceof Error ? err.message : err);
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
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s but have ${accumulated.length} chars — returning partial response`);
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
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s${toolCallCount} tool call(s) executed but no text yet. Session is still working.`);
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
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s with no activity. Session may be stuck.`);
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
- console.log(`[chapterhouse] Session [${sessionKey}] appears dead, will recreate: ${msg}`);
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
- console.log(`[chapterhouse] Message queued (${messageQueue.length} waiting orchestrator is busy)`);
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
- console.log(`[chapterhouse] Auto: switching to ${routeResult.model} (${routeResult.overrideName || routeResult.tier})`);
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
- console.log(`[chapterhouse] Model switched in-place via setModel() for [${sessionKey}]`);
527
+ log.info({ sessionKey }, "Model switched in-place");
497
528
  }
498
529
  catch (err) {
499
- console.log(`[chapterhouse] setModel() failed for [${sessionKey}], will recreate: ${err instanceof Error ? err.message : err}`);
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
- console.error("[chapterhouse] Episode write failed (non-fatal):", err instanceof Error ? err.message : err);
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
- console.error(`[chapterhouse] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
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
- console.error(`[chapterhouse] Error processing message: ${msg}`);
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
- console.log(`[chapterhouse] Aborted in-flight request on [${currentProcessingSessionKey}]`);
670
+ log.info({ sessionKey: currentProcessingSessionKey }, "Aborted in-flight request");
638
671
  return true;
639
672
  }
640
673
  catch (err) {
641
- console.error(`[chapterhouse] Abort failed:`, err instanceof Error ? err.message : err);
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
- console.error(`[orchestrator] Error disconnecting session "${key}" during shutdown:`, err instanceof Error ? err.message : err);
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
- return new FakeSession();
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
@@ -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
- console.log(`[chapterhouse] Classifier: ${tier}`);
101
+ log.debug({ tier }, "LLM classifier result");
100
102
  return tier;
101
103
  }
102
104
  }
103
105
  // Fallback — standard is always safe
104
- console.log(`[chapterhouse] Classifier (fallback): standard`);
106
+ log.debug({ tier: "standard" }, "Classifier fallback: using standard tier");
105
107
  return "standard";
106
108
  }
107
109
  // ---------------------------------------------------------------------------
@@ -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', ?, ?)`).run(task.taskId, delegatedSlug, args.summary, task.originChannel || null, getCurrentSessionKey());
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
- console.log(`[squad] Post-task decisions sync: ${syncResult.entriesSynced} entries → ${syncResult.wikiPath}`);
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
- console.error("[tools] Failed to mirror squad decision to wiki (non-fatal):", mirrorErr instanceof Error ? mirrorErr.message : mirrorErr);
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
- console.log(`[chapterhouse] setModel() failed during switch_model (will apply on next session): ${err instanceof Error ? err.message : err}`);
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
- console.error("[chapterhouse] Restart failed:", err);
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.`;