@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 +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +5 -11
- package/src/__tests__/heartbeat-service.test.ts +29 -17
- package/src/background-wake/wake-intent-hooks.test.ts +4 -3
- package/src/daemon/lifecycle.ts +11 -51
- package/src/daemon/shutdown-handlers.ts +11 -11
- package/src/filing/filing-service.ts +20 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +45 -39
- package/src/heartbeat/heartbeat-service.ts +24 -11
- package/src/workspace/heartbeat-service.ts +29 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
//
|
|
239
|
-
//
|
|
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
|
-
/
|
|
242
|
+
/registerBackgroundWakeRuntime\(\{ scheduler, heartbeat \}\);\n[ \t]*refreshBackgroundWakeIntent\("daemon-startup"\);/,
|
|
242
243
|
);
|
|
243
244
|
});
|
|
244
245
|
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
36
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
1324
|
-
workspaceHeartbeat.start();
|
|
1323
|
+
startWorkspaceHeartbeatService();
|
|
1325
1324
|
|
|
1326
|
-
const
|
|
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
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
|
|
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
|
|
4
|
-
import
|
|
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
|
|
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
|
|
74
|
-
await
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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
|
|
326
|
-
// outer-timeout failure to surface.
|
|
327
|
-
expect(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|