Initial commit: DevClaw OpenClaw plugin

Multi-project dev/qa pipeline orchestration with 4 agent tools:
- task_pickup: atomic task pickup with model selection and session reuse
- task_complete: DEV done, QA pass/fail/refine with label transitions
- queue_status: task queue and worker status across projects
- session_health: zombie detection and state consistency checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lauren ten Hoor
2026-02-08 15:26:29 +08:00
commit 9ace15dad5
14 changed files with 1313 additions and 0 deletions

111
lib/tools/queue-status.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* queue_status — Show task queue and worker status across projects.
*
* Replaces manual GitLab scanning in HEARTBEAT.md.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, getProject } from "../projects.js";
import { listIssuesByLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
import { log as auditLog } from "../audit.js";
export function createQueueStatusTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "queue_status",
description: `Show task queue counts and worker status for all projects (or a specific project). Returns To Improve, To Test, To Do issue counts and active DEV/QA session state.`,
parameters: {
type: "object",
properties: {
projectGroupId: {
type: "string",
description: "Specific project group ID to check. Omit to check all projects.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string | undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
const data = await readProjects(workspaceDir);
const projectIds = groupId
? [groupId]
: Object.keys(data.projects);
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const projects: Array<Record<string, unknown>> = [];
for (const pid of projectIds) {
const project = getProject(data, pid);
if (!project) continue;
const repoPath = resolveRepoPath(project.repo);
const glabOpts = { glabPath, repoPath };
// Fetch queue counts from GitLab
const queueLabels: StateLabel[] = ["To Improve", "To Test", "To Do"];
const queue: Record<string, Array<{ id: number; title: string }>> = {};
for (const label of queueLabels) {
try {
const issues = await listIssuesByLabel(label, glabOpts);
queue[label] = issues.map((i) => ({ id: i.iid, title: i.title }));
} catch {
queue[label] = [];
}
}
projects.push({
name: project.name,
groupId: pid,
dev: {
active: project.dev.active,
sessionId: project.dev.sessionId,
issueId: project.dev.issueId,
model: project.dev.model,
},
qa: {
active: project.qa.active,
sessionId: project.qa.sessionId,
issueId: project.qa.issueId,
model: project.qa.model,
},
queue: {
toImprove: queue["To Improve"],
toTest: queue["To Test"],
toDo: queue["To Do"],
},
});
}
// Audit log
await auditLog(workspaceDir, "queue_status", {
projectCount: projects.length,
totalToImprove: projects.reduce(
(sum, p) => sum + ((p.queue as Record<string, unknown[]>).toImprove?.length ?? 0),
0,
),
totalToTest: projects.reduce(
(sum, p) => sum + ((p.queue as Record<string, unknown[]>).toTest?.length ?? 0),
0,
),
totalToDo: projects.reduce(
(sum, p) => sum + ((p.queue as Record<string, unknown[]>).toDo?.length ?? 0),
0,
),
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ projects }, null, 2),
},
],
};
},
});
}

189
lib/tools/session-health.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* session_health — Check and fix session state consistency.
*
* Detects zombie sessions (active=true but session dead) and stale workers.
* Replaces manual HEARTBEAT.md step 1.
*
* NOTE: This tool checks projects.json state only. The agent should verify
* session liveness via sessions_list and pass the results. The tool cannot
* call sessions_list directly (it's an agent-level tool).
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, updateWorker } from "../projects.js";
import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
import { log as auditLog } from "../audit.js";
export function createSessionHealthTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "session_health",
description: `Check session state consistency across all projects. Detects: active workers with dead sessions, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`,
parameters: {
type: "object",
properties: {
autoFix: {
type: "boolean",
description: "Automatically fix zombie sessions and stale active flags. Default: false.",
},
activeSessions: {
type: "array",
items: { type: "string" },
description: "List of currently alive session IDs from sessions_list. Used to detect zombies.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const autoFix = (params.autoFix as boolean) ?? false;
const activeSessions = (params.activeSessions as string[]) ?? [];
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
const data = await readProjects(workspaceDir);
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const issues: Array<Record<string, unknown>> = [];
let fixesApplied = 0;
for (const [groupId, project] of Object.entries(data.projects)) {
const repoPath = resolveRepoPath(project.repo);
const glabOpts = { glabPath, repoPath };
for (const role of ["dev", "qa"] as const) {
const worker = project[role];
// Check 1: Active but no sessionId
if (worker.active && !worker.sessionId) {
const issue: Record<string, unknown> = {
type: "active_no_session",
severity: "critical",
project: project.name,
groupId,
role,
message: `${role.toUpperCase()} marked active but has no sessionId`,
};
if (autoFix) {
await updateWorker(workspaceDir, groupId, role, {
active: false,
issueId: null,
});
issue.fixed = true;
fixesApplied++;
}
issues.push(issue);
}
// Check 2: Active with sessionId but session is dead (zombie)
if (
worker.active &&
worker.sessionId &&
activeSessions.length > 0 &&
!activeSessions.includes(worker.sessionId)
) {
const issue: Record<string, unknown> = {
type: "zombie_session",
severity: "critical",
project: project.name,
groupId,
role,
sessionId: worker.sessionId,
message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`,
};
if (autoFix) {
// Revert GitLab label
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
try {
if (worker.issueId) {
const primaryIssueId = Number(worker.issueId.split(",")[0]);
await transitionLabel(primaryIssueId, currentLabel, revertLabel, glabOpts);
issue.labelReverted = `${currentLabel}${revertLabel}`;
}
} catch {
issue.labelRevertFailed = true;
}
await updateWorker(workspaceDir, groupId, role, {
active: false,
issueId: null,
});
issue.fixed = true;
fixesApplied++;
}
issues.push(issue);
}
// Check 3: Active for >2 hours (stale)
if (worker.active && worker.startTime) {
const startMs = new Date(worker.startTime).getTime();
const nowMs = Date.now();
const hoursActive = (nowMs - startMs) / (1000 * 60 * 60);
if (hoursActive > 2) {
issues.push({
type: "stale_worker",
severity: "warning",
project: project.name,
groupId,
role,
hoursActive: Math.round(hoursActive * 10) / 10,
sessionId: worker.sessionId,
issueId: worker.issueId,
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
});
}
}
// Check 4: Inactive but still has issueId (should have been cleared)
if (!worker.active && worker.issueId) {
const issue: Record<string, unknown> = {
type: "inactive_with_issue",
severity: "warning",
project: project.name,
groupId,
role,
issueId: worker.issueId,
message: `${role.toUpperCase()} inactive but still has issueId "${worker.issueId}"`,
};
if (autoFix) {
await updateWorker(workspaceDir, groupId, role, {
issueId: null,
});
issue.fixed = true;
fixesApplied++;
}
issues.push(issue);
}
}
}
// Audit log
await auditLog(workspaceDir, "health_check", {
projectsScanned: Object.keys(data.projects).length,
issuesFound: issues.length,
fixesApplied,
autoFix,
activeSessionsProvided: activeSessions.length > 0,
});
const result = {
healthy: issues.length === 0,
issuesFound: issues.length,
fixesApplied,
issues,
note: activeSessions.length === 0
? "No activeSessions provided — zombie detection skipped. Call sessions_list and pass the result for full health check."
: undefined,
};
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
},
});
}

198
lib/tools/task-complete.ts Normal file
View File

@@ -0,0 +1,198 @@
/**
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
*
* Handles: validation, GitLab label transition, projects.json state update,
* issue close/reopen, and audit logging.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
readProjects,
getProject,
getWorker,
deactivateWorker,
activateWorker,
} from "../projects.js";
import {
getIssue,
transitionLabel,
closeIssue,
reopenIssue,
resolveRepoPath,
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export function createTaskCompleteTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_complete",
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. For QA fail, also prepares DEV session instructions for the fix cycle.`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
properties: {
role: { type: "string", enum: ["dev", "qa"], description: "Worker role completing the task" },
result: {
type: "string",
enum: ["done", "pass", "fail", "refine"],
description: 'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input)',
},
projectGroupId: { type: "string", description: "Telegram group ID (key in projects.json)" },
summary: { type: "string", description: "Brief summary for Telegram announcement" },
},
},
async execute(_id: string, params: Record<string, unknown>) {
const role = params.role as "dev" | "qa";
const result = params.result as "done" | "pass" | "fail" | "refine";
const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// Validate result matches role
if (role === "dev" && result !== "done") {
throw new Error(`DEV can only complete with result "done", got "${result}"`);
}
if (role === "qa" && result === "done") {
throw new Error(`QA cannot use result "done". Use "pass", "fail", or "refine".`);
}
// Resolve project
const data = await readProjects(workspaceDir);
const project = getProject(data, groupId);
if (!project) {
throw new Error(`Project not found for groupId: ${groupId}`);
}
const worker = getWorker(project, role);
if (!worker.active) {
throw new Error(
`${role.toUpperCase()} worker is not active on ${project.name}. Nothing to complete.`,
);
}
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
if (!issueId) {
throw new Error(`No issueId found for active ${role.toUpperCase()} worker on ${project.name}`);
}
const repoPath = resolveRepoPath(project.repo);
const glabOpts = {
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
repoPath,
};
const output: Record<string, unknown> = {
success: true,
project: project.name,
groupId,
issueId,
role,
result,
};
// === DEV DONE ===
if (role === "dev" && result === "done") {
// Pull latest on the project repo
try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
output.gitPull = "success";
} catch (err) {
output.gitPull = `warning: ${(err as Error).message}`;
}
// Deactivate DEV (preserves sessionId, model, startTime)
await deactivateWorker(workspaceDir, groupId, "dev");
// Transition label: Doing → To Test
await transitionLabel(issueId, "Doing", "To Test", glabOpts);
output.labelTransition = "Doing → To Test";
output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`;
}
// === QA PASS ===
if (role === "qa" && result === "pass") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Done, close issue
await transitionLabel(issueId, "Testing", "Done", glabOpts);
await closeIssue(issueId, glabOpts);
output.labelTransition = "Testing → Done";
output.issueClosed = true;
output.announcement = `🎉 QA PASS #${issueId}${summary ? `${summary}` : ""}. Issue closed.`;
}
// === QA FAIL ===
if (role === "qa" && result === "fail") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → To Improve, reopen issue
await transitionLabel(issueId, "Testing", "To Improve", glabOpts);
await reopenIssue(issueId, glabOpts);
// Prepare DEV fix cycle
const issue = await getIssue(issueId, glabOpts);
const devModel = selectModel(issue.title, issue.description ?? "", "dev");
const devWorker = getWorker(project, "dev");
output.labelTransition = "Testing → To Improve";
output.issueReopened = true;
output.announcement = `❌ QA FAIL #${issueId}${summary ? `${summary}` : ""}. Sent back to DEV.`;
// If DEV session exists, prepare reuse instructions
if (devWorker.sessionId) {
output.devFixInstructions =
`Send QA feedback to existing DEV session ${devWorker.sessionId}. ` +
`If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` +
`Then sessions_send with QA failure details. ` +
`DEV will pick up from To Improve → Doing automatically.`;
output.devSessionId = devWorker.sessionId;
output.devModel = devModel.alias;
} else {
output.devFixInstructions =
`No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`;
output.devModel = devModel.alias;
}
}
// === QA REFINE ===
if (role === "qa" && result === "refine") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Refining
await transitionLabel(issueId, "Testing", "Refining", glabOpts);
output.labelTransition = "Testing → Refining";
output.announcement = `🤔 QA REFINE #${issueId}${summary ? `${summary}` : ""}. Awaiting human decision.`;
}
// Audit log
await auditLog(workspaceDir, "task_complete", {
project: project.name,
groupId,
issue: issueId,
role,
result,
summary: summary ?? null,
labelTransition: output.labelTransition,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(output, null, 2) }],
};
},
});
}

196
lib/tools/task-pickup.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* task_pickup — Atomically pick up a task from the GitLab queue.
*
* Handles: validation, model selection, GitLab label transition,
* projects.json state update, and audit logging.
*
* Returns structured instructions for the agent to spawn/send a session.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
readProjects,
getProject,
getWorker,
activateWorker,
} from "../projects.js";
import {
getIssue,
getCurrentStateLabel,
transitionLabel,
resolveRepoPath,
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js";
export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_pickup",
description: `Pick up a task from the GitLab queue for a DEV or QA worker. Atomically handles: label transition, model selection, projects.json update, and audit logging. Returns session action instructions (spawn or send) for the agent to execute.`,
parameters: {
type: "object",
required: ["issueId", "role", "projectGroupId"],
properties: {
issueId: { type: "number", description: "GitLab issue ID to pick up" },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" },
projectGroupId: {
type: "string",
description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
},
modelOverride: {
type: "string",
description: "Force a specific model alias (e.g. haiku, sonnet, opus, grok). Overrides automatic selection.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const issueId = params.issueId as number;
const role = params.role as "dev" | "qa";
const groupId = params.projectGroupId as string;
const modelOverride = params.modelOverride as string | undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = getProject(data, groupId);
if (!project) {
throw new Error(
`Project not found for groupId: ${groupId}. Available: ${Object.keys(data.projects).join(", ")}`,
);
}
// 2. Check no active worker for this role
const worker = getWorker(project, role);
if (worker.active) {
throw new Error(
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}, session: ${worker.sessionId}). Complete current task first.`,
);
}
// 3. Fetch issue from GitLab and verify state
const repoPath = resolveRepoPath(project.repo);
const glabOpts = {
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
repoPath,
};
const issue = await getIssue(issueId, glabOpts);
const currentLabel = getCurrentStateLabel(issue);
// Validate label matches expected state for the role
const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"];
const validLabelsForQa: StateLabel[] = ["To Test"];
const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa;
if (!currentLabel || !validLabels.includes(currentLabel)) {
throw new Error(
`Issue #${issueId} has label "${currentLabel ?? "none"}" but expected one of: ${validLabels.join(", ")}. Cannot pick up for ${role.toUpperCase()}.`,
);
}
// 4. Select model
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let selectedModel = selectModel(issue.title, issue.description ?? "", role);
if (modelOverride) {
selectedModel = {
model: modelOverride,
alias: modelOverride,
reason: `User override: ${modelOverride}`,
};
}
// 5. Determine session action (spawn vs reuse)
const existingSessionId = worker.sessionId;
const sessionAction = existingSessionId ? "send" : "spawn";
// 6. Transition GitLab label
await transitionLabel(issueId, currentLabel, targetLabel, glabOpts);
// 7. Update projects.json
const now = new Date().toISOString();
if (sessionAction === "spawn") {
// New spawn — agent will provide sessionId after spawning
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: selectedModel.alias,
startTime: now,
});
} else {
// Reuse existing session — preserve sessionId and startTime
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: selectedModel.alias,
});
}
// 8. Audit log
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
groupId,
issue: issueId,
issueTitle: issue.title,
role,
model: selectedModel.alias,
modelReason: selectedModel.reason,
sessionAction,
sessionId: existingSessionId,
labelTransition: `${currentLabel}${targetLabel}`,
});
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: selectedModel.alias,
fullModel: selectedModel.model,
reason: selectedModel.reason,
override: modelOverride ?? null,
});
// 9. Build announcement and session instructions
const emoji = role === "dev"
? (selectedModel.alias === "haiku" ? "⚡" : selectedModel.alias === "opus" ? "🧠" : "🔧")
: "🔍";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${selectedModel.alias}) for #${issueId}: ${issue.title}`;
const result: Record<string, unknown> = {
success: true,
project: project.name,
groupId,
issueId,
issueTitle: issue.title,
role,
model: selectedModel.alias,
fullModel: selectedModel.model,
modelReason: selectedModel.reason,
sessionAction,
announcement,
labelTransition: `${currentLabel}${targetLabel}`,
};
if (sessionAction === "send") {
result.sessionId = existingSessionId;
result.instructions =
`Session reuse: send new task to existing session ${existingSessionId}. ` +
`If model "${selectedModel.alias}" differs from current session model, call sessions.patch first to update the model. ` +
`Then call sessions_send with the task description. ` +
`After spawning/sending, update projects.json sessionId if it changed.`;
result.tokensSavedEstimate = "~50K (session reuse)";
} else {
result.instructions =
`New session: call sessions_spawn with model "${selectedModel.model}" for this ${role.toUpperCase()} task. ` +
`After spawn completes, call task_pickup_confirm with the returned sessionId to update projects.json.`;
}
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
},
});
}