chapterhouse 0.4.1 → 0.4.3

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.
@@ -65,7 +65,7 @@ test("runEndOfTaskMemoryHook does nothing when CHAPTERHOUSE_MEMORY_EOT_HOOK_ENAB
65
65
  },
66
66
  });
67
67
  assert.equal(llmCalls, 0);
68
- assert.equal(listObservations({ scope_id: chapterhouse.id }).length, 0);
68
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Disabled hooks must not persist memory."), false);
69
69
  const row = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
70
70
  assert.equal(row.status, "pending");
71
71
  });
@@ -155,6 +155,85 @@ test("runEndOfTaskMemoryHook accepts matching proposals, rejects others from the
155
155
  auto_accept: true,
156
156
  }]);
157
157
  });
158
+ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items", async () => {
159
+ const { dbModule, memoryModule, eotModule } = await loadModules("action-item-accept");
160
+ const db = dbModule.getDb();
161
+ const getScope = getFunction(memoryModule, "getScope");
162
+ const listActionItems = getFunction(memoryModule, "listActionItems");
163
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
164
+ const chapterhouse = getScope("chapterhouse");
165
+ assert.ok(chapterhouse);
166
+ const inserted = db.prepare(`
167
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
168
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item', 'pending')
169
+ `).run(chapterhouse.id, JSON.stringify({
170
+ kind: "action_item",
171
+ payload: {
172
+ title: "Migrate feature ideas",
173
+ detail: "Move feature-ideas.md into mem_action_items.",
174
+ source: "subagent_proposal",
175
+ },
176
+ confidence: 0.9,
177
+ }));
178
+ await runEndOfTaskMemoryHook({
179
+ taskId: "task-eot-action-item",
180
+ finalResult: "Completed and proposed a follow-up action item.",
181
+ copilotClient: {},
182
+ callLLM: async () => JSON.stringify({
183
+ decisions: [{
184
+ proposal_id: Number(inserted.lastInsertRowid),
185
+ decision: "accept",
186
+ reason: "Concrete follow-up.",
187
+ }],
188
+ implicit_memories: [],
189
+ }),
190
+ });
191
+ const actionItems = listActionItems({ scope_id: chapterhouse.id });
192
+ assert.equal(actionItems.some((item) => item.title === "Migrate feature ideas"
193
+ && item.detail === "Move feature-ideas.md into mem_action_items."), true);
194
+ const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
195
+ assert.equal(inbox.status, "accepted");
196
+ });
197
+ test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
198
+ const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
199
+ const db = dbModule.getDb();
200
+ const getScope = getFunction(memoryModule, "getScope");
201
+ const listEntities = getFunction(memoryModule, "listEntities");
202
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
203
+ const chapterhouse = getScope("chapterhouse");
204
+ assert.ok(chapterhouse);
205
+ const inserted = db.prepare(`
206
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
207
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-entity', 'pending')
208
+ `).run(chapterhouse.id, JSON.stringify({
209
+ kind: "entity",
210
+ payload: {
211
+ name: "truenas",
212
+ entity_kind: "host",
213
+ summary: "NAS host used by Bellonda.",
214
+ },
215
+ confidence: 0.9,
216
+ }));
217
+ await runEndOfTaskMemoryHook({
218
+ taskId: "task-eot-entity",
219
+ finalResult: "Completed and proposed a durable host entity.",
220
+ copilotClient: {},
221
+ callLLM: async () => JSON.stringify({
222
+ decisions: [{
223
+ proposal_id: Number(inserted.lastInsertRowid),
224
+ decision: "accept",
225
+ reason: "Durable entity.",
226
+ }],
227
+ implicit_memories: [],
228
+ }),
229
+ });
230
+ const entities = listEntities({ scope_id: chapterhouse.id, kind: "host" });
231
+ assert.equal(entities.some((entity) => entity.name === "truenas"
232
+ && entity.kind === "host"
233
+ && entity.summary === "NAS host used by Bellonda."), true);
234
+ const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
235
+ assert.equal(inbox.status, "accepted");
236
+ });
158
237
  test("runEndOfTaskMemoryHook leaves reviewed proposals pending when CHAPTERHOUSE_MEMORY_AUTO_ACCEPT=0", async () => {
159
238
  process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT = "0";
160
239
  const { dbModule, memoryModule, eotModule } = await loadModules("pending");
@@ -185,7 +264,7 @@ test("runEndOfTaskMemoryHook leaves reviewed proposals pending when CHAPTERHOUSE
185
264
  implicit_memories: [],
186
265
  }),
187
266
  });
188
- assert.equal(listObservations({ scope_id: chapterhouse.id }).length, 0);
267
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Review can approve this, but auto-accept is disabled."), false);
189
268
  const row = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
190
269
  assert.equal(row.status, "pending");
191
270
  });
@@ -214,7 +293,7 @@ test("runEndOfTaskMemoryHook rejects pending same-task proposals omitted by the
214
293
  implicit_memories: [],
215
294
  }),
216
295
  });
217
- assert.equal(listObservations({ scope_id: chapterhouse.id }).length, 0);
296
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Reviewer omissions should not silently leave proposals pending."), false);
218
297
  const row = db.prepare(`
219
298
  SELECT status, resolution_reason
220
299
  FROM mem_inbox
@@ -260,4 +339,33 @@ test("runEndOfTaskMemoryHook can persist implicit extracted memories that were n
260
339
  auto_accept: true,
261
340
  }]);
262
341
  });
342
+ test("runEndOfTaskMemoryHook accepts implicit entity memories with entity_kind", async () => {
343
+ const { memoryModule, eotModule } = await loadModules("implicit-entity");
344
+ const getScope = getFunction(memoryModule, "getScope");
345
+ const listEntities = getFunction(memoryModule, "listEntities");
346
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
347
+ const chapterhouse = getScope("chapterhouse");
348
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
349
+ await runEndOfTaskMemoryHook({
350
+ taskId: "task-eot-implicit-entity",
351
+ finalResult: "The subagent discovered a durable host entity while finishing the task.",
352
+ copilotClient: {},
353
+ callLLM: async () => JSON.stringify({
354
+ decisions: [],
355
+ implicit_memories: [{
356
+ kind: "entity",
357
+ scope_slug: "chapterhouse",
358
+ payload: {
359
+ name: "synology",
360
+ entity_kind: "host",
361
+ summary: "NAS host used by Bellonda.",
362
+ },
363
+ }],
364
+ }),
365
+ });
366
+ const entities = listEntities({ scope_id: chapterhouse.id, kind: "host" });
367
+ assert.equal(entities.some((entity) => entity.name === "synology"
368
+ && entity.kind === "host"
369
+ && entity.summary === "NAS host used by Bellonda."), true);
370
+ });
263
371
  //# sourceMappingURL=eot.test.js.map
@@ -2,6 +2,7 @@ import { getDb } from "../store/db.js";
2
2
  import { getActiveScope } from "./active-scope.js";
3
3
  import { getScope } from "./scopes.js";
4
4
  const HOT_TIER_LIMIT = 30;
5
+ const HOT_TIER_ACTION_ITEM_LIMIT = 10;
5
6
  function toEntity(row) {
6
7
  return {
7
8
  id: row.id,
@@ -44,6 +45,27 @@ function toDecision(row) {
44
45
  createdAt: row.created_at,
45
46
  };
46
47
  }
48
+ function toActionItem(row) {
49
+ return {
50
+ id: row.id,
51
+ scopeId: row.scope_id,
52
+ entityId: row.entity_id ?? undefined,
53
+ title: row.title,
54
+ detail: row.detail ?? undefined,
55
+ status: row.status,
56
+ dueAt: row.due_at ?? undefined,
57
+ snoozeUntil: row.snooze_until ?? undefined,
58
+ source: row.source ?? undefined,
59
+ tier: row.tier,
60
+ tierPinnedAt: row.tier_pinned_at ?? undefined,
61
+ tierReason: row.tier_reason ?? undefined,
62
+ lastRecalledAt: row.last_recalled_at ?? undefined,
63
+ createdAt: row.created_at,
64
+ updatedAt: row.updated_at,
65
+ resolvedAt: row.resolved_at ?? undefined,
66
+ resolutionReason: row.resolution_reason ?? undefined,
67
+ };
68
+ }
47
69
  function escapeXmlText(value) {
48
70
  return value
49
71
  .replaceAll("&", "&")
@@ -108,6 +130,27 @@ function loadHotDecisions(scopeId, options) {
108
130
  `).all(scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, HOT_TIER_LIMIT);
109
131
  return rows.map((row) => ({ ...toDecision(row), sortKey: row.decided_at }));
110
132
  }
133
+ function loadOpenActionItems(scopeId) {
134
+ const rows = getDb().prepare(`
135
+ SELECT
136
+ id, scope_id, entity_id, title, detail, status, due_at, snooze_until, source,
137
+ created_at, updated_at, resolved_at, resolution_reason, tier, tier_pinned_at,
138
+ tier_reason, last_recalled_at
139
+ FROM mem_action_items
140
+ WHERE scope_id = ?
141
+ AND (
142
+ status = 'open'
143
+ OR (status = 'snoozed' AND snooze_until IS NOT NULL AND datetime(snooze_until) <= datetime('now'))
144
+ )
145
+ ORDER BY
146
+ CASE WHEN due_at IS NULL THEN 1 ELSE 0 END ASC,
147
+ datetime(due_at) ASC,
148
+ datetime(created_at) DESC,
149
+ id DESC
150
+ LIMIT ?
151
+ `).all(scopeId, HOT_TIER_ACTION_ITEM_LIMIT);
152
+ return rows.map(toActionItem);
153
+ }
111
154
  export function getHotTierEntries(scope_id, options = {}) {
112
155
  const scope = getHotTierScope(scope_id);
113
156
  if (!scope) {
@@ -116,6 +159,7 @@ export function getHotTierEntries(scope_id, options = {}) {
116
159
  entities: [],
117
160
  observations: [],
118
161
  decisions: [],
162
+ actionItems: [],
119
163
  };
120
164
  }
121
165
  const merged = [
@@ -130,13 +174,17 @@ export function getHotTierEntries(scope_id, options = {}) {
130
174
  entities: merged.filter((entry) => entry.type === "entity"),
131
175
  observations: merged.filter((entry) => entry.type === "observation"),
132
176
  decisions: merged.filter((entry) => entry.type === "decision"),
177
+ actionItems: loadOpenActionItems(scope.id),
133
178
  };
134
179
  }
135
180
  export function renderHotTierXML(entries) {
136
181
  if (!entries.scope) {
137
182
  return "";
138
183
  }
139
- if (entries.entities.length === 0 && entries.observations.length === 0 && entries.decisions.length === 0) {
184
+ if (entries.entities.length === 0
185
+ && entries.observations.length === 0
186
+ && entries.decisions.length === 0
187
+ && entries.actionItems.length === 0) {
140
188
  return "";
141
189
  }
142
190
  const observationsByEntity = new Map();
@@ -174,6 +222,17 @@ export function renderHotTierXML(entries) {
174
222
  const truncated = truncateObservation(observation.content);
175
223
  lines.push(` <observation id="observation-${observation.id}" tier="${escapeXmlAttr(observation.tier)}" confidence="${observation.confidence}" created_at="${escapeXmlAttr(observation.createdAt)}"${truncated.truncated ? ` truncated="true"` : ""}>`, ` ${escapeXmlText(truncated.content)}`, " </observation>");
176
224
  }
225
+ if (entries.actionItems.length > 0) {
226
+ lines.push(" <action_items>");
227
+ for (const item of entries.actionItems) {
228
+ lines.push(` <action_item id="action-item-${item.id}" status="${escapeXmlAttr(item.status)}" created_at="${escapeXmlAttr(item.createdAt)}"${item.dueAt ? ` due_at="${escapeXmlAttr(item.dueAt)}"` : ""}>`, ` <title>${escapeXmlText(item.title)}</title>`);
229
+ if (item.detail) {
230
+ lines.push(` <detail>${escapeXmlText(item.detail)}</detail>`);
231
+ }
232
+ lines.push(" </action_item>");
233
+ }
234
+ lines.push(" </action_items>");
235
+ }
177
236
  lines.push("</memory_context>");
178
237
  return `${lines.join("\n")}\n`;
179
238
  }
@@ -41,6 +41,7 @@ test("renderHotTierForActiveScope returns an empty string when no active scope i
41
41
  entities: [],
42
42
  observations: [],
43
43
  decisions: [],
44
+ actionItems: [],
44
45
  });
45
46
  });
46
47
  test("renderHotTierXML escapes content, merges recency across stores, and enforces the 30-entry cap", async () => {
@@ -130,6 +131,43 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
130
131
  assert.match(xml, /Chapterhouse hot entry/);
131
132
  assert.doesNotMatch(xml, /Team-only hot entry/);
132
133
  });
134
+ test("renderHotTierXML includes open active-scope action items in a bounded action_items block", async () => {
135
+ const { dbModule, memoryModule, hotTierModule } = await loadModules();
136
+ dbModule.getDb();
137
+ const getScope = getFunction(memoryModule, "getScope");
138
+ const recordActionItem = getFunction(memoryModule, "recordActionItem");
139
+ const chapterhouse = getScope("chapterhouse");
140
+ const team = getScope("team");
141
+ assert.ok(chapterhouse);
142
+ assert.ok(team);
143
+ const urgent = recordActionItem({
144
+ scope_id: chapterhouse.id,
145
+ title: "Migrate <feature ideas>",
146
+ detail: "Move feature-ideas.md into memory & keep source links.",
147
+ due_at: "2026-05-14T12:00:00.000Z",
148
+ source: "test",
149
+ });
150
+ recordActionItem({
151
+ scope_id: chapterhouse.id,
152
+ title: "Undated backlog item",
153
+ detail: "Should appear after dated items.",
154
+ source: "test",
155
+ });
156
+ recordActionItem({
157
+ scope_id: team.id,
158
+ title: "Other scope action",
159
+ source: "test",
160
+ });
161
+ const entries = hotTierModule.getHotTierEntries(chapterhouse.id);
162
+ const xml = hotTierModule.renderHotTierXML(entries);
163
+ assert.equal(entries.actionItems.length, 2);
164
+ assert.match(xml, /<action_items>/);
165
+ assert.match(xml, new RegExp(`<action_item[^>]*id="action-item-${urgent.id}"[^>]*status="open"`));
166
+ assert.match(xml, /<title>Migrate &lt;feature ideas&gt;<\/title>/);
167
+ assert.match(xml, /<detail>Move feature-ideas\.md into memory &amp; keep source links\.<\/detail>/);
168
+ assert.ok(xml.indexOf("Migrate &lt;feature ideas&gt;") < xml.indexOf("Undated backlog item"));
169
+ assert.doesNotMatch(xml, /Other scope action/);
170
+ });
133
171
  test("hot-tier entries exclude superseded and archived observations and decisions by default with opt-in inclusion", async () => {
134
172
  const { dbModule, memoryModule, hotTierModule } = await loadModules();
135
173
  const db = dbModule.getDb();
@@ -0,0 +1,152 @@
1
+ import { childLogger } from "../util/logger.js";
2
+ import { isHousekeepingInFlight, runHousekeeping } from "./housekeeping.js";
3
+ export const DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS = 21_600_000;
4
+ export const DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS = 300_000;
5
+ function parseNonNegativeIntegerEnv(name, rawValue, defaultValue) {
6
+ const normalized = rawValue?.trim();
7
+ if (!normalized) {
8
+ return defaultValue;
9
+ }
10
+ const parsed = Number(normalized);
11
+ if (!Number.isInteger(parsed) || parsed < 0) {
12
+ throw new Error(`${name} must be a non-negative integer, got: "${rawValue}"`);
13
+ }
14
+ return parsed;
15
+ }
16
+ function toPassCounts(result) {
17
+ return Object.fromEntries(result.summaries.map((summary) => [
18
+ summary.pass,
19
+ {
20
+ examined: summary.examined,
21
+ modified: summary.modified,
22
+ errors: summary.errors,
23
+ },
24
+ ]));
25
+ }
26
+ export class MemoryHousekeepingScheduler {
27
+ env;
28
+ runHousekeepingImpl;
29
+ log;
30
+ setTimeoutImpl;
31
+ clearTimeoutImpl;
32
+ setIntervalImpl;
33
+ clearIntervalImpl;
34
+ timeoutHandle;
35
+ intervalHandle;
36
+ activeRun;
37
+ running = false;
38
+ started = false;
39
+ constructor(options = {}) {
40
+ this.env = options.env ?? process.env;
41
+ this.runHousekeepingImpl = options.runHousekeeping ?? runHousekeeping;
42
+ this.log = options.log ?? childLogger("memory.housekeeping.scheduler");
43
+ this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
44
+ this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
45
+ this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
46
+ this.clearIntervalImpl = options.clearIntervalImpl ?? ((handle) => clearInterval(handle));
47
+ }
48
+ start() {
49
+ if (this.started) {
50
+ return;
51
+ }
52
+ const intervalMs = parseNonNegativeIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS", this.env.CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS, DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS);
53
+ if (intervalMs === 0) {
54
+ this.log.info({ interval_ms: intervalMs }, "Memory housekeeping scheduler disabled");
55
+ return;
56
+ }
57
+ const initialDelayMs = parseNonNegativeIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS", this.env.CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS, DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS);
58
+ this.started = true;
59
+ this.timeoutHandle = this.setTimeoutImpl(() => {
60
+ this.timeoutHandle = undefined;
61
+ this.startScheduledRun("initial_delay");
62
+ this.intervalHandle = this.setIntervalImpl(() => {
63
+ this.startScheduledRun("interval");
64
+ }, intervalMs);
65
+ this.intervalHandle?.unref?.();
66
+ }, initialDelayMs);
67
+ this.timeoutHandle?.unref?.();
68
+ this.log.info({ interval_ms: intervalMs, initial_delay_ms: initialDelayMs }, "Memory housekeeping scheduler started");
69
+ }
70
+ async stop() {
71
+ if (this.timeoutHandle) {
72
+ this.clearTimeoutImpl(this.timeoutHandle);
73
+ this.timeoutHandle = undefined;
74
+ }
75
+ if (this.intervalHandle) {
76
+ this.clearIntervalImpl(this.intervalHandle);
77
+ this.intervalHandle = undefined;
78
+ }
79
+ this.started = false;
80
+ await this.activeRun;
81
+ }
82
+ startScheduledRun(trigger) {
83
+ const run = this.runScheduledHousekeeping(trigger);
84
+ const tracked = run.finally(() => {
85
+ if (this.activeRun === tracked) {
86
+ this.activeRun = undefined;
87
+ }
88
+ });
89
+ this.activeRun = tracked;
90
+ void this.activeRun;
91
+ }
92
+ async runScheduledHousekeeping(trigger) {
93
+ if (this.running || isHousekeepingInFlight()) {
94
+ this.log.warn({ trigger }, "Memory housekeeping run skipped because a previous run is still active");
95
+ return;
96
+ }
97
+ this.running = true;
98
+ try {
99
+ const result = await this.runHousekeepingImpl({ allScopes: true });
100
+ if (isHousekeepingRunResult(result)) {
101
+ if (isContentionResult(result)) {
102
+ this.log.warn({
103
+ trigger,
104
+ scope_ids: result.scopeIds,
105
+ summaries: result.summaries,
106
+ }, "Memory housekeeping run skipped because a previous run is still active");
107
+ return;
108
+ }
109
+ this.log.info({
110
+ trigger,
111
+ scope_ids: result.scopeIds,
112
+ summaries: result.summaries,
113
+ pass_counts: toPassCounts(result),
114
+ total_examined: result.totalExamined,
115
+ total_modified: result.totalModified,
116
+ duration_ms: result.durationMs,
117
+ }, "Memory housekeeping scheduled run complete");
118
+ }
119
+ else {
120
+ this.log.info({ trigger }, "Memory housekeeping scheduled run complete");
121
+ }
122
+ }
123
+ catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ if (this.log.error) {
126
+ this.log.error({ trigger, err: message }, "Memory housekeeping scheduled run failed");
127
+ }
128
+ else {
129
+ this.log.warn({ trigger, err: message }, "Memory housekeeping scheduled run failed");
130
+ }
131
+ }
132
+ finally {
133
+ this.running = false;
134
+ }
135
+ }
136
+ }
137
+ function isContentionResult(result) {
138
+ return result.summaries.some((summary) => (summary.pass === "runHousekeeping"
139
+ && summary.errors.some((error) => /already in flight/i.test(error))));
140
+ }
141
+ function isHousekeepingRunResult(value) {
142
+ if (!value || typeof value !== "object") {
143
+ return false;
144
+ }
145
+ const candidate = value;
146
+ return Array.isArray(candidate.scopeIds)
147
+ && Array.isArray(candidate.summaries)
148
+ && typeof candidate.totalExamined === "number"
149
+ && typeof candidate.totalModified === "number"
150
+ && typeof candidate.durationMs === "number";
151
+ }
152
+ //# sourceMappingURL=housekeeping-scheduler.js.map
@@ -0,0 +1,187 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function loadSchedulerModule() {
4
+ return await import(new URL(`./housekeeping-scheduler.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
5
+ }
6
+ function createTimers() {
7
+ let nextId = 1;
8
+ const timeouts = [];
9
+ const intervals = [];
10
+ return {
11
+ timeouts,
12
+ intervals,
13
+ setTimeoutImpl(callback, delayMs) {
14
+ const handle = { kind: "timeout", id: nextId++, unref() { } };
15
+ timeouts.push({ handle, callback, delayMs, cleared: false });
16
+ return handle;
17
+ },
18
+ clearTimeoutImpl(handle) {
19
+ const entry = timeouts.find((item) => item.handle === handle);
20
+ if (entry)
21
+ entry.cleared = true;
22
+ },
23
+ setIntervalImpl(callback, delayMs) {
24
+ const handle = { kind: "interval", id: nextId++, unref() { } };
25
+ intervals.push({ handle, callback, delayMs, cleared: false });
26
+ return handle;
27
+ },
28
+ clearIntervalImpl(handle) {
29
+ const entry = intervals.find((item) => item.handle === handle);
30
+ if (entry)
31
+ entry.cleared = true;
32
+ },
33
+ };
34
+ }
35
+ test("MemoryHousekeepingScheduler registers with the default 6h interval when env is unset", async () => {
36
+ const schedulerModule = await loadSchedulerModule();
37
+ const timers = createTimers();
38
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
39
+ env: {},
40
+ runHousekeeping: () => ({ scopeIds: [], summaries: [], totalExamined: 0, totalModified: 0, durationMs: 0 }),
41
+ setTimeoutImpl: timers.setTimeoutImpl,
42
+ clearTimeoutImpl: timers.clearTimeoutImpl,
43
+ setIntervalImpl: timers.setIntervalImpl,
44
+ clearIntervalImpl: timers.clearIntervalImpl,
45
+ });
46
+ scheduler.start();
47
+ timers.timeouts[0]?.callback();
48
+ assert.equal(schedulerModule.DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS, 21_600_000);
49
+ assert.equal(timers.intervals.length, 1);
50
+ assert.equal(timers.intervals[0]?.delayMs, 21_600_000);
51
+ });
52
+ test("MemoryHousekeepingScheduler is disabled when CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS is 0", async () => {
53
+ const schedulerModule = await loadSchedulerModule();
54
+ const timers = createTimers();
55
+ let runs = 0;
56
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
57
+ env: { CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS: "0" },
58
+ runHousekeeping: () => {
59
+ runs += 1;
60
+ },
61
+ setTimeoutImpl: timers.setTimeoutImpl,
62
+ clearTimeoutImpl: timers.clearTimeoutImpl,
63
+ setIntervalImpl: timers.setIntervalImpl,
64
+ clearIntervalImpl: timers.clearIntervalImpl,
65
+ });
66
+ scheduler.start();
67
+ assert.equal(timers.timeouts.length, 0);
68
+ assert.equal(timers.intervals.length, 0);
69
+ assert.equal(runs, 0);
70
+ });
71
+ test("MemoryHousekeepingScheduler waits for the initial delay before the first run", async () => {
72
+ const schedulerModule = await loadSchedulerModule();
73
+ const timers = createTimers();
74
+ let runs = 0;
75
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
76
+ env: {},
77
+ runHousekeeping: () => {
78
+ runs += 1;
79
+ },
80
+ setTimeoutImpl: timers.setTimeoutImpl,
81
+ clearTimeoutImpl: timers.clearTimeoutImpl,
82
+ setIntervalImpl: timers.setIntervalImpl,
83
+ clearIntervalImpl: timers.clearIntervalImpl,
84
+ });
85
+ scheduler.start();
86
+ assert.equal(schedulerModule.DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS, 300_000);
87
+ assert.equal(timers.timeouts.length, 1);
88
+ assert.equal(timers.timeouts[0]?.delayMs, 300_000);
89
+ assert.equal(runs, 0);
90
+ timers.timeouts[0]?.callback();
91
+ await Promise.resolve();
92
+ assert.equal(runs, 1);
93
+ });
94
+ test("MemoryHousekeepingScheduler does not overlap runs when an interval fires during an active run", async () => {
95
+ const schedulerModule = await loadSchedulerModule();
96
+ const timers = createTimers();
97
+ const warnings = [];
98
+ let runs = 0;
99
+ let releaseRun;
100
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
101
+ env: {
102
+ CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS: "1",
103
+ CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS: "2",
104
+ },
105
+ runHousekeeping: () => {
106
+ runs += 1;
107
+ return new Promise((resolve) => {
108
+ releaseRun = resolve;
109
+ });
110
+ },
111
+ log: {
112
+ info: () => { },
113
+ warn: (_obj, msg) => warnings.push(msg),
114
+ },
115
+ setTimeoutImpl: timers.setTimeoutImpl,
116
+ clearTimeoutImpl: timers.clearTimeoutImpl,
117
+ setIntervalImpl: timers.setIntervalImpl,
118
+ clearIntervalImpl: timers.clearIntervalImpl,
119
+ });
120
+ scheduler.start();
121
+ timers.timeouts[0]?.callback();
122
+ await Promise.resolve();
123
+ timers.intervals[0]?.callback();
124
+ await Promise.resolve();
125
+ assert.equal(runs, 1);
126
+ assert.ok(warnings.some((entry) => entry.includes("Memory housekeeping run skipped")));
127
+ releaseRun?.();
128
+ await Promise.resolve();
129
+ timers.intervals[0]?.callback();
130
+ await Promise.resolve();
131
+ assert.equal(runs, 2);
132
+ });
133
+ test("MemoryHousekeepingScheduler stop clears pending initial-delay and active interval timers", async () => {
134
+ const schedulerModule = await loadSchedulerModule();
135
+ const timers = createTimers();
136
+ const beforeInitialRun = new schedulerModule.MemoryHousekeepingScheduler({
137
+ env: {},
138
+ runHousekeeping: () => { },
139
+ setTimeoutImpl: timers.setTimeoutImpl,
140
+ clearTimeoutImpl: timers.clearTimeoutImpl,
141
+ setIntervalImpl: timers.setIntervalImpl,
142
+ clearIntervalImpl: timers.clearIntervalImpl,
143
+ });
144
+ beforeInitialRun.start();
145
+ await beforeInitialRun.stop();
146
+ assert.equal(timers.timeouts[0]?.cleared, true);
147
+ const afterInitialRun = new schedulerModule.MemoryHousekeepingScheduler({
148
+ env: {},
149
+ runHousekeeping: () => { },
150
+ setTimeoutImpl: timers.setTimeoutImpl,
151
+ clearTimeoutImpl: timers.clearTimeoutImpl,
152
+ setIntervalImpl: timers.setIntervalImpl,
153
+ clearIntervalImpl: timers.clearIntervalImpl,
154
+ });
155
+ afterInitialRun.start();
156
+ timers.timeouts[1]?.callback();
157
+ await afterInitialRun.stop();
158
+ assert.equal(timers.intervals[0]?.cleared, true);
159
+ });
160
+ test("MemoryHousekeepingScheduler stop waits for an in-flight run to finish without aborting it", async () => {
161
+ const schedulerModule = await loadSchedulerModule();
162
+ const timers = createTimers();
163
+ let releaseRun;
164
+ let stopResolved = false;
165
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
166
+ env: {},
167
+ runHousekeeping: () => new Promise((resolve) => {
168
+ releaseRun = resolve;
169
+ }),
170
+ setTimeoutImpl: timers.setTimeoutImpl,
171
+ clearTimeoutImpl: timers.clearTimeoutImpl,
172
+ setIntervalImpl: timers.setIntervalImpl,
173
+ clearIntervalImpl: timers.clearIntervalImpl,
174
+ });
175
+ scheduler.start();
176
+ timers.timeouts[0]?.callback();
177
+ await Promise.resolve();
178
+ const stopped = scheduler.stop().then(() => {
179
+ stopResolved = true;
180
+ });
181
+ await Promise.resolve();
182
+ assert.equal(stopResolved, false);
183
+ releaseRun?.();
184
+ await stopped;
185
+ assert.equal(stopResolved, true);
186
+ });
187
+ //# sourceMappingURL=housekeeping-scheduler.test.js.map