- Updated WorkerState type to use 'level' instead of 'tier'. - Modified functions related to worker state management, including parseWorkerState, emptyWorkerState, getSessionForLevel, activateWorker, and deactivateWorker to reflect the new terminology. - Adjusted health check logic to utilize 'level' instead of 'tier'. - Refactored tick and setup tools to accommodate the change from 'tier' to 'level', including model configuration and workspace scaffolding. - Updated tests to ensure consistency with the new 'level' terminology. - Revised documentation and comments to reflect the changes in terminology from 'tier' to 'level'.
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
/**
|
|
* 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]"));
|
|
});
|
|
});
|