chapterhouse 0.9.2 → 0.11.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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
  104. package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
  106. package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
  107. package/web/dist/assets/icon-acolyte-cream.svg +10 -0
  108. package/web/dist/assets/icon-acolyte-dark.svg +10 -0
  109. package/web/dist/assets/icon-acolyte-gold.svg +10 -0
  110. package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
  111. package/web/dist/assets/icon-acolyte-lit.svg +10 -0
  112. package/web/dist/assets/icon-acolyte-mono.svg +10 -0
  113. package/web/dist/assets/icon-acolyte.png +0 -0
  114. package/web/dist/assets/icon-acolyte.svg +10 -0
  115. package/web/dist/assets/index-BGLL9pgM.css +10 -0
  116. package/web/dist/assets/index-KFX8UmOb.js +250 -0
  117. package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
  118. package/web/dist/index.html +6 -4
  119. package/web/dist/assets/index-5kz9aRU9.css +0 -10
  120. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  121. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -10,6 +10,10 @@ function createFakeClient(state) {
10
10
  this.options = options;
11
11
  }
12
12
  on(eventName, handler) {
13
+ state.sessionOnCalls++;
14
+ if (state.sessionOnThrowAt === state.sessionOnCalls) {
15
+ throw new Error(`session.on failed for ${eventName}`);
16
+ }
13
17
  const handlers = this.listeners.get(eventName) || [];
14
18
  handlers.push(handler);
15
19
  this.listeners.set(eventName, handlers);
@@ -67,6 +71,7 @@ function createFakeClient(state) {
67
71
  const session = new FakeSession(options);
68
72
  state.lastSession = {
69
73
  emit: (eventName, data) => session.emit(eventName, data),
74
+ listenerCount: (eventName) => session.listeners.get(eventName)?.length ?? 0,
70
75
  };
71
76
  return session;
72
77
  },
@@ -116,6 +121,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
116
121
  ],
117
122
  sendResult: "Finished successfully",
118
123
  promptMemoryContexts: [],
124
+ loggerWarnings: [],
125
+ loggerErrors: [],
126
+ sessionOnCalls: 0,
119
127
  taskEvents: new Map(),
120
128
  projectRegistry: {},
121
129
  resolveProjectArgs: [],
@@ -130,6 +138,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
130
138
  housekeepingRuns: [],
131
139
  housekeepingInFlight: false,
132
140
  activeScope: makeScope(1, "chapterhouse", "Chapterhouse", "Core Chapterhouse work."),
141
+ stopClassifierCalls: 0,
133
142
  wikiPages: [{ title: "Chapterhouse", summary: "wiki summary", last_updated: "2026-05-13" }],
134
143
  ...overrides,
135
144
  };
@@ -153,6 +162,19 @@ async function loadOrchestratorModule(t, overrides = {}) {
153
162
  },
154
163
  },
155
164
  });
165
+ t.mock.module("../util/logger.js", {
166
+ namedExports: {
167
+ childLogger: () => ({
168
+ info: () => { },
169
+ warn: (...args) => {
170
+ state.loggerWarnings.push(args);
171
+ },
172
+ error: (...args) => {
173
+ state.loggerErrors.push(args);
174
+ },
175
+ }),
176
+ },
177
+ });
156
178
  t.mock.module("../config.js", {
157
179
  namedExports: {
158
180
  config: state.config,
@@ -417,8 +439,13 @@ async function loadOrchestratorModule(t, overrides = {}) {
417
439
  deleteState: (key) => {
418
440
  state.store.delete(key);
419
441
  },
420
- getCopilotSession: (_sessionKey) => undefined,
421
- upsertCopilotSession: (_sessionKey, _mode, _copilotSessionId, _projectRoot, _model) => { },
442
+ getCopilotSession: (sessionKey) => state.copilotSessions?.get(sessionKey),
443
+ upsertCopilotSession: (sessionKey, _mode, copilotSessionId, _projectRoot, model) => {
444
+ state.copilotSessions?.set(sessionKey, { copilotSessionId, model });
445
+ },
446
+ deleteCopilotSession: (sessionKey) => {
447
+ state.copilotSessions?.delete(sessionKey);
448
+ },
422
449
  getTaskSessionKey: (taskId) => state.taskSessionKeys?.get(taskId) ?? "default",
423
450
  getDb: () => ({
424
451
  prepare: (sql) => ({
@@ -485,6 +512,13 @@ async function loadOrchestratorModule(t, overrides = {}) {
485
512
  },
486
513
  },
487
514
  });
515
+ t.mock.module("./classifier.js", {
516
+ namedExports: {
517
+ stopClassifier: () => {
518
+ state.stopClassifierCalls++;
519
+ },
520
+ },
521
+ });
488
522
  t.mock.module("./agents.js", {
489
523
  namedExports: {
490
524
  loadAgents: () => {
@@ -904,8 +938,8 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
904
938
  assert.equal(final, "All green");
905
939
  assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] Summarize the deployment" }]);
906
940
  assert.deepEqual(state.routerArgs, [["[via web] Summarize the deployment", "claude-sonnet-4.6", []]]);
907
- assert.deepEqual(orchestrator.getCurrentAuthenticatedUser(), user);
908
- assert.equal(orchestrator.getCurrentAuthorizationHeader(), "Bearer token-123");
941
+ assert.equal(orchestrator.getCurrentAuthenticatedUser(), undefined);
942
+ assert.equal(orchestrator.getCurrentAuthorizationHeader(), undefined);
909
943
  assert.deepEqual(orchestrator.getLastAuthenticatedUser(), user);
910
944
  assert.deepEqual(orchestrator.getLastRouteResult(), {
911
945
  model: "claude-sonnet-4.6",
@@ -1438,6 +1472,47 @@ test("feedAgentResult emits a delta even when the agent result is empty", async
1438
1472
  assert.equal(deltas.length, 1);
1439
1473
  assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
1440
1474
  });
1475
+ test("feedAgentResult logs fire-and-forget acknowledgement failures instead of leaking rejections", async (t) => {
1476
+ const { orchestrator, state } = await loadOrchestratorModule(t, {
1477
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
1478
+ });
1479
+ const unhandled = [];
1480
+ const onUnhandledRejection = (reason) => {
1481
+ unhandled.push(reason);
1482
+ };
1483
+ process.on("unhandledRejection", onUnhandledRejection);
1484
+ t.after(() => {
1485
+ process.off("unhandledRejection", onUnhandledRejection);
1486
+ });
1487
+ t.mock.method(global, "setImmediate", (() => {
1488
+ throw new Error("setImmediate exploded");
1489
+ }));
1490
+ orchestrator.feedAgentResult("task-uninitialized", "coder", "done");
1491
+ await new Promise((resolve) => setTimeout(resolve, 0));
1492
+ assert.deepEqual(unhandled, []);
1493
+ assert.ok(state.loggerErrors.some((entry) => entry[1] === "unhandled rejection in feedAgentResult"), "feedAgentResult should log fire-and-forget failures");
1494
+ });
1495
+ test("sendToOrchestrator logs top-level async callback failures on error delivery", async (t) => {
1496
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1497
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
1498
+ createSessionError: "fatal: SDK host permanently unavailable",
1499
+ });
1500
+ const unhandled = [];
1501
+ const onUnhandledRejection = (reason) => {
1502
+ unhandled.push(reason);
1503
+ };
1504
+ process.on("unhandledRejection", onUnhandledRejection);
1505
+ t.after(() => {
1506
+ process.off("unhandledRejection", onUnhandledRejection);
1507
+ });
1508
+ await orchestrator.initOrchestrator(client);
1509
+ orchestrator.sendToOrchestrator("hello", { type: "background" }, () => {
1510
+ throw new Error("callback exploded");
1511
+ });
1512
+ await new Promise((resolve) => setTimeout(resolve, 20));
1513
+ assert.deepEqual(unhandled, []);
1514
+ assert.ok(state.loggerErrors.some((entry) => entry[1] === "unhandled rejection in sendToOrchestrator"), "sendToOrchestrator should log top-level async failures");
1515
+ });
1441
1516
  test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
1442
1517
  const { orchestrator, client } = await loadOrchestratorModule(t, {
1443
1518
  config: {
@@ -1698,10 +1773,54 @@ test("shutdownAgents disconnects all sessions, clears maps, and clears active ta
1698
1773
  await orchestrator.shutdownAgents();
1699
1774
  assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + chat:1)");
1700
1775
  assert.equal(state.clearActiveTasksCalls, 1, "clearActiveTasks must be called exactly once");
1776
+ assert.equal(state.stopClassifierCalls, 1, "classifier session must be stopped during shutdown");
1701
1777
  // Re-init after shutdown must create a fresh session (proves sessionMap was cleared)
1702
1778
  await orchestrator.initOrchestrator(client);
1703
1779
  assert.ok(state.createSessionCalls.length > sessionsAfterInit + 1, "re-init after shutdown must create a new session (not reuse stale sessionMap entry)");
1704
1780
  });
1781
+ test("failed persistent session resume clears the stored session id even when replacement creation fails", async (t) => {
1782
+ const copilotSessions = new Map([
1783
+ ["chat:stale", { copilotSessionId: "stale-session", model: "claude-sonnet-4.6" }],
1784
+ ]);
1785
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1786
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
1787
+ copilotSessions,
1788
+ createSessionError: "replacement create failed",
1789
+ });
1790
+ await orchestrator.initOrchestrator(client);
1791
+ let final = "";
1792
+ orchestrator.sendToOrchestrator("hello", { type: "background", sessionKey: "chat:stale" }, (text, done) => {
1793
+ if (done)
1794
+ final = text;
1795
+ });
1796
+ await new Promise((resolve) => setTimeout(resolve, 10));
1797
+ assert.deepEqual(state.resumeSessionCalls.map((call) => call.savedId), ["stale-session"]);
1798
+ assert.match(final, /replacement create failed/);
1799
+ assert.equal(copilotSessions.has("chat:stale"), false, "failed resume must remove the stale persisted session id");
1800
+ });
1801
+ test("subagent completion logs a warning when the persisted result is truncated", async (t) => {
1802
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1803
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false },
1804
+ sendResult: "__PENDING__",
1805
+ });
1806
+ await orchestrator.initOrchestrator(client);
1807
+ orchestrator.sendToOrchestrator("run a subagent", { type: "background", sessionKey: "chat:long-result" }, () => { });
1808
+ await new Promise((resolve) => setTimeout(resolve, 10));
1809
+ const longContent = "x".repeat(10_001);
1810
+ state.lastSession?.emit("subagent.started", {
1811
+ toolCallId: "task-long",
1812
+ agentName: "coder",
1813
+ agentDisplayName: "Coder",
1814
+ agentDescription: "Writes code",
1815
+ });
1816
+ state.lastSession?.emit("subagent.completed", {
1817
+ toolCallId: "task-long",
1818
+ result: { content: longContent },
1819
+ });
1820
+ assert.ok(state.loggerWarnings.some((args) => JSON.stringify(args).includes("truncated")), "long subagent result truncation should be logged");
1821
+ state.pendingReject?.(new Error("test teardown"));
1822
+ await new Promise((resolve) => setTimeout(resolve, 10));
1823
+ });
1705
1824
  // ---------------------------------------------------------------------------
1706
1825
  // feedAgentResult routes to the correct non-default session
1707
1826
  // ---------------------------------------------------------------------------
@@ -1762,6 +1881,21 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
1762
1881
  // agent_tasks, so agent dispatches were invisible.
1763
1882
  // Fix: unconditional DB subscriptions in executeOnSession write/update rows.
1764
1883
  // ---------------------------------------------------------------------------
1884
+ test("executeOnSession unsubscribes partially registered listeners when setup throws", async (t) => {
1885
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1886
+ sendResult: "__PENDING__",
1887
+ sessionOnThrowAt: 2,
1888
+ });
1889
+ await orchestrator.initOrchestrator(client);
1890
+ const received = [];
1891
+ orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, (text, done) => {
1892
+ received.push({ text, done });
1893
+ });
1894
+ await new Promise((resolve) => setTimeout(resolve, 20));
1895
+ assert.ok(state.lastSession, "FakeSession should have been created");
1896
+ assert.equal(state.lastSession.listenerCount("tool.execution_start"), 0, "setup failures must clean up listeners that were already registered");
1897
+ assert.ok(received.some((entry) => entry.done && entry.text.startsWith("Error:")), "setup failure should still surface as a turn error");
1898
+ });
1765
1899
  test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", async (t) => {
1766
1900
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1767
1901
  sendResult: "__PENDING__",
@@ -2070,6 +2204,7 @@ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn",
2070
2204
  await orchestrator.interruptCurrentTurn("default", "replacement request", { type: "background" }, (text, done) => { if (done)
2071
2205
  secondResults.push(text); }, undefined, undefined, (abortedId) => { interruptedTurnId = abortedId; });
2072
2206
  assert.equal(state.abortCalls, 1, "abort must be called exactly once");
2207
+ assert.ok(interruptedTurnId, "interrupt callback should report the aborted turn id");
2073
2208
  // Resolve the pending session so the replacement turn can complete
2074
2209
  state.pendingReject?.(new Error("aborted"));
2075
2210
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -1,5 +1,6 @@
1
+ import { config } from "../config.js";
1
2
  export function getCurrentDateSystemLine() {
2
- if (process.env.CHAPTERHOUSE_INJECT_DATE === "0") {
3
+ if (!config.injectDate) {
3
4
  return undefined;
4
5
  }
5
6
  const now = new Date();
@@ -15,30 +15,26 @@
15
15
  // CHAPTERHOUSE_SESSION_MAX_ACTIVE (default: 20)
16
16
  // ---------------------------------------------------------------------------
17
17
  import { childLogger } from "../util/logger.js";
18
+ import { config } from "../config.js";
18
19
  const log = childLogger("session-manager");
19
20
  // ---------------------------------------------------------------------------
20
21
  // Env-configurable eviction parameters
21
22
  // ---------------------------------------------------------------------------
22
- const DEFAULT_SESSION_IDLE_TTL_MS = 1_800_000; // 30 min
23
- export const SESSION_IDLE_TTL_MS = (() => {
24
- const env = process.env.CHAPTERHOUSE_SESSION_IDLE_TTL_MS;
25
- if (env) {
26
- const parsed = parseInt(env, 10);
27
- if (!isNaN(parsed) && parsed > 0)
28
- return parsed;
29
- }
30
- return DEFAULT_SESSION_IDLE_TTL_MS;
31
- })();
32
- const DEFAULT_SESSION_MAX_ACTIVE = 20;
33
- export const SESSION_MAX_ACTIVE = (() => {
34
- const env = process.env.CHAPTERHOUSE_SESSION_MAX_ACTIVE;
35
- if (env) {
36
- const parsed = parseInt(env, 10);
37
- if (!isNaN(parsed) && parsed > 0)
38
- return parsed;
39
- }
40
- return DEFAULT_SESSION_MAX_ACTIVE;
41
- })();
23
+ export const SESSION_IDLE_TTL_MS = config.sessionIdleTtlMs;
24
+ export const SESSION_MAX_ACTIVE = config.sessionMaxActive;
25
+ export class SessionCapacityError extends Error {
26
+ statusCode = 503;
27
+ expose = true;
28
+ activeSessions;
29
+ maxActive;
30
+ constructor(sessionKey, activeSessions, maxActive) {
31
+ super(`Cannot create session '${sessionKey}': maximum active sessions (${maxActive}) reached and no idle session is available to evict.`);
32
+ this.name = "SessionCapacityError";
33
+ Object.setPrototypeOf(this, new.target.prototype);
34
+ this.activeSessions = activeSessions;
35
+ this.maxActive = maxActive;
36
+ }
37
+ }
42
38
  // ---------------------------------------------------------------------------
43
39
  // SessionManager — owns one session key
44
40
  // ---------------------------------------------------------------------------
@@ -310,8 +306,10 @@ export class SessionRegistry {
310
306
  const existing = this.managers.get(sessionKey);
311
307
  if (existing)
312
308
  return existing;
313
- if (this.nonPersistentSize() >= this.options.maxActive) {
314
- this.evictLRU();
309
+ if (!this.isPersistentSessionKey(sessionKey) && this.nonPersistentSize() >= this.options.maxActive) {
310
+ if (!this.evictLRU()) {
311
+ throw new SessionCapacityError(sessionKey, this.nonPersistentSize(), this.options.maxActive);
312
+ }
315
313
  }
316
314
  const manager = this.createManager(sessionKey);
317
315
  this.managers.set(sessionKey, manager);
@@ -384,16 +382,20 @@ export class SessionRegistry {
384
382
  .sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
385
383
  if (evictable.length === 0) {
386
384
  log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
387
- return;
385
+ return false;
388
386
  }
389
387
  const [sessionKey, manager] = evictable[0];
390
388
  this.managers.delete(sessionKey);
391
389
  void manager.evict("lru-bumped");
392
390
  log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
391
+ return true;
393
392
  }
394
393
  nonPersistentSize() {
395
394
  return [...this.managers.values()].filter((manager) => !manager.isPersistent).length;
396
395
  }
396
+ isPersistentSessionKey(sessionKey) {
397
+ return sessionKey.startsWith("agent:");
398
+ }
397
399
  /** Shut down all sessions. Stops the eviction timer and disconnects every session. */
398
400
  async shutdown() {
399
401
  this.stopEvictionTimer();
@@ -197,6 +197,41 @@ test("SessionRegistry: LRU eviction fires when at maxActive capacity", async ()
197
197
  assert.ok(disconnectLog.includes("s1"), "s1 (oldest) should be LRU-evicted");
198
198
  assert.ok(!registry.get("s1"), "s1 should be removed from registry");
199
199
  });
200
+ test("SessionRegistry: getOrCreate rejects new non-persistent sessions when cap is full and all sessions are busy", async () => {
201
+ let unblock1;
202
+ let unblock2;
203
+ let callCount = 0;
204
+ const { registry } = (() => {
205
+ const r = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 2 }, (sk) => {
206
+ const t = makeFakeSession();
207
+ const worker = () => {
208
+ callCount++;
209
+ return new Promise((res) => {
210
+ if (callCount === 1)
211
+ unblock1 = () => res("one");
212
+ else
213
+ unblock2 = () => res("two");
214
+ });
215
+ };
216
+ return new SessionManager(sk, worker, factory(t.session));
217
+ });
218
+ return { registry: r };
219
+ })();
220
+ const first = registry.getOrCreate("s1");
221
+ const second = registry.getOrCreate("s2");
222
+ first.enqueue(makeDeferred().item);
223
+ second.enqueue(makeDeferred().item);
224
+ await new Promise((r) => setTimeout(r, 0));
225
+ assert.throws(() => registry.getOrCreate("s3"), (error) => error instanceof Error
226
+ && error.name === "SessionCapacityError"
227
+ && error.statusCode === 503
228
+ && /maximum active sessions/i.test(error.message));
229
+ assert.equal(registry.size(), 2, "registry must not exceed maxActive");
230
+ assert.equal(registry.get("s3"), undefined);
231
+ unblock1();
232
+ unblock2();
233
+ await new Promise((r) => setTimeout(r, 0));
234
+ });
200
235
  test("SessionRegistry: explicit close evicts an idle session", async () => {
201
236
  const { registry, disconnectLog } = makeRegistry();
202
237
  const m = registry.getOrCreate("my-session");
@@ -208,7 +243,6 @@ test("SessionRegistry: explicit close evicts an idle session", async () => {
208
243
  assert.ok(disconnectLog.includes("my-session"), "session should be disconnected");
209
244
  });
210
245
  test("SessionRegistry: close is deferred when session is processing", async () => {
211
- const { registry, disconnectLog } = makeRegistry();
212
246
  let unblock;
213
247
  // Replace the factory with a blocking one
214
248
  const { registry: reg2, disconnectLog: dl2 } = (() => {
@@ -1,5 +1,5 @@
1
1
  import { config } from "../config.js";
2
- import { sendToOrchestrator, getCurrentAuthorizationHeader, getCurrentAuthenticatedUser, getLastAuthenticatedUser, } from "./orchestrator.js";
2
+ import { sendToOrchestrator, getCurrentAuthorizationHeader, getCurrentAuthenticatedUser, } from "./orchestrator.js";
3
3
  import { TeamPushClient } from "../integrations/team-push.js";
4
4
  import { getMyOkrsSummary } from "./tools.js";
5
5
  import { readPage, writePage } from "../wiki/fs.js";
@@ -106,7 +106,7 @@ function formatDate(now) {
106
106
  return `${year}-${month}-${day}`;
107
107
  }
108
108
  async function defaultGetMyOkrs(period) {
109
- const getCurrentUser = () => getCurrentAuthenticatedUser() ?? getLastAuthenticatedUser();
109
+ const getCurrentUser = () => getCurrentAuthenticatedUser();
110
110
  return await getMyOkrsSummary({
111
111
  createTeamPushClient: () => new TeamPushClient({
112
112
  getAuthorizationHeader: getCurrentAuthorizationHeader,
@@ -34,6 +34,7 @@ export const RING_BUFFER_CAPACITY = 500;
34
34
  // ---------------------------------------------------------------------------
35
35
  const taskBuffers = new Map();
36
36
  const taskListeners = new Map();
37
+ let eventBusCleanup;
37
38
  // ---------------------------------------------------------------------------
38
39
  // Internal helpers
39
40
  // ---------------------------------------------------------------------------
@@ -64,6 +65,9 @@ function notifyListeners(taskId, event) {
64
65
  * Call once from initOrchestrator(). Returns an unsub / cleanup function.
65
66
  */
66
67
  export function initTaskEventLog() {
68
+ if (eventBusCleanup) {
69
+ return eventBusCleanup;
70
+ }
67
71
  const unsubToolCall = agentEventBus.subscribe("session:tool_call", (event) => {
68
72
  const taskId = event.sessionId;
69
73
  if (!taskId)
@@ -91,10 +95,12 @@ export function initTaskEventLog() {
91
95
  if (event.sessionId)
92
96
  clearTaskLog(event.sessionId);
93
97
  });
94
- return () => {
98
+ eventBusCleanup = () => {
95
99
  unsubToolCall();
96
100
  unsubDestroyed();
101
+ eventBusCleanup = undefined;
97
102
  };
103
+ return eventBusCleanup;
98
104
  }
99
105
  /**
100
106
  * Return all ring-buffered events for a task, optionally filtered to seq > afterSeq.
@@ -128,6 +128,19 @@ describe("task event log — bus-wired", () => {
128
128
  assert.equal(events[0].seq, 1);
129
129
  clearTaskLog("task-001");
130
130
  });
131
+ it("does not double-subscribe when initialized twice", () => {
132
+ const secondShutdown = initTaskEventLog();
133
+ try {
134
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-idempotent", kind: "tool_start", seq: 1, toolName: "view" }));
135
+ const events = getTaskLogEvents("task-idempotent");
136
+ assert.equal(events.length, 1);
137
+ assert.equal(events[0].toolName, "view");
138
+ }
139
+ finally {
140
+ secondShutdown();
141
+ clearTaskLog("task-idempotent");
142
+ }
143
+ });
131
144
  it("accumulates multiple events in order", () => {
132
145
  agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
133
146
  agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));