@vellumai/assistant 0.10.5-dev.202607060745.b26cd90 → 0.10.5-dev.202607061137.28aab2f

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 (40) hide show
  1. package/openapi.yaml +31 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
  4. package/src/__tests__/job-handler-registration-guard.test.ts +1 -1
  5. package/src/__tests__/memory-jobs-worker-backoff.test.ts +1 -1
  6. package/src/__tests__/memory-jobs-worker-lanes.test.ts +1 -1
  7. package/src/__tests__/memory-worker-tool-registry-guard.test.ts +2 -2
  8. package/src/__tests__/mtime-cache.test.ts +3 -4
  9. package/src/__tests__/persistence-layering-guard.test.ts +3 -7
  10. package/src/__tests__/plugin-import-boundary-guard.test.ts +7 -1
  11. package/src/cli/commands/monitoring.ts +16 -0
  12. package/src/config/schemas/monitoring.ts +10 -0
  13. package/src/daemon/shutdown-handlers.ts +0 -5
  14. package/src/hooks/__tests__/hook-live-reload.test.ts +153 -0
  15. package/src/hooks/hook-loader.ts +40 -8
  16. package/src/monitoring/__tests__/active-conversations.test.ts +87 -0
  17. package/src/monitoring/__tests__/resource-sampler.test.ts +1 -0
  18. package/src/monitoring/__tests__/stall-capture.test.ts +1 -0
  19. package/src/monitoring/active-conversations.ts +97 -0
  20. package/src/monitoring/resource-sampler.ts +62 -21
  21. package/src/persistence/job-handlers/message-lexical.ts +1 -1
  22. package/src/persistence/worker-control.ts +1 -1
  23. package/src/plugins/defaults/index.ts +2 -0
  24. package/src/{persistence → plugins/defaults/memory}/__tests__/jobs-worker-memory-disabled.test.ts +8 -8
  25. package/src/plugins/defaults/memory/__tests__/jobs-worker-pkb-schedule.test.ts +1 -1
  26. package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +1 -1
  27. package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-schedule.test.ts +1 -2
  28. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +26 -0
  29. package/src/plugins/defaults/memory/hooks/shutdown.ts +21 -0
  30. package/src/plugins/defaults/memory/job-handler-registration.ts +1 -1
  31. package/src/{persistence → plugins/defaults/memory}/jobs-worker.ts +17 -14
  32. package/src/plugins/defaults/memory/startup.ts +1 -1
  33. package/src/{jobs → plugins/defaults/memory}/worker.ts +7 -7
  34. package/src/plugins/surface-import.ts +27 -6
  35. package/src/plugins/types.ts +1 -1
  36. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +45 -0
  37. package/src/runtime/routes/consolidation-routes.ts +4 -2
  38. package/src/runtime/routes/filing-routes.ts +1 -1
  39. package/src/runtime/routes/monitoring-routes.ts +23 -1
  40. package/src/util/process-tree.ts +3 -3
package/openapi.yaml CHANGED
@@ -19830,6 +19830,36 @@ paths:
19830
19830
  - freeMb
19831
19831
  additionalProperties: false
19832
19832
  - type: "null"
19833
+ activeConversations:
19834
+ anyOf:
19835
+ - type: array
19836
+ items:
19837
+ type: object
19838
+ properties:
19839
+ conversationId:
19840
+ type: string
19841
+ title:
19842
+ anyOf:
19843
+ - type: string
19844
+ - type: "null"
19845
+ originChannel:
19846
+ anyOf:
19847
+ - type: string
19848
+ - type: "null"
19849
+ originInterface:
19850
+ anyOf:
19851
+ - type: string
19852
+ - type: "null"
19853
+ processingStartedAt:
19854
+ type: number
19855
+ required:
19856
+ - conversationId
19857
+ - title
19858
+ - originChannel
19859
+ - originInterface
19860
+ - processingStartedAt
19861
+ additionalProperties: false
19862
+ - type: "null"
19833
19863
  required:
19834
19864
  - ts
19835
19865
  - memory
@@ -19839,6 +19869,7 @@ paths:
19839
19869
  - events
19840
19870
  - deltas
19841
19871
  - disk
19872
+ - activeConversations
19842
19873
  additionalProperties: false
19843
19874
  - type: "null"
19844
19875
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.5-dev.202607060745.b26cd90",
3
+ "version": "0.10.5-dev.202607061137.28aab2f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -213,7 +213,8 @@ mock.module("../persistence/cleanup-schedule-state.js", () => ({
213
213
  markScheduledCleanupEnqueued: mock(() => {}),
214
214
  }));
215
215
 
216
- const { runMemoryJobsOnce } = await import("../persistence/jobs-worker.js");
216
+ const { runMemoryJobsOnce } =
217
+ await import("../plugins/defaults/memory/jobs-worker.js");
217
218
  const { WorkspaceHeartbeatService } =
218
219
  await import("../workspace/heartbeat-service.js");
219
220
 
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it, spyOn } from "bun:test";
2
2
 
3
- import * as jobsWorker from "../persistence/jobs-worker.js";
4
3
  import { registerMemoryPluginJobHandlers } from "../plugins/defaults/memory/job-handler-registration.js";
4
+ import * as jobsWorker from "../plugins/defaults/memory/jobs-worker.js";
5
5
  import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/memory/persistence-hooks-registration.js";
6
6
  import {
7
7
  getMemoryPersistenceHooks,
@@ -41,7 +41,7 @@ import {
41
41
  POLL_INTERVAL_MAX_MS,
42
42
  POLL_INTERVAL_MIN_MS,
43
43
  startMemoryJobsWorker,
44
- } from "../persistence/jobs-worker.js";
44
+ } from "../plugins/defaults/memory/jobs-worker.js";
45
45
 
46
46
  describe("memory jobs worker adaptive poll interval", () => {
47
47
  test("exports expected poll interval constants", () => {
@@ -114,9 +114,9 @@ import { getMemoryDb } from "../persistence/db-connection.js";
114
114
  import { initializeDb } from "../persistence/db-init.js";
115
115
  import { _resetQdrantBreaker } from "../persistence/embeddings/qdrant-circuit-breaker.js";
116
116
  import { enqueueMemoryJob } from "../persistence/jobs-store.js";
117
- import { runMemoryJobsOnce } from "../persistence/jobs-worker.js";
118
117
  import { memoryJobs } from "../persistence/schema/index.js";
119
118
  import { registerMemoryPluginJobHandlers } from "../plugins/defaults/memory/job-handler-registration.js";
119
+ import { runMemoryJobsOnce } from "../plugins/defaults/memory/jobs-worker.js";
120
120
 
121
121
  describe("memory jobs worker lane scheduling", () => {
122
122
  beforeAll(async () => {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Guard test: memory-worker tool registry bootstrap
3
3
  *
4
- * The standalone memory jobs worker (`src/jobs/worker.ts`) hosts real agent
4
+ * The standalone memory jobs worker (`src/plugins/defaults/memory/worker.ts`) hosts real agent
5
5
  * conversations — retrospective and consolidation passes, plus any subagents
6
6
  * they spawn — and those conversations resolve their tool surface from that
7
7
  * process's registry. The daemon and the schedule worker populate the
@@ -34,7 +34,7 @@ afterAll(() => {
34
34
  describe("memory worker tool registry", () => {
35
35
  test("worker entrypoint populates the tool registry at startup", () => {
36
36
  const source = readFileSync(
37
- join(import.meta.dir, "..", "jobs", "worker.ts"),
37
+ join(import.meta.dir, "..", "plugins", "defaults", "memory", "worker.ts"),
38
38
  "utf8",
39
39
  );
40
40
  expect(source).toContain("await initializeTools()");
@@ -438,10 +438,9 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
438
438
  });
439
439
 
440
440
  // NB: each test below uses a distinct hook event name so the workspace
441
- // hook file path is unique. Bun caches dynamic import() by URL and does not
442
- // bust on content change, so reusing `<workspace>/hooks/<name>.ts` across
443
- // tests would return a stale module (the existing plugin tests dodge this
444
- // by using a fresh plugin directory per test).
441
+ // hook file path is unique and each test stays independent of the cache
442
+ // state its siblings leave behind (the plugin tests get the same isolation
443
+ // from a fresh plugin directory per test).
445
444
  test("plugin hooks run before the workspace hook for the same event", async () => {
446
445
  const dir = freshPluginDir("ordering-plugin");
447
446
  writePackageJson(dir, { ...SIMPLE_PKG, name: "ordering-plugin" });
@@ -45,10 +45,9 @@ const MEMORY_DIR = join(ASSISTANT_SRC, "plugins", "defaults", "memory");
45
45
  * (`persistence-lifecycle-seam.ts` — every event and query on it is
46
46
  * memory-domain), and these persistence call sites invoke the seam's
47
47
  * registered handlers through `getMemoryPersistenceHooks()` (no-op when
48
- * memory is not present). Decoupling plan: the memory jobs worker moves into
49
- * the memory plugin (retiring the jobs-worker entry), and the per-event
50
- * lifecycle notifications migrate to the first-class `hooks` system (retiring
51
- * the conversation-crud entry).
48
+ * memory is not present). Decoupling plan: the per-event lifecycle
49
+ * notifications migrate to the first-class `hooks` system, retiring the
50
+ * conversation-crud entry.
52
51
  *
53
52
  * Keyed by the importing persistence file (relative to repo root); the value
54
53
  * is the set of allowed `memory/<specifier>` module paths it may import.
@@ -57,9 +56,6 @@ const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {
57
56
  "assistant/src/persistence/conversation-crud.ts": new Set([
58
57
  "persistence-lifecycle-seam",
59
58
  ]),
60
- "assistant/src/persistence/jobs-worker.ts": new Set([
61
- "persistence-lifecycle-seam",
62
- ]),
63
59
  };
64
60
 
65
61
  /**
@@ -168,6 +168,7 @@ const BASELINE: Record<string, readonly string[]> = {
168
168
  "../../../context/token-estimator.js",
169
169
  "../../../conversations/job-handlers/summarization.js",
170
170
  "../../../daemon/date-context.js",
171
+ "../../../daemon/disk-pressure-background-gate.js",
171
172
  "../../../daemon/embedding-reconcile.js",
172
173
  "../../../daemon/pkb-context-tracker.js",
173
174
  "../../../daemon/pkb-reminder-builder.js",
@@ -178,12 +179,16 @@ const BASELINE: Record<string, readonly string[]> = {
178
179
  "../../../media/job-handlers/media-processing.js",
179
180
  "../../../permissions/types.js",
180
181
  "../../../persistence/checkpoints.js",
182
+ "../../../persistence/cleanup-schedule-state.js",
181
183
  "../../../persistence/conversation-crud.js",
182
184
  "../../../persistence/db-connection.js",
185
+ "../../../persistence/db-maintenance.js",
183
186
  "../../../persistence/embeddings/embedding-backend.js",
187
+ "../../../persistence/embeddings/embedding-billing-breaker.js",
184
188
  "../../../persistence/embeddings/embedding-runtime-manager.js",
185
189
  "../../../persistence/embeddings/embedding-types.js",
186
190
  "../../../persistence/embeddings/messages-lexical-index.js",
191
+ "../../../persistence/embeddings/qdrant-circuit-breaker.js",
187
192
  "../../../persistence/embeddings/qdrant-client.js",
188
193
  "../../../persistence/embeddings/qdrant-manager.js",
189
194
  "../../../persistence/job-handlers/cleanup.js",
@@ -191,10 +196,10 @@ const BASELINE: Record<string, readonly string[]> = {
191
196
  "../../../persistence/job-handlers/message-lexical.js",
192
197
  "../../../persistence/job-utils.js",
193
198
  "../../../persistence/jobs-store.js",
194
- "../../../persistence/jobs-worker.js",
195
199
  "../../../persistence/message-content.js",
196
200
  "../../../persistence/raw-query.js",
197
201
  "../../../persistence/schema/index.js",
202
+ "../../../persistence/worker-control.js",
198
203
  "../../../prompts/persona-resolver.js",
199
204
  "../../../prompts/system-prompt.js",
200
205
  "../../../runtime/actor-trust-resolver.js",
@@ -204,6 +209,7 @@ const BASELINE: Record<string, readonly string[]> = {
204
209
  "../../../runtime/services/auto-analysis-enqueue.js",
205
210
  "../../../runtime/services/auto-analysis-guard.js",
206
211
  "../../../runtime/services/conversation-analyze-job.js",
212
+ "../../../tools/registry.js",
207
213
  "../../../tools/types.js",
208
214
  "../../../types.js",
209
215
  "../../../util/logger.js",
@@ -76,6 +76,13 @@ interface LatestSample {
76
76
  totalMb: number;
77
77
  freeMb: number;
78
78
  } | null;
79
+ activeConversations: Array<{
80
+ conversationId: string;
81
+ title: string | null;
82
+ originChannel: string | null;
83
+ originInterface: string | null;
84
+ processingStartedAt: number;
85
+ }> | null;
79
86
  }
80
87
 
81
88
  interface StartResponse {
@@ -262,6 +269,15 @@ Examples:
262
269
  ` Disk (${sample.disk.path}): ${sample.disk.usedMb} / ${sample.disk.totalMb} MiB used`,
263
270
  );
264
271
  }
272
+ if (sample.activeConversations?.length) {
273
+ const items = sample.activeConversations
274
+ .map(
275
+ (c) =>
276
+ `${c.conversationId}${c.title ? ` "${c.title}"` : ""}${c.originChannel ? ` via ${c.originChannel}` : ""}`,
277
+ )
278
+ .join(", ");
279
+ log.info(` Processing: ${items}`);
280
+ }
265
281
  });
266
282
  },
267
283
  });
@@ -55,6 +55,16 @@ export const MonitoringConfigSchema = z
55
55
  .describe(
56
56
  "Minimum interval between high-memory snapshots, in milliseconds, so a sustained spike does not write a snapshot on every sample.",
57
57
  ),
58
+ baselineSnapshotIntervalMs: z
59
+ .number({
60
+ error: "monitoring.baselineSnapshotIntervalMs must be a number",
61
+ })
62
+ .int("monitoring.baselineSnapshotIntervalMs must be an integer")
63
+ .min(60_000, "monitoring.baselineSnapshotIntervalMs must be >= 60000ms")
64
+ .default(600_000)
65
+ .describe(
66
+ "Interval between periodic baseline snapshots, in milliseconds (default 10 minutes). Baselines capture the same forensics as high-memory snapshots but on a steady cadence, so a spike can be diffed against a healthy reference instead of only against other over-threshold captures. Retained separately from high-memory snapshots.",
67
+ ),
58
68
  })
59
69
  .describe("Resource monitor process configuration");
60
70
 
@@ -13,7 +13,6 @@ import {
13
13
  } from "../persistence/db-async-query.js";
14
14
  import { getSqlite, isDbOpen, resetDb } from "../persistence/db-connection.js";
15
15
  import { stopQdrantManager } from "../persistence/embeddings/qdrant-manager.js";
16
- import { stopMemoryJobsWorker } from "../persistence/jobs-worker.js";
17
16
  import { stopMemoryWorkerProcess } from "../persistence/worker-control.js";
18
17
  import { stopConsentRefresh } from "../platform/consent-cache.js";
19
18
  import { HOOKS } from "../plugin-api/constants.js";
@@ -165,10 +164,6 @@ async function shutdown(): Promise<void> {
165
164
  cleanupShellOutputTempFiles();
166
165
  stopScheduler();
167
166
 
168
- // Stop the in-process memory worker supervisor if it was started on the
169
- // daemon's event loop (memory.worker.enabled = false).
170
- stopMemoryJobsWorker();
171
-
172
167
  // Stop the out-of-process memory worker if it's actually running. This is
173
168
  // keyed off live state rather than config: the worker may have been
174
169
  // spawned at startup (memory.worker.enabled = true) or out of band via
@@ -0,0 +1,153 @@
1
+ /**
2
+ * End-to-end coverage for hook live-reload through the hook loader: an
3
+ * edited hook file takes effect on the next dispatch, without a daemon
4
+ * restart.
5
+ *
6
+ * The loader keys each hook on its source file's mtime; when the mtime
7
+ * moves it evicts the module from the runtime registry (`evictModule` in
8
+ * `../../plugins/surface-import.ts`) and re-imports, so the fresh content is
9
+ * what dispatches. These tests drive the public collection API against real
10
+ * hook files on disk, which also makes them the regression guard for the
11
+ * Bun behavior the eviction is built on — `require.cache` deletion
12
+ * invalidating dynamic `import()` is intended per
13
+ * github.com/oven-sh/bun/discussions/10162 but not spec-guaranteed, and a
14
+ * Bun upgrade that breaks it must fail here rather than silently degrade
15
+ * hook edits back to restart-required.
16
+ */
17
+
18
+ import {
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ rmSync,
22
+ unlinkSync,
23
+ utimesSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
29
+
30
+ import type { HookEntry } from "../../plugins/types.js";
31
+ import { getWorkspaceHooksDir } from "../../util/platform.js";
32
+ import {
33
+ collectUserHookEntries,
34
+ resetHookCacheForTests,
35
+ } from "../hook-loader.js";
36
+
37
+ const HOOK_NAME = "user-prompt-submit";
38
+
39
+ const root = mkdtempSync(join(tmpdir(), "hook-live-reload-"));
40
+
41
+ afterAll(() => {
42
+ rmSync(root, { recursive: true, force: true });
43
+ });
44
+
45
+ beforeEach(() => {
46
+ resetHookCacheForTests();
47
+ });
48
+
49
+ /**
50
+ * Each write gets an explicitly bumped, strictly increasing mtime: two
51
+ * writes can land inside the same filesystem-timestamp granule, which the
52
+ * loader's mtime check would read as "unchanged". Real edits arrive seconds
53
+ * apart; the tests must not race that granularity.
54
+ */
55
+ let mtimeSeq = 1_750_000_000;
56
+ function writeHookFile(hooksDir: string, marker: string): string {
57
+ const path = join(hooksDir, `${HOOK_NAME}.ts`);
58
+ writeFileSync(path, `export default () => ${JSON.stringify(marker)};\n`);
59
+ const stamp = new Date(++mtimeSeq * 1000);
60
+ utimesSync(path, stamp, stamp);
61
+ return path;
62
+ }
63
+
64
+ /** Fresh plugin fixture: `<root>/<name>/hooks/`, unique name per call. */
65
+ let pluginSeq = 0;
66
+ function makePlugin(): { dir: string; hooksDir: string; name: string } {
67
+ const name = `reload-plugin-${++pluginSeq}`;
68
+ const dir = join(root, name);
69
+ const hooksDir = join(dir, "hooks");
70
+ mkdirSync(hooksDir, { recursive: true });
71
+ return { dir, hooksDir, name };
72
+ }
73
+
74
+ /** Collect the hook chain for a plugin, exactly as dispatch does. */
75
+ async function collect(
76
+ pluginDir: string,
77
+ pluginName: string,
78
+ ): Promise<HookEntry[]> {
79
+ return collectUserHookEntries(HOOK_NAME, [[pluginDir, pluginName]]);
80
+ }
81
+
82
+ /** Run the single expected hook in the chain and return its result. */
83
+ async function dispatchOne(
84
+ pluginDir: string,
85
+ pluginName: string,
86
+ ): Promise<unknown> {
87
+ const entries = await collect(pluginDir, pluginName);
88
+ expect(entries).toHaveLength(1);
89
+ return (entries[0]!.fn as () => unknown)();
90
+ }
91
+
92
+ describe("hook live-reload", () => {
93
+ test("an edited hook file takes effect on the next dispatch, repeatably", async () => {
94
+ const plugin = makePlugin();
95
+ writeHookFile(plugin.hooksDir, "v1");
96
+ expect(await dispatchOne(plugin.dir, plugin.name)).toBe("v1");
97
+
98
+ // Two edit → dispatch cycles: the first proves reload works at all, the
99
+ // second proves a reloaded hook is itself reloadable (edits keep landing
100
+ // turn after turn, not just once).
101
+ for (const marker of ["v2", "v3"]) {
102
+ writeHookFile(plugin.hooksDir, marker);
103
+ expect(await dispatchOne(plugin.dir, plugin.name)).toBe(marker);
104
+ }
105
+ });
106
+
107
+ test("an unchanged hook file is served from the cache, not re-imported", async () => {
108
+ const plugin = makePlugin();
109
+ writeHookFile(plugin.hooksDir, "stable");
110
+
111
+ const first = await collect(plugin.dir, plugin.name);
112
+ const second = await collect(plugin.dir, plugin.name);
113
+
114
+ // Same function instance means the cache hit — dispatch after dispatch
115
+ // costs a stat, not an import.
116
+ expect(second[0]!.fn).toBe(first[0]!.fn);
117
+ });
118
+
119
+ test("a deleted hook stops dispatching; recreating it serves the new content", async () => {
120
+ const plugin = makePlugin();
121
+ const path = writeHookFile(plugin.hooksDir, "original");
122
+ expect(await dispatchOne(plugin.dir, plugin.name)).toBe("original");
123
+
124
+ unlinkSync(path);
125
+ expect(await collect(plugin.dir, plugin.name)).toHaveLength(0);
126
+
127
+ // The recreated file must dispatch its own content, not the module the
128
+ // registry cached for the deleted original.
129
+ writeHookFile(plugin.hooksDir, "recreated");
130
+ expect(await dispatchOne(plugin.dir, plugin.name)).toBe("recreated");
131
+ });
132
+
133
+ test("standalone workspace hooks reload the same way", async () => {
134
+ const wsHooksDir = getWorkspaceHooksDir();
135
+ mkdirSync(wsHooksDir, { recursive: true });
136
+ const path = writeHookFile(wsHooksDir, "ws-v1");
137
+
138
+ try {
139
+ const before = await collectUserHookEntries(HOOK_NAME, []);
140
+ expect(before).toHaveLength(1);
141
+ expect(before[0]!.owner.kind).toBe("workspace");
142
+ expect((before[0]!.fn as () => unknown)()).toBe("ws-v1");
143
+
144
+ writeHookFile(wsHooksDir, "ws-v2");
145
+ const after = await collectUserHookEntries(HOOK_NAME, []);
146
+ expect((after[0]!.fn as () => unknown)()).toBe("ws-v2");
147
+ } finally {
148
+ // The workspace hooks dir is shared per-process test state — leave it
149
+ // the way this test found it.
150
+ unlinkSync(path);
151
+ }
152
+ });
153
+ });
@@ -37,7 +37,11 @@ import type {
37
37
  ShutdownContext,
38
38
  } from "../plugin-api/types.js";
39
39
  import { listSurfaceDir } from "../plugins/external-plugin-loader.js";
40
- import { getMtime, importWithTimeout } from "../plugins/surface-import.js";
40
+ import {
41
+ evictModule,
42
+ getMtime,
43
+ importWithTimeout,
44
+ } from "../plugins/surface-import.js";
41
45
  import type { HookEntry } from "../plugins/types.js";
42
46
  import { getLogger } from "../util/logger.js";
43
47
  import { getWorkspaceDir, getWorkspaceHooksDir } from "../util/platform.js";
@@ -112,6 +116,18 @@ async function resolveCachedHook<TCtx>(
112
116
  return undefined;
113
117
  }
114
118
 
119
+ // The file is new, recreated, or edited since it was last imported. Evict
120
+ // it from the runtime module registry first: without this, the import
121
+ // below would return the module Bun cached at the old content and the
122
+ // edit would never take effect. No-op for a first-ever load.
123
+ //
124
+ // Note the reload swaps only the exported function for future dispatches.
125
+ // The hook file's top-level code runs again on re-import, and nothing
126
+ // tears down the previous instance — so hook files must keep long-lived
127
+ // side effects (timers, listeners) in the owner's `init`/`shutdown`
128
+ // lifecycle, not at module top level.
129
+ evictModule(filePath);
130
+
115
131
  try {
116
132
  const hook = await importWithTimeout<HookFunction>(filePath);
117
133
  if (hook === undefined || typeof hook !== "function") {
@@ -142,9 +158,12 @@ async function resolveCachedHook<TCtx>(
142
158
  * a plugin can shape the threaded context before a workspace-wide hook
143
159
  * observes or finalizes it.
144
160
  *
145
- * Added and removed hook files are picked up live (discovery is by directory
146
- * listing). A content edit to an existing file is only reflected after a
147
- * process restart, since Bun caches dynamic imports by resolved path.
161
+ * Added, removed, and edited hook files are all picked up live: discovery is
162
+ * by directory listing, and a changed source mtime evicts the module from
163
+ * the runtime registry before re-import, so the edited hook takes effect on
164
+ * the next dispatch without a daemon restart. Eviction is per-file — an edit
165
+ * to a helper module a hook imports is not detected (the hook file's own
166
+ * mtime is what's watched) and still needs a restart.
148
167
  *
149
168
  * `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null, a
150
169
  * plugin whose name is not in the set is skipped (its hooks do not run for this
@@ -168,7 +187,9 @@ export async function collectUserHookEntries<TCtx = unknown>(
168
187
  const hookFile = listSurfaceDir(join(pluginDir, "hooks")).find(
169
188
  (f) => f.name === hookName,
170
189
  );
171
- if (hookFile === undefined) continue;
190
+ if (hookFile === undefined) {
191
+ continue;
192
+ }
172
193
 
173
194
  const hook = await resolveCachedHook<TCtx>(
174
195
  pluginName,
@@ -233,7 +254,14 @@ export async function preImportHooksDir(
233
254
  for (const file of listSurfaceDir(hooksDir)) {
234
255
  const key = hookKey(ownerName, file.name);
235
256
  const currentMtime = getMtime(file.path);
236
- if (currentMtime === 0) continue;
257
+ if (currentMtime === 0) {
258
+ continue;
259
+ }
260
+
261
+ // A plugin directory can be removed and reinstalled while the daemon
262
+ // runs; evict any module cached from the prior install so the
263
+ // pre-import reflects what's on disk now. No-op at boot.
264
+ evictModule(file.path);
237
265
 
238
266
  try {
239
267
  const hook = await importWithTimeout<HookFunction>(file.path);
@@ -293,7 +321,9 @@ export async function runInitHook(
293
321
  pluginDir: string | null = null,
294
322
  ): Promise<void> {
295
323
  const initHookEntry = hookCache.get(hookKey(ownerName, HOOKS.INIT));
296
- if (initHookEntry === undefined) return;
324
+ if (initHookEntry === undefined) {
325
+ return;
326
+ }
297
327
 
298
328
  try {
299
329
  const initContext: InitContext = {
@@ -323,7 +353,9 @@ export async function runShutdownHook(
323
353
  reason: string,
324
354
  ): Promise<void> {
325
355
  const shutdownHookEntry = hookCache.get(hookKey(ownerName, HOOKS.SHUTDOWN));
326
- if (shutdownHookEntry === undefined) return;
356
+ if (shutdownHookEntry === undefined) {
357
+ return;
358
+ }
327
359
 
328
360
  try {
329
361
  await shutdownHookEntry.hook(context);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Tests for the monitor-side read of in-flight conversations. A minimal
3
+ * conversations table (the columns the query touches) is created in the
4
+ * per-test workspace database via a separate writer connection; the module
5
+ * under test reads it through its own read-only connection.
6
+ */
7
+
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { getDbPath } from "../../util/platform.js";
14
+ import { readActiveConversations } from "../active-conversations.js";
15
+
16
+ function openWriter(): Database {
17
+ mkdirSync(dirname(getDbPath()), { recursive: true });
18
+ const db = new Database(getDbPath());
19
+ db.run(
20
+ `CREATE TABLE IF NOT EXISTS conversations (
21
+ id TEXT PRIMARY KEY,
22
+ title TEXT,
23
+ origin_channel TEXT,
24
+ origin_interface TEXT,
25
+ processing_started_at INTEGER
26
+ )`,
27
+ );
28
+ return db;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ const db = openWriter();
33
+ db.run("DELETE FROM conversations");
34
+ db.close();
35
+ });
36
+
37
+ describe("readActiveConversations", () => {
38
+ test("returns processing conversations, longest-running first", () => {
39
+ const db = openWriter();
40
+ db.run(
41
+ "INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
42
+ ["conv-new", "Newer turn", "slack", "web", 2_000],
43
+ );
44
+ db.run(
45
+ "INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
46
+ ["conv-old", "Memory consolidation", null, null, 1_000],
47
+ );
48
+ db.run(
49
+ "INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
50
+ ["conv-idle", "Finished turn", null, null, null],
51
+ );
52
+ db.close();
53
+
54
+ expect(readActiveConversations()).toEqual([
55
+ {
56
+ conversationId: "conv-old",
57
+ title: "Memory consolidation",
58
+ originChannel: null,
59
+ originInterface: null,
60
+ processingStartedAt: 1_000,
61
+ },
62
+ {
63
+ conversationId: "conv-new",
64
+ title: "Newer turn",
65
+ originChannel: "slack",
66
+ originInterface: "web",
67
+ processingStartedAt: 2_000,
68
+ },
69
+ ]);
70
+ });
71
+
72
+ test("truncates long titles", () => {
73
+ const db = openWriter();
74
+ db.run(
75
+ "INSERT INTO conversations (id, title, processing_started_at) VALUES (?, ?, ?)",
76
+ ["conv-long", "x".repeat(500), 1_000],
77
+ );
78
+ db.close();
79
+
80
+ const active = readActiveConversations();
81
+ expect(active![0].title).toHaveLength(80);
82
+ });
83
+
84
+ test("returns null when nothing is processing", () => {
85
+ expect(readActiveConversations()).toBeNull();
86
+ });
87
+ });
@@ -21,6 +21,7 @@ function makeSample(overrides: Partial<ResourceSample>): ResourceSample {
21
21
  events: null,
22
22
  deltas: null,
23
23
  disk: null,
24
+ activeConversations: null,
24
25
  ...overrides,
25
26
  };
26
27
  }
@@ -28,6 +28,7 @@ function makeSample(ts: number): ResourceSample {
28
28
  events: null,
29
29
  deltas: null,
30
30
  disk: null,
31
+ activeConversations: null,
31
32
  };
32
33
  }
33
34