1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
/**
* Heartbeat extension — manifest + activate(host).
*
* Wires the heartbeat service against the session-orchestrator + a storage
* namespace, registers the typed service handle, and arms every enabled
* workspace's scheduler on boot. Prompt templates (`systemPrompt` /
* `taskPrompt`) are resolved against the SAME variable catalog the global
* system-prompt template uses (via the system-prompt service's `resolveText`),
* so `[type:name]` placeholders reach the model substituted — not raw. An empty
* heartbeat `systemPrompt` inherits the global system prompt template (via
* `getTemplate`) before variable resolution runs — empty = inherit, not "no
* system prompt".
*/
import type { ConversationStore } from "@dispatch/conversation-store";
import { conversationStoreHandle } from "@dispatch/conversation-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import {
type SessionOrchestrator,
sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { systemPromptHandle } from "@dispatch/system-prompt";
import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js";
export const manifest: Manifest = {
id: "heartbeat",
name: "Heartbeat",
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
// system-prompt provides `resolveText` (the variable resolver used to
// substitute [type:name] placeholders in heartbeat prompts); conversation-
// store resolves the workspace's default cwd (the resolver runs git / reads
// files against it, mirroring the global template). Both lookups are lazy
// (at fire time, not activation), but declaring them keeps the DAG honest.
dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"],
activation: "eager",
contributes: { services: ["heartbeat"] },
};
// Module-scoped store for deactivate (the extension object is created once).
const store: { service: { stopAll: () => void } | null } = { service: null };
export const extension: Extension = {
manifest,
async activate(host: HostAPI) {
const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle);
const storage = host.storage("heartbeat");
const logger = host.logger;
// Resolve [type:name] placeholders in heartbeat prompts via the
// system-prompt service (same resolver + variables as the global
// template). The cwd is the workspace's defaultCwd (resolved the same
// way the orchestrator resolves a new conversation's effective cwd);
// falling back to process.cwd() when the workspace has none. Both
// services are declared `dependsOn` (always activated before heartbeat).
const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle);
const conversationStore = host.getService<ConversationStore>(conversationStoreHandle);
const resolvePrompt = async (
template: string,
ctx: {
readonly workspaceId: string;
readonly conversationId: string;
readonly model: string;
},
): Promise<string> => {
const workspace = await conversationStore.getWorkspace(ctx.workspaceId);
const cwd = workspace?.defaultCwd ?? process.cwd();
return systemPromptService.resolveText(template, cwd, {
...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}),
...(ctx.model !== "" ? { model: ctx.model } : {}),
workspaceId: ctx.workspaceId,
});
};
const service = createHeartbeatService({
storage,
orchestrator,
logger,
resolvePrompt,
// CR-HB-2: an empty heartbeat systemPrompt inherits the global
// system prompt template (GET /system-prompt / regular
// conversations resolve) before variable resolution runs.
getGlobalSystemPrompt: () => systemPromptService.getTemplate(),
// Pin the heartbeat turn's cwd to the CONFIGURED workspace's
// defaultCwd (not the heartbeat workspace's empty defaultCwd) so
// the turn's tools run where the prompt's [prompt:cwd] advertises.
// Lazy (resolved at fire time, mirroring resolvePrompt).
getWorkspaceCwd: async (wsId) =>
(await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
// inactiveOnly: the configured workspace is "busy" while any of its
// conversations are driving or queued for a turn. The orchestrator
// sets persisted status "active" on turn start and "idle" on settle,
// so a single store read (filtered to the configured workspaceId) is
// the live active-agent check. The heartbeat's own spawned conversation
// lives in the DEDICATED heartbeat workspace, so it never self-blocks.
hasActiveAgents: async (wsId) => {
const active = await conversationStore.listConversations({
workspaceId: wsId,
status: ["active", "queued"],
});
return active.length > 0;
},
});
// Reconcile stale runs + arm enabled workspaces on boot.
await service.startAll();
store.service = service;
host.provideService(heartbeatServiceHandle, service);
host.logger.info("heartbeat extension activated");
},
deactivate() {
// Stop schedulers so no new fires happen during shutdown.
store.service?.stopAll();
store.service = null;
},
};
|