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>
199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
/**
|
|
* 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) }],
|
|
};
|
|
},
|
|
});
|
|
}
|