chapterhouse 0.3.13 → 0.3.14

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.
Files changed (52) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +8 -155
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/cli.js +0 -30
  5. package/dist/config.js +0 -3
  6. package/dist/copilot/agent-event-bus.js +41 -0
  7. package/dist/copilot/agent-event-bus.test.js +23 -0
  8. package/dist/copilot/agents.js +4 -59
  9. package/dist/copilot/orchestrator.js +20 -39
  10. package/dist/copilot/orchestrator.test.js +73 -158
  11. package/dist/copilot/task-event-log.js +5 -5
  12. package/dist/copilot/task-event-log.test.js +68 -142
  13. package/dist/copilot/tools.js +9 -85
  14. package/dist/daemon.js +0 -22
  15. package/dist/store/db.js +2 -50
  16. package/dist/store/db.test.js +0 -45
  17. package/package.json +1 -3
  18. package/web/dist/assets/index-BlIWCM11.js +217 -0
  19. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  20. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  21. package/web/dist/index.html +2 -2
  22. package/dist/api/ralph.js +0 -153
  23. package/dist/api/ralph.test.js +0 -101
  24. package/dist/copilot/agents.squad.test.js +0 -72
  25. package/dist/copilot/hooks.js +0 -157
  26. package/dist/copilot/hooks.test.js +0 -315
  27. package/dist/copilot/squad-event-bus.js +0 -27
  28. package/dist/copilot/tools.squad.test.js +0 -168
  29. package/dist/squad/charter.js +0 -125
  30. package/dist/squad/charter.test.js +0 -89
  31. package/dist/squad/context.js +0 -48
  32. package/dist/squad/context.test.js +0 -59
  33. package/dist/squad/discovery.js +0 -268
  34. package/dist/squad/discovery.test.js +0 -154
  35. package/dist/squad/index.js +0 -9
  36. package/dist/squad/init-cli.js +0 -109
  37. package/dist/squad/init.js +0 -395
  38. package/dist/squad/init.test.js +0 -351
  39. package/dist/squad/mirror.js +0 -83
  40. package/dist/squad/mirror.scheduler.js +0 -80
  41. package/dist/squad/mirror.scheduler.test.js +0 -197
  42. package/dist/squad/mirror.test.js +0 -172
  43. package/dist/squad/registry.js +0 -162
  44. package/dist/squad/registry.test.js +0 -31
  45. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  46. package/dist/squad/squad-session-routing.test.js +0 -260
  47. package/dist/squad/types.js +0 -4
  48. package/dist/squad/worktree.js +0 -295
  49. package/dist/squad/worktree.test.js +0 -189
  50. package/dist/store/squad-sessions.test.js +0 -341
  51. package/web/dist/assets/index-IgSOXx_a.js +0 -219
  52. package/web/dist/assets/index-IgSOXx_a.js.map +0 -1
@@ -1,7 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { approveAll } from "@github/copilot-sdk";
4
- import { initHookPipeline, createSessionHooks } from "./hooks.js";
5
4
  import { createTools } from "./tools.js";
6
5
  import { getOrchestratorSystemMessage } from "./system-message.js";
7
6
  import { CHAPTERHOUSE_VERSION } from "../version.js";
@@ -9,16 +8,14 @@ import { config, DEFAULT_MODEL } from "../config.js";
9
8
  import { loadMcpConfig } from "./mcp-config.js";
10
9
  import { getSkillDirectories } from "./skills.js";
11
10
  import { resetClient } from "./client.js";
12
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed, appendTaskEvent } from "../store/db.js";
11
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
13
12
  import { maybeWriteEpisode } from "./episode-writer.js";
14
13
  import { getWikiSummary } from "../wiki/context.js";
15
14
  import { SESSIONS_DIR } from "../paths.js";
16
15
  import { resolveModel } from "./router.js";
17
16
  import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
18
- import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
19
- import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
20
17
  import { childLogger } from "../util/logger.js";
21
- import { squadEventBus } from "./squad-event-bus.js";
18
+ import { agentEventBus } from "./agent-event-bus.js";
22
19
  import { initTaskEventLog } from "./task-event-log.js";
23
20
  import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
24
21
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
@@ -56,7 +53,7 @@ export function getLastRouteResult() {
56
53
  return lastRouteResult;
57
54
  }
58
55
  export function subscribeTaskEvents(taskId, listener) {
59
- return squadEventBus.subscribe("session:tool_call", (event) => {
56
+ return agentEventBus.subscribe("session:tool_call", (event) => {
60
57
  if (event.sessionId !== taskId)
61
58
  return;
62
59
  const p = event.payload;
@@ -70,7 +67,7 @@ export function subscribeTaskEvents(taskId, listener) {
70
67
  });
71
68
  }
72
69
  function emitTaskEvent(taskId, event) {
73
- void squadEventBus.emit({
70
+ void agentEventBus.emit({
74
71
  type: "session:tool_call",
75
72
  sessionId: taskId,
76
73
  payload: {
@@ -140,11 +137,11 @@ function getSessionConfig() {
140
137
  const skillDirectories = getSkillDirectories();
141
138
  return { tools, mcpServers, skillDirectories };
142
139
  }
143
- function getSystemMessageOptions(memorySummary, projectRoot) {
140
+ function getSystemMessageOptions(memorySummary) {
144
141
  return {
145
142
  selfEditEnabled: config.selfEditEnabled,
146
143
  memorySummary: memorySummary || undefined,
147
- agentRoster: buildAgentRoster(projectRoot),
144
+ agentRoster: buildAgentRoster(),
148
145
  userContext: currentUserContext,
149
146
  };
150
147
  }
@@ -234,17 +231,11 @@ async function createOrResumeSession(sessionKey, projectRoot) {
234
231
  backgroundCompactionThreshold: 0.80,
235
232
  bufferExhaustionThreshold: 0.95,
236
233
  };
237
- let systemMessageContent;
238
- if (isProjectSession && projectRoot) {
239
- systemMessageContent = await getSquadCoordinatorSystemMessage(projectRoot);
240
- }
241
- else {
242
- const memorySummary = getWikiSummary();
243
- systemMessageContent = getOrchestratorSystemMessage({
244
- ...getSystemMessageOptions(memorySummary, isProjectSession ? projectRoot : undefined),
245
- version: CHAPTERHOUSE_VERSION,
246
- });
247
- }
234
+ const memorySummary = getWikiSummary();
235
+ const systemMessageContent = getOrchestratorSystemMessage({
236
+ ...getSystemMessageOptions(memorySummary),
237
+ version: CHAPTERHOUSE_VERSION,
238
+ });
248
239
  const stored = getCopilotSession(sessionKey);
249
240
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
250
241
  if (savedSessionId) {
@@ -259,7 +250,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
259
250
  mcpServers,
260
251
  skillDirectories,
261
252
  onPermissionRequest: approveAll,
262
- hooks: createSessionHooks("orchestrator"),
263
253
  infiniteSessions,
264
254
  });
265
255
  log.info({ sessionKey }, "Session resumed successfully");
@@ -285,7 +275,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
285
275
  mcpServers,
286
276
  skillDirectories,
287
277
  onPermissionRequest: approveAll,
288
- hooks: createSessionHooks("orchestrator"),
289
278
  infiniteSessions,
290
279
  });
291
280
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
@@ -299,9 +288,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
299
288
  }
300
289
  export async function initOrchestrator(client) {
301
290
  copilotClient = client;
302
- // Initialize governance hook pipeline before any session is created.
303
- initHookPipeline();
304
- // Initialize per-task ring buffer — subscribes to squadEventBus for session:tool_call events.
291
+ // Initialize per-task ring buffer subscribes to agentEventBus for session:tool_call events.
305
292
  initTaskEventLog();
306
293
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
307
294
  if (registry) {
@@ -450,7 +437,7 @@ async function executeOnSession(manager, item) {
450
437
  .replace(/\s+/g, "-");
451
438
  const resolvedDescription = (typeof spawnArgs?.description === "string"
452
439
  ? spawnArgs.description
453
- : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
440
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
454
441
  item.onActivity({
455
442
  kind: "subagent_started",
456
443
  toolCallId: data.toolCallId,
@@ -521,10 +508,10 @@ async function executeOnSession(manager, item) {
521
508
  .replace(/\s+/g, "-");
522
509
  const description = (typeof spawnArgs?.description === "string"
523
510
  ? spawnArgs.description
524
- : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
525
- 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, item.sourceChannel || null, sessionKey);
511
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
512
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
526
513
  activeSubagentTaskIds.add(data.toolCallId);
527
- void squadEventBus.emit({
514
+ void agentEventBus.emit({
528
515
  type: "session:created",
529
516
  sessionId: data.toolCallId,
530
517
  agentName: agentSlug,
@@ -552,7 +539,7 @@ async function executeOnSession(manager, item) {
552
539
  db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
553
540
  const taskId = event.data.toolCallId;
554
541
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
555
- void squadEventBus.emit({
542
+ void agentEventBus.emit({
556
543
  type: "session:destroyed",
557
544
  sessionId: taskId,
558
545
  agentName: taskRow?.agent_slug,
@@ -580,7 +567,7 @@ async function executeOnSession(manager, item) {
580
567
  activeSubagentTaskIds.delete(data.toolCallId);
581
568
  db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
582
569
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
583
- void squadEventBus.emit({
570
+ void agentEventBus.emit({
584
571
  type: "session:error",
585
572
  sessionId: data.toolCallId,
586
573
  agentName: taskRow?.agent_slug,
@@ -758,20 +745,14 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
758
745
  const sourceLabel = source.type === "background" ? "background" : "web";
759
746
  logMessage("in", sourceLabel, prompt);
760
747
  let sessionKey;
761
- if (source.type === "web" && source.projectPath && config.squadEnabled) {
762
- sessionKey = "project:" + normalizeProjectPath(source.projectPath);
763
- setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
764
- bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
765
- }
766
- else if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
748
+ if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
767
749
  sessionKey = source.sessionKey;
768
750
  }
769
751
  else {
770
752
  sessionKey = "default";
771
753
  }
772
754
  const channelKey = source.type === "web" ? source.connectionId : "default";
773
- const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
774
- const mention = parseAtMention(prompt, projectRoot);
755
+ const mention = parseAtMention(prompt);
775
756
  const targetAgent = mention?.agentSlug;
776
757
  const routedPrompt = mention ? mention.message : prompt;
777
758
  const taggedPrompt = source.type === "background"
@@ -68,7 +68,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
68
68
  config: {
69
69
  copilotModel: "missing-model",
70
70
  selfEditEnabled: true,
71
- squadEnabled: false,
72
71
  },
73
72
  routeResults: [],
74
73
  routerArgs: [],
@@ -85,8 +84,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
85
84
  ensureDefaultAgentsCalls: 0,
86
85
  loadAgentsCalls: 0,
87
86
  setActiveAgentCalls: [],
88
- setChannelProjectCalls: [],
89
- channelProjects: new Map(),
90
87
  parseMentionArgs: [],
91
88
  buildAgentRosterArgs: [],
92
89
  clearActiveTasksCalls: 0,
@@ -142,22 +139,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
142
139
  resetClient: async () => client,
143
140
  },
144
141
  });
145
- t.mock.module("../squad/context.js", {
146
- namedExports: {
147
- setChannelProject: (channelKey, projectRoot) => {
148
- state.setChannelProjectCalls.push({ channelKey, projectRoot });
149
- state.channelProjects.set(channelKey, projectRoot);
150
- },
151
- getChannelProject: (channelKey) => state.channelProjects.get(channelKey),
152
- normalizeProjectPath: (projectPath) => `normalized:${projectPath}`,
153
- },
154
- });
155
- t.mock.module("../squad/charter.js", {
156
- namedExports: {
157
- buildSquadSystemPrefix: async (_projectRoot) => "# Squad context\n",
158
- getSquadCoordinatorSystemMessage: async (_projectRoot) => "# Squad Coordinator\nYou are the Squad coordinator.\n",
159
- },
160
- });
161
142
  t.mock.module("../store/db.js", {
162
143
  namedExports: {
163
144
  logConversation: (role, content, source) => {
@@ -184,7 +165,6 @@ async function loadOrchestratorModule(t, overrides = {}) {
184
165
  }),
185
166
  transaction: (fn) => fn,
186
167
  }),
187
- bumpProjectLastUsed: (_projectRoot) => { },
188
168
  appendTaskEvent: (taskId, kind, toolName, summary) => {
189
169
  const seq = (state.taskEvents.get(taskId)?.length ?? 0) + 1;
190
170
  const ev = { id: seq, taskId, seq, ts: Date.now(), kind, toolName, summary };
@@ -281,7 +261,6 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
281
261
  config: {
282
262
  copilotModel: "claude-sonnet-4.6",
283
263
  selfEditEnabled: true,
284
- squadEnabled: false,
285
264
  },
286
265
  routeResults: [
287
266
  {
@@ -337,7 +316,6 @@ test("@mentions route through the orchestrator session without invoking the mode
337
316
  config: {
338
317
  copilotModel: "claude-sonnet-4.6",
339
318
  selfEditEnabled: true,
340
- squadEnabled: false,
341
319
  },
342
320
  parseMentionResult: { agentSlug: "designer", message: "polish the landing page" },
343
321
  sendResult: "Delegated",
@@ -355,48 +333,11 @@ test("@mentions route through the orchestrator session without invoking the mode
355
333
  assert.deepEqual(state.routerArgs, []);
356
334
  assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
357
335
  });
358
- test("web projectPath activates squad context, rebuilds the orchestrator roster, and routes mentions per connection", async (t) => {
359
- const { orchestrator, state, client } = await loadOrchestratorModule(t, {
360
- config: {
361
- copilotModel: "claude-sonnet-4.6",
362
- selfEditEnabled: true,
363
- squadEnabled: true,
364
- },
365
- parseMentionResult: { agentSlug: "ripley", message: "audit the squad routing" },
366
- sendResult: "Squad handled it",
367
- });
368
- await orchestrator.initOrchestrator(client);
369
- const final = await new Promise((resolve) => {
370
- orchestrator.sendToOrchestrator("@ripley audit the squad routing", { type: "web", connectionId: "conn-squad", projectPath: "~/workspace/mock-squad" }, (text, done) => {
371
- if (done) {
372
- resolve(text);
373
- }
374
- });
375
- });
376
- assert.equal(final, "Squad handled it");
377
- assert.deepEqual(state.setChannelProjectCalls, [{
378
- channelKey: "conn-squad",
379
- projectRoot: "normalized:~/workspace/mock-squad",
380
- }]);
381
- assert.deepEqual(state.parseMentionArgs.at(-1), {
382
- text: "@ripley audit the squad routing",
383
- projectRoot: "normalized:~/workspace/mock-squad",
384
- });
385
- assert.deepEqual(state.setActiveAgentCalls, [{ channelKey: "conn-squad", agentSlug: "ripley" }]);
386
- assert.deepEqual(state.sessionPrompts.at(-1), [{ prompt: "[via web] audit the squad routing" }][0]);
387
- // Project sessions use getSquadCoordinatorSystemMessage (not buildAgentRoster) — only the default
388
- // session init triggers a buildAgentRoster call.
389
- assert.deepEqual(state.buildAgentRosterArgs, [undefined]);
390
- assert.equal(state.createSessionCalls.length, 2);
391
- assert.equal(typeof orchestrator.getCurrentChannelKey, "function");
392
- assert.equal(orchestrator.getCurrentChannelKey(), undefined);
393
- });
394
336
  test("feedAgentResult injects a background completion turn and proactively notifies listeners", async (t) => {
395
337
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
396
338
  config: {
397
339
  copilotModel: "claude-sonnet-4.6",
398
340
  selfEditEnabled: true,
399
- squadEnabled: false,
400
341
  },
401
342
  sendResult: "Agent complete",
402
343
  });
@@ -427,7 +368,6 @@ test("cancelCurrentMessage aborts the active request and agent helpers expose ru
427
368
  config: {
428
369
  copilotModel: "claude-sonnet-4.6",
429
370
  selfEditEnabled: true,
430
- squadEnabled: false,
431
371
  },
432
372
  sendResult: "__PENDING__",
433
373
  });
@@ -449,85 +389,99 @@ test("cancelCurrentMessage aborts the active request and agent helpers expose ru
449
389
  await orchestrator.shutdownAgents();
450
390
  assert.equal(state.clearActiveTasksCalls, 1);
451
391
  });
452
- test("feedAgentResult routes to project session when task session_key is a project key", async (t) => {
392
+ // ---------------------------------------------------------------------------
393
+ // REGRESSION: #35 — per-session isolation
394
+ // This test would have caught the original bug. With a global shared queue,
395
+ // session B's message would queue behind session A's blocking turn and the
396
+ // Promise.race would time out. With per-session queues, session B completes
397
+ // independently.
398
+ // ---------------------------------------------------------------------------
399
+ test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
453
400
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
454
- config: {
455
- copilotModel: "claude-sonnet-4.6",
456
- selfEditEnabled: true,
457
- squadEnabled: false,
458
- },
459
- sendResult: "Routed correctly",
460
- taskSessionKeys: new Map([["proj-task-1", "project:/repo/squad"]]),
401
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
402
+ sendResult: "__PENDING__", // session A will block in sendAndWait
461
403
  });
462
404
  await orchestrator.initOrchestrator(client);
463
- // initOrchestrator creates the default sessionrecord that baseline
464
- const sessionsAfterInit = state.createSessionCalls.length;
465
- const notified = new Promise((resolve) => {
466
- orchestrator.setProactiveNotify(resolve);
405
+ // Send to session A (explicit sessionKey "chat:a") parks because sendResult is __PENDING__
406
+ const sessionACallbacks = [];
407
+ orchestrator.sendToOrchestrator("slow request to session A", { type: "background", sessionKey: "chat:a" }, (text, done) => sessionACallbacks.push({ text, done }));
408
+ // Yield to event loop so session A's drain loop runs and parks in sendAndWait
409
+ await new Promise((resolve) => setTimeout(resolve, 20));
410
+ // Now session A is blocked inside sendAndWait. Switch sendResult so session B
411
+ // gets a fast response. Session A's pending reject is still captured in its closure.
412
+ state.sendResult = "session B complete";
413
+ // Send to session B (different sessionKey) — must complete without waiting for session A
414
+ const sessionBDone = new Promise((resolve) => {
415
+ orchestrator.sendToOrchestrator("quick request to session B", { type: "background", sessionKey: "chat:b" }, (text, done) => {
416
+ if (done)
417
+ resolve(text);
418
+ });
467
419
  });
468
- // Task belongs to the project session; feedAgentResult must open that session
469
- orchestrator.feedAgentResult("proj-task-1", "coder", "Squad feature done");
470
- assert.equal(await notified, "Routed correctly");
471
- // A second createSession call proves the orchestrator opened a fresh project session
472
- // rather than reusing the already-open default session.
473
- assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a project session, not recycle the default one");
474
- // The prompt must reference the task and agent
475
- const prompt = state.sessionPrompts.at(-1);
476
- assert.ok(prompt?.prompt.includes("proj-task-1"), "prompt should reference the task id");
477
- assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
478
- assert.ok(prompt?.prompt.includes("Squad feature done"), "prompt should include the result text");
420
+ // Session B must respond before the deadline (session A is still blocked indefinitely)
421
+ const result = await Promise.race([
422
+ sessionBDone,
423
+ new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
424
+ ]);
425
+ assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
426
+ assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
427
+ // Clean up: reject session A's pending promise so the test can finish
428
+ state.pendingReject?.(new Error("test teardown"));
429
+ await new Promise((resolve) => setTimeout(resolve, 10));
479
430
  });
480
431
  // ---------------------------------------------------------------------------
481
- // Sprint 3, Item 5 orchestrator lifecycle: project-session cancel/shutdown
432
+ // Orchestrator lifecycle: shutdown clears all sessions
482
433
  // ---------------------------------------------------------------------------
483
- test("cancelCurrentMessage targets the active project session, not the default session", async (t) => {
484
- const { orchestrator, state, client } = await loadOrchestratorModule(t, {
485
- config: {
486
- copilotModel: "claude-sonnet-4.6",
487
- selfEditEnabled: false,
488
- // squadEnabled true so sendToOrchestrator derives a "project:…" session key
489
- squadEnabled: true,
490
- },
491
- sendResult: "__PENDING__",
492
- });
493
- await orchestrator.initOrchestrator(client);
494
- // Default session was created during init
495
- const sessionsAfterInit = state.createSessionCalls.length;
496
- // Send to a project path — this should open a separate project session
497
- orchestrator.sendToOrchestrator("scaffold the new API", { type: "web", connectionId: "conn-project", projectPath: "/repo/squad-proj" }, () => { });
498
- // Yield to the event loop so the project session is created and the
499
- // sendAndWait promise is parked in PENDING state.
500
- await new Promise((resolve) => setTimeout(resolve, 0));
501
- assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "a distinct project session must be created, separate from the default session");
502
- // Cancel should abort the active project session, not the default one
503
- const cancelled = await orchestrator.cancelCurrentMessage();
504
- assert.equal(cancelled, true, "cancelCurrentMessage should return true when an active request was aborted");
505
- assert.equal(state.abortCalls, 1, "exactly one abort — on the in-flight project session");
506
- });
507
434
  test("shutdownAgents disconnects all sessions, clears maps, and clears active tasks", async (t) => {
508
435
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
509
- config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
436
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
510
437
  sendResult: "ok",
511
438
  });
512
439
  // Init default session
513
440
  await orchestrator.initOrchestrator(client);
514
441
  const sessionsAfterInit = state.createSessionCalls.length;
515
- // Trigger a project session by sending a message with a projectPath
516
- orchestrator.sendToOrchestrator("hello from project", { type: "web", connectionId: "conn-1", projectPath: "/fake/project" }, (_text, _done) => { });
442
+ // Trigger a second session by sending a background message with a distinct sessionKey
443
+ orchestrator.sendToOrchestrator("hello from session chat:1", { type: "background", sessionKey: "chat:1" }, (_text, _done) => { });
517
444
  // Yield so the async session-creation IIFE runs
518
445
  await new Promise((resolve) => setTimeout(resolve, 10));
519
- assert.ok(state.createSessionCalls.length > sessionsAfterInit, "a project session should have been created");
446
+ assert.ok(state.createSessionCalls.length > sessionsAfterInit, "a second session should have been created");
520
447
  // Shutdown — both sessions should be disconnected
521
448
  await orchestrator.shutdownAgents();
522
- assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + project)");
449
+ assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + chat:1)");
523
450
  assert.equal(state.clearActiveTasksCalls, 1, "clearActiveTasks must be called exactly once");
524
451
  // Re-init after shutdown must create a fresh session (proves sessionMap was cleared)
525
452
  await orchestrator.initOrchestrator(client);
526
453
  assert.ok(state.createSessionCalls.length > sessionsAfterInit + 1, "re-init after shutdown must create a new session (not reuse stale sessionMap entry)");
527
454
  });
455
+ // ---------------------------------------------------------------------------
456
+ // feedAgentResult routes to the correct non-default session
457
+ // ---------------------------------------------------------------------------
458
+ test("feedAgentResult routes to a non-default session when the task's session_key is non-default", async (t) => {
459
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
460
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
461
+ sendResult: "Routed correctly",
462
+ taskSessionKeys: new Map([["chat-task-1", "chat:abc"]]),
463
+ });
464
+ await orchestrator.initOrchestrator(client);
465
+ // initOrchestrator creates the default session — record that baseline
466
+ const sessionsAfterInit = state.createSessionCalls.length;
467
+ const notified = new Promise((resolve) => {
468
+ orchestrator.setProactiveNotify(resolve);
469
+ });
470
+ // Task belongs to "chat:abc" session; feedAgentResult must open that session
471
+ orchestrator.feedAgentResult("chat-task-1", "coder", "Feature done");
472
+ assert.equal(await notified, "Routed correctly");
473
+ // A second createSession call proves the orchestrator opened a fresh non-default session
474
+ // rather than reusing the already-open default session.
475
+ assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
476
+ // The prompt must reference the task and agent
477
+ const prompt = state.sessionPrompts.at(-1);
478
+ assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
479
+ assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
480
+ assert.ok(prompt?.prompt.includes("Feature done"), "prompt should include the result text");
481
+ });
528
482
  test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
529
483
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
530
- config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: false },
484
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
531
485
  // Non-recoverable error so the retry loop exits immediately
532
486
  createSessionError: "fatal: SDK host permanently unavailable",
533
487
  sendResult: "unreachable",
@@ -552,13 +506,13 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
552
506
  assert.ok(state.createSessionCalls.length > countAfterFirst, "a second message must trigger a new createSession attempt, proving sessionCreatePromises was cleaned up");
553
507
  });
554
508
  // ---------------------------------------------------------------------------
555
- // S5-01: SDK subagent events persist to agent_tasks (squad dispatch tracking)
509
+ // S5-01: SDK subagent events persist to agent_tasks (agent dispatch tracking)
556
510
  // Root cause: executeOnSession subscribed to subagent.* events for the UI
557
511
  // activity feed but never wrote rows to agent_tasks. Workers tab only reads
558
- // agent_tasks, so squad coordinator dispatches were invisible.
512
+ // agent_tasks, so agent dispatches were invisible.
559
513
  // Fix: unconditional DB subscriptions in executeOnSession write/update rows.
560
514
  // ---------------------------------------------------------------------------
561
- test("S5-01: subagent.started event inserts a squad row into agent_tasks", async (t) => {
515
+ test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", async (t) => {
562
516
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
563
517
  sendResult: "__PENDING__",
564
518
  });
@@ -571,7 +525,7 @@ test("S5-01: subagent.started event inserts a squad row into agent_tasks", async
571
525
  // Yield so the async IIFE reaches sendAndWait (which is pending)
572
526
  await new Promise((resolve) => setTimeout(resolve, 10));
573
527
  assert.ok(state.lastSession, "FakeSession should have been created");
574
- // Fire a subagent.started event — simulates the SDK's task tool starting a squad dispatch
528
+ // Fire a subagent.started event — simulates the SDK's task tool starting an agent dispatch
575
529
  state.lastSession.emit("subagent.started", {
576
530
  toolCallId: "subagent-call-001",
577
531
  agentName: "Kaylee",
@@ -581,7 +535,7 @@ test("S5-01: subagent.started event inserts a squad row into agent_tasks", async
581
535
  // The handler is synchronous — DB write should be in state.dbWrites immediately
582
536
  const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
583
537
  assert.ok(insertWrite, "subagent.started must INSERT a row into agent_tasks");
584
- assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("squad"), "inserted row must carry source='squad'");
538
+ assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("adhoc"), "inserted row must carry source='adhoc'");
585
539
  assert.ok(JSON.stringify(insertWrite.args).includes("subagent-call-001"), "task_id must equal the toolCallId from the event");
586
540
  // Resolve the pending sendAndWait so the test can clean up
587
541
  state.pendingReject?.(new Error("test teardown"));
@@ -637,45 +591,6 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
637
591
  state.pendingReject?.(new Error("test teardown"));
638
592
  });
639
593
  // ---------------------------------------------------------------------------
640
- // REGRESSION: #35 — per-session isolation
641
- // This test would have caught the original bug. With a global shared queue,
642
- // session B's message would queue behind session A's blocking turn and the
643
- // Promise.race would time out. With per-session queues, session B completes
644
- // independently.
645
- // ---------------------------------------------------------------------------
646
- test("regression #35: session A blocking does not delay session B (concurrent sessions)", async (t) => {
647
- const { orchestrator, state, client } = await loadOrchestratorModule(t, {
648
- config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
649
- sendResult: "__PENDING__", // session A will block in sendAndWait
650
- });
651
- await orchestrator.initOrchestrator(client);
652
- // Send to session A (project /project/a) — parks because sendResult is __PENDING__
653
- const sessionACallbacks = [];
654
- orchestrator.sendToOrchestrator("slow request to project A", { type: "web", connectionId: "conn-a", projectPath: "/project/a" }, (text, done) => sessionACallbacks.push({ text, done }));
655
- // Yield to event loop so session A's drain loop runs and parks in sendAndWait
656
- await new Promise((resolve) => setTimeout(resolve, 20));
657
- // Now session A is blocked inside sendAndWait. Switch sendResult so session B
658
- // gets a fast response. session A's pending reject is still captured in its closure.
659
- state.sendResult = "session B complete";
660
- // Send to session B (different project) — must complete without waiting for session A
661
- const sessionBDone = new Promise((resolve) => {
662
- orchestrator.sendToOrchestrator("quick request to project B", { type: "web", connectionId: "conn-b", projectPath: "/project/b" }, (text, done) => {
663
- if (done)
664
- resolve(text);
665
- });
666
- });
667
- // Session B must respond before the deadline (session A is still blocked indefinitely)
668
- const result = await Promise.race([
669
- sessionBDone,
670
- new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A — global queue bug reproduced")), 300)),
671
- ]);
672
- assert.equal(result, "session B complete", "session B must resolve independently of blocked session A");
673
- assert.equal(sessionACallbacks.length, 0, "session A must still be pending (no response yet)");
674
- // Clean up: reject session A's pending promise so the test can finish
675
- state.pendingReject?.(new Error("test teardown"));
676
- await new Promise((resolve) => setTimeout(resolve, 10));
677
- });
678
- // ---------------------------------------------------------------------------
679
594
  // #81 — task spawn args (name/description) must win over SDK agent_type fields
680
595
  // Root cause: subagent.started only carries agent_type boilerplate. The actual
681
596
  // spawn params (name, description) arrive earlier via tool.execution_start for
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Maintains a `Map<taskId, RingBuffer<TaskEvent>>` in the daemon process,
5
5
  * capped at 500 events per task. Subscribes to the process-wide
6
- * `squadEventBus` for `session:tool_call` events so the ring buffer stays
6
+ * `agentEventBus` for `session:tool_call` events so the ring buffer stays
7
7
  * in sync without the caller needing to wire anything extra.
8
8
  *
9
9
  * Consumers:
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * @module copilot/task-event-log
23
23
  */
24
- import { squadEventBus } from "./squad-event-bus.js";
24
+ import { agentEventBus } from "./agent-event-bus.js";
25
25
  import { RingBuffer } from "./ring-buffer.js";
26
26
  // ---------------------------------------------------------------------------
27
27
  // Ring buffer — re-export so existing imports stay compatible
@@ -59,11 +59,11 @@ function notifyListeners(taskId, event) {
59
59
  // Public API
60
60
  // ---------------------------------------------------------------------------
61
61
  /**
62
- * Subscribe to the squadEventBus and maintain the ring buffer.
62
+ * Subscribe to the agent event bus and maintain the ring buffer.
63
63
  * Call once from initOrchestrator(). Returns an unsub / cleanup function.
64
64
  */
65
65
  export function initTaskEventLog() {
66
- const unsubToolCall = squadEventBus.subscribe("session:tool_call", (event) => {
66
+ const unsubToolCall = agentEventBus.subscribe("session:tool_call", (event) => {
67
67
  const taskId = event.sessionId;
68
68
  if (!taskId)
69
69
  return;
@@ -81,7 +81,7 @@ export function initTaskEventLog() {
81
81
  buf.push(taskEvent);
82
82
  notifyListeners(taskId, taskEvent);
83
83
  });
84
- const unsubDestroyed = squadEventBus.subscribe("session:destroyed", (event) => {
84
+ const unsubDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
85
85
  if (event.sessionId)
86
86
  clearTaskLog(event.sessionId);
87
87
  });