@vellumai/assistant 0.10.3-dev.202606281440.a7b42bb → 0.10.3-dev.202606281633.f2981fa

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606281440.a7b42bb",
3
+ "version": "0.10.3-dev.202606281633.f2981fa",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -70,8 +70,8 @@ mock.module("../schedule/recurrence-engine.js", () => ({
70
70
 
71
71
  const createdConversations: Array<{ conversationType: string }> = [];
72
72
  mock.module("../memory/conversation-crud.js", () => ({
73
- setConversationProcessingStartedAt: () => {},
74
- isConversationProcessing: () => false,
73
+ setConversationProcessingStartedAt: () => {},
74
+ isConversationProcessing: () => false,
75
75
  getConversation: () => null,
76
76
  getMessages: () => [],
77
77
  createConversation: (opts: { conversationType: string }) => {
@@ -164,9 +164,7 @@ describe("HeartbeatService disk pressure gate", () => {
164
164
  });
165
165
 
166
166
  test("skips without creating heartbeat rows, conversations, or notifications", async () => {
167
- const service = new HeartbeatService({
168
- alerter: () => {},
169
- });
167
+ const service = new HeartbeatService();
170
168
 
171
169
  const ran = await service.runOnce();
172
170
 
@@ -181,9 +179,7 @@ describe("HeartbeatService disk pressure gate", () => {
181
179
  });
182
180
 
183
181
  test("allows forced user-initiated heartbeat runs while locked", async () => {
184
- const service = new HeartbeatService({
185
- alerter: () => {},
186
- });
182
+ const service = new HeartbeatService();
187
183
 
188
184
  const ran = await service.runOnce({ force: true });
189
185
 
@@ -199,9 +195,7 @@ describe("HeartbeatService disk pressure gate", () => {
199
195
 
200
196
  test("start recovery skips missed-run notifications while locked", async () => {
201
197
  mockMarkStaleRunsAsMissed.mockImplementationOnce(() => 1);
202
- const service = new HeartbeatService({
203
- alerter: () => {},
204
- });
198
+ const service = new HeartbeatService();
205
199
 
206
200
  service.start();
207
201
  await service.stop();
@@ -137,8 +137,8 @@ const mockStoredMessages: Array<{
137
137
  }> = [];
138
138
 
139
139
  mock.module("../memory/conversation-crud.js", () => ({
140
- setConversationProcessingStartedAt: () => {},
141
- isConversationProcessing: () => false,
140
+ setConversationProcessingStartedAt: () => {},
141
+ isConversationProcessing: () => false,
142
142
  setConversationOriginChannelIfUnset: () => {},
143
143
  updateConversationContextWindow: () => {},
144
144
  deleteMessageById: () => {},
@@ -335,6 +335,23 @@ const LLM_DEFAULT = {
335
335
  },
336
336
  };
337
337
 
338
+ // Capture broadcastMessage so tests can observe the alerts and
339
+ // conversation-created events the heartbeat service emits directly.
340
+ type BroadcastedMessage = { type: string; [key: string]: unknown };
341
+ const broadcastedMessages: BroadcastedMessage[] = [];
342
+ let onBroadcast: ((msg: BroadcastedMessage) => void) | null = null;
343
+
344
+ mock.module("../runtime/assistant-event-hub.js", () => ({
345
+ assistantEventHub: {
346
+ publish: async () => {},
347
+ subscribe: () => () => {},
348
+ },
349
+ broadcastMessage: (msg: BroadcastedMessage) => {
350
+ broadcastedMessages.push(msg);
351
+ onBroadcast?.(msg);
352
+ },
353
+ }));
354
+
338
355
  describe("HeartbeatService", () => {
339
356
  let processMessageCalls: Array<{
340
357
  conversationId: string;
@@ -353,6 +370,12 @@ describe("HeartbeatService", () => {
353
370
  beforeEach(() => {
354
371
  processMessageCalls = [];
355
372
  alerterCalls = [];
373
+ broadcastedMessages.length = 0;
374
+ onBroadcast = (msg) => {
375
+ if (msg.type === "heartbeat_alert") {
376
+ alerterCalls.push(msg as { type: string; title: string; body: string });
377
+ }
378
+ };
356
379
  createdConversations.length = 0;
357
380
  conversationIdCounter = 0;
358
381
  mockStoredMessages.length = 0;
@@ -413,19 +436,11 @@ describe("HeartbeatService", () => {
413
436
  function createService(overrides?: {
414
437
  processMessage?: (...args: unknown[]) => Promise<{ messageId: string }>;
415
438
  getCurrentHour?: () => number;
416
- onConversationCreated?: (info: {
417
- conversationId: string;
418
- title: string;
419
- }) => void;
420
439
  }) {
421
440
  if (overrides?.processMessage) {
422
441
  setTestProcessMessage(overrides.processMessage);
423
442
  }
424
443
  return new HeartbeatService({
425
- alerter: (alert: { type: string; title: string; body: string }) => {
426
- alerterCalls.push(alert);
427
- },
428
- onConversationCreated: overrides?.onConversationCreated,
429
444
  getCurrentHour: overrides?.getCurrentHour,
430
445
  });
431
446
  }
@@ -770,17 +785,14 @@ describe("HeartbeatService", () => {
770
785
  });
771
786
 
772
787
  test("conversation surfaces to the sidebar on every successful run", async () => {
773
- const conversationCreatedCalls: Array<{
774
- conversationId: string;
775
- title: string;
776
- }> = [];
777
- const service = createService({
778
- onConversationCreated: (info) => conversationCreatedCalls.push(info),
779
- });
788
+ const service = createService();
780
789
 
781
790
  await service.runOnce();
782
791
  await new Promise((resolve) => setTimeout(resolve, 0));
783
792
 
793
+ const conversationCreatedCalls = broadcastedMessages
794
+ .filter((m) => m.type === "heartbeat_conversation_created")
795
+ .map((m) => ({ conversationId: m.conversationId, title: m.title }));
784
796
  expect(conversationCreatedCalls).toEqual([
785
797
  { conversationId: "conv-1", title: "Heartbeat" },
786
798
  ]);
@@ -235,10 +235,11 @@ describe("background wake intent publisher hooks", () => {
235
235
  "utf-8",
236
236
  );
237
237
 
238
- // Match the three calls in order, tolerant of indentation, so the
239
- // assertion survives reformatting/re-indentation of lifecycle.ts.
238
+ // The wake intent is published once, immediately after the scheduler and
239
+ // heartbeat are wired into the background-wake runtime. Tolerant of
240
+ // indentation so the assertion survives reformatting of lifecycle.ts.
240
241
  expect(lifecycleSource).toMatch(
241
- /heartbeat\.start\(\);\n[ \t]*registerBackgroundWakeRuntime\(\{ scheduler, heartbeat \}\);\n[ \t]*refreshBackgroundWakeIntent\("daemon-startup"\);/,
242
+ /registerBackgroundWakeRuntime\(\{ scheduler, heartbeat \}\);\n[ \t]*refreshBackgroundWakeIntent\("daemon-startup"\);/,
242
243
  );
243
244
  });
244
245
 
@@ -32,8 +32,8 @@ import {
32
32
  DEFAULT_CES_STARTUP_TIMEOUT_MS,
33
33
  injectCesClientWhenReady,
34
34
  } from "../credential-execution/startup-timeout.js";
35
- import { FilingService } from "../filing/filing-service.js";
36
- import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
35
+ import { startFilingService } from "../filing/filing-service.js";
36
+ import { startHeartbeatService } from "../heartbeat/heartbeat-service.js";
37
37
  import { backfillRelationshipStateIfMissing } from "../home/relationship-state-writer.js";
38
38
  import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
39
39
  import { startGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
@@ -104,7 +104,7 @@ import {
104
104
  } from "../work-items/work-item-store.js";
105
105
  import { getWorkflowRunManager } from "../workflows/run-manager.js";
106
106
  import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/adaptive-thinking-repair.js";
107
- import { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
107
+ import { startWorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
108
108
  import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
109
109
  import { runWorkspaceMigrations } from "../workspace/migrations/runner.js";
110
110
  import { writePid } from "./daemon-control.js";
@@ -1320,59 +1320,19 @@ export async function runDaemon(): Promise<void> {
1320
1320
  });
1321
1321
  }
1322
1322
 
1323
- const workspaceHeartbeat = new WorkspaceHeartbeatService();
1324
- workspaceHeartbeat.start();
1323
+ startWorkspaceHeartbeatService();
1325
1324
 
1326
- const heartbeatConfig = config.heartbeat;
1327
- const heartbeat = new HeartbeatService({
1328
- alerter: (alert) => broadcastMessage(alert),
1329
- onConversationCreated: (info) =>
1330
- broadcastMessage({
1331
- type: "heartbeat_conversation_created",
1332
- conversationId: info.conversationId,
1333
- title: info.title,
1334
- }),
1335
- });
1336
- heartbeat.start();
1325
+ const heartbeat = startHeartbeatService();
1337
1326
  registerBackgroundWakeRuntime({ scheduler, heartbeat });
1338
1327
  refreshBackgroundWakeIntent("daemon-startup");
1339
- log.info(
1340
- {
1341
- enabled: heartbeatConfig.enabled,
1342
- intervalMs: heartbeatConfig.intervalMs,
1343
- },
1344
- "Heartbeat service configured",
1345
- );
1346
1328
 
1347
- // Filing yields to the memory v2 consolidation job when v2 is enabled —
1348
- // both serve the same role (periodic background memory processing) and
1349
- // running both is redundant. The consolidation job runs through the
1350
- // memory jobs worker (see `maybeEnqueueGraphMaintenanceJobs`).
1351
- const memoryV2Enabled = config.memory.v2.enabled;
1352
- let filing: FilingService | null = null;
1353
- if (!memoryV2Enabled) {
1354
- const filingConfig = config.filing;
1355
- filing = new FilingService();
1356
- filing.start();
1357
- log.info(
1358
- {
1359
- enabled: filingConfig.enabled,
1360
- intervalMs: filingConfig.intervalMs,
1361
- },
1362
- "Filing service configured",
1363
- );
1364
- } else {
1365
- log.info(
1366
- "Filing service skipped — memory v2 consolidation is the active background memory job",
1367
- );
1368
- }
1329
+ // Filing yields to the memory v2 consolidation job when v2 is enabled — both
1330
+ // serve the same role (periodic background memory processing) and running both
1331
+ // is redundant. The consolidation job runs through the memory jobs worker
1332
+ // (see `maybeEnqueueGraphMaintenanceJobs`).
1333
+ startFilingService();
1369
1334
 
1370
- installShutdownHandlers({
1371
- server,
1372
- workspaceHeartbeat,
1373
- heartbeat,
1374
- filing,
1375
- });
1335
+ installShutdownHandlers({ server });
1376
1336
 
1377
1337
  log.info(
1378
1338
  {
@@ -1,7 +1,7 @@
1
1
  import * as Sentry from "@sentry/node";
2
2
 
3
- import type { FilingService } from "../filing/filing-service.js";
4
- import type { HeartbeatService } from "../heartbeat/heartbeat-service.js";
3
+ import { stopFilingService } from "../filing/filing-service.js";
4
+ import { stopHeartbeatService } from "../heartbeat/heartbeat-service.js";
5
5
  import { stopGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
6
6
  import { stopMcpServerManager } from "../mcp/manager.js";
7
7
  import { getSqlite, resetDb } from "../memory/db-connection.js";
@@ -15,7 +15,10 @@ import { browserManager } from "../tools/browser/browser-manager.js";
15
15
  import { cleanupShellOutputTempFiles } from "../tools/shared/shell-output.js";
16
16
  import { getLogger } from "../util/logger.js";
17
17
  import { getEnrichmentService } from "../workspace/commit-message-enrichment-service.js";
18
- import type { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
18
+ import {
19
+ commitAllPendingWorkspaceChanges,
20
+ stopWorkspaceHeartbeatService,
21
+ } from "../workspace/heartbeat-service.js";
19
22
  import { cleanupPidFile } from "./daemon-control.js";
20
23
  import { stopEventLoopWatchdog } from "./event-loop-watchdog.js";
21
24
  import { stopDiskPressureGuardForLifecycle } from "./lifecycle.js";
@@ -40,9 +43,6 @@ function stopBackgroundServicesAndCleanupPidFile(): void {
40
43
 
41
44
  export interface ShutdownDeps {
42
45
  server: DaemonServer;
43
- workspaceHeartbeat: WorkspaceHeartbeatService;
44
- heartbeat: HeartbeatService;
45
- filing: FilingService | null;
46
46
  }
47
47
 
48
48
  export function installShutdownHandlers(deps: ShutdownDeps): void {
@@ -70,9 +70,9 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
70
70
  }, 20_000);
71
71
  forceTimer.unref();
72
72
 
73
- await deps.workspaceHeartbeat.stop();
74
- await deps.heartbeat.stop();
75
- if (deps.filing) await deps.filing.stop();
73
+ await stopWorkspaceHeartbeatService();
74
+ await stopHeartbeatService();
75
+ await stopFilingService();
76
76
 
77
77
  // Run registered skill shutdown hooks (e.g. meet-join session teardown)
78
78
  // before stopping the server so any HTTP round-trips and SSE emissions
@@ -87,7 +87,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
87
87
  // This ensures no workspace state is lost during graceful shutdown.
88
88
  try {
89
89
  log.info({ phase: "pre_stop" }, "Committing pending workspace changes");
90
- await deps.workspaceHeartbeat.commitAllPending();
90
+ await commitAllPendingWorkspaceChanges();
91
91
  } catch (err) {
92
92
  log.warn({ err, phase: "pre_stop" }, "Shutdown workspace commit failed");
93
93
  }
@@ -98,7 +98,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
98
98
  // (e.g. in-flight tool executions completing during drain).
99
99
  try {
100
100
  log.info({ phase: "post_stop" }, "Final workspace commit sweep");
101
- await deps.workspaceHeartbeat.commitAllPending();
101
+ await commitAllPendingWorkspaceChanges();
102
102
  } catch (err) {
103
103
  log.warn(
104
104
  { err, phase: "post_stop" },
@@ -417,6 +417,26 @@ export class FilingService {
417
417
  }
418
418
  }
419
419
 
420
+ /**
421
+ * Construct and start the filing service singleton. Skipped under memory v2 —
422
+ * the v2 consolidation job owns periodic background memory processing, so
423
+ * running filing too would be redundant.
424
+ */
425
+ export function startFilingService(): void {
426
+ if (getConfig().memory.v2.enabled) {
427
+ log.info(
428
+ "Filing service skipped — memory v2 consolidation is the active background memory job",
429
+ );
430
+ return;
431
+ }
432
+ new FilingService().start();
433
+ }
434
+
435
+ /** Stop the filing service singleton if one is running; no-op otherwise. */
436
+ export async function stopFilingService(): Promise<void> {
437
+ await FilingService.getInstance()?.stop();
438
+ }
439
+
420
440
  function isWithinActiveHours(
421
441
  hour: number,
422
442
  start: number,
@@ -9,12 +9,21 @@ let workspaceDir: string;
9
9
  // no-op in these tests.
10
10
  const publishSpy = mock<(event: unknown) => Promise<void>>(async () => {});
11
11
 
12
+ // Capture broadcastMessage calls so tests can assert on the alerts and
13
+ // conversation-created events the heartbeat service emits directly.
14
+ type BroadcastedMessage = { type: string; [key: string]: unknown };
15
+ const broadcastedMessages: BroadcastedMessage[] = [];
16
+ let onBroadcast: ((msg: BroadcastedMessage) => void) | null = null;
17
+
12
18
  mock.module("../../runtime/assistant-event-hub.js", () => ({
13
19
  assistantEventHub: {
14
20
  publish: publishSpy,
15
21
  subscribe: () => () => {},
16
22
  },
17
- broadcastMessage: () => {},
23
+ broadcastMessage: (msg: BroadcastedMessage) => {
24
+ broadcastedMessages.push(msg);
25
+ onBroadcast?.(msg);
26
+ },
18
27
  }));
19
28
 
20
29
  // Stub workspace prompt reads so the heartbeat service doesn't try to
@@ -224,6 +233,8 @@ beforeEach(() => {
224
233
  origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
225
234
  process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
226
235
  publishSpy.mockClear();
236
+ broadcastedMessages.length = 0;
237
+ onBroadcast = null;
227
238
  runBackgroundJobCalls.length = 0;
228
239
  skipHeartbeatRunCalls.length = 0;
229
240
  preFirstMessageGateOpen = true;
@@ -249,9 +260,7 @@ afterEach(() => {
249
260
 
250
261
  describe("HeartbeatService", () => {
251
262
  test("invokes runBackgroundJob with expected options on each tick", async () => {
252
- const service = new HeartbeatService({
253
- alerter: () => {},
254
- });
263
+ const service = new HeartbeatService();
255
264
 
256
265
  await service.runOnce({ force: true });
257
266
 
@@ -286,13 +295,17 @@ describe("HeartbeatService", () => {
286
295
  return { conversationId: STUB_CONVERSATION_ID, ok: true };
287
296
  };
288
297
 
289
- const service = new HeartbeatService({
290
- alerter: () => {},
291
- onConversationCreated: (info) => {
292
- created.push(info);
298
+ onBroadcast = (msg) => {
299
+ if (msg.type === "heartbeat_conversation_created") {
300
+ created.push({
301
+ conversationId: msg.conversationId as string,
302
+ title: msg.title as string,
303
+ });
293
304
  callbackFiredBeforeRunnerResolved = !runnerHasResolved;
294
- },
295
- });
305
+ }
306
+ };
307
+
308
+ const service = new HeartbeatService();
296
309
 
297
310
  await service.runOnce({ force: true });
298
311
 
@@ -314,17 +327,16 @@ describe("HeartbeatService", () => {
314
327
  return { conversationId: STUB_CONVERSATION_ID, ok: true };
315
328
  };
316
329
 
317
- const alerts: unknown[] = [];
318
- const service = new HeartbeatService({
319
- alerter: (alert) => alerts.push(alert),
320
- });
330
+ const service = new HeartbeatService();
321
331
 
322
332
  await service.runOnce({ force: true });
323
333
 
324
334
  expect(runnerCompleted).toBe(true);
325
- // No alerter call because the runner returned ok=true and there was no
326
- // outer-timeout failure to surface.
327
- expect(alerts).toHaveLength(0);
335
+ // No heartbeat_alert broadcast because the runner returned ok=true and
336
+ // there was no outer-timeout failure to surface.
337
+ expect(
338
+ broadcastedMessages.filter((m) => m.type === "heartbeat_alert"),
339
+ ).toHaveLength(0);
328
340
  });
329
341
 
330
342
  test("calls alerter with the failure message when the runner reports ok=false", async () => {
@@ -335,14 +347,13 @@ describe("HeartbeatService", () => {
335
347
  errorKind: "exception",
336
348
  });
337
349
 
338
- const alerts: Array<{ type: string; title: string; body: string }> = [];
339
- const service = new HeartbeatService({
340
- alerter: (alert) =>
341
- alerts.push(alert as { type: string; title: string; body: string }),
342
- });
350
+ const service = new HeartbeatService();
343
351
 
344
352
  await service.runOnce({ force: true });
345
353
 
354
+ const alerts = broadcastedMessages.filter(
355
+ (m) => m.type === "heartbeat_alert",
356
+ );
346
357
  expect(alerts).toHaveLength(1);
347
358
  expect(alerts[0]).toMatchObject({
348
359
  type: "heartbeat_alert",
@@ -352,21 +363,18 @@ describe("HeartbeatService", () => {
352
363
  });
353
364
 
354
365
  test("does not call alerter when the runner reports ok=true", async () => {
355
- const alerts: unknown[] = [];
356
- const service = new HeartbeatService({
357
- alerter: (alert) => alerts.push(alert),
358
- });
366
+ const service = new HeartbeatService();
359
367
 
360
368
  await service.runOnce({ force: true });
361
369
 
362
- expect(alerts).toHaveLength(0);
370
+ expect(
371
+ broadcastedMessages.filter((m) => m.type === "heartbeat_alert"),
372
+ ).toHaveLength(0);
363
373
  });
364
374
 
365
375
  test("scheduled run skips with reason 'pre_first_user_message' when the user has not yet interacted", async () => {
366
376
  preFirstMessageGateOpen = false;
367
- const service = new HeartbeatService({
368
- alerter: () => {},
369
- });
377
+ const service = new HeartbeatService();
370
378
  // start() seeds `_pendingRunId` via `scheduleNextRun` so the skip is
371
379
  // recorded with the pending run id. Without start() the test still
372
380
  // passes the no-LLM-call assertion but the skip record would be a
@@ -393,9 +401,7 @@ describe("HeartbeatService", () => {
393
401
 
394
402
  test("forced run bypasses the pre-first-message gate (manual operator action)", async () => {
395
403
  preFirstMessageGateOpen = false;
396
- const service = new HeartbeatService({
397
- alerter: () => {},
398
- });
404
+ const service = new HeartbeatService();
399
405
 
400
406
  await service.runOnce({ force: true });
401
407
 
@@ -408,7 +414,7 @@ describe("HeartbeatService", () => {
408
414
  describe("max consecutive runs cap", () => {
409
415
  test("skips with reason 'max_consecutive_runs' after the cap is hit", async () => {
410
416
  stubConfig.heartbeat.maxConsecutiveRuns = 2;
411
- const service = new HeartbeatService({ alerter: () => {} });
417
+ const service = new HeartbeatService();
412
418
 
413
419
  expect(await service.runOnce({ force: false })).toBe(true);
414
420
  expect(await service.runOnce({ force: false })).toBe(true);
@@ -422,7 +428,7 @@ describe("HeartbeatService", () => {
422
428
 
423
429
  test("resetTimer() clears the counter so auto runs resume", async () => {
424
430
  stubConfig.heartbeat.maxConsecutiveRuns = 1;
425
- const service = new HeartbeatService({ alerter: () => {} });
431
+ const service = new HeartbeatService();
426
432
  service.start();
427
433
  try {
428
434
  await service.runOnce({ force: false });
@@ -452,7 +458,7 @@ describe("HeartbeatService", () => {
452
458
  return { conversationId: STUB_CONVERSATION_ID, ok: true };
453
459
  };
454
460
 
455
- const service = new HeartbeatService({ alerter: () => {} });
461
+ const service = new HeartbeatService();
456
462
  service.start();
457
463
  try {
458
464
  const runPromise = service.runOnce({ force: false });
@@ -480,7 +486,7 @@ describe("HeartbeatService", () => {
480
486
 
481
487
  test("null disables the cap entirely", async () => {
482
488
  stubConfig.heartbeat.maxConsecutiveRuns = null;
483
- const service = new HeartbeatService({ alerter: () => {} });
489
+ const service = new HeartbeatService();
484
490
 
485
491
  for (let i = 0; i < 5; i++) {
486
492
  expect(await service.runOnce({ force: false })).toBe(true);
@@ -493,7 +499,7 @@ describe("HeartbeatService", () => {
493
499
 
494
500
  test("force runs bypass the cap and do not increment the counter", async () => {
495
501
  stubConfig.heartbeat.maxConsecutiveRuns = 2;
496
- const service = new HeartbeatService({ alerter: () => {} });
502
+ const service = new HeartbeatService();
497
503
 
498
504
  // Five force runs would push us well past the cap if force counted.
499
505
  for (let i = 0; i < 5; i++) {
@@ -511,7 +517,7 @@ describe("HeartbeatService", () => {
511
517
 
512
518
  test("reconfigure() resets the counter", async () => {
513
519
  stubConfig.heartbeat.maxConsecutiveRuns = 1;
514
- const service = new HeartbeatService({ alerter: () => {} });
520
+ const service = new HeartbeatService();
515
521
  service.start();
516
522
  try {
517
523
  await service.runOnce({ force: false });
@@ -16,6 +16,7 @@ import {
16
16
  resolveGuardianPersona,
17
17
  } from "../prompts/persona-resolver.js";
18
18
  import { isTemplateContent } from "../prompts/system-prompt.js";
19
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
19
20
  import { runBackgroundJob } from "../runtime/background-job-runner.js";
20
21
  import { hasReceivedUserMessage } from "../runtime/pre-first-message-gate.js";
21
22
  import { computeNextRunAt } from "../schedule/recurrence-engine.js";
@@ -110,11 +111,6 @@ function refreshBackgroundWakeIntentSoon(reason: string): void {
110
111
  }
111
112
 
112
113
  export interface HeartbeatDeps {
113
- alerter: (alert: HeartbeatAlert) => void;
114
- onConversationCreated?: (info: {
115
- conversationId: string;
116
- title: string;
117
- }) => void;
118
114
  /** Override for current hour (0-23), for testing. */
119
115
  getCurrentHour?: () => number;
120
116
  }
@@ -163,7 +159,7 @@ export class HeartbeatService {
163
159
  // after a guardian message can detect the reset and skip its increment.
164
160
  private _resetGeneration = 0;
165
161
 
166
- constructor(deps: HeartbeatDeps) {
162
+ constructor(deps: HeartbeatDeps = {}) {
167
163
  this.deps = deps;
168
164
  HeartbeatService.instance = this;
169
165
  }
@@ -670,13 +666,13 @@ export class HeartbeatService {
670
666
  } catch (err) {
671
667
  log.error({ err }, "Credential health check failed");
672
668
  try {
673
- this.deps.alerter({
669
+ broadcastMessage({
674
670
  type: "heartbeat_alert",
675
671
  title: "Credential Health Check Failed",
676
672
  body:
677
673
  "Could not verify OAuth credential health. " +
678
674
  (err instanceof Error ? err.message : String(err)),
679
- });
675
+ } satisfies HeartbeatAlert);
680
676
  } catch {
681
677
  // Last resort — alerter itself failed. Already logged above.
682
678
  }
@@ -798,7 +794,8 @@ export class HeartbeatService {
798
794
  deferNotifications: true,
799
795
  onConversationCreated: (newConversationId) => {
800
796
  conversationId = newConversationId;
801
- this.deps.onConversationCreated?.({
797
+ broadcastMessage({
798
+ type: "heartbeat_conversation_created",
802
799
  conversationId: newConversationId,
803
800
  title: "Heartbeat",
804
801
  });
@@ -860,11 +857,11 @@ export class HeartbeatService {
860
857
  // recovery sweep) already alerted for this run.
861
858
  if (transitioned) {
862
859
  try {
863
- this.deps.alerter({
860
+ broadcastMessage({
864
861
  type: "heartbeat_alert",
865
862
  title: "Heartbeat Failed",
866
863
  body: result.error?.message ?? "Unknown error",
867
- });
864
+ } satisfies HeartbeatAlert);
868
865
  } catch (alertErr) {
869
866
  log.error({ alertErr }, "Failed to broadcast heartbeat alert");
870
867
  }
@@ -919,6 +916,22 @@ This is one of your first heartbeats. Your user hasn't heard from you yet and ma
919
916
  }
920
917
  }
921
918
 
919
+ /**
920
+ * Construct and start the heartbeat service singleton, returning it so callers
921
+ * can wire it into the background-wake runtime. start() self-gates on
922
+ * `heartbeat.enabled` and logs its own status.
923
+ */
924
+ export function startHeartbeatService(): HeartbeatService {
925
+ const service = new HeartbeatService();
926
+ service.start();
927
+ return service;
928
+ }
929
+
930
+ /** Stop the heartbeat service singleton if one is running; no-op otherwise. */
931
+ export async function stopHeartbeatService(): Promise<void> {
932
+ await HeartbeatService.getInstance()?.stop();
933
+ }
934
+
922
935
  function isDiskPressureBackgroundLocked(logKey: string): boolean {
923
936
  const diskPressureGate = checkDiskPressureBackgroundGate("background-work");
924
937
  if (diskPressureGate.action === "allow") return false;
@@ -374,6 +374,35 @@ export class WorkspaceHeartbeatService {
374
374
  }
375
375
  }
376
376
 
377
+ // ---------------------------------------------------------------------------
378
+ // Singleton
379
+ // ---------------------------------------------------------------------------
380
+
381
+ let instance: WorkspaceHeartbeatService | null = null;
382
+
383
+ /** Construct and start the workspace heartbeat service singleton. */
384
+ export function startWorkspaceHeartbeatService(): void {
385
+ instance = new WorkspaceHeartbeatService();
386
+ instance.start();
387
+ }
388
+
389
+ /**
390
+ * Stop the workspace heartbeat service singleton if one is running. The instance
391
+ * is retained so a final commitAllPendingWorkspaceChanges() during shutdown can
392
+ * still flush after the periodic loop has stopped.
393
+ */
394
+ export async function stopWorkspaceHeartbeatService(): Promise<void> {
395
+ await instance?.stop();
396
+ }
397
+
398
+ /**
399
+ * Commit any uncommitted workspace changes via the singleton; no-op when the
400
+ * service was never started.
401
+ */
402
+ export async function commitAllPendingWorkspaceChanges(): Promise<void> {
403
+ await instance?.commitAllPending();
404
+ }
405
+
377
406
  /**
378
407
  * @internal Test-only: clear the dirty tracking state
379
408
  */