From 81543600fea920462c4bfa5a48d4449a2e3fbdd1 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Thu, 12 Feb 2026 00:02:18 +0800 Subject: [PATCH] refactor: remove work_heartbeat tool and related tests; update documentation and notification logic --- index.ts | 17 +- lib/audit.ts | 4 +- lib/dispatch.ts | 2 +- lib/notify.ts | 91 +------ lib/services/tick.ts | 4 +- lib/templates.ts | 10 +- lib/tools/work-heartbeat.test.ts | 401 ------------------------------- lib/tools/work-heartbeat.ts | 310 ------------------------ lib/tools/work-start.ts | 3 +- 9 files changed, 27 insertions(+), 815 deletions(-) delete mode 100644 lib/tools/work-heartbeat.test.ts delete mode 100644 lib/tools/work-heartbeat.ts diff --git a/index.ts b/index.ts index 875efa4..1a8be44 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,6 @@ import { createTaskUpdateTool } from "./lib/tools/task-update.js"; import { createTaskCommentTool } from "./lib/tools/task-comment.js"; import { createStatusTool } from "./lib/tools/status.js"; import { createHealthTool } from "./lib/tools/health.js"; -import { createWorkHeartbeatTool } from "./lib/tools/work-heartbeat.js"; import { createProjectRegisterTool } from "./lib/tools/project-register.js"; import { createSetupTool } from "./lib/tools/setup.js"; import { createOnboardTool } from "./lib/tools/onboard.js"; @@ -53,9 +52,9 @@ const plugin = { }, notifications: { type: "object", - description: "Notification settings", + description: + "Per-event-type notification toggles. All default to true — set to false to suppress.", properties: { - heartbeatDm: { type: "boolean", default: true }, workerStart: { type: "boolean", default: true }, workerComplete: { type: "boolean", default: true }, }, @@ -63,17 +62,17 @@ const plugin = { work_heartbeat: { type: "object", description: - "Token-free interval-based heartbeat service. Runs health checks + queue dispatch automatically. Discovers all DevClaw agents from openclaw.json and processes each independently. Can also be triggered on-demand via CLI command `devclaw heartbeat:tick`.", + "Token-free interval-based heartbeat service. Runs health checks + queue dispatch automatically. Discovers all DevClaw agents from openclaw.json and processes each independently.", properties: { enabled: { type: "boolean", default: true, - description: "Enable automatic periodic heartbeat service. When disabled, heartbeat can still be run on-demand via `devclaw heartbeat:tick` CLI command.", + description: "Enable automatic periodic heartbeat service.", }, intervalSeconds: { type: "number", default: 60, - description: "Seconds between automatic heartbeat ticks (only applies when service is enabled). Can be overridden per-tick via CLI option.", + description: "Seconds between automatic heartbeat ticks.", }, maxPickupsPerTick: { type: "number", @@ -98,10 +97,6 @@ const plugin = { // Operations api.registerTool(createStatusTool(api), { names: ["status"] }); api.registerTool(createHealthTool(api), { names: ["health"] }); - api.registerTool(createWorkHeartbeatTool(api), { - names: ["work_heartbeat"], - }); - // Setup & config api.registerTool(createProjectRegisterTool(api), { names: ["project_register"], @@ -118,7 +113,7 @@ const plugin = { registerHeartbeatService(api); api.logger.info( - "DevClaw plugin registered (11 tools, 1 CLI command group, 1 service)", + "DevClaw plugin registered (10 tools, 1 CLI command group, 1 service)", ); }, }; diff --git a/lib/audit.ts b/lib/audit.ts index 4a2e3b2..512dfee 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -6,7 +6,7 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, dirname } from "node:path"; -const MAX_LOG_LINES = 250; +const MAX_LOG_LINES = 50; export async function log( workspaceDir: string, @@ -35,7 +35,7 @@ export async function log( async function truncateIfNeeded(filePath: string): Promise { try { const content = await readFile(filePath, "utf-8"); - const lines = content.split("\n").filter(line => line.length > 0); + const lines = content.split("\n").filter((line) => line.length > 0); if (lines.length > MAX_LOG_LINES) { const keptLines = lines.slice(-MAX_LOG_LINES); diff --git a/lib/dispatch.ts b/lib/dispatch.ts index a83ffc9..db26715 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -1,5 +1,5 @@ /** - * dispatch.ts — Core dispatch logic shared by work_start, work_heartbeat, and projectTick. + * dispatch.ts — Core dispatch logic shared by work_start and projectTick. * * Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI, * state update (activateWorker), and audit logging. diff --git a/lib/notify.ts b/lib/notify.ts index 3d5b4dd..3e9eac7 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -1,13 +1,11 @@ /** * notify.ts — Programmatic alerting for worker lifecycle events. * - * Sends notifications to project groups and orchestrator DM for visibility - * into the DevClaw pipeline. + * Sends notifications to project groups for visibility into the DevClaw pipeline. * * Event types: * - workerStart: Worker spawned/resumed for a task (→ project group) * - workerComplete: Worker completed task (→ project group) - * - heartbeat: Heartbeat tick summary (→ orchestrator DM) */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -16,14 +14,8 @@ import type { TickAction } from "./services/tick.js"; const execFileAsync = promisify(execFile); -export type NotificationConfig = { - /** Send heartbeat summaries to orchestrator DM. Default: true */ - heartbeatDm?: boolean; - /** Post when worker starts a task. Default: true */ - workerStart?: boolean; - /** Post when worker completes a task. Default: true */ - workerComplete?: boolean; -}; +/** Per-event-type toggle. All default to true — set to false to suppress. */ +export type NotificationConfig = Partial>; export type NotifyEvent = | { @@ -47,19 +39,6 @@ export type NotifyEvent = result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; - } - | { - type: "heartbeat"; - projectsScanned: number; - healthFixes: number; - pickups: number; - skipped: number; - dryRun: boolean; - pickupDetails?: Array<{ - project: string; - issueId: number; - role: "dev" | "qa"; - }>; }; /** @@ -99,29 +78,6 @@ function buildMessage(event: NotifyEvent): string { msg += `\n🔗 ${event.issueUrl}`; return msg; } - - case "heartbeat": { - if (event.dryRun) { - return `🔄 Heartbeat (dry run): scanned ${event.projectsScanned} projects, would pick up ${event.pickups} tasks`; - } - const parts = [`🔄 Heartbeat: scanned ${event.projectsScanned} projects`]; - if (event.healthFixes > 0) { - parts.push(`fixed ${event.healthFixes} zombie(s)`); - } - if (event.pickups > 0) { - parts.push(`spawned ${event.pickups} worker(s)`); - if (event.pickupDetails && event.pickupDetails.length > 0) { - const details = event.pickupDetails - .map((p) => `${p.project}#${p.issueId}(${p.role})`) - .join(", "); - parts.push(`[${details}]`); - } - } - if (event.pickups === 0 && event.healthFixes === 0) { - parts.push("no actions needed"); - } - return parts.join(", "); - } } } @@ -169,8 +125,7 @@ async function sendMessage( /** * Send a notification for a worker lifecycle event. * - * Respects notification config settings. - * Returns true if notification was sent (or skipped due to config), false on error. + * Returns true if notification was sent, false on error. */ export async function notify( event: NotifyEvent, @@ -181,36 +136,15 @@ export async function notify( groupId?: string; /** Channel type for routing (e.g. "telegram", "whatsapp", "discord", "slack") */ channel?: string; - /** Target for DM notifications (orchestrator) */ - orchestratorDm?: string; }, ): Promise { - const config = opts.config ?? {}; + if (opts.config?.[event.type] === false) return true; + const channel = opts.channel ?? "telegram"; - - // Check if notification is enabled - if (event.type === "workerStart" && config.workerStart === false) { - return true; // Skipped, not an error - } - if (event.type === "workerComplete" && config.workerComplete === false) { - return true; - } - if (event.type === "heartbeat" && config.heartbeatDm === false) { - return true; - } - const message = buildMessage(event); - - // Determine target - let target: string | undefined; - if (event.type === "heartbeat") { - target = opts.orchestratorDm; - } else { - target = opts.groupId ?? (event as { groupId?: string }).groupId; - } + const target = opts.groupId ?? (event as { groupId?: string }).groupId; if (!target) { - // No target specified, can't send await auditLog(opts.workspaceDir, "notify_skip", { eventType: event.type, reason: "no target", @@ -218,7 +152,6 @@ export async function notify( return true; // Not an error, just nothing to do } - // Audit the notification attempt await auditLog(opts.workspaceDir, "notify", { eventType: event.type, target, @@ -267,15 +200,11 @@ export async function notifyTickPickups( } /** - * Get notification config from plugin config. + * Extract notification config from plugin config. + * All event types default to enabled (true). */ export function getNotificationConfig( pluginConfig?: Record, ): NotificationConfig { - const notifications = pluginConfig?.notifications as NotificationConfig | undefined; - return { - heartbeatDm: notifications?.heartbeatDm ?? true, - workerStart: notifications?.workerStart ?? true, - workerComplete: notifications?.workerComplete ?? true, - }; + return (pluginConfig?.notifications as NotificationConfig) ?? {}; } diff --git a/lib/services/tick.ts b/lib/services/tick.ts index e50645b..1e45cb1 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -2,7 +2,7 @@ * tick.ts — Project-level queue scan + dispatch. * * Core function: projectTick() scans one project's queue and fills free worker slots. - * Called by: work_start (fill parallel slot), work_finish (next pipeline step), work_heartbeat (sweep). + * Called by: work_start (fill parallel slot), work_finish (next pipeline step), heartbeat service (sweep). */ import type { Issue, StateLabel } from "../providers/provider.js"; import type { IssueProvider } from "../providers/provider.js"; @@ -103,7 +103,7 @@ export type TickResult = { /** * Scan one project's queue and fill free worker slots. * - * Does NOT run health checks (that's work_heartbeat's job). + * Does NOT run health checks (that's the heartbeat service's job). * Non-destructive: only dispatches if slots are free and issues are queued. */ export async function projectTick(opts: { diff --git a/lib/templates.ts b/lib/templates.ts index ad970b2..c47c8f7 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -26,7 +26,7 @@ Read the comments carefully — they often contain clarifications, decisions, or - Clean up the worktree after merging - When done, call work_finish with role "dev", result "done", and a brief summary - If you discover unrelated bugs, call task_create to file them -- Do NOT call work_start, status, health, work_heartbeat, or project_register +- Do NOT call work_start, status, health, or project_register `; export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions @@ -41,7 +41,7 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions - result "fail" with specific issues if problems found - result "refine" if you need human input to decide - If you discover unrelated bugs, call task_create to file them -- Do NOT call work_start, status, health, work_heartbeat, or project_register +- Do NOT call work_start, status, health, or project_register `; export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) @@ -82,7 +82,7 @@ If you discover unrelated bugs or needed improvements during your work, call \`t ### Tools You Should NOT Use These are orchestrator-only tools. Do not call them: -- \`work_start\`, \`status\`, \`health\`, \`work_heartbeat\`, \`project_register\` +- \`work_start\`, \`status\`, \`health\`, \`project_register\` --- @@ -150,7 +150,7 @@ Workers receive role-specific instructions appended to their task message. These ### Heartbeats -**Do nothing.** The \`work_heartbeat\` service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. +**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. ### Safety @@ -162,5 +162,5 @@ Workers receive role-specific instructions appended to their task message. These export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md -Do nothing. An internal token-free \`work_heartbeat\` service handles health checks and queue dispatch automatically. +Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; diff --git a/lib/tools/work-heartbeat.test.ts b/lib/tools/work-heartbeat.test.ts deleted file mode 100644 index 7e92ffe..0000000 --- a/lib/tools/work-heartbeat.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * Tests for work_heartbeat logic: project resolution, tick behavior, execution guards. - * - * Uses projectTick with dryRun: true to test the decision logic without - * requiring OpenClaw API (sessions, dispatch). Mock providers simulate - * issue queues; real projects.json fixtures simulate worker state. - * - * Run with: npx tsx --test lib/tools/work-heartbeat.test.ts - */ -import { describe, it, afterEach } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs/promises"; -import path from "node:path"; -import os from "node:os"; -import type { Project, WorkerState } from "../projects.js"; -import { readProjects } from "../projects.js"; -import { projectTick } from "../services/tick.js"; -import type { StateLabel } from "../providers/provider.js"; - -// --------------------------------------------------------------------------- -// Test fixtures -// --------------------------------------------------------------------------- - -const INACTIVE_WORKER: WorkerState = { - active: false, issueId: null, startTime: null, level: null, sessions: {}, -}; - -const ACTIVE_DEV: WorkerState = { - active: true, issueId: "42", startTime: new Date().toISOString(), level: "medior", - sessions: { medior: "session-dev-42" }, -}; - -const ACTIVE_QA: WorkerState = { - active: true, issueId: "42", startTime: new Date().toISOString(), level: "reviewer", - sessions: { reviewer: "session-qa-42" }, -}; - -function makeProject(overrides: Partial = {}): Project { - return { - name: "Test Project", - repo: "https://github.com/test/repo", - groupName: "Test Group", - deployUrl: "", - baseBranch: "main", - deployBranch: "main", - dev: { ...INACTIVE_WORKER }, - qa: { ...INACTIVE_WORKER }, - ...overrides, - }; -} - -/** Minimal mock provider that returns pre-configured issues per label. */ -function mockProvider(issuesByLabel: Partial>>) { - return { - listIssuesByLabel: async (label: string) => issuesByLabel[label as StateLabel] ?? [], - getIssue: async () => { throw new Error("not implemented"); }, - transitionLabel: async () => {}, - getCurrentStateLabel: () => null, - }; -} - -// --------------------------------------------------------------------------- -// Temp workspace helpers -// --------------------------------------------------------------------------- - -let tmpDir: string; - -async function setupWorkspace(projects: Record): Promise { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); - const projectsDir = path.join(tmpDir, "projects"); - await fs.mkdir(projectsDir, { recursive: true }); - await fs.writeFile( - path.join(projectsDir, "projects.json"), - JSON.stringify({ projects }, null, 2) + "\n", - "utf-8", - ); - return tmpDir; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("work_heartbeat: project resolution", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("resolves all projects when no targetGroupId", async () => { - // Given: two registered projects - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha" }), - "-200": makeProject({ name: "Beta" }), - }); - - const data = await readProjects(workspaceDir); - const entries = Object.entries(data.projects); - - assert.strictEqual(entries.length, 2); - assert.deepStrictEqual(entries.map(([, p]) => p.name).sort(), ["Alpha", "Beta"]); - }); - - it("resolves single project when targetGroupId given", async () => { - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha" }), - "-200": makeProject({ name: "Beta" }), - }); - - const data = await readProjects(workspaceDir); - const project = data.projects["-100"]; - - assert.ok(project); - assert.strictEqual(project.name, "Alpha"); - }); - - it("returns empty for unknown targetGroupId", async () => { - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha" }), - }); - - const data = await readProjects(workspaceDir); - assert.strictEqual(data.projects["-999"], undefined); - }); -}); - -describe("work_heartbeat: global state snapshot", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("counts active workers across projects", async () => { - // Given: Alpha has active DEV, Beta has active QA, Gamma is idle - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", dev: { ...ACTIVE_DEV } }), - "-200": makeProject({ name: "Beta", qa: { ...ACTIVE_QA } }), - "-300": makeProject({ name: "Gamma" }), - }); - - const data = await readProjects(workspaceDir); - let activeDev = 0, activeQa = 0, activeProjects = 0; - for (const p of Object.values(data.projects)) { - if (p.dev.active) activeDev++; - if (p.qa.active) activeQa++; - if (p.dev.active || p.qa.active) activeProjects++; - } - - assert.strictEqual(activeDev, 1, "One active DEV worker (Alpha)"); - assert.strictEqual(activeQa, 1, "One active QA worker (Beta)"); - assert.strictEqual(activeProjects, 2, "Two projects have active workers"); - }); -}); - -describe("work_heartbeat: priority ordering (dry run)", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("picks To Improve over To Do for dev", async () => { - // Given: project with both "To Improve" and "To Do" issues - // Expected: projectTick picks the To Improve issue (higher priority) - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - // To Improve = fix failures (priority 1), To Do = new work (priority 3) - // Priority order: To Improve > To Test > To Do - const provider = mockProvider({ - "To Improve": [{ iid: 10, title: "Fix login bug", description: "", labels: ["To Improve"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - "To Do": [{ iid: 20, title: "Add dark mode", description: "", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/20", state: "opened" }], - }); - - // projectTick with dryRun shows what would be picked up - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - // Should pick up #10 (To Improve) for dev, not #20 (To Do) - const devPickup = result.pickups.find((p) => p.role === "dev"); - assert.ok(devPickup, "Should pick up a dev task"); - assert.strictEqual(devPickup.issueId, 10, "Should pick To Improve (#10) over To Do (#20)"); - assert.strictEqual(devPickup.announcement, "[DRY RUN] Would pick up #10"); - }); - - it("picks To Test for qa role", async () => { - // Given: project with "To Test" issue, QA slot free - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Test": [{ iid: 42, title: "Verify auth flow", description: "", labels: ["To Test"], web_url: "https://github.com/test/alpha/issues/42", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - const qaPickup = result.pickups.find((p) => p.role === "qa"); - assert.ok(qaPickup, "Should pick up a QA task"); - assert.strictEqual(qaPickup.issueId, 42); - assert.strictEqual(qaPickup.role, "qa"); - }); -}); - -describe("work_heartbeat: worker slot guards", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("skips role when worker already active", async () => { - // Given: DEV worker active on #42, To Do issues in queue - // Expected: skips DEV slot, only picks up QA if To Test available - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ - name: "Alpha", - repo: "https://github.com/test/alpha", - dev: { ...ACTIVE_DEV }, - }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 99, title: "New feature", description: "", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/99", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - // DEV already active → skipped, no To Test → QA skipped too - assert.strictEqual(result.pickups.length, 0, "No pickups: DEV busy, no QA work"); - const devSkip = result.skipped.find((s) => s.role === "dev"); - assert.ok(devSkip, "Should have a skip reason for dev"); - assert.ok(devSkip.reason.includes("Already active"), "Skip reason should mention active worker"); - }); - - it("fills both slots in parallel mode", async () => { - // Given: parallel roleExecution (default), both DEV and QA slots free - // To Do issue + To Test issue available - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ - name: "Alpha", - repo: "https://github.com/test/alpha", - roleExecution: "parallel", - }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 10, title: "Build API", description: "", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - "To Test": [{ iid: 20, title: "Verify API", description: "", labels: ["To Test"], web_url: "https://github.com/test/alpha/issues/20", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - // Both slots should be filled - assert.strictEqual(result.pickups.length, 2, "Should pick up both DEV and QA"); - assert.ok(result.pickups.some((p) => p.role === "dev"), "Should have a dev pickup"); - assert.ok(result.pickups.some((p) => p.role === "qa"), "Should have a qa pickup"); - }); - - it("respects sequential roleExecution", async () => { - // Given: sequential roleExecution, DEV active on #42 - // To Test issue available for QA - // Expected: QA skipped because DEV is active (sequential = one role at a time) - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ - name: "Alpha", - repo: "https://github.com/test/alpha", - roleExecution: "sequential", - dev: { ...ACTIVE_DEV }, - }), - }); - - const provider = mockProvider({ - "To Test": [{ iid: 20, title: "Verify fix", description: "", labels: ["To Test"], web_url: "https://github.com/test/alpha/issues/20", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - // DEV active + sequential → QA blocked - assert.strictEqual(result.pickups.length, 0, "No pickups in sequential mode with active DEV"); - const qaSkip = result.skipped.find((s) => s.role === "qa"); - assert.ok(qaSkip, "Should skip QA"); - assert.ok(qaSkip.reason.includes("Sequential"), "Skip reason should mention sequential"); - }); -}); - -describe("work_heartbeat: level assignment", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("uses label-based level when present", async () => { - // Given: issue with "dev.senior" label → level should be "senior" - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 10, title: "Refactor auth", description: "", labels: ["To Do", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - const pickup = result.pickups.find((p) => p.role === "dev"); - assert.ok(pickup); - assert.strictEqual(pickup.level, "senior", "Should use label-based level"); - }); - - it("overrides to reviewer level for qa role regardless of label", async () => { - // Given: issue with "dev.senior" label but picked up by QA - // Expected: level = "reviewer" (QA always uses reviewer level) - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Test": [{ iid: 10, title: "Review auth", description: "", labels: ["To Test", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - const qaPickup = result.pickups.find((p) => p.role === "qa"); - assert.ok(qaPickup); - assert.strictEqual(qaPickup.level, "reviewer", "QA always uses reviewer level regardless of issue label"); - }); - - it("falls back to heuristic when no level label", async () => { - // Given: issue with no level label → heuristic selects based on title/description - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 10, title: "Fix typo in README", description: "Simple typo fix", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - const pickup = result.pickups.find((p) => p.role === "dev"); - assert.ok(pickup); - // Heuristic should select junior for a typo fix - assert.strictEqual(pickup.level, "junior", "Heuristic should assign junior for simple typo fix"); - }); -}); - -describe("work_heartbeat: maxPickups budget", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("respects maxPickups limit", async () => { - // Given: both DEV and QA slots free, issues available for both - // maxPickups = 1 - // Expected: only one pickup - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 10, title: "Feature A", description: "", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - "To Test": [{ iid: 20, title: "Review B", description: "", labels: ["To Test"], web_url: "https://github.com/test/alpha/issues/20", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, maxPickups: 1, provider, - }); - - assert.strictEqual(result.pickups.length, 1, "Should respect maxPickups=1"); - }); -}); - -describe("work_heartbeat: TickAction output shape", () => { - afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - - it("includes all fields needed for notifications", async () => { - // The TickAction must include issueUrl for workerStart notifications - const workspaceDir = await setupWorkspace({ - "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), - }); - - const provider = mockProvider({ - "To Do": [{ iid: 10, title: "Build feature", description: "Details here", labels: ["To Do"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], - }); - - const result = await projectTick({ - workspaceDir, groupId: "-100", dryRun: true, provider, - }); - - const pickup = result.pickups[0]; - assert.ok(pickup, "Should have a pickup"); - - // Verify all fields needed by notifyTickPickups - assert.strictEqual(pickup.project, "Alpha"); - assert.strictEqual(pickup.groupId, "-100"); - assert.strictEqual(pickup.issueId, 10); - assert.strictEqual(pickup.issueTitle, "Build feature"); - assert.strictEqual(pickup.issueUrl, "https://github.com/test/alpha/issues/10"); - assert.ok(["dev", "qa"].includes(pickup.role)); - assert.ok(typeof pickup.level === "string"); - assert.ok(["spawn", "send"].includes(pickup.sessionAction)); - assert.ok(pickup.announcement.includes("[DRY RUN]")); - }); -}); diff --git a/lib/tools/work-heartbeat.ts b/lib/tools/work-heartbeat.ts deleted file mode 100644 index d16a445..0000000 --- a/lib/tools/work-heartbeat.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * work_heartbeat — Heartbeat handler: health fix + dispatch. - * - * Two-pass sweep: - * 1. Health pass: zombie detection + stale worker cleanup per project - * 2. Tick pass: fill free worker slots per project by priority - * - * Execution guards: - * - projectExecution (parallel|sequential): cross-project parallelism (this file) - * - roleExecution (parallel|sequential): DEV/QA parallelism (handled by projectTick) - */ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { jsonResult } from "openclaw/plugin-sdk"; -import type { ToolContext } from "../types.js"; -import type { Project } from "../projects.js"; -import { readProjects } from "../projects.js"; -import { log as auditLog } from "../audit.js"; -import { notify, notifyTickPickups, getNotificationConfig } from "../notify.js"; -import { checkWorkerHealth, type HealthFix } from "../services/health.js"; -import { projectTick, type TickAction } from "../services/tick.js"; -import { - requireWorkspaceDir, - resolveContext, - resolveProvider, - getPluginConfig, -} from "../tool-helpers.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type ProjectEntry = readonly [groupId: string, project: Project]; - -type GlobalState = { - activeProjects: number; - activeDev: number; - activeQa: number; -}; - -// --------------------------------------------------------------------------- -// Tool -// --------------------------------------------------------------------------- - -export function createWorkHeartbeatTool(api: OpenClawPluginApi) { - return (ctx: ToolContext) => ({ - name: "work_heartbeat", - label: "Work Heartbeat", - description: `Heartbeat handler: health fix + dispatch. With projectGroupId: targets one project. Without: sweeps all. Runs health checks, then fills free worker slots by priority.`, - parameters: { - type: "object", - properties: { - projectGroupId: { - type: "string", - description: "Target a single project. Omit to sweep all.", - }, - dryRun: { - type: "boolean", - description: "Report only, don't dispatch. Default: false.", - }, - maxPickups: { type: "number", description: "Max pickups per tick." }, - activeSessions: { - type: "array", - items: { type: "string" }, - description: "Active session IDs for zombie detection.", - }, - }, - }, - - async execute(_id: string, params: Record) { - const targetGroupId = params.projectGroupId as string | undefined; - const dryRun = (params.dryRun as boolean) ?? false; - const maxPickups = params.maxPickups as number | undefined; - const activeSessions = (params.activeSessions as string[]) ?? []; - const workspaceDir = requireWorkspaceDir(ctx); - const pluginConfig = getPluginConfig(api); - const projectExecution = - (pluginConfig?.projectExecution as string) ?? "parallel"; - - // Resolve target projects - const entries = await resolveTargetProjects(workspaceDir, targetGroupId); - if (!entries.length) { - return jsonResult({ - success: true, - dryRun, - healthFixes: [], - pickups: [], - skipped: [{ project: "(none)", reason: "No projects" }], - }); - } - - // Pass 1: health checks (zombie detection, stale worker cleanup) - const healthFixes = await runHealthPass(entries, { - workspaceDir, - activeSessions, - dryRun, - }); - - // Snapshot global state after health fixes - const globalState = await snapshotGlobalState(workspaceDir, entries); - - // Pass 2: fill free worker slots per project - const notifyConfig = getNotificationConfig(pluginConfig); - const { pickups, skipped } = await runTickPass(entries, { - workspaceDir, - pluginConfig, - dryRun, - maxPickups, - notifyConfig, - agentId: ctx.agentId, - sessionKey: ctx.sessionKey, - projectExecution, - initialActiveProjects: globalState.activeProjects, - }); - - // Update global state with new pickups - for (const p of pickups) { - if (p.role === "dev") globalState.activeDev++; - else globalState.activeQa++; - } - globalState.activeProjects += pickups.filter( - (p, i, arr) => arr.findIndex((x) => x.groupId === p.groupId) === i, - ).length; - - // Audit - await auditLog(workspaceDir, "work_heartbeat", { - dryRun, - projectExecution, - projectsScanned: entries.length, - healthFixes: healthFixes.length, - pickups: pickups.length, - skipped: skipped.length, - }); - - // Heartbeat summary notification - const context = await resolveContext(ctx, api); - await notify( - { - type: "heartbeat", - projectsScanned: entries.length, - dryRun, - healthFixes: healthFixes.length, - pickups: pickups.length, - skipped: skipped.length, - pickupDetails: pickups.map((p) => ({ - project: p.project, - issueId: p.issueId, - role: p.role, - })), - }, - { - workspaceDir, - config: notifyConfig, - orchestratorDm: - context.type === "direct" ? context.chatId : undefined, - channel: "channel" in context ? context.channel : undefined, - }, - ); - - return jsonResult({ - success: true, - dryRun, - projectExecution, - healthFixes, - pickups, - skipped, - globalState, - }); - }, - }); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function resolveTargetProjects( - workspaceDir: string, - targetGroupId?: string, -): Promise { - const data = await readProjects(workspaceDir); - if (targetGroupId) { - const project = data.projects[targetGroupId]; - return project ? [[targetGroupId, project]] : []; - } - return Object.entries(data.projects) as ProjectEntry[]; -} - -async function runHealthPass( - entries: ProjectEntry[], - opts: { workspaceDir: string; activeSessions: string[]; dryRun: boolean }, -): Promise> { - const fixes: Array = []; - for (const [groupId, project] of entries) { - const { provider } = resolveProvider(project); - for (const role of ["dev", "qa"] as const) { - const roleFixes = await checkWorkerHealth({ - workspaceDir: opts.workspaceDir, - groupId, - project, - role, - activeSessions: opts.activeSessions, - autoFix: !opts.dryRun, - provider, - }); - fixes.push( - ...roleFixes.map((f) => ({ ...f, project: project.name, role })), - ); - } - } - return fixes; -} - -async function snapshotGlobalState( - workspaceDir: string, - entries: ProjectEntry[], -): Promise { - const data = await readProjects(workspaceDir); - let activeDev = 0, - activeQa = 0, - activeProjects = 0; - for (const [groupId] of entries) { - const p = data.projects[groupId]; - if (!p) continue; - if (p.dev.active) activeDev++; - if (p.qa.active) activeQa++; - if (p.dev.active || p.qa.active) activeProjects++; - } - return { activeDev, activeQa, activeProjects }; -} - -async function runTickPass( - entries: ProjectEntry[], - opts: { - workspaceDir: string; - pluginConfig?: Record; - dryRun: boolean; - maxPickups?: number; - notifyConfig: ReturnType; - agentId?: string; - sessionKey?: string; - projectExecution: string; - initialActiveProjects: number; - }, -): Promise<{ - pickups: Array; - skipped: Array<{ project: string; role?: string; reason: string }>; -}> { - const pickups: Array = []; - const skipped: Array<{ project: string; role?: string; reason: string }> = []; - let pickupCount = 0; - let activeProjects = opts.initialActiveProjects; - - for (const [groupId] of entries) { - const current = (await readProjects(opts.workspaceDir)).projects[groupId]; - if (!current) continue; - - // Budget check - if (opts.maxPickups !== undefined && pickupCount >= opts.maxPickups) { - skipped.push({ project: current.name, reason: "Max pickups reached" }); - continue; - } - - // Sequential project guard: only one project active at a time - const projectActive = current.dev.active || current.qa.active; - if ( - opts.projectExecution === "sequential" && - !projectActive && - activeProjects >= 1 - ) { - skipped.push({ - project: current.name, - reason: "Sequential: another project active", - }); - continue; - } - - // projectTick handles roleExecution (parallel|sequential) internally - const remaining = - opts.maxPickups !== undefined ? opts.maxPickups - pickupCount : undefined; - const result = await projectTick({ - workspaceDir: opts.workspaceDir, - groupId, - agentId: opts.agentId, - pluginConfig: opts.pluginConfig, - sessionKey: opts.sessionKey, - dryRun: opts.dryRun, - maxPickups: remaining, - }); - - pickups.push( - ...result.pickups.map((p) => ({ ...p, project: current.name })), - ); - skipped.push( - ...result.skipped.map((s) => ({ project: current.name, ...s })), - ); - pickupCount += result.pickups.length; - - // Notify workerStart for each pickup in this project - if (!opts.dryRun && result.pickups.length > 0) { - await notifyTickPickups(result.pickups, { - workspaceDir: opts.workspaceDir, - config: opts.notifyConfig, - channel: current.channel ?? "telegram", - }); - if (!projectActive) activeProjects++; - } - } - - return { pickups, skipped }; -} diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 6f46886..2c1a5e7 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -111,8 +111,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { ); // Auto-tick disabled per issue #125 - work_start should only pick up the explicitly requested issue - // To fill parallel slots, use work_heartbeat instead - // const tickPickups = await tickAndNotify({ ... }); + // The heartbeat service fills parallel slots automatically const output: Record = { success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,