diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 00:17:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 00:17:16 +0900 |
| commit | 038e0d72e0fb66b293cb9dbb93c25c4818cf07c3 (patch) | |
| tree | 69e45ec478c839dc30712dea29a2fec3494dcfe9 | |
| parent | b3fc161c846f6495815e69b013ea5e4f4b73d3f5 (diff) | |
| download | dispatch-038e0d72e0fb66b293cb9dbb93c25c4818cf07c3.tar.gz dispatch-038e0d72e0fb66b293cb9dbb93c25c4818cf07c3.zip | |
feat(heartbeat): send heartbeat conversations to a dedicated heartbeat workspace
| -rw-r--r-- | packages/heartbeat/src/extension.ts | 6 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.test.ts | 96 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.ts | 51 | ||||
| -rw-r--r-- | packages/heartbeat/src/index.ts | 1 |
4 files changed, 149 insertions, 5 deletions
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts index 0b2d0d3..81e675a 100644 --- a/packages/heartbeat/src/extension.ts +++ b/packages/heartbeat/src/extension.ts @@ -84,6 +84,12 @@ export const extension: Extension = { // 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, }); // Reconcile stale runs + arm enabled workspaces on boot. diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts index 770d2ad..b2a0644 100644 --- a/packages/heartbeat/src/heartbeat.test.ts +++ b/packages/heartbeat/src/heartbeat.test.ts @@ -65,6 +65,7 @@ interface PendingTurn { readonly modelName?: string; readonly reasoningEffort?: unknown; readonly workspaceId?: string; + readonly cwd?: string; resolve: () => void; } @@ -117,6 +118,7 @@ function createFakeOrchestrator(): SessionOrchestrator & { ? { reasoningEffort: input.reasoningEffort } : {}), ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), resolve, }; pending.push(entry); @@ -154,6 +156,7 @@ function createService(opts: { }, ) => Promise<string>; readonly getGlobalSystemPrompt?: () => Promise<string>; + readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; }) { const fake = createFakeTimers(); let id = 0; @@ -167,6 +170,7 @@ function createService(opts: { ...(opts.getGlobalSystemPrompt !== undefined ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt } : {}), + ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}), }); return { svc, advance: fake.advance, storage }; } @@ -210,7 +214,9 @@ describe("createHeartbeatService", () => { expect(turn.systemPrompt).toBe("you are a monitor"); expect(turn.modelName).toBe("opencode/gpt-4o"); expect(turn.reasoningEffort).toBe("high"); - expect(turn.workspaceId).toBe("ws-1"); + // Spawned conversations go to the DEDICATED heartbeat workspace (not + // the configured workspace), while the run stays tracked under ws-1. + expect(turn.workspaceId).toBe("heartbeat"); expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); @@ -219,6 +225,88 @@ describe("createHeartbeatService", () => { expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed"); }); + it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + // Two DIFFERENT workspaces each configure a heartbeat. + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); // both fire (1-minute interval) + await flush(); + expect(orch.pending).toHaveLength(2); + // Every spawned conversation is filed in the heartbeat workspace — + // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's + // tabs. The run history, however, stays tracked per configured workspace. + for (const turn of orch.pending) { + expect(turn.workspaceId).toBe("heartbeat"); + } + // Run history is still per configured workspace. + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); + expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running"); + for (const turn of orch.pending) { + turn.resolve(); + } + await flush(); + }); + + it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ + orch, + // The configured workspace has a defaultCwd. + getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null), + }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + // The conversation is filed in the heartbeat workspace … + expect(turn.workspaceId).toBe("heartbeat"); + // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so + // tools run where the prompt's [prompt:cwd] advertises (not the + // heartbeat workspace's empty defaultCwd → process.cwd()). + expect(turn.cwd).toBe("/home/proj/ws-1"); + turn.resolve(); + await flush(); + }); + + it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ + orch, + // The configured workspace has NO defaultCwd. + getWorkspaceCwd: () => Promise.resolve(null), + }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.workspaceId).toBe("heartbeat"); + // No cwd override sent — the orchestrator resolves the turn cwd from + // the heartbeat workspace (no defaultCwd → server default cwd). + expect(turn.cwd).toBeUndefined(); + turn.resolve(); + await flush(); + }); + + it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => { + const orch = createFakeOrchestrator(); + // No getWorkspaceCwd → no cwd override (the default). + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.workspaceId).toBe("heartbeat"); + expect(turn.cwd).toBeUndefined(); + turn.resolve(); + await flush(); + }); + it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => { const orch = createFakeOrchestrator(); const { svc, advance } = createService({ orch }); @@ -508,7 +596,9 @@ describe("createHeartbeatService", () => { await svc2.startAll(); fake.advance(60_000); await flush(); - expect(orch.pending.filter((t) => t.workspaceId === "ws-1")).toHaveLength(1); - expect(orch.pending.filter((t) => t.workspaceId === "ws-2")).toHaveLength(0); + // ws-1 (enabled) fired → its spawned conversation is filed in the + // heartbeat workspace; ws-2 (disabled) never fired. + expect(orch.pending).toHaveLength(1); + expect(orch.pending[0]?.workspaceId).toBe("heartbeat"); }); }); diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts index 118e244..cb5afb4 100644 --- a/packages/heartbeat/src/heartbeat.ts +++ b/packages/heartbeat/src/heartbeat.ts @@ -12,6 +12,16 @@ import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js" import { HeartbeatScheduler, realTimers, type Timers } from "./scheduler.js"; /** + * The dedicated workspace heartbeat-spawned conversations are filed in (NOT the + * configured workspace). The config + run history stay per-workspace (tracked + * under the configured workspaceId); only the spawned conversation's PLACEMENT + * moves here, so heartbeat conversations don't clog the configured workspace's + * tabs. The orchestrator auto-creates this workspace on first fire (via + * `ensureWorkspace`), so it appears on the workspaces home page like any other. + */ +export const HEARTBEAT_WORKSPACE_ID = "heartbeat"; + +/** * The heartbeat service surface — what transport-http consumes and what the * extension wires into the host. */ @@ -83,6 +93,19 @@ export interface HeartbeatServiceDeps { * wires the real getter; tests inject a fake. */ readonly getGlobalSystemPrompt?: () => Promise<string>; + /** + * The configured workspace's `defaultCwd` (or `null` when the workspace has + * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's + * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the + * turn's tools run in the same directory the prompt's `[prompt:cwd]` + * variable advertises. Passed to the orchestrator as an explicit `cwd` + * override only when non-null; when `null` no override is sent and the + * orchestrator falls back to the server default cwd (matching the + * pre-heartbeat-workspace behavior for a workspace without a defaultCwd). + * When omitted, `null` (no override) — the extension wires the real getter + * (against `conversationStore.getWorkspace`); tests inject a fake. + */ + readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; } interface ActiveRun { @@ -106,6 +129,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer // extension wires the real getter so empty INHERITS the global system // prompt template (GET /system-prompt); tests inject a fake. const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve("")); + // Default: no cwd override (the orchestrator resolves the turn cwd from the + // conversation's workspace). The extension wires the real getter so the + // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake. + const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null)); // runId → active-run tracking (in-memory; the durable record lives in the // run store). Used to (a) map a stop request to its conversation, and @@ -156,9 +183,16 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer const baseSystemPrompt = config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt; const resolveCtx = { workspaceId, conversationId, model: config.model }; - const [systemPrompt, taskPrompt] = await Promise.all([ + // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates + // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and + // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned + // conversation is filed in). The turn's cwd is pinned to the SAME + // configured workspace's defaultCwd (below) so tools run where the + // prompt's `[prompt:cwd]` advertises. + const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([ resolvePrompt(baseSystemPrompt, resolveCtx), resolvePrompt(config.taskPrompt, resolveCtx), + getWorkspaceCwd(workspaceId), ]); try { @@ -177,7 +211,20 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer systemPrompt, ...(config.model !== "" ? { modelName: config.model } : {}), ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}), - workspaceId, + // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so + // the heartbeat's tools run in the same directory its prompt + // variables advertise — NOT the heartbeat workspace's (empty) + // defaultCwd → process.cwd(). Omitted when the configured + // workspace has no defaultCwd (the orchestrator then falls back + // to the server default cwd, matching the pre-heartbeat- + // workspace behavior for a workspace without one). + ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}), + // File the spawned conversation in the DEDICATED heartbeat + // workspace (not the configured workspace) so heartbeat + // conversations don't clog the configured workspace's tabs. The + // orchestrator auto-creates this workspace on first fire + // (ensureWorkspace), so it appears on the workspaces home page. + workspaceId: HEARTBEAT_WORKSPACE_ID, }); } finally { const entry = activeRuns.get(runId); diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts index 985ad68..3c2cbaa 100644 --- a/packages/heartbeat/src/index.ts +++ b/packages/heartbeat/src/index.ts @@ -8,6 +8,7 @@ export { export { extension, manifest } from "./extension.js"; export { createHeartbeatService, + HEARTBEAT_WORKSPACE_ID, type HeartbeatService, type HeartbeatServiceDeps, heartbeatServiceHandle, |
