feat: create TEST.md markdown file (#78)
This commit is contained in:
@@ -55,7 +55,7 @@ export type DispatchResult = {
|
||||
* Reads role-specific instructions from workspace/roles/<project>/<role>.md
|
||||
* with fallback to workspace/roles/default/<role>.md.
|
||||
*/
|
||||
async function buildTaskMessage(opts: {
|
||||
export async function buildTaskMessage(opts: {
|
||||
workspaceDir: string;
|
||||
projectName: string;
|
||||
role: "dev" | "qa";
|
||||
@@ -104,6 +104,12 @@ async function buildTaskMessage(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Build available results based on role
|
||||
const availableResults =
|
||||
role === "dev"
|
||||
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
||||
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
|
||||
|
||||
const parts = [
|
||||
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
|
||||
``,
|
||||
@@ -118,6 +124,24 @@ async function buildTaskMessage(opts: {
|
||||
parts.push(``, `---`, ``, roleInstructions.trim());
|
||||
}
|
||||
|
||||
// Mandatory completion contract
|
||||
parts.push(
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
`## MANDATORY: Task Completion`,
|
||||
``,
|
||||
`When you finish this task, you MUST call \`task_complete\` with:`,
|
||||
`- \`role\`: "${role}"`,
|
||||
`- \`projectGroupId\`: "${groupId}"`,
|
||||
`- \`result\`: ${availableResults}`,
|
||||
`- \`summary\`: brief description of what you did`,
|
||||
``,
|
||||
`⚠️ You MUST call task_complete even if you encounter errors or cannot finish.`,
|
||||
`Use "blocked" with a summary explaining why you're stuck.`,
|
||||
`Never end your session without calling task_complete.`,
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -193,8 +217,18 @@ export async function dispatchTask(
|
||||
|
||||
await execFileAsync(
|
||||
"openclaw",
|
||||
["agent", "--session-id", sessionKey!, "--message", taskMessage],
|
||||
{ timeout: 60_000 },
|
||||
[
|
||||
"gateway",
|
||||
"call",
|
||||
"agent",
|
||||
"--params",
|
||||
JSON.stringify({
|
||||
idempotencyKey: randomUUID(),
|
||||
sessionId: sessionKey!,
|
||||
message: taskMessage,
|
||||
}),
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
dispatched = true;
|
||||
|
||||
@@ -42,7 +42,7 @@ export type NotifyEvent =
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
role: "dev" | "qa";
|
||||
result: "done" | "pass" | "fail" | "refine";
|
||||
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
||||
summary?: string;
|
||||
nextState?: string;
|
||||
}
|
||||
@@ -76,6 +76,7 @@ function buildMessage(event: NotifyEvent): string {
|
||||
pass: "🎉",
|
||||
fail: "❌",
|
||||
refine: "🤔",
|
||||
blocked: "🚫",
|
||||
};
|
||||
const icon = icons[event.result] ?? "📋";
|
||||
const resultText: Record<string, string> = {
|
||||
@@ -83,6 +84,7 @@ function buildMessage(event: NotifyEvent): string {
|
||||
pass: "PASSED",
|
||||
fail: "FAILED",
|
||||
refine: "needs refinement",
|
||||
blocked: "BLOCKED",
|
||||
};
|
||||
const text = resultText[event.result] ?? event.result;
|
||||
let msg = `${icon} ${event.role.toUpperCase()} ${text} #${event.issueId}`;
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Tests for projects.ts session persistence
|
||||
* Run with: npm test
|
||||
*/
|
||||
import { describe, it, before, after } 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 ProjectsData,
|
||||
activateWorker,
|
||||
deactivateWorker,
|
||||
readProjects,
|
||||
writeProjects,
|
||||
} from "./projects.js";
|
||||
|
||||
describe("Session persistence", () => {
|
||||
let tempDir: string;
|
||||
let testWorkspaceDir: string;
|
||||
|
||||
before(async () => {
|
||||
// Create temp directory for test workspace
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
testWorkspaceDir = tempDir;
|
||||
await fs.mkdir(path.join(testWorkspaceDir, "memory"), { recursive: true });
|
||||
|
||||
// Create initial projects.json
|
||||
const initialData: ProjectsData = {
|
||||
projects: {
|
||||
"-test-group": {
|
||||
name: "test-project",
|
||||
repo: "~/git/test-project",
|
||||
groupName: "Test Project",
|
||||
deployUrl: "https://test.example.com",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
autoChain: false,
|
||||
channel: "telegram",
|
||||
dev: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: {
|
||||
junior: null,
|
||||
medior: null,
|
||||
senior: null,
|
||||
},
|
||||
},
|
||||
qa: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: {
|
||||
qa: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeProjects(testWorkspaceDir, initialData);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should preserve sessions after task completion (single tier)", async () => {
|
||||
// Simulate task pickup: activate worker with senior tier
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "42",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-session-123",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Verify session was stored
|
||||
let data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-123",
|
||||
"Senior session should be stored after activation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.active,
|
||||
true,
|
||||
"Worker should be active",
|
||||
);
|
||||
|
||||
// Simulate task completion: deactivate worker
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Verify session persists after deactivation
|
||||
data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-123",
|
||||
"Senior session should persist after deactivation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.active,
|
||||
false,
|
||||
"Worker should be inactive",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.issueId,
|
||||
null,
|
||||
"Issue ID should be cleared",
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve all tier sessions after completion (multiple tiers)", async () => {
|
||||
// Setup: create sessions for multiple tiers
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "10",
|
||||
model: "junior",
|
||||
sessionKey: "agent:test:subagent:junior-session-111",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "20",
|
||||
model: "medior",
|
||||
sessionKey: "agent:test:subagent:medior-session-222",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "30",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-session-333",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Verify all sessions persisted
|
||||
const data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.junior,
|
||||
"agent:test:subagent:junior-session-111",
|
||||
"Junior session should persist",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.medior,
|
||||
"agent:test:subagent:medior-session-222",
|
||||
"Medior session should persist",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-333",
|
||||
"Senior session should persist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reuse existing session when picking up new task", async () => {
|
||||
// Setup: create a session for senior tier
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "100",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-reuse-999",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Pick up new task with same tier (no sessionKey = reuse)
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "200",
|
||||
model: "senior",
|
||||
});
|
||||
|
||||
// Verify session was preserved (not overwritten)
|
||||
const data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-reuse-999",
|
||||
"Senior session should be reused (not cleared)",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.issueId,
|
||||
"200",
|
||||
"Issue ID should be updated to new task",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,41 @@ async function checkAndFixWorkerHealth(
|
||||
});
|
||||
}
|
||||
|
||||
// Check 4: Active for >2 hours (stale watchdog)
|
||||
// A stale worker likely crashed or ran out of context without calling task_complete.
|
||||
// Auto-fix reverts the label back to queue so the issue can be picked up again.
|
||||
if (worker.active && worker.startTime && currentSessionKey) {
|
||||
const startMs = new Date(worker.startTime).getTime();
|
||||
const nowMs = Date.now();
|
||||
const hoursActive = (nowMs - startMs) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursActive > 2) {
|
||||
if (autoFix) {
|
||||
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 provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort label revert
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "stale_worker",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fixes;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,14 +129,15 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// Check 3: Active for >2 hours (stale)
|
||||
// Check 3: Active for >2 hours (stale watchdog)
|
||||
// Worker likely crashed or ran out of context without calling task_complete.
|
||||
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({
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "stale_worker",
|
||||
severity: "warning",
|
||||
project: project.name,
|
||||
@@ -146,7 +147,30 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
sessionKey: currentSessionKey,
|
||||
issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
|
||||
});
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
// Revert issue label back to queue
|
||||
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 provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
issue.labelReverted = `${currentLabel} → ${revertLabel}`;
|
||||
}
|
||||
} catch {
|
||||
issue.labelRevertFailed = true;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_complete",
|
||||
label: "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. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix).`,
|
||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix). Use "blocked" when the worker cannot complete the task (errors, missing info, etc.).`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
@@ -45,9 +45,9 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
result: {
|
||||
type: "string",
|
||||
enum: ["done", "pass", "fail", "refine"],
|
||||
enum: ["done", "pass", "fail", "refine", "blocked"],
|
||||
description:
|
||||
'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input)',
|
||||
'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input), "blocked" (cannot complete, needs escalation)',
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
@@ -62,7 +62,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
|
||||
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 result = params.result as "done" | "pass" | "fail" | "refine" | "blocked";
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
@@ -72,14 +72,14 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
}
|
||||
|
||||
// Validate result matches role
|
||||
if (role === "dev" && result !== "done") {
|
||||
if (role === "dev" && result !== "done" && result !== "blocked") {
|
||||
throw new Error(
|
||||
`DEV can only complete with result "done", got "${result}"`,
|
||||
`DEV can only complete with "done" or "blocked", got "${result}"`,
|
||||
);
|
||||
}
|
||||
if (role === "qa" && result === "done") {
|
||||
throw new Error(
|
||||
`QA cannot use result "done". Use "pass", "fail", or "refine".`,
|
||||
`QA cannot use result "done". Use "pass", "fail", "refine", or "blocked".`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,24 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
output.announcement = `🤔 QA REFINE #${issueId}${summary ? ` — ${summary}` : ""}. Awaiting human decision.`;
|
||||
}
|
||||
|
||||
// === DEV BLOCKED ===
|
||||
if (role === "dev" && result === "blocked") {
|
||||
await deactivateWorker(workspaceDir, groupId, "dev");
|
||||
await provider.transitionLabel(issueId, "Doing", "To Do");
|
||||
|
||||
output.labelTransition = "Doing → To Do";
|
||||
output.announcement = `🚫 DEV BLOCKED #${issueId}${summary ? ` — ${summary}` : ""}. Returned to queue.`;
|
||||
}
|
||||
|
||||
// === QA BLOCKED ===
|
||||
if (role === "qa" && result === "blocked") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "To Test");
|
||||
|
||||
output.labelTransition = "Testing → To Test";
|
||||
output.announcement = `🚫 QA BLOCKED #${issueId}${summary ? ` — ${summary}` : ""}. Returned to QA queue.`;
|
||||
}
|
||||
|
||||
// Send notification to project group
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
@@ -275,12 +293,16 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
let nextState: string | undefined;
|
||||
if (role === "dev" && result === "done") {
|
||||
nextState = "QA queue";
|
||||
} else if (role === "dev" && result === "blocked") {
|
||||
nextState = "returned to queue";
|
||||
} else if (role === "qa" && result === "pass") {
|
||||
nextState = "Done!";
|
||||
} else if (role === "qa" && result === "fail") {
|
||||
nextState = "back to DEV";
|
||||
} else if (role === "qa" && result === "refine") {
|
||||
nextState = "awaiting human decision";
|
||||
} else if (role === "qa" && result === "blocked") {
|
||||
nextState = "returned to QA queue";
|
||||
}
|
||||
|
||||
await notify(
|
||||
|
||||
Reference in New Issue
Block a user