feat: implement work heartbeat service for health checks and task dispatching

- Introduced a new heartbeat service that runs at defined intervals to perform health checks on workers and fill available task slots based on priority.
- Added a health tool to scan worker health across projects with optional auto-fix capabilities.
- Updated the status tool to provide a lightweight overview of worker states and queue counts without health checks.
- Enhanced task creation tool descriptions to clarify task state handling.
- Implemented tests for the work heartbeat logic, ensuring proper project resolution, worker state management, and task prioritization.
This commit is contained in:
Lauren ten Hoor
2026-02-11 01:04:30 +08:00
parent 71a3ea2352
commit f2e71a35d8
13 changed files with 1044 additions and 426 deletions

View File

@@ -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), auto_pickup (sweep).
* Called by: work_start (fill parallel slot), work_finish (next pipeline step), work_heartbeat (sweep).
*/
import type { Issue, StateLabel } from "../providers/provider.js";
import type { IssueProvider } from "../providers/provider.js";
@@ -90,7 +90,7 @@ export type TickResult = {
/**
* Scan one project's queue and fill free worker slots.
*
* Does NOT run health checks (that's auto_pickup's job).
* Does NOT run health checks (that's work_heartbeat's job).
* Non-destructive: only dispatches if slots are free and issues are queued.
*/
export async function projectTick(opts: {
@@ -103,13 +103,15 @@ export async function projectTick(opts: {
maxPickups?: number;
/** Only attempt this role. Used by work_start to fill the other slot. */
targetRole?: "dev" | "qa";
/** Optional provider override (for testing). Uses createProvider if omitted. */
provider?: Pick<IssueProvider, "listIssuesByLabel" | "transitionLabel">;
}): Promise<TickResult> {
const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole } = opts;
const project = (await readProjects(workspaceDir)).projects[groupId];
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
const { provider } = createProvider({ repo: project.repo });
const provider = opts.provider ?? createProvider({ repo: project.repo }).provider;
const roleExecution = project.roleExecution ?? "parallel";
const roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"];