refactor: remove work_heartbeat tool and related tests; update documentation and notification logic
This commit is contained in:
17
index.ts
17
index.ts
@@ -6,7 +6,6 @@ import { createTaskUpdateTool } from "./lib/tools/task-update.js";
|
|||||||
import { createTaskCommentTool } from "./lib/tools/task-comment.js";
|
import { createTaskCommentTool } from "./lib/tools/task-comment.js";
|
||||||
import { createStatusTool } from "./lib/tools/status.js";
|
import { createStatusTool } from "./lib/tools/status.js";
|
||||||
import { createHealthTool } from "./lib/tools/health.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 { createProjectRegisterTool } from "./lib/tools/project-register.js";
|
||||||
import { createSetupTool } from "./lib/tools/setup.js";
|
import { createSetupTool } from "./lib/tools/setup.js";
|
||||||
import { createOnboardTool } from "./lib/tools/onboard.js";
|
import { createOnboardTool } from "./lib/tools/onboard.js";
|
||||||
@@ -53,9 +52,9 @@ const plugin = {
|
|||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description: "Notification settings",
|
description:
|
||||||
|
"Per-event-type notification toggles. All default to true — set to false to suppress.",
|
||||||
properties: {
|
properties: {
|
||||||
heartbeatDm: { type: "boolean", default: true },
|
|
||||||
workerStart: { type: "boolean", default: true },
|
workerStart: { type: "boolean", default: true },
|
||||||
workerComplete: { type: "boolean", default: true },
|
workerComplete: { type: "boolean", default: true },
|
||||||
},
|
},
|
||||||
@@ -63,17 +62,17 @@ const plugin = {
|
|||||||
work_heartbeat: {
|
work_heartbeat: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description:
|
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: {
|
properties: {
|
||||||
enabled: {
|
enabled: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
default: true,
|
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: {
|
intervalSeconds: {
|
||||||
type: "number",
|
type: "number",
|
||||||
default: 60,
|
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: {
|
maxPickupsPerTick: {
|
||||||
type: "number",
|
type: "number",
|
||||||
@@ -98,10 +97,6 @@ const plugin = {
|
|||||||
// Operations
|
// Operations
|
||||||
api.registerTool(createStatusTool(api), { names: ["status"] });
|
api.registerTool(createStatusTool(api), { names: ["status"] });
|
||||||
api.registerTool(createHealthTool(api), { names: ["health"] });
|
api.registerTool(createHealthTool(api), { names: ["health"] });
|
||||||
api.registerTool(createWorkHeartbeatTool(api), {
|
|
||||||
names: ["work_heartbeat"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup & config
|
// Setup & config
|
||||||
api.registerTool(createProjectRegisterTool(api), {
|
api.registerTool(createProjectRegisterTool(api), {
|
||||||
names: ["project_register"],
|
names: ["project_register"],
|
||||||
@@ -118,7 +113,7 @@ const plugin = {
|
|||||||
registerHeartbeatService(api);
|
registerHeartbeatService(api);
|
||||||
|
|
||||||
api.logger.info(
|
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)",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
|
||||||
const MAX_LOG_LINES = 250;
|
const MAX_LOG_LINES = 50;
|
||||||
|
|
||||||
export async function log(
|
export async function log(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
@@ -35,7 +35,7 @@ export async function log(
|
|||||||
async function truncateIfNeeded(filePath: string): Promise<void> {
|
async function truncateIfNeeded(filePath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(filePath, "utf-8");
|
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) {
|
if (lines.length > MAX_LOG_LINES) {
|
||||||
const keptLines = lines.slice(-MAX_LOG_LINES);
|
const keptLines = lines.slice(-MAX_LOG_LINES);
|
||||||
|
|||||||
@@ -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,
|
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
|
||||||
* state update (activateWorker), and audit logging.
|
* state update (activateWorker), and audit logging.
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* notify.ts — Programmatic alerting for worker lifecycle events.
|
* notify.ts — Programmatic alerting for worker lifecycle events.
|
||||||
*
|
*
|
||||||
* Sends notifications to project groups and orchestrator DM for visibility
|
* Sends notifications to project groups for visibility into the DevClaw pipeline.
|
||||||
* into the DevClaw pipeline.
|
|
||||||
*
|
*
|
||||||
* Event types:
|
* Event types:
|
||||||
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
||||||
* - workerComplete: Worker completed task (→ project group)
|
* - workerComplete: Worker completed task (→ project group)
|
||||||
* - heartbeat: Heartbeat tick summary (→ orchestrator DM)
|
|
||||||
*/
|
*/
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -16,14 +14,8 @@ import type { TickAction } from "./services/tick.js";
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export type NotificationConfig = {
|
/** Per-event-type toggle. All default to true — set to false to suppress. */
|
||||||
/** Send heartbeat summaries to orchestrator DM. Default: true */
|
export type NotificationConfig = Partial<Record<NotifyEvent["type"], boolean>>;
|
||||||
heartbeatDm?: boolean;
|
|
||||||
/** Post when worker starts a task. Default: true */
|
|
||||||
workerStart?: boolean;
|
|
||||||
/** Post when worker completes a task. Default: true */
|
|
||||||
workerComplete?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NotifyEvent =
|
export type NotifyEvent =
|
||||||
| {
|
| {
|
||||||
@@ -47,19 +39,6 @@ export type NotifyEvent =
|
|||||||
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
nextState?: 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}`;
|
msg += `\n🔗 ${event.issueUrl}`;
|
||||||
return msg;
|
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.
|
* Send a notification for a worker lifecycle event.
|
||||||
*
|
*
|
||||||
* Respects notification config settings.
|
* Returns true if notification was sent, false on error.
|
||||||
* Returns true if notification was sent (or skipped due to config), false on error.
|
|
||||||
*/
|
*/
|
||||||
export async function notify(
|
export async function notify(
|
||||||
event: NotifyEvent,
|
event: NotifyEvent,
|
||||||
@@ -181,36 +136,15 @@ export async function notify(
|
|||||||
groupId?: string;
|
groupId?: string;
|
||||||
/** Channel type for routing (e.g. "telegram", "whatsapp", "discord", "slack") */
|
/** Channel type for routing (e.g. "telegram", "whatsapp", "discord", "slack") */
|
||||||
channel?: string;
|
channel?: string;
|
||||||
/** Target for DM notifications (orchestrator) */
|
|
||||||
orchestratorDm?: string;
|
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const config = opts.config ?? {};
|
if (opts.config?.[event.type] === false) return true;
|
||||||
|
|
||||||
const channel = opts.channel ?? "telegram";
|
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);
|
const message = buildMessage(event);
|
||||||
|
const target = opts.groupId ?? (event as { groupId?: string }).groupId;
|
||||||
// Determine target
|
|
||||||
let target: string | undefined;
|
|
||||||
if (event.type === "heartbeat") {
|
|
||||||
target = opts.orchestratorDm;
|
|
||||||
} else {
|
|
||||||
target = opts.groupId ?? (event as { groupId?: string }).groupId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// No target specified, can't send
|
|
||||||
await auditLog(opts.workspaceDir, "notify_skip", {
|
await auditLog(opts.workspaceDir, "notify_skip", {
|
||||||
eventType: event.type,
|
eventType: event.type,
|
||||||
reason: "no target",
|
reason: "no target",
|
||||||
@@ -218,7 +152,6 @@ export async function notify(
|
|||||||
return true; // Not an error, just nothing to do
|
return true; // Not an error, just nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit the notification attempt
|
|
||||||
await auditLog(opts.workspaceDir, "notify", {
|
await auditLog(opts.workspaceDir, "notify", {
|
||||||
eventType: event.type,
|
eventType: event.type,
|
||||||
target,
|
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(
|
export function getNotificationConfig(
|
||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
): NotificationConfig {
|
): NotificationConfig {
|
||||||
const notifications = pluginConfig?.notifications as NotificationConfig | undefined;
|
return (pluginConfig?.notifications as NotificationConfig) ?? {};
|
||||||
return {
|
|
||||||
heartbeatDm: notifications?.heartbeatDm ?? true,
|
|
||||||
workerStart: notifications?.workerStart ?? true,
|
|
||||||
workerComplete: notifications?.workerComplete ?? true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* tick.ts — Project-level queue scan + dispatch.
|
* tick.ts — Project-level queue scan + dispatch.
|
||||||
*
|
*
|
||||||
* Core function: projectTick() scans one project's queue and fills free worker slots.
|
* 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 { Issue, StateLabel } from "../providers/provider.js";
|
||||||
import type { IssueProvider } 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.
|
* 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.
|
* Non-destructive: only dispatches if slots are free and issues are queued.
|
||||||
*/
|
*/
|
||||||
export async function projectTick(opts: {
|
export async function projectTick(opts: {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Read the comments carefully — they often contain clarifications, decisions, or
|
|||||||
- Clean up the worktree after merging
|
- Clean up the worktree after merging
|
||||||
- When done, call work_finish with role "dev", result "done", and a brief summary
|
- 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
|
- 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
|
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 "fail" with specific issues if problems found
|
||||||
- result "refine" if you need human input to decide
|
- result "refine" if you need human input to decide
|
||||||
- If you discover unrelated bugs, call task_create to file them
|
- 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)
|
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
|
### Tools You Should NOT Use
|
||||||
|
|
||||||
These are orchestrator-only tools. Do not call them:
|
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
|
### 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
|
### Safety
|
||||||
|
|
||||||
@@ -162,5 +162,5 @@ Workers receive role-specific instructions appended to their task message. These
|
|||||||
|
|
||||||
export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
|
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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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> = {}): 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<Record<StateLabel, Array<{ iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }>>>) {
|
|
||||||
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<string, Project>): Promise<string> {
|
|
||||||
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]"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<string, unknown>) {
|
|
||||||
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<ProjectEntry[]> {
|
|
||||||
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<Array<HealthFix & { project: string; role: string }>> {
|
|
||||||
const fixes: Array<HealthFix & { project: string; role: string }> = [];
|
|
||||||
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<GlobalState> {
|
|
||||||
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<string, unknown>;
|
|
||||||
dryRun: boolean;
|
|
||||||
maxPickups?: number;
|
|
||||||
notifyConfig: ReturnType<typeof getNotificationConfig>;
|
|
||||||
agentId?: string;
|
|
||||||
sessionKey?: string;
|
|
||||||
projectExecution: string;
|
|
||||||
initialActiveProjects: number;
|
|
||||||
},
|
|
||||||
): Promise<{
|
|
||||||
pickups: Array<TickAction & { project: string }>;
|
|
||||||
skipped: Array<{ project: string; role?: string; reason: string }>;
|
|
||||||
}> {
|
|
||||||
const pickups: Array<TickAction & { project: string }> = [];
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -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
|
// Auto-tick disabled per issue #125 - work_start should only pick up the explicitly requested issue
|
||||||
// To fill parallel slots, use work_heartbeat instead
|
// The heartbeat service fills parallel slots automatically
|
||||||
// const tickPickups = await tickAndNotify({ ... });
|
|
||||||
|
|
||||||
const output: Record<string, unknown> = {
|
const output: Record<string, unknown> = {
|
||||||
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
||||||
|
|||||||
Reference in New Issue
Block a user