diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 92afb4a43f..57a7d80f25 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -520,6 +520,19 @@ If a value is too large for the environment, it may be omitted (not set). Mux al +
+heartbeat (5) + +| Env var | JSON path | Type | Description | +| ----------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace's heartbeat settings. | +| `MUX_TOOL_INPUT_CONTEXT_MODE` | `contextMode` | enum | set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode. | +| `MUX_TOOL_INPUT_ENABLED` | `enabled` | boolean | set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled". | +| `MUX_TOOL_INPUT_INTERVAL_MS` | `intervalMs` | number | set: heartbeat interval in milliseconds (300000–86400000). Omit to preserve the current interval or use the global default for new settings. | +| `MUX_TOOL_INPUT_MESSAGE` | `message` | string | set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message. | + +
+
memory (11) diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 8329d06f90..24af034597 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -23,18 +23,9 @@ import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; -import type { APIClient } from "@/browser/contexts/API"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; -// Mock API let currentClientMock: RecursivePartial = {}; -void mock.module("@/browser/contexts/API", () => ({ - useAPI: () => ({ - api: currentClientMock as APIClient, - status: "connected" as const, - error: null, - }), - APIProvider: ({ children }: { children: React.ReactNode }) => children, -})); // Helper to create test workspace metadata with default runtime config const createWorkspaceMetadata = ( @@ -1490,13 +1481,15 @@ async function setupWithProjectContext() { } render( - - - - - - - + + + + + + + + + ); // Inject client immediately to handle race conditions where effects run before store update. diff --git a/src/browser/hooks/useWorkspaceHeartbeat.test.tsx b/src/browser/hooks/useWorkspaceHeartbeat.test.tsx index 87baa5e88f..c8792dc0a4 100644 --- a/src/browser/hooks/useWorkspaceHeartbeat.test.tsx +++ b/src/browser/hooks/useWorkspaceHeartbeat.test.tsx @@ -1,10 +1,15 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { GlobalWindow } from "happy-dom"; -import * as APIModule from "@/browser/contexts/API"; -import * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type React from "react"; + +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { + WorkspaceContext, + type WorkspaceContext as WorkspaceContextValue, +} from "@/browser/contexts/WorkspaceContext"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { HeartbeatFormSettings } from "./useWorkspaceHeartbeat"; +import { installDom } from "../../../tests/ui/dom"; +import { useWorkspaceHeartbeat, type HeartbeatFormSettings } from "./useWorkspaceHeartbeat"; interface HeartbeatApi { workspace: { @@ -29,39 +34,33 @@ const TEST_WORKSPACE_ID = "workspace-1"; type WorkspaceMetadataMap = Map; type WorkspaceMetadataUpdater = (prev: WorkspaceMetadataMap) => WorkspaceMetadataMap; -let apiMock: HeartbeatApi | null = null; let capturedWorkspaceMetadataUpdate: WorkspaceMetadataUpdater | null = null; const setWorkspaceMetadataMock = mock((update: WorkspaceMetadataUpdater) => { capturedWorkspaceMetadataUpdate = update; }); -const actualAPIModule = { ...APIModule }; -const actualWorkspaceContextModule = { ...WorkspaceContextModule }; - -// Keep module mocks inside test hooks: Bun loads test files before afterAll runs, so -// file-scope mock.module() calls can pollute unrelated files during collection. -async function installWorkspaceHeartbeatModuleMocks() { - await mock.module("@/browser/contexts/API", () => ({ - ...actualAPIModule, - useAPI: () => ({ api: apiMock }), - })); - await mock.module("@/browser/contexts/WorkspaceContext", () => ({ - ...actualWorkspaceContextModule, - useWorkspaceActions: () => ({ +// Use real providers instead of mock.module(): Bun runs test files in one process, so +// module-level API/context mocks can leak into unrelated hook tests. +function createWrapper(api: HeartbeatApi): React.FC<{ children: React.ReactNode }> { + return function Wrapper(props) { + const workspaceContext = { + workspaceMetadata: new Map(), + loading: false, + loaded: true, + loadError: null, setWorkspaceMetadata: setWorkspaceMetadataMock, - }), - })); -} - -async function restoreWorkspaceHeartbeatModuleMocks() { - // Bun 1.3.6's mock.module() has no disposer, and mock.restore() does not undo - // module mocks. Restore the real exports so these stubs do not leak into later files. - await mock.module("@/browser/contexts/API", () => actualAPIModule); - await mock.module("@/browser/contexts/WorkspaceContext", () => actualWorkspaceContextModule); + } as unknown as WorkspaceContextValue; + + return ( + + + {props.children} + + + ); + }; } -import { useWorkspaceHeartbeat } from "./useWorkspaceHeartbeat"; - function createMetadata( overrides: Partial = {} ): FrontendWorkspaceMetadata { @@ -96,36 +95,24 @@ function applyCapturedMetadataUpdate( } describe("useWorkspaceHeartbeat", () => { - let originalWindow: typeof globalThis.window; - let originalDocument: typeof globalThis.document; + let cleanupDom: (() => void) | null = null; - afterAll(async () => { - await restoreWorkspaceHeartbeatModuleMocks(); - }); - - beforeEach(async () => { - originalWindow = globalThis.window; - originalDocument = globalThis.document; - globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; - globalThis.document = globalThis.window.document; - await installWorkspaceHeartbeatModuleMocks(); + beforeEach(() => { + cleanupDom = installDom(); capturedWorkspaceMetadataUpdate = null; setWorkspaceMetadataMock.mockClear(); }); - afterEach(async () => { + afterEach(() => { cleanup(); - await restoreWorkspaceHeartbeatModuleMocks(); - mock.restore(); - apiMock = null; capturedWorkspaceMetadataUpdate = null; - globalThis.window = originalWindow; - globalThis.document = originalDocument; + cleanupDom?.(); + cleanupDom = null; }); test("optimistically enables heartbeat metadata after a successful save", async () => { const saveHeartbeat = mock(() => Promise.resolve({ success: true })); - apiMock = { + const api: HeartbeatApi = { workspace: { heartbeat: { get: () => Promise.resolve(null), @@ -137,7 +124,9 @@ describe("useWorkspaceHeartbeat", () => { }, }; - const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID })); + const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), { + wrapper: createWrapper(api), + }); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -182,7 +171,7 @@ describe("useWorkspaceHeartbeat", () => { contextMode: "compact", message: "Keep watching", }; - apiMock = { + const api: HeartbeatApi = { workspace: { heartbeat: { get: () => Promise.resolve(initialSettings), @@ -194,7 +183,9 @@ describe("useWorkspaceHeartbeat", () => { }, }; - const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID })); + const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), { + wrapper: createWrapper(api), + }); await waitFor(() => { expect(result.current.isLoading).toBe(false); diff --git a/src/cli/run.ts b/src/cli/run.ts index cf45a0578a..ce6b249a2c 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -286,6 +286,7 @@ function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["ex execSubagentHardRestart: experimentIds.includes("exec-subagent-hard-restart"), dynamicWorkflows: experimentIds.includes("dynamic-workflows"), subagentFileReports: experimentIds.includes("subagent-file-reports"), + workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS), }; } diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 3fbfe22d6b..e0b6bac298 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -231,6 +231,7 @@ function buildExperimentsObject(experimentIds: readonly string[]) { // experiment is enabled implicitly for this invocation (never persisted). dynamicWorkflows: true, subagentFileReports: experimentIds.includes(EXPERIMENT_IDS.SUBAGENT_FILE_REPORTS), + workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS), }; } diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 87f2c1c505..2288f1420d 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -709,6 +709,7 @@ export const ExperimentsSchema = z.object({ subagentFileReports: z.boolean().optional(), execSubagentHardRestart: z.boolean().optional(), memory: z.boolean().optional(), + workspaceHeartbeats: z.boolean().optional(), }); /** diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 1179e8119f..b498e371eb 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -22,6 +22,7 @@ import type { MuxAgentsReadToolResultSchema, MuxAgentsWriteToolResultSchema, FileReadToolResultSchema, + HeartbeatToolResultSchema, MemoryToolResultSchema, AttachFileToolResultSchema, TaskToolResultSchema, @@ -133,6 +134,10 @@ export interface ToolOutputUiOnlyFields { // FileReadToolResult derived from Zod schema (single source of truth) export type FileReadToolResult = z.infer; +// Heartbeat tool types, derived from schema (avoid drift) +export type HeartbeatToolArgs = z.infer; +export type HeartbeatToolResult = z.infer; + // Memory tool types, derived from schema (avoid drift) export type MemoryToolArgs = z.infer; export type MemoryToolResult = z.infer; diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index 04981917f7..4f1a39b4ee 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -229,6 +229,29 @@ describe("TOOL_DEFINITIONS", () => { } }); + it("validates heartbeat tool configuration bounds", () => { + expect(TOOL_DEFINITIONS.heartbeat.schema.safeParse({ action: "get" }).success).toBe(true); + expect( + TOOL_DEFINITIONS.heartbeat.schema.safeParse({ + action: "set", + enabled: true, + intervalMs: 5 * 60 * 1000, + contextMode: "compact", + }).success + ).toBe(true); + expect( + TOOL_DEFINITIONS.heartbeat.schema.safeParse({ + action: "set", + intervalMs: 60 * 1000, + }).success + ).toBe(false); + expect( + TOOL_DEFINITIONS.heartbeat.schema.safeParse({ + action: "configure", + }).success + ).toBe(false); + }); + it("requires complete_goal summary", () => { expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({}).success).toBe(false); expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({ summary: "Done." }).success).toBe( @@ -443,6 +466,12 @@ describe("TOOL_DEFINITIONS", () => { expect(tools).toContain("skills_catalog_read"); }); + it("includes the workspace heartbeat tool", () => { + const tools = getAvailableTools("openai:gpt-4o"); + + expect(tools).toContain("heartbeat"); + }); + it("only includes Review pane tools when enableReviewPane is not disabled", () => { const defaultTools = getAvailableTools("openai:gpt-4o"); expect(defaultTools).toContain("review_pane_update"); diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 1c78881909..a68a0f5397 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -39,6 +39,7 @@ import { WorkflowRunRecordSchema, WorkflowRunStatusSchema, WorkflowStepStatusSchema, + WorkspaceHeartbeatSettingsSchema, } from "@/common/orpc/schemas"; import { RUNTIME_MODE, @@ -63,6 +64,12 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath"; import { TASK_VARIANT_PLACEHOLDER, TASK_GROUP_KIND_VALUES } from "@/common/utils/tools/taskGroups"; +import { + HEARTBEAT_CONTEXT_MODE_VALUES, + HEARTBEAT_MAX_INTERVAL_MS, + HEARTBEAT_MIN_INTERVAL_MS, +} from "@/constants/heartbeat"; + // ----------------------------------------------------------------------------- // ask_user_question (plan-mode interactive questions) // ----------------------------------------------------------------------------- @@ -164,6 +171,46 @@ export const AskUserQuestionToolResultSchema = z.union([ AskUserQuestionToolLegacySchema, ]); +// ----------------------------------------------------------------------------- +// heartbeat (workspace idle check-in schedule) +// ----------------------------------------------------------------------------- + +export const HeartbeatToolActionSchema = z.enum(["get", "set", "unset"]); +export const HeartbeatToolArgsSchema = z + .object({ + action: HeartbeatToolActionSchema.describe( + 'Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace\'s heartbeat settings.' + ), + enabled: z + .boolean() + .nullish() + .describe( + 'set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled".' + ), + intervalMs: z + .number() + .int() + .min(HEARTBEAT_MIN_INTERVAL_MS) + .max(HEARTBEAT_MAX_INTERVAL_MS) + .nullish() + .describe( + `set: heartbeat interval in milliseconds (${HEARTBEAT_MIN_INTERVAL_MS}–${HEARTBEAT_MAX_INTERVAL_MS}). Omit to preserve the current interval or use the global default for new settings.` + ), + message: z + .string() + .nullish() + .describe( + "set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message." + ), + contextMode: z + .enum(HEARTBEAT_CONTEXT_MODE_VALUES) + .nullish() + .describe( + 'set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode.' + ), + }) + .strict(); + // ----------------------------------------------------------------------------- // advisor (nested strategic guidance) // ----------------------------------------------------------------------------- @@ -1910,6 +1957,15 @@ export const TOOL_DEFINITIONS = { .strict(), }, + heartbeat: { + description: + "Read or change this workspace's scheduled heartbeat. " + + "The tool only affects the current workspace; it does not accept a workspaceId. " + + "Use action='set' to enable or configure the heartbeat interval, custom message, context mode, or enabled flag. " + + "Use action='unset' to remove this workspace's heartbeat settings entirely. " + + "Use action='get' before changing settings when you need to preserve existing values.", + schema: HeartbeatToolArgsSchema, + }, todo_write: { description: "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + @@ -2522,6 +2578,20 @@ export const WebFetchToolResultSchema = z.union([ }), ]); +export const HeartbeatToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + action: HeartbeatToolActionSchema, + configured: z.boolean(), + settings: WorkspaceHeartbeatSettingsSchema.nullable(), + summary: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + export const MemoryToolResultSchema = z.union([ z.object({ success: z.literal(true), @@ -2557,6 +2627,7 @@ export type BridgeableToolName = | "task_apply_git_patch" | "task_list" | "task_terminate" + | "heartbeat" | "memory"; /** @@ -2582,6 +2653,7 @@ export const RESULT_SCHEMAS: Record = { task_apply_git_patch: TaskApplyGitPatchToolResultSchema, task_list: TaskListToolResultSchema, task_terminate: TaskTerminateToolResultSchema, + heartbeat: HeartbeatToolResultSchema, memory: MemoryToolResultSchema, }; @@ -2700,6 +2772,7 @@ export function getAvailableTools( ...(enableAgentReport ? ["agent_report"] : []), "get_goal", "complete_goal", + "heartbeat", "todo_write", "todo_read", ...(enableReviewPane ? ["review_pane_update", "review_pane_get"] : []), diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts index 8004875a5f..810cc88c2b 100644 --- a/src/common/utils/tools/tools.test.ts +++ b/src/common/utils/tools/tools.test.ts @@ -2,10 +2,11 @@ import { describe, expect, mock, test } from "bun:test"; import { z } from "zod"; +import { Ok } from "@/common/types/result"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import { getToolsForModel } from "./tools"; +import { getToolsForModel, type WorkspaceHeartbeatToolService } from "./tools"; const DESKTOP_TOOL_NAMES = [ "desktop_screenshot", @@ -90,6 +91,78 @@ describe("getToolsForModel", () => { expect(toolsWithReport.agent_report).toBeDefined(); }); + test("includes heartbeat only when the heartbeat service and experiment are configured", async () => { + const runtime = new LocalRuntime(process.cwd()); + const initStateManager = createInitStateManager(); + + const toolsWithoutHeartbeat = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + experiments: { workspaceHeartbeats: true }, + }, + "ws-1", + initStateManager + ); + expect(toolsWithoutHeartbeat.heartbeat).toBeUndefined(); + + const heartbeatService: WorkspaceHeartbeatToolService = { + getHeartbeatSettings: mock(() => null), + setHeartbeatSettings: mock(() => + Promise.resolve( + Ok({ enabled: true, intervalMs: 30 * 60 * 1000, contextMode: "normal" as const }) + ) + ), + unsetHeartbeatSettings: mock(() => Promise.resolve(Ok(undefined))), + }; + const toolsWithExperimentDisabled = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + workspaceHeartbeatService: heartbeatService, + }, + "ws-1", + initStateManager + ); + expect(toolsWithExperimentDisabled.heartbeat).toBeUndefined(); + + const toolsWithHeartbeat = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + experiments: { workspaceHeartbeats: true }, + workspaceHeartbeatService: heartbeatService, + }, + "ws-1", + initStateManager + ); + const childToolsWithHeartbeat = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "child-ws", + enableAgentReport: true, + experiments: { workspaceHeartbeats: true }, + workspaceHeartbeatService: heartbeatService, + }, + "child-ws", + initStateManager + ); + expect(childToolsWithHeartbeat.heartbeat).toBeUndefined(); + expect(toolsWithHeartbeat.heartbeat).toBeDefined(); + }); + test("withholds review_pane_* tools from sub-agents (enableAgentReport=true)", async () => { const runtime = new LocalRuntime(process.cwd()); const initStateManager = createInitStateManager(); diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 04f867ac0b..70d2fdb58b 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -18,6 +18,7 @@ import { createReviewPaneUpdateTool, createReviewPaneGetTool, } from "@/node/services/tools/review_pane"; +import { createHeartbeatTool } from "@/node/services/tools/heartbeat"; import { createGetGoalTool } from "@/node/services/tools/get_goal"; import { createCompleteGoalTool } from "@/node/services/tools/complete_goal"; import { createNotifyTool } from "@/node/services/tools/notify"; @@ -60,6 +61,7 @@ import { } from "@/common/utils/tools/toolDefinitions"; import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer"; +import type { Result } from "@/common/types/result"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; @@ -74,7 +76,7 @@ import type { FileState } from "@/node/services/agentSession"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { ModelMessage } from "@/common/types/message"; -import type { ProjectRef } from "@/common/types/workspace"; +import type { ProjectRef, WorkspaceMetadata } from "@/common/types/workspace"; export interface ToolModelUsageEvent { source: "tool"; @@ -100,6 +102,18 @@ export interface AdvisorStepCaptureRef { frozenSnapshotsByToolCallId: Map; } +export type WorkspaceHeartbeatSettings = NonNullable; +export type WorkspaceHeartbeatSettingsUpdate = Partial; + +export interface WorkspaceHeartbeatToolService { + getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null; + setHeartbeatSettings( + workspaceId: string, + settings: WorkspaceHeartbeatSettingsUpdate + ): Promise>; + unsetHeartbeatSettings(workspaceId: string): Promise>; +} + /** * Configuration for tools that need runtime context */ @@ -218,6 +232,8 @@ export interface ToolConfiguration { projectTrusted: boolean; }): Promise<{ runId: string; status: string; result: unknown }>; }; + /** Workspace heartbeat settings service for model-facing heartbeat configuration. */ + workspaceHeartbeatService?: WorkspaceHeartbeatToolService; /** Workspace goal lifecycle service for model-facing goal tools. */ goalService?: WorkspaceGoalService; /** Per-request goal tool gates derived from goal status and agent capabilities. */ @@ -240,6 +256,7 @@ export interface ToolConfiguration { dynamicWorkflows?: boolean; subagentFileReports?: boolean; memory?: boolean; + workspaceHeartbeats?: boolean; }; /** Available sub-agents for the task tool description (dynamic context) */ availableSubagents?: AgentDefinitionDescriptor[]; @@ -533,6 +550,13 @@ export async function getToolsForModel( : {}), }; + // HeartbeatService intentionally skips child task workspaces, and the + // workspace-heartbeats experiment gates every user-facing way to create schedules. + const shouldExposeHeartbeatTool = + config.workspaceHeartbeatService != null && + config.experiments?.workspaceHeartbeats === true && + !config.enableAgentReport; + // Non-runtime tools execute immediately (no init wait needed) // Note: Tool availability is controlled by agent tool policy (allowlist), not mode checks here. const nonRuntimeTools: Record = { @@ -564,6 +588,7 @@ export async function getToolsForModel( } : {}), ...(config.enableAgentReport ? { agent_report: createAgentReportTool(config) } : {}), + ...(shouldExposeHeartbeatTool ? { heartbeat: createHeartbeatTool(config) } : {}), ...(config.goalService && config.enableGoalTools?.getGoal ? { get_goal: createGetGoalTool(config) } : {}), diff --git a/src/node/config.test.ts b/src/node/config.test.ts index b4aaef9f81..7c904606d5 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -12,6 +12,7 @@ import { } from "@/common/config/worktreeArchiveBehavior"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; import { type ExternalSecretResolver, secretsToRecord } from "@/common/types/secrets"; describe("Config", () => { @@ -1576,6 +1577,37 @@ describe("Config", () => { expect(workspace.name).toBe("feature-branch"); }); + it("defaults sparse persisted heartbeat intervals in workspace metadata", async () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "heartbeat-sparse"); + const sparseHeartbeat = { enabled: true } as const; + + await config.editConfig((cfg) => { + cfg.heartbeatDefaultIntervalMs = 45 * 60 * 1000; + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: "workspace-heartbeat-sparse", + name: "heartbeat-sparse", + createdAt: "2025-01-01T00:00:00.000Z", + runtimeConfig: { type: "local" }, + // Simulates older/corrupt persisted config; workspace metadata must stay schema-valid. + heartbeat: sparseHeartbeat as NonNullable, + }, + ], + }); + return cfg; + }); + + const [metadata] = await config.getAllWorkspaceMetadata(); + + expect(metadata.heartbeat).toEqual({ + enabled: true, + intervalMs: 45 * 60 * 1000, + }); + }); + it("should use existing metadata file if present (legacy format)", async () => { const projectPath = "/fake/project"; const workspaceName = "my-feature"; diff --git a/src/node/config.ts b/src/node/config.ts index 838d6360f1..5bf1d4c8d9 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -56,7 +56,12 @@ import { type WorktreeArchiveBehavior, } from "@/common/config/worktreeArchiveBehavior"; import { PlatformPaths } from "@/common/utils/paths"; -import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; +import { + HEARTBEAT_CONTEXT_MODE_VALUES, + HEARTBEAT_DEFAULT_INTERVAL_MS, + HEARTBEAT_MAX_INTERVAL_MS, + HEARTBEAT_MIN_INTERVAL_MS, +} from "@/constants/heartbeat"; import { normalizeGoalDefaults } from "@/constants/goals"; import { isValidModelFormat, @@ -78,6 +83,51 @@ import { export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig }; export type ProvidersConfig = CanonicalProvidersConfig | Record; +function isValidHeartbeatIntervalMs(intervalMs: unknown): intervalMs is number { + return ( + typeof intervalMs === "number" && + Number.isInteger(intervalMs) && + intervalMs >= HEARTBEAT_MIN_INTERVAL_MS && + intervalMs <= HEARTBEAT_MAX_INTERVAL_MS + ); +} + +function isWorkspaceHeartbeatContextMode( + value: unknown +): value is NonNullable["contextMode"]> { + return ( + typeof value === "string" && + HEARTBEAT_CONTEXT_MODE_VALUES.some((candidate) => candidate === value) + ); +} + +function normalizeWorkspaceMetadataHeartbeat( + heartbeat: Workspace["heartbeat"] | undefined, + config: ProjectsConfig +): WorkspaceMetadata["heartbeat"] | undefined { + if (!heartbeat) { + return undefined; + } + + const persisted = heartbeat as Partial>; + const defaultIntervalMs = isValidHeartbeatIntervalMs(config.heartbeatDefaultIntervalMs) + ? config.heartbeatDefaultIntervalMs + : HEARTBEAT_DEFAULT_INTERVAL_MS; + const message = typeof persisted.message === "string" ? persisted.message : undefined; + const contextMode = isWorkspaceHeartbeatContextMode(persisted.contextMode) + ? persisted.contextMode + : undefined; + + return { + enabled: persisted.enabled === true, + intervalMs: isValidHeartbeatIntervalMs(persisted.intervalMs) + ? persisted.intervalMs + : defaultIntervalMs, + ...(message != null ? { message } : {}), + ...(contextMode != null ? { contextMode } : {}), + }; +} + function parseOptionalNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -1663,7 +1713,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (apply default if missing) runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, - heartbeat: workspace.heartbeat, + heartbeat: normalizeWorkspaceMetadataHeartbeat(workspace.heartbeat, config), workflowSchedule: parsePersistedWorkflowSchedule(workspace.workflowSchedule), goalDefaults: workspace.goalDefaults, aiSettingsByAgent: diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 1f767b1495..68f45114ce 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -4251,14 +4251,18 @@ export const router = (authToken?: string) => { set: t .input(schemas.workspace.heartbeat.set.input) .output(schemas.workspace.heartbeat.set.output) - .handler(({ context, input }) => - context.workspaceService.setHeartbeatSettings(input.workspaceId, { + .handler(async ({ context, input }) => { + const result = await context.workspaceService.setHeartbeatSettings(input.workspaceId, { enabled: input.enabled, intervalMs: input.intervalMs, ...(input.message != null ? { message: input.message } : {}), ...(input.contextMode != null ? { contextMode: input.contextMode } : {}), - }) - ), + }); + if (!result.success) { + return result; + } + return Ok(undefined); + }), }, goalDefaults: { // Per-workspace override of the global `goalDefaults` block. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index ca29a53683..5a9819e792 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4448,6 +4448,19 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "", "
", + "heartbeat (5)", + "", + "| Env var | JSON path | Type | Description |", + "| ----------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", + '| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace\'s heartbeat settings. |', + '| `MUX_TOOL_INPUT_CONTEXT_MODE` | `contextMode` | enum | set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode. |', + '| `MUX_TOOL_INPUT_ENABLED` | `enabled` | boolean | set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled". |', + "| `MUX_TOOL_INPUT_INTERVAL_MS` | `intervalMs` | number | set: heartbeat interval in milliseconds (300000–86400000). Omit to preserve the current interval or use the global default for new settings. |", + "| `MUX_TOOL_INPUT_MESSAGE` | `message` | string | set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message. |", + "", + "
", + "", + "
", "memory (11)", "", "| Env var | JSON path | Type | Description |", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 0bc60a29cd..76022bc593 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -445,6 +445,7 @@ export class AIService extends EventEmitter { event: WorkflowRunStatusChangedEvent ) => Promise | void; private workflowResultContinuationSender?: WorkflowResultContinuationSender; + private workspaceHeartbeatService?: ToolConfiguration["workspaceHeartbeatService"]; private analyticsService?: { executeRawQuery(sql: string): Promise }; private desktopSessionManager?: DesktopSessionManager; @@ -512,6 +513,12 @@ export class AIService extends EventEmitter { this.taskService = taskService; } + setWorkspaceHeartbeatService( + service: NonNullable + ): void { + this.workspaceHeartbeatService = service; + } + /** * Host actions (workspace.*) for workflow runners. Built once in coreServices * — which owns WorkspaceService/HistoryService/Config — and injected here so @@ -1346,6 +1353,9 @@ export class AIService extends EventEmitter { const memoryExperimentEnabled = experiments?.memory ?? this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; + const workspaceHeartbeatsExperimentEnabled = + experiments?.workspaceHeartbeats ?? + this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS) === true; const memoryHotSetExperimentEnabled = this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === true; // Once final tool policy keeps the memory tool, upgrade the index-only @@ -1795,6 +1805,7 @@ export class AIService extends EventEmitter { ...experiments, dynamicWorkflows: dynamicWorkflowsExperimentEnabled, subagentFileReports: subagentFileReportsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, }, }), // Background workflow tools outlive the model turn that started them. Feed the @@ -1848,6 +1859,7 @@ export class AIService extends EventEmitter { ...experiments, dynamicWorkflows: dynamicWorkflowsExperimentEnabled, subagentFileReports: subagentFileReportsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, }, skipAiSettingsPersistence: true, muxMetadata: { @@ -1979,6 +1991,7 @@ export class AIService extends EventEmitter { ancestorPlanFilePaths, workspaceId, muxScope, + workspaceHeartbeatService: this.workspaceHeartbeatService, workflowService, goalService: workspaceGoalService, enableGoalTools: goalToolAvailability, @@ -2075,6 +2088,7 @@ export class AIService extends EventEmitter { dynamicWorkflows: dynamicWorkflowsExperimentEnabled, subagentFileReports: subagentFileReportsExperimentEnabled, memory: memoryExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, }, // Dynamic context for tool descriptions (moved from system prompt for better model attention) availableSubagents: agentDefinitions, diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 73bc83520a..fd2393681e 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -160,6 +160,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.sessionTimingService, opts.opResolver ); + aiService.setWorkspaceHeartbeatService(workspaceService); // Tool-started workflows share the same sidebar activity cache as ORPC-started workflows, // so terminal updates must prune active run counts regardless of launch path. aiService.setWorkflowRunStatusChangedHandler((event) => diff --git a/src/node/services/ptc/typeGenerator.test.ts b/src/node/services/ptc/typeGenerator.test.ts index 5801bb5c2e..f401e3a4f1 100644 --- a/src/node/services/ptc/typeGenerator.test.ts +++ b/src/node/services/ptc/typeGenerator.test.ts @@ -220,6 +220,14 @@ describe("generateMuxTypes", () => { expect(types).not.toContain("function file_read(args: FileReadArgs): unknown"); }); + test("heartbeat tool gets a typed result so sandbox code can check result.success", async () => { + const types = await generateMuxTypes({ + heartbeat: createMockTool(z.object({ action: z.string() })), + }); + expect(types).toContain("HeartbeatResult"); + expect(types).not.toContain("function heartbeat(args: HeartbeatArgs): unknown"); + }); + test("memory tool gets a typed result so sandbox code can check result.success", async () => { const types = await generateMuxTypes({ memory: createMockTool(z.object({ command: z.string() })), diff --git a/src/node/services/tools/heartbeat.test.ts b/src/node/services/tools/heartbeat.test.ts new file mode 100644 index 0000000000..45f992ed51 --- /dev/null +++ b/src/node/services/tools/heartbeat.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, mock, test } from "bun:test"; + +import { Ok } from "@/common/types/result"; +import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { + WorkspaceHeartbeatSettingsUpdate, + WorkspaceHeartbeatToolService, +} from "@/common/utils/tools/tools"; +import { HEARTBEAT_DEFAULT_CONTEXT_MODE } from "@/constants/heartbeat"; +import { createHeartbeatTool } from "./heartbeat"; +import { createTestToolConfig, mockToolCallOptions, TestTempDir } from "./testHelpers"; + +type WorkspaceHeartbeatSettings = NonNullable; + +function createService(initial: WorkspaceHeartbeatSettings | null = null): { + service: WorkspaceHeartbeatToolService; + getStored: () => WorkspaceHeartbeatSettings | null; +} { + let stored = initial; + const service: WorkspaceHeartbeatToolService = { + getHeartbeatSettings: mock((workspaceId: string) => { + expect(workspaceId).toBe("ws-heartbeat"); + return stored; + }), + setHeartbeatSettings: mock( + (workspaceId: string, settings: WorkspaceHeartbeatSettingsUpdate) => { + expect(workspaceId).toBe("ws-heartbeat"); + const hasMessageUpdate = Object.prototype.hasOwnProperty.call(settings, "message"); + const nextMessage = hasMessageUpdate ? settings.message?.trim() : stored?.message; + stored = { + enabled: settings.enabled ?? stored?.enabled ?? true, + intervalMs: settings.intervalMs ?? stored?.intervalMs ?? 15 * 60 * 1000, + contextMode: + settings.contextMode ?? stored?.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE, + ...(nextMessage ? { message: nextMessage } : {}), + }; + return Promise.resolve(Ok(stored)); + } + ), + unsetHeartbeatSettings: mock((workspaceId: string) => { + expect(workspaceId).toBe("ws-heartbeat"); + stored = null; + return Promise.resolve(Ok(undefined)); + }), + }; + return { service, getStored: () => stored }; +} + +async function execute( + service: WorkspaceHeartbeatToolService | undefined, + args: HeartbeatToolArgs +): Promise { + using tempDir = new TestTempDir("heartbeat-tool-test"); + const tool = createHeartbeatTool({ + ...createTestToolConfig(tempDir.path, { workspaceId: "ws-heartbeat" }), + ...(service ? { workspaceHeartbeatService: service } : {}), + }); + const result: unknown = await Promise.resolve(tool.execute!(args, mockToolCallOptions)); + return result as HeartbeatToolResult; +} + +describe("heartbeat tool", () => { + test("gets current heartbeat settings for the owning workspace", async () => { + const current = { + enabled: true, + intervalMs: 30 * 60 * 1000, + contextMode: "compact" as const, + message: "Review idle work.", + }; + const { service } = createService(current); + + const result = await execute(service, { action: "get" }); + + expect(result).toEqual({ + success: true, + action: "get", + configured: true, + settings: current, + summary: "Heartbeat is enabled for this workspace at 30 minutes.", + }); + }); + + test("set creates an enabled heartbeat from global defaults", async () => { + const { service, getStored } = createService(null); + + const result = await execute(service, { action: "set" }); + + expect(result.success).toBe(true); + expect(getStored()).toEqual({ + enabled: true, + intervalMs: 15 * 60 * 1000, + contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE, + }); + }); + + test("set updates only provided fields and can clear the custom message", async () => { + const { service, getStored } = createService({ + enabled: true, + intervalMs: 30 * 60 * 1000, + contextMode: "reset", + message: "Old custom prompt", + }); + + const result = await execute(service, { + action: "set", + intervalMs: 45 * 60 * 1000, + message: "", + }); + + expect(result.success).toBe(true); + expect(getStored()).toEqual({ + enabled: true, + intervalMs: 45 * 60 * 1000, + contextMode: "reset", + }); + }); + + test("unset removes heartbeat settings", async () => { + const { service, getStored } = createService({ + enabled: true, + intervalMs: 30 * 60 * 1000, + contextMode: "normal", + }); + + const result = await execute(service, { action: "unset" }); + + expect(result).toEqual({ + success: true, + action: "unset", + configured: false, + settings: null, + summary: "Heartbeat settings removed for this workspace.", + }); + expect(getStored()).toBeNull(); + }); + + test("returns a typed error when the service is unavailable", async () => { + const result = await execute(undefined, { action: "get" }); + + expect(result).toEqual({ success: false, error: "Heartbeat service is unavailable" }); + }); +}); diff --git a/src/node/services/tools/heartbeat.ts b/src/node/services/tools/heartbeat.ts new file mode 100644 index 0000000000..6330271b1a --- /dev/null +++ b/src/node/services/tools/heartbeat.ts @@ -0,0 +1,123 @@ +import { tool } from "ai"; +import assert from "@/common/utils/assert"; +import type { ToolFactory, WorkspaceHeartbeatSettingsUpdate } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools"; +import { getErrorMessage } from "@/common/utils/errors"; +import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; +import { requireWorkspaceId } from "./toolUtils"; + +function hasProvided( + args: HeartbeatToolArgs, + key: K +): args is HeartbeatToolArgs & { [P in K]-?: NonNullable } { + return Object.prototype.hasOwnProperty.call(args, key) && args[key] != null; +} + +function formatInterval(intervalMs: number): string { + assert( + Number.isInteger(intervalMs) && + intervalMs >= HEARTBEAT_MIN_INTERVAL_MS && + intervalMs <= HEARTBEAT_MAX_INTERVAL_MS, + "formatInterval requires a supported heartbeat interval" + ); + + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + if (intervalMs % hourMs === 0) { + const hours = intervalMs / hourMs; + return `${hours} ${hours === 1 ? "hour" : "hours"}`; + } + if (intervalMs % minuteMs === 0) { + const minutes = intervalMs / minuteMs; + return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`; + } + return `${intervalMs} ms`; +} + +function summarize( + result: Pick +): string { + if (result.action === "unset") { + return "Heartbeat settings removed for this workspace."; + } + + const settings = result.settings; + if (!settings) { + return "No heartbeat settings are configured for this workspace."; + } + + const status = settings.enabled ? "enabled" : "disabled"; + return `Heartbeat is ${status} for this workspace at ${formatInterval(settings.intervalMs)}.`; +} + +export const createHeartbeatTool: ToolFactory = (config) => + tool({ + description: TOOL_DEFINITIONS.heartbeat.description, + inputSchema: TOOL_DEFINITIONS.heartbeat.schema, + execute: async (args): Promise => { + try { + const workspaceId = requireWorkspaceId(config, "heartbeat"); + + const heartbeatService = config.workspaceHeartbeatService; + if (!heartbeatService) { + return { success: false, error: "Heartbeat service is unavailable" }; + } + + if (args.action === "get") { + const settings = heartbeatService.getHeartbeatSettings(workspaceId); + return { + success: true, + action: args.action, + configured: settings != null, + settings, + summary: summarize({ action: args.action, settings }), + }; + } + + if (args.action === "unset") { + const unsetResult = await heartbeatService.unsetHeartbeatSettings(workspaceId); + if (!unsetResult.success) { + return { success: false, error: unsetResult.error }; + } + return { + success: true, + action: args.action, + configured: false, + settings: null, + summary: summarize({ action: args.action, settings: null }), + }; + } + + const settingsUpdate: WorkspaceHeartbeatSettingsUpdate = {}; + if (hasProvided(args, "enabled")) { + settingsUpdate.enabled = args.enabled; + } + if (hasProvided(args, "intervalMs")) { + settingsUpdate.intervalMs = args.intervalMs; + } + if (hasProvided(args, "contextMode")) { + settingsUpdate.contextMode = args.contextMode; + } + if (hasProvided(args, "message")) { + settingsUpdate.message = args.message; + } + + const setResult = await heartbeatService.setHeartbeatSettings(workspaceId, settingsUpdate); + if (!setResult.success) { + return { success: false, error: setResult.error }; + } + + const settings = setResult.data; + return { + success: true, + action: args.action, + configured: true, + settings, + summary: summarize({ action: args.action, settings }), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts index f9478c62a1..e7bddb0930 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts @@ -21,6 +21,7 @@ interface WorkflowTaskExperiments { programmaticToolCallingExclusive?: boolean; advisorTool?: boolean; execSubagentHardRestart?: boolean; + workspaceHeartbeats?: boolean; dynamicWorkflows?: boolean; subagentFileReports?: boolean; } diff --git a/src/node/services/workspaceService.heartbeatSettings.test.ts b/src/node/services/workspaceService.heartbeatSettings.test.ts index bf784b4d14..88d8d61e9e 100644 --- a/src/node/services/workspaceService.heartbeatSettings.test.ts +++ b/src/node/services/workspaceService.heartbeatSettings.test.ts @@ -135,6 +135,27 @@ describe("WorkspaceService heartbeat settings", () => { expect(updateRecencyTimestamp).not.toHaveBeenCalled(); }); + test("unsets heartbeat settings and updates workspace recency", async () => { + const updateRecencyTimestamp = mock<(workspaceId: string, timestamp?: number) => Promise>( + () => Promise.resolve() + ); + ( + service as unknown as { + updateRecencyTimestamp: (workspaceId: string, timestamp?: number) => Promise; + } + ).updateRecencyTimestamp = updateRecencyTimestamp; + + const result = await service.unsetHeartbeatSettings(TEST_WORKSPACE_ID); + + expect(result.success).toBe(true); + const persistedHeartbeat = currentProjectsConfig.projects + .get(TEST_PROJECT_PATH) + ?.workspaces.at(0)?.heartbeat; + expect(persistedHeartbeat).toBeUndefined(); + expect(service.getHeartbeatSettings(TEST_WORKSPACE_ID)).toBeNull(); + expect(updateRecencyTimestamp).toHaveBeenCalledTimes(1); + }); + test("preserves the existing message when a write omits the message field", async () => { const result = await service.setHeartbeatSettings(TEST_WORKSPACE_ID, { enabled: true, @@ -207,6 +228,24 @@ describe("WorkspaceService heartbeat settings", () => { }); }); + test("defaults sparse persisted heartbeat intervals to the global default on read", () => { + currentProjectsConfig.heartbeatDefaultIntervalMs = 45 * 60 * 1000; + const persistedHeartbeat = currentProjectsConfig.projects + .get(TEST_PROJECT_PATH) + ?.workspaces.at(0)?.heartbeat as { intervalMs?: number } | undefined; + if (!persistedHeartbeat) { + throw new Error("Expected persisted heartbeat settings"); + } + delete persistedHeartbeat.intervalMs; + + expect(service.getHeartbeatSettings(TEST_WORKSPACE_ID)).toEqual({ + enabled: true, + intervalMs: 45 * 60 * 1000, + message: "Keep this custom heartbeat message.", + contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE, + }); + }); + test("persists an explicit heartbeat context mode", async () => { const result = await service.setHeartbeatSettings(TEST_WORKSPACE_ID, { enabled: true, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3df4092952..d9a4089eb9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8,7 +8,7 @@ import { isWorkspaceArchived } from "@/common/utils/archive"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { Config } from "@/node/config"; -import type { Workspace } from "@/common/types/project"; +import type { ProjectsConfig, Workspace } from "@/common/types/project"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import { normalizeTaskSettings } from "@/common/types/tasks"; @@ -265,7 +265,13 @@ const AUTO_NEW_WORKSPACE_BASE_NAME = "workspace"; // Shared type for workspace-scoped AI settings (model + thinking) type WorkspaceAISettings = z.infer; type WorkspaceHeartbeatSettings = z.infer; +type WorkspaceHeartbeatSettingsUpdate = Partial; type WorkspaceGoalDefaultsOverride = z.infer; +interface HeartbeatWorkspaceConfigEntry { + normalizedWorkspaceId: string; + config: ProjectsConfig; + workspaceEntry: Workspace; +} interface HeartbeatExecutionRequest { contextMode: HeartbeatContextMode; sendOptions: SendMessageOptions; @@ -422,6 +428,43 @@ function sanitizeHeartbeatContextMode(value: unknown): HeartbeatContextMode { return isHeartbeatContextMode(value) ? value : HEARTBEAT_DEFAULT_CONTEXT_MODE; } +function sanitizeHeartbeatIntervalMs(intervalMs: unknown, defaultIntervalMs: number): number { + assert( + Number.isInteger(defaultIntervalMs) && + defaultIntervalMs >= HEARTBEAT_MIN_INTERVAL_MS && + defaultIntervalMs <= HEARTBEAT_MAX_INTERVAL_MS, + "sanitizeHeartbeatIntervalMs requires a supported default interval" + ); + + if ( + typeof intervalMs === "number" && + Number.isInteger(intervalMs) && + intervalMs >= HEARTBEAT_MIN_INTERVAL_MS && + intervalMs <= HEARTBEAT_MAX_INTERVAL_MS + ) { + return intervalMs; + } + + return defaultIntervalMs; +} + +function normalizeHeartbeatSettings( + settings: Partial | null | undefined, + defaultIntervalMs: number +): WorkspaceHeartbeatSettings | null { + if (!settings) { + return null; + } + + const message = sanitizeHeartbeatMessage(settings.message); + return { + enabled: settings.enabled === true, + intervalMs: sanitizeHeartbeatIntervalMs(settings.intervalMs, defaultIntervalMs), + contextMode: sanitizeHeartbeatContextMode(settings.contextMode), + ...(message != null ? { message } : {}), + }; +} + interface WorkspaceAgentStatus { emoji: string; message: string; @@ -3975,52 +4018,110 @@ export class WorkspaceService extends EventEmitter { return this.enrichMaybeFrontendMetadata(found); } - getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null { + private resolveHeartbeatWorkspaceEntry( + workspaceId: string, + methodName: "getHeartbeatSettings" | "setHeartbeatSettings" | "unsetHeartbeatSettings" + ): Result { const normalizedWorkspaceId = workspaceId.trim(); - assert( - normalizedWorkspaceId.length > 0, - "getHeartbeatSettings requires a non-empty workspaceId" - ); + assert(normalizedWorkspaceId.length > 0, `${methodName} requires a non-empty workspaceId`); const found = this.config.findWorkspace(normalizedWorkspaceId); if (!found) { - return null; + return Err("Workspace not found"); } const config = this.config.loadConfigOrDefault(); const projectConfig = config.projects.get(found.projectPath); + if (!projectConfig) { + return Err(`Project not found: ${found.projectPath}`); + } + const workspaceEntry = - projectConfig?.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ?? - projectConfig?.workspaces.find((workspace) => workspace.path === found.workspacePath); - if (!workspaceEntry?.heartbeat) { + projectConfig.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ?? + projectConfig.workspaces.find((workspace) => workspace.path === found.workspacePath); + if (!workspaceEntry) { + return Err("Workspace not found"); + } + + return Ok({ normalizedWorkspaceId, config, workspaceEntry }); + } + + getHeartbeatSettings(workspaceId: string): WorkspaceHeartbeatSettings | null { + const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "getHeartbeatSettings"); + if (!resolved.success) { return null; } - const message = sanitizeHeartbeatMessage(workspaceEntry.heartbeat.message); - const contextMode = sanitizeHeartbeatContextMode(workspaceEntry.heartbeat.contextMode); - return { - enabled: workspaceEntry.heartbeat.enabled, - intervalMs: workspaceEntry.heartbeat.intervalMs, - contextMode, - ...(message != null ? { message } : {}), - }; + const defaultIntervalMs = this.getHeartbeatDefaultIntervalMsFromConfig(resolved.data.config); + return normalizeHeartbeatSettings(resolved.data.workspaceEntry.heartbeat, defaultIntervalMs); + } + + private getHeartbeatDefaultIntervalMsFromConfig(config: ProjectsConfig): number { + const intervalMs = config.heartbeatDefaultIntervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS; + assert( + Number.isInteger(intervalMs) && + intervalMs >= HEARTBEAT_MIN_INTERVAL_MS && + intervalMs <= HEARTBEAT_MAX_INTERVAL_MS, + "Configured heartbeat default interval must be within supported bounds" + ); + return intervalMs; + } + + getHeartbeatDefaultIntervalMs(): number { + const config = this.config.loadConfigOrDefault(); + return this.getHeartbeatDefaultIntervalMsFromConfig(config); + } + + async unsetHeartbeatSettings(workspaceId: string): Promise> { + try { + const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "unsetHeartbeatSettings"); + if (!resolved.success) { + return Err(resolved.error); + } + + const { normalizedWorkspaceId, config, workspaceEntry } = resolved.data; + if (!workspaceEntry.heartbeat) { + return Ok(undefined); + } + + delete workspaceEntry.heartbeat; + await this.config.saveConfig(config); + + const interactionTimestamp = Date.now(); + await this.updateRecencyTimestamp(normalizedWorkspaceId, interactionTimestamp); + await this.emitCurrentWorkspaceMetadata(normalizedWorkspaceId); + + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to unset heartbeat settings: ${message}`); + } } async setHeartbeatSettings( workspaceId: string, - settings: WorkspaceHeartbeatSettings - ): Promise> { + settings: WorkspaceHeartbeatSettingsUpdate + ): Promise> { try { - const normalizedWorkspaceId = workspaceId.trim(); assert( - normalizedWorkspaceId.length > 0, - "setHeartbeatSettings requires a non-empty workspaceId" + settings != null && typeof settings === "object", + "setHeartbeatSettings requires settings" + ); + + const hasEnabledUpdate = Object.prototype.hasOwnProperty.call(settings, "enabled"); + assert( + !hasEnabledUpdate || typeof settings.enabled === "boolean", + "Heartbeat enabled flag must be a boolean when provided" ); - assert(typeof settings.enabled === "boolean", "Heartbeat enabled flag must be a boolean"); - assert(Number.isInteger(settings.intervalMs), "Heartbeat interval must be an integer"); + const hasIntervalUpdate = Object.prototype.hasOwnProperty.call(settings, "intervalMs"); assert( - settings.intervalMs >= HEARTBEAT_MIN_INTERVAL_MS && - settings.intervalMs <= HEARTBEAT_MAX_INTERVAL_MS, + !hasIntervalUpdate || Number.isInteger(settings.intervalMs), + "Heartbeat interval must be an integer when provided" + ); + assert( + !hasIntervalUpdate || + (settings.intervalMs! >= HEARTBEAT_MIN_INTERVAL_MS && + settings.intervalMs! <= HEARTBEAT_MAX_INTERVAL_MS), `Heartbeat interval must be between ${HEARTBEAT_MIN_INTERVAL_MS} and ${HEARTBEAT_MAX_INTERVAL_MS} ms` ); const hasMessageUpdate = Object.prototype.hasOwnProperty.call(settings, "message"); @@ -4036,36 +4137,29 @@ export class WorkspaceService extends EventEmitter { "Heartbeat context mode must be a supported value when provided" ); - const found = this.config.findWorkspace(normalizedWorkspaceId); - if (!found) { - return Err("Workspace not found"); - } - - const { projectPath, workspacePath } = found; - const config = this.config.loadConfigOrDefault(); - const projectConfig = config.projects.get(projectPath); - if (!projectConfig) { - return Err(`Project not found: ${projectPath}`); - } - - const workspaceEntry = - projectConfig.workspaces.find((workspace) => workspace.id === normalizedWorkspaceId) ?? - projectConfig.workspaces.find((workspace) => workspace.path === workspacePath); - if (!workspaceEntry) { - return Err("Workspace not found"); + const resolved = this.resolveHeartbeatWorkspaceEntry(workspaceId, "setHeartbeatSettings"); + if (!resolved.success) { + return Err(resolved.error); } + const { normalizedWorkspaceId, config, workspaceEntry } = resolved.data; + const defaultIntervalMs = this.getHeartbeatDefaultIntervalMsFromConfig(config); + const currentSettings = normalizeHeartbeatSettings( + workspaceEntry.heartbeat, + defaultIntervalMs + ); const nextMessage = hasMessageUpdate ? sanitizeHeartbeatMessage(settings.message) - : sanitizeHeartbeatMessage(workspaceEntry.heartbeat?.message); - const nextContextMode = hasContextModeUpdate - ? sanitizeHeartbeatContextMode(settings.contextMode) - : sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode); + : currentSettings?.message; // Keep the interval on disk even when disabled so re-enabling restores the user's choice. const nextSettings: WorkspaceHeartbeatSettings = { - enabled: settings.enabled, - intervalMs: settings.intervalMs, - contextMode: nextContextMode, + enabled: hasEnabledUpdate ? settings.enabled! : (currentSettings?.enabled ?? true), + intervalMs: hasIntervalUpdate + ? settings.intervalMs! + : (currentSettings?.intervalMs ?? defaultIntervalMs), + contextMode: hasContextModeUpdate + ? sanitizeHeartbeatContextMode(settings.contextMode) + : (currentSettings?.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE), ...(nextMessage != null ? { message: nextMessage } : {}), }; @@ -4073,9 +4167,10 @@ export class WorkspaceService extends EventEmitter { workspaceEntry.heartbeat?.enabled !== nextSettings.enabled || workspaceEntry.heartbeat?.intervalMs !== nextSettings.intervalMs || workspaceEntry.heartbeat?.message !== nextSettings.message || - sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode) !== nextContextMode; + sanitizeHeartbeatContextMode(workspaceEntry.heartbeat?.contextMode) !== + nextSettings.contextMode; if (!changed) { - return Ok(undefined); + return Ok(nextSettings); } workspaceEntry.heartbeat = nextSettings; @@ -4088,7 +4183,7 @@ export class WorkspaceService extends EventEmitter { await this.updateRecencyTimestamp(normalizedWorkspaceId, interactionTimestamp); await this.emitCurrentWorkspaceMetadata(normalizedWorkspaceId); - return Ok(undefined); + return Ok(nextSettings); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to set heartbeat settings: ${message}`);