chapterhouse 0.5.1 → 0.5.2

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.
@@ -8,7 +8,8 @@ import { tieringPass } from "./tiering.js";
8
8
  export { tieringPass };
9
9
  const log = childLogger("memory.housekeeping");
10
10
  const SIMILARITY_THRESHOLD = 0.8;
11
- const inFlightKeys = new Set();
11
+ const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
12
+ const inFlightScopesByPass = new Map();
12
13
  const PASS_ORDER = [
13
14
  "dedup_observations",
14
15
  "dedup_decisions",
@@ -280,38 +281,91 @@ function resolveScopeIds(input) {
280
281
  const activeScope = getActiveScope();
281
282
  return activeScope ? [activeScope.id] : [];
282
283
  }
283
- function runPass(pass, scopeId) {
284
+ function getInFlightScopes(pass) {
285
+ let scopes = inFlightScopesByPass.get(pass);
286
+ if (!scopes) {
287
+ scopes = new Set();
288
+ inFlightScopesByPass.set(pass, scopes);
289
+ }
290
+ return scopes;
291
+ }
292
+ function getReservedPassScopes(scopeIds, passes) {
293
+ const reserved = [];
294
+ for (const pass of passes) {
295
+ if (pass === "compact_inbox") {
296
+ reserved.push({ pass, scope: GLOBAL_PASS_SCOPE });
297
+ continue;
298
+ }
299
+ for (const scopeId of scopeIds) {
300
+ reserved.push({ pass, scope: scopeId });
301
+ }
302
+ }
303
+ return reserved;
304
+ }
305
+ function reservePassScopes(reserved) {
306
+ if (reserved.some(({ pass, scope }) => getInFlightScopes(pass).has(scope))) {
307
+ return false;
308
+ }
309
+ for (const { pass, scope } of reserved) {
310
+ getInFlightScopes(pass).add(scope);
311
+ }
312
+ return true;
313
+ }
314
+ function releasePassScopes(reserved) {
315
+ for (const { pass, scope } of reserved) {
316
+ const scopes = inFlightScopesByPass.get(pass);
317
+ scopes?.delete(scope);
318
+ if (scopes && scopes.size === 0) {
319
+ inFlightScopesByPass.delete(pass);
320
+ }
321
+ }
322
+ }
323
+ async function runPass(pass, scopeId) {
284
324
  switch (pass) {
285
325
  case "dedup_observations":
286
- return dedupObservationsPass(scopeId);
326
+ return await Promise.resolve(dedupObservationsPass(scopeId));
287
327
  case "dedup_decisions":
288
- return dedupDecisionsPass(scopeId);
328
+ return await Promise.resolve(dedupDecisionsPass(scopeId));
289
329
  case "orphan_cleanup":
290
- return orphanCleanupPass(scopeId);
330
+ return await Promise.resolve(orphanCleanupPass(scopeId));
291
331
  case "decay":
292
- return decayPass(scopeId);
332
+ return await Promise.resolve(decayPass(scopeId));
293
333
  case "compact_inbox":
294
- return compactInboxPass();
334
+ return await Promise.resolve(compactInboxPass());
295
335
  case "tiering":
296
- return tieringPass(scopeId);
336
+ return await Promise.resolve(tieringPass(scopeId));
297
337
  }
298
338
  }
299
- function inFlightKey(scopeIds, passes) {
300
- return `${scopeIds.join(",") || "none"}:${passes.join(",")}`;
339
+ async function runScopePasses(scopeId, passes) {
340
+ const summaries = [];
341
+ for (const pass of passes) {
342
+ summaries.push(await runPass(pass, scopeId));
343
+ }
344
+ return summaries;
301
345
  }
302
346
  export function isHousekeepingInFlight(scopeIds, passes) {
347
+ const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
303
348
  if (!scopeIds || scopeIds.length === 0) {
304
- return inFlightKeys.size > 0;
349
+ return normalizedPasses.some((pass) => (inFlightScopesByPass.get(pass)?.size ?? 0) > 0);
305
350
  }
306
- const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
307
- return inFlightKeys.has(inFlightKey([...new Set(scopeIds)].sort((a, b) => a - b), normalizedPasses));
351
+ const uniqueScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b);
352
+ return normalizedPasses.some((pass) => {
353
+ const scopes = inFlightScopesByPass.get(pass);
354
+ if (!scopes) {
355
+ return false;
356
+ }
357
+ if (pass === "compact_inbox") {
358
+ return scopes.has(GLOBAL_PASS_SCOPE);
359
+ }
360
+ return uniqueScopeIds.some((scopeId) => scopes.has(scopeId));
361
+ });
308
362
  }
309
- export function runHousekeeping(opts = {}) {
363
+ export async function runHousekeeping(opts = {}) {
310
364
  const started = performance.now();
311
365
  const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
312
366
  const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
313
- const key = inFlightKey(scopeIds, passes);
314
- if (inFlightKeys.has(key)) {
367
+ const reservedPassScopes = getReservedPassScopes(scopeIds, passes);
368
+ if (!reservePassScopes(reservedPassScopes)) {
315
369
  return {
316
370
  scopeIds,
317
371
  summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
@@ -320,18 +374,12 @@ export function runHousekeeping(opts = {}) {
320
374
  durationMs: 0,
321
375
  };
322
376
  }
323
- inFlightKeys.add(key);
324
- const summaries = [];
325
377
  try {
326
378
  const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
327
379
  const hasCompactInbox = passes.includes("compact_inbox");
328
- for (const scopeId of scopeIds) {
329
- for (const pass of scopedPasses) {
330
- summaries.push(runPass(pass, scopeId));
331
- }
332
- }
380
+ const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
333
381
  if (hasCompactInbox) {
334
- summaries.push(compactInboxPass());
382
+ summaries.push(await runPass("compact_inbox", undefined));
335
383
  }
336
384
  const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
337
385
  const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
@@ -346,7 +394,7 @@ export function runHousekeeping(opts = {}) {
346
394
  return { scopeIds, summaries, totalExamined, totalModified, durationMs };
347
395
  }
348
396
  finally {
349
- inFlightKeys.delete(key);
397
+ releasePassScopes(reservedPassScopes);
350
398
  }
351
399
  }
352
400
  //# sourceMappingURL=housekeeping.js.map
@@ -16,9 +16,51 @@ function resetSandbox() {
16
16
  async function loadModules() {
17
17
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
18
18
  const memoryModule = await import(new URL("./index.js", import.meta.url).href);
19
- const housekeepingModule = await import(new URL("./housekeeping.js", import.meta.url).href);
19
+ const housekeepingModule = await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
20
20
  return { dbModule, memoryModule, housekeepingModule };
21
21
  }
22
+ async function loadMockedHousekeepingModule(t, options = {}) {
23
+ t.mock.module("../config.js", {
24
+ namedExports: {
25
+ config: {
26
+ memoryDecayDays: 30,
27
+ memoryInboxRetentionDays: 7,
28
+ },
29
+ },
30
+ });
31
+ t.mock.module("../store/db.js", {
32
+ namedExports: {
33
+ getDb: () => {
34
+ throw new Error("getDb should not be called in this test");
35
+ },
36
+ },
37
+ });
38
+ t.mock.module("../util/logger.js", {
39
+ namedExports: {
40
+ childLogger: () => ({
41
+ info: () => { },
42
+ warn: () => { },
43
+ error: () => { },
44
+ }),
45
+ },
46
+ });
47
+ t.mock.module("./active-scope.js", {
48
+ namedExports: {
49
+ getActiveScope: () => (options.activeScopeId === undefined ? null : { id: options.activeScopeId }),
50
+ },
51
+ });
52
+ t.mock.module("./scopes.js", {
53
+ namedExports: {
54
+ listScopes: () => options.scopes ?? [],
55
+ },
56
+ });
57
+ t.mock.module("./tiering.js", {
58
+ namedExports: {
59
+ tieringPass: (scopeId) => options.tieringPass?.(scopeId) ?? { pass: "tieringPass", examined: scopeId, modified: 1, errors: [] },
60
+ },
61
+ });
62
+ return await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
63
+ }
22
64
  function getFunction(module, name) {
23
65
  const value = module[name];
24
66
  assert.equal(typeof value, "function", `expected ${name} to be exported`);
@@ -215,17 +257,67 @@ test("runHousekeeping defaults to the active scope and can target all active sco
215
257
  const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
216
258
  db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
217
259
  setActiveScope("chapterhouse");
218
- const activeOnly = housekeepingModule.runHousekeeping({ passes: ["decay"] });
260
+ const activeOnly = await housekeepingModule.runHousekeeping({ passes: ["decay"] });
219
261
  assert.deepEqual(activeOnly.scopeIds, [chapterhouse.id]);
220
262
  assert.equal(activeOnly.summaries.length, 1);
221
263
  assert.equal(activeOnly.summaries[0]?.modified, 1);
222
264
  assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(chapterhouseOld.id).archived_at);
223
265
  assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at, null);
224
- const allScopes = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
266
+ const allScopes = await housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
225
267
  assert.ok(allScopes.scopeIds.includes(team.id));
226
268
  assert.equal(allScopes.summaries.some((summary) => summary.modified === 1), true);
227
269
  assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at);
228
270
  });
271
+ test("runHousekeeping starts all scoped passes before awaiting completion", async (t) => {
272
+ const releases = new Map();
273
+ const startedScopes = [];
274
+ const housekeepingModule = await loadMockedHousekeepingModule(t, {
275
+ scopes: [
276
+ { id: 11, active: true },
277
+ { id: 22, active: true },
278
+ ],
279
+ tieringPass: async (scopeId) => {
280
+ startedScopes.push(scopeId);
281
+ await new Promise((resolve) => {
282
+ releases.set(scopeId, resolve);
283
+ });
284
+ return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
285
+ },
286
+ });
287
+ const pending = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["tiering"] });
288
+ assert.equal(typeof pending.then, "function");
289
+ await Promise.resolve();
290
+ assert.deepEqual(startedScopes.sort((left, right) => left - right), [11, 22]);
291
+ releases.get(11)?.();
292
+ releases.get(22)?.();
293
+ const result = await pending;
294
+ assert.deepEqual(result.scopeIds, [11, 22]);
295
+ assert.deepEqual(result.summaries.map((summary) => summary.pass), ["tieringPass:11", "tieringPass:22"]);
296
+ });
297
+ test("runHousekeeping rejects overlapping runs that share an in-flight scope", async (t) => {
298
+ const releases = new Map();
299
+ const housekeepingModule = await loadMockedHousekeepingModule(t, {
300
+ scopes: [
301
+ { id: 11, active: true },
302
+ { id: 22, active: true },
303
+ ],
304
+ tieringPass: async (scopeId) => {
305
+ await new Promise((resolve) => {
306
+ releases.set(scopeId, resolve);
307
+ });
308
+ return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
309
+ },
310
+ });
311
+ const firstRun = housekeepingModule.runHousekeeping({ scopeIds: [11, 22], passes: ["tiering"] });
312
+ await Promise.resolve();
313
+ assert.equal(housekeepingModule.isHousekeepingInFlight([22], ["tiering"]), true);
314
+ const secondRun = await housekeepingModule.runHousekeeping({ scopeIds: [22], passes: ["tiering"] });
315
+ assert.deepEqual(secondRun.scopeIds, [22]);
316
+ assert.match(secondRun.summaries[0]?.errors[0] ?? "", /already in flight/i);
317
+ releases.get(11)?.();
318
+ releases.get(22)?.();
319
+ await firstRun;
320
+ });
229
321
  test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
230
322
  const { dbModule, memoryModule, housekeepingModule } = await loadModules();
231
323
  const db = dbModule.getDb();
@@ -0,0 +1,178 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-inbox-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadRealModules(cacheBust = `${Date.now()}-${Math.random()}`) {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
17
+ const inboxModule = await import(new URL(`./inbox.js?case=${cacheBust}`, import.meta.url).href);
18
+ return { dbModule, memoryModule, inboxModule };
19
+ }
20
+ async function loadMockedInboxModule(t, options) {
21
+ t.mock.module("../store/db.js", {
22
+ namedExports: {
23
+ getDb: () => ({ prepare: options.prepare }),
24
+ },
25
+ });
26
+ t.mock.module("./active-scope.js", {
27
+ namedExports: {
28
+ getActiveScope: () => options.activeScope ?? null,
29
+ },
30
+ });
31
+ t.mock.module("./scopes.js", {
32
+ namedExports: {
33
+ getScope: (slug) => options.explicitScopes?.[slug],
34
+ },
35
+ });
36
+ return await import(new URL(`./inbox.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
37
+ }
38
+ function getFunction(module, name) {
39
+ const value = module[name];
40
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
41
+ return value;
42
+ }
43
+ function createTestScope(memoryModule, slug) {
44
+ const createScope = getFunction(memoryModule, "createScope");
45
+ return createScope({
46
+ slug,
47
+ title: slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "),
48
+ description: `${slug} test scope`,
49
+ keywords: [slug],
50
+ });
51
+ }
52
+ test.beforeEach(async () => {
53
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
54
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
55
+ dbModule.closeDb();
56
+ resetSandbox();
57
+ });
58
+ test.after(async () => {
59
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
60
+ dbModule.closeDb();
61
+ rmSync(sandboxRoot, { recursive: true, force: true });
62
+ });
63
+ test("resolveProposalScope returns explicit scopes and falls back to the active scope for missing or empty values", async () => {
64
+ const { dbModule, memoryModule, inboxModule } = await loadRealModules("resolve-fallback");
65
+ dbModule.getDb();
66
+ const docs = createTestScope(memoryModule, "docs");
67
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
68
+ const active = createTestScope(memoryModule, "team-memory");
69
+ setActiveScope(active.slug);
70
+ assert.deepEqual(inboxModule.resolveProposalScope(docs.slug), { scopeId: docs.id, scopeSlug: docs.slug });
71
+ assert.deepEqual(inboxModule.resolveProposalScope(undefined), { scopeId: active.id, scopeSlug: active.slug });
72
+ assert.deepEqual(inboxModule.resolveProposalScope(""), { scopeId: active.id, scopeSlug: active.slug });
73
+ });
74
+ test("resolveProposalScope rejects invalid explicit scopes and missing default scopes", async () => {
75
+ const { dbModule, inboxModule } = await loadRealModules("resolve-errors");
76
+ dbModule.getDb();
77
+ assert.throws(() => inboxModule.resolveProposalScope("does-not-exist"), /Unknown memory scope 'does-not-exist'\./);
78
+ assert.throws(() => inboxModule.resolveProposalScope(), /No active memory scope is set\. Use memory_set_scope or pass scope_slug explicitly\./);
79
+ });
80
+ test("queueMemoryProposal enqueues explicit-scope proposals with the expected envelope defaults", async () => {
81
+ const { dbModule, memoryModule, inboxModule } = await loadRealModules("explicit-queue");
82
+ const db = dbModule.getDb();
83
+ const docs = createTestScope(memoryModule, "docs-queue");
84
+ const queued = inboxModule.queueMemoryProposal({
85
+ kind: "observation",
86
+ scopeSlug: docs.slug,
87
+ payload: {
88
+ content: "Inbox tests should verify the stored proposal envelope.",
89
+ source: "oracle",
90
+ },
91
+ sourceAgent: "oracle",
92
+ sourceTaskId: "task-inbox-explicit",
93
+ });
94
+ assert.equal(typeof queued.id, "number");
95
+ assert.equal(queued.scopeId, docs.id);
96
+ assert.equal(queued.kind, "memory_proposal");
97
+ assert.equal(queued.status, "pending");
98
+ assert.equal(queued.sourceAgent, "oracle");
99
+ assert.equal(queued.sourceTaskId, "task-inbox-explicit");
100
+ assert.match(queued.createdAt, /\d{4}-\d{2}-\d{2}/);
101
+ const persisted = db.prepare(`
102
+ SELECT payload
103
+ FROM mem_inbox
104
+ WHERE id = ?
105
+ `).get(queued.id);
106
+ assert.ok(persisted, "queueMemoryProposal should persist a mem_inbox row");
107
+ assert.deepEqual(JSON.parse(persisted.payload), {
108
+ kind: "observation",
109
+ scope_slug: docs.slug,
110
+ confidence: 0.5,
111
+ payload: {
112
+ content: "Inbox tests should verify the stored proposal envelope.",
113
+ source: "oracle",
114
+ },
115
+ });
116
+ assert.deepEqual(inboxModule.getInboxItem(queued.id), queued);
117
+ });
118
+ test("queueMemoryProposal falls back to the active scope, does not deduplicate duplicates, and pending lists exclude resolved items", async () => {
119
+ const { dbModule, memoryModule, inboxModule } = await loadRealModules("active-scope-queue");
120
+ dbModule.getDb();
121
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
122
+ setActiveScope("chapterhouse");
123
+ const first = inboxModule.queueMemoryProposal({
124
+ kind: "observation",
125
+ scopeSlug: "",
126
+ payload: { content: "Duplicate proposal payloads should still queue separately." },
127
+ confidence: 0.8,
128
+ reason: "First proposal",
129
+ sourceAgent: "oracle",
130
+ sourceTaskId: "task-inbox-duplicates",
131
+ });
132
+ const second = inboxModule.queueMemoryProposal({
133
+ kind: "observation",
134
+ payload: { content: "Duplicate proposal payloads should still queue separately." },
135
+ confidence: 0.8,
136
+ reason: "Second proposal",
137
+ sourceAgent: "oracle",
138
+ sourceTaskId: "task-inbox-duplicates",
139
+ });
140
+ assert.notEqual(first.id, second.id);
141
+ assert.equal(first.scopeId, second.scopeId);
142
+ assert.deepEqual(inboxModule.listPendingMemoryProposalsForTask("task-inbox-duplicates").map((item) => item.id), [first.id, second.id]);
143
+ inboxModule.resolveInboxItem(first.id, "accepted", "Durable enough to keep");
144
+ const resolved = inboxModule.getInboxItem(first.id);
145
+ assert.equal(resolved?.status, "accepted");
146
+ assert.equal(resolved?.resolutionReason, "Durable enough to keep");
147
+ assert.match(resolved?.resolvedAt ?? "", /\d{4}-\d{2}-\d{2}/);
148
+ assert.deepEqual(inboxModule.listPendingMemoryProposalsForTask("task-inbox-duplicates").map((item) => item.id), [second.id]);
149
+ });
150
+ test("queueMemoryProposal surfaces insert failures such as a full queue", async (t) => {
151
+ const fullError = new Error("database or disk is full");
152
+ const seenStatements = [];
153
+ const inboxModule = await loadMockedInboxModule(t, {
154
+ activeScope: { id: 99, slug: "chapterhouse" },
155
+ prepare: (sql) => {
156
+ seenStatements.push(sql);
157
+ if (sql.includes("INSERT INTO mem_inbox")) {
158
+ return {
159
+ run: () => {
160
+ throw fullError;
161
+ },
162
+ };
163
+ }
164
+ return {
165
+ run: () => ({ changes: 0 }),
166
+ get: () => undefined,
167
+ all: () => [],
168
+ };
169
+ },
170
+ });
171
+ assert.throws(() => inboxModule.queueMemoryProposal({
172
+ kind: "observation",
173
+ payload: { content: "This proposal should fail before it is persisted." },
174
+ sourceAgent: "oracle",
175
+ }), /database or disk is full/);
176
+ assert.equal(seenStatements.some((sql) => sql.includes("INSERT INTO mem_inbox")), true);
177
+ });
178
+ //# sourceMappingURL=inbox.test.js.map