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

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) }],
};
},
});
}