summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 00:17:16 +0900
committerAdam Malczewski <[email protected]>2026-06-27 00:17:16 +0900
commit038e0d72e0fb66b293cb9dbb93c25c4818cf07c3 (patch)
tree69e45ec478c839dc30712dea29a2fec3494dcfe9
parentb3fc161c846f6495815e69b013ea5e4f4b73d3f5 (diff)
downloaddispatch-038e0d72e0fb66b293cb9dbb93c25c4818cf07c3.tar.gz
dispatch-038e0d72e0fb66b293cb9dbb93c25c4818cf07c3.zip
feat(heartbeat): send heartbeat conversations to a dedicated heartbeat workspace
-rw-r--r--packages/heartbeat/src/extension.ts6
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts96
-rw-r--r--packages/heartbeat/src/heartbeat.ts51
-rw-r--r--packages/heartbeat/src/index.ts1
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,