diff options
Diffstat (limited to 'packages/heartbeat/src/extension.ts')
| -rw-r--r-- | packages/heartbeat/src/extension.ts | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts new file mode 100644 index 0000000..b044619 --- /dev/null +++ b/packages/heartbeat/src/extension.ts @@ -0,0 +1,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; + }, +}; |
