refactor: reorganize task management imports and update task handling tools
- Updated import paths for task management providers in task-comment, task-create, and task-update tools. - Removed deprecated task-complete and task-pickup tools, replacing them with work-finish and work-start tools for improved task handling. - Enhanced work-finish and work-start tools to streamline task completion and pickup processes, including context-aware detection and auto-scheduling features. - Updated package.json to include build scripts and main entry point. - Modified tsconfig.json to enable output directory, declaration files, and source maps for better TypeScript support.
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
* context-guard.ts — Detect interaction context and provide guardrails.
|
||||
*
|
||||
* DevClaw should respond differently based on how it's being contacted:
|
||||
* 1. Via another agent (setup/onboarding) - guide to devclaw_onboard/devclaw_setup
|
||||
* 2. Direct to DevClaw agent (status queries) - use queue_status, session_health
|
||||
* 3. Via Telegram group (project work) - use task_pickup, task_complete, task_create
|
||||
* 1. Via another agent (setup/onboarding) - guide to onboard/setup
|
||||
* 2. Direct to DevClaw agent (status queries) - use status
|
||||
* 3. Via Telegram group (project work) - use work_start, work_finish, task_create
|
||||
*/
|
||||
import type { ToolContext } from "./types.js";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -94,13 +94,13 @@ export function generateGuardrails(context: InteractionContext): string {
|
||||
You're being called by another agent. This is likely a **setup or onboarding** scenario.
|
||||
|
||||
**What you should do:**
|
||||
- If the user mentions "setup", "install", "configure", or "onboard" → call \`devclaw_onboard\` first
|
||||
- Then follow the guidance to call \`devclaw_setup\` with collected answers
|
||||
- If the user mentions "setup", "install", "configure", or "onboard" → call \`onboard\` first
|
||||
- Then follow the guidance to call \`setup\` with collected answers
|
||||
- After setup, offer to register a project via \`project_register\`
|
||||
|
||||
**What to avoid:**
|
||||
- Don't discuss ongoing development tasks (those happen in group chats)
|
||||
- Don't use task_pickup/task_complete/queue_status (not relevant during setup)
|
||||
- Don't use work_start/work_finish/status (not relevant during setup)
|
||||
`;
|
||||
|
||||
case "direct":
|
||||
@@ -109,13 +109,12 @@ You're being called by another agent. This is likely a **setup or onboarding** s
|
||||
You're in a **direct message** with the DevClaw agent (not a project group).
|
||||
|
||||
**What you should do:**
|
||||
- Provide **general status** via \`queue_status\` (across all projects)
|
||||
- Check system health via \`session_health\`
|
||||
- Provide **general status** via \`status\` (across all projects)
|
||||
- Answer questions about DevClaw configuration
|
||||
- Guide to project-specific work: "For project tasks, please message the relevant Telegram/WhatsApp group"
|
||||
|
||||
**What to avoid:**
|
||||
- Don't start development tasks here (use \`task_pickup\` only in project groups)
|
||||
- Don't start development tasks here (use \`work_start\` only in project groups)
|
||||
- Don't discuss project-specific issues (redirect to the group)
|
||||
`;
|
||||
|
||||
@@ -125,9 +124,9 @@ You're in a **direct message** with the DevClaw agent (not a project group).
|
||||
You're in a **Telegram/WhatsApp group** bound to ${context.projectName ? `project **${context.projectName}**` : "a project"}.
|
||||
|
||||
**What you should do:**
|
||||
- Handle task lifecycle: \`task_pickup\` (start work), \`task_complete\` (finish)
|
||||
- Handle task lifecycle: \`work_start\` (start work), \`work_finish\` (finish)
|
||||
- Create new issues via \`task_create\`
|
||||
- Check this project's queue via \`queue_status\` (with projectName filter)
|
||||
- Check this project's queue via \`status\`
|
||||
- Discuss implementation details, code reviews, bugs
|
||||
|
||||
**What to avoid:**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* dispatch.ts — Core dispatch logic shared by task_pickup and task_complete (auto-chain).
|
||||
* dispatch.ts — Core dispatch logic shared by work_start and work_finish (auto-chain).
|
||||
*
|
||||
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
|
||||
* state update (activateWorker), and audit logging.
|
||||
@@ -132,15 +132,15 @@ export async function buildTaskMessage(opts: {
|
||||
``,
|
||||
`## MANDATORY: Task Completion`,
|
||||
``,
|
||||
`When you finish this task, you MUST call \`task_complete\` with:`,
|
||||
`When you finish this task, you MUST call \`work_finish\` 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.`,
|
||||
`⚠️ You MUST call work_finish 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.`,
|
||||
`Never end your session without calling work_finish.`,
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
@@ -219,7 +219,7 @@ export async function dispatchTask(
|
||||
// Dispatch via `gateway call agent --expect-final` as a detached background process.
|
||||
// Without --expect-final the gateway accepts but never processes the request.
|
||||
// Running with --expect-final in a detached process ensures the agent runs
|
||||
// while task_pickup returns immediately.
|
||||
// while work_start returns immediately.
|
||||
// Using the gateway RPC (not `openclaw agent` CLI) lets us set lane, spawnedBy,
|
||||
// and deliver — matching the official sessions_spawn internals.
|
||||
const orchestratorSessionKey = opts.sessionKey;
|
||||
@@ -269,7 +269,7 @@ export async function dispatchTask(
|
||||
} catch (err) {
|
||||
if (dispatched) {
|
||||
// State update failed but session IS running — log warning, don't rollback
|
||||
await auditLog(workspaceDir, "task_pickup", {
|
||||
await auditLog(workspaceDir, "work_start", {
|
||||
project: project.name,
|
||||
groupId,
|
||||
issue: issueId,
|
||||
@@ -296,7 +296,7 @@ export async function dispatchTask(
|
||||
}
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "task_pickup", {
|
||||
await auditLog(workspaceDir, "work_start", {
|
||||
project: project.name,
|
||||
groupId,
|
||||
issue: issueId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* onboarding.ts — Conversational onboarding context templates.
|
||||
*
|
||||
* Provides context templates for the devclaw_onboard tool.
|
||||
* Provides context templates for the onboard tool.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -27,7 +27,7 @@ export async function hasWorkspaceFiles(
|
||||
path.join(workspaceDir, "AGENTS.md"),
|
||||
"utf-8",
|
||||
);
|
||||
return content.includes("DevClaw") && content.includes("task_pickup");
|
||||
return content.includes("DevClaw") && content.includes("work_start");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -57,12 +57,12 @@ The user wants to reconfigure DevClaw. Current model configuration:
|
||||
${modelTable}
|
||||
|
||||
## What can be changed
|
||||
1. **Model tiers** — call \`devclaw_setup\` with a \`models\` object containing only the tiers to change
|
||||
2. **Workspace files** — \`devclaw_setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||
1. **Model tiers** — call \`setup\` with a \`models\` object containing only the tiers to change
|
||||
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||
3. **Register new projects** — use \`project_register\`
|
||||
|
||||
Ask what they want to change, then call the appropriate tool.
|
||||
\`devclaw_setup\` is safe to re-run — it backs up existing files before overwriting.
|
||||
\`setup\` is safe to re-run — it backs up existing files before overwriting.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
|
||||
1. Agent name
|
||||
2. **Channel binding**: "Which channel should this agent listen to? (telegram/whatsapp/none)"
|
||||
- If telegram/whatsapp selected:
|
||||
a) Call \`analyze_channel_bindings\` to check for conflicts
|
||||
a) Check openclaw.json for existing channel bindings
|
||||
b) If channel not configured/enabled → warn and recommend skipping binding for now
|
||||
c) If channel-wide binding exists on another agent → ask: "Migrate binding from {agentName}?"
|
||||
d) Collect migration decision
|
||||
@@ -104,9 +104,9 @@ Show the default tier-to-model mapping and ask if they want to customize:
|
||||
If the defaults are fine, proceed. If customizing, ask which tiers to change.
|
||||
|
||||
**Step 3: Run Setup**
|
||||
Call \`devclaw_setup\` with the collected answers:
|
||||
- Current agent: \`devclaw_setup({})\` or \`devclaw_setup({ models: { ... } })\`
|
||||
- New agent: \`devclaw_setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
||||
Call \`setup\` with the collected answers:
|
||||
- Current agent: \`setup({})\` or \`setup({ models: { ... } })\`
|
||||
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
||||
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding
|
||||
|
||||
**Step 4: Optional Project Registration**
|
||||
|
||||
123
lib/providers/github.ts
Normal file
123
lib/providers/github.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* GitHubProvider — IssueProvider implementation using gh CLI.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
type IssueProvider,
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
STATE_LABELS,
|
||||
LABEL_COLORS,
|
||||
} from "./provider.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type GhIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
labels: Array<{ name: string }>;
|
||||
state: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function toIssue(gh: GhIssue): Issue {
|
||||
return {
|
||||
iid: gh.number, title: gh.title, description: gh.body ?? "",
|
||||
labels: gh.labels.map((l) => l.name), state: gh.state, web_url: gh.url,
|
||||
};
|
||||
}
|
||||
|
||||
export class GitHubProvider implements IssueProvider {
|
||||
private repoPath: string;
|
||||
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
|
||||
|
||||
private async gh(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("gh", args, { cwd: this.repoPath, timeout: 30_000 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
try { await this.gh(["label", "create", name, "--color", color.replace(/^#/, "")]); }
|
||||
catch (err) { if (!(err as Error).message?.includes("already exists")) throw err; }
|
||||
}
|
||||
|
||||
async ensureAllStateLabels(): Promise<void> {
|
||||
for (const label of STATE_LABELS) await this.ensureLabel(label, LABEL_COLORS[label]);
|
||||
}
|
||||
|
||||
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
|
||||
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
|
||||
await writeFile(tempFile, description, "utf-8");
|
||||
try {
|
||||
const args = ["issue", "create", "--title", title, "--body-file", tempFile, "--label", label];
|
||||
if (assignees?.length) args.push("--assignee", assignees.join(","));
|
||||
const url = await this.gh(args);
|
||||
const match = url.match(/\/issues\/(\d+)$/);
|
||||
if (!match) throw new Error(`Failed to parse issue URL: ${url}`);
|
||||
return this.getIssue(parseInt(match[1], 10));
|
||||
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||
try {
|
||||
const raw = await this.gh(["issue", "list", "--label", label, "--state", "open", "--json", "number,title,body,labels,state,url"]);
|
||||
return (JSON.parse(raw) as GhIssue[]).map(toIssue);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async getIssue(issueId: number): Promise<Issue> {
|
||||
const raw = await this.gh(["issue", "view", String(issueId), "--json", "number,title,body,labels,state,url"]);
|
||||
return toIssue(JSON.parse(raw) as GhIssue);
|
||||
}
|
||||
|
||||
async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
|
||||
const issue = await this.getIssue(issueId);
|
||||
const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel));
|
||||
const args = ["issue", "edit", String(issueId)];
|
||||
for (const l of stateLabels) args.push("--remove-label", l);
|
||||
args.push("--add-label", to);
|
||||
await this.gh(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> { await this.gh(["issue", "close", String(issueId)]); }
|
||||
async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); }
|
||||
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||
return STATE_LABELS.find((l) => issue.labels.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body"]);
|
||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return prs.some((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "number,title,body,url,mergedAt", "--limit", "20"]);
|
||||
const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string; mergedAt: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return prs.find((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat))?.url ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
|
||||
await writeFile(tempFile, body, "utf-8");
|
||||
try { await this.gh(["issue", "comment", String(issueId), "--body-file", tempFile]); }
|
||||
finally { try { await unlink(tempFile); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try { await this.gh(["auth", "status"]); return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
116
lib/providers/gitlab.ts
Normal file
116
lib/providers/gitlab.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* GitLabProvider — IssueProvider implementation using glab CLI.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
type IssueProvider,
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
STATE_LABELS,
|
||||
LABEL_COLORS,
|
||||
} from "./provider.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export class GitLabProvider implements IssueProvider {
|
||||
private repoPath: string;
|
||||
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
|
||||
|
||||
private async glab(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("glab", args, { cwd: this.repoPath, timeout: 30_000 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
try { await this.glab(["label", "create", "--name", name, "--color", color]); }
|
||||
catch (err) { const msg = (err as Error).message ?? ""; if (!msg.includes("already exists") && !msg.includes("409")) throw err; }
|
||||
}
|
||||
|
||||
async ensureAllStateLabels(): Promise<void> {
|
||||
for (const label of STATE_LABELS) await this.ensureLabel(label, LABEL_COLORS[label]);
|
||||
}
|
||||
|
||||
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
|
||||
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
|
||||
await writeFile(tempFile, description, "utf-8");
|
||||
try {
|
||||
const { exec } = await import("node:child_process");
|
||||
const execAsync = promisify(exec);
|
||||
let cmd = `glab issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}"`;
|
||||
if (assignees?.length) cmd += ` --assignee "${assignees.join(",")}"`;
|
||||
const { stdout } = await execAsync(cmd, { cwd: this.repoPath, timeout: 30_000 });
|
||||
// glab issue create returns the issue URL
|
||||
const match = stdout.trim().match(/\/issues\/(\d+)/);
|
||||
if (!match) throw new Error(`Failed to parse issue URL: ${stdout.trim()}`);
|
||||
return this.getIssue(parseInt(match[1], 10));
|
||||
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||
try {
|
||||
const raw = await this.glab(["issue", "list", "--label", label, "--output", "json"]);
|
||||
return JSON.parse(raw) as Issue[];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async getIssue(issueId: number): Promise<Issue> {
|
||||
const raw = await this.glab(["issue", "view", String(issueId), "--output", "json"]);
|
||||
return JSON.parse(raw) as Issue;
|
||||
}
|
||||
|
||||
async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void> {
|
||||
const issue = await this.getIssue(issueId);
|
||||
const stateLabels = issue.labels.filter((l) => STATE_LABELS.includes(l as StateLabel));
|
||||
const args = ["issue", "update", String(issueId)];
|
||||
for (const l of stateLabels) args.push("--unlabel", l);
|
||||
args.push("--label", to);
|
||||
await this.glab(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> { await this.glab(["issue", "close", String(issueId)]); }
|
||||
async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); }
|
||||
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); }
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||
return STATE_LABELS.find((l) => issue.labels.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return mrs.some((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string; web_url: string; merged_at: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
const mr = mrs
|
||||
.filter((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat))
|
||||
.sort((a, b) => new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime())[0];
|
||||
return mr?.web_url ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
|
||||
await writeFile(tempFile, body, "utf-8");
|
||||
try {
|
||||
const { exec } = await import("node:child_process");
|
||||
const execAsync = promisify(exec);
|
||||
await execAsync(`glab issue note ${issueId} --message "$(cat ${tempFile})"`, { cwd: this.repoPath, timeout: 30_000 });
|
||||
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try { await this.glab(["auth", "status"]); return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
36
lib/providers/index.ts
Normal file
36
lib/providers/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Provider factory — auto-detects GitHub vs GitLab from git remote.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { IssueProvider } from "./provider.js";
|
||||
import { GitLabProvider } from "./gitlab.js";
|
||||
import { GitHubProvider } from "./github.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
|
||||
export type ProviderOptions = {
|
||||
provider?: "gitlab" | "github";
|
||||
repo?: string;
|
||||
repoPath?: string;
|
||||
};
|
||||
|
||||
export type ProviderWithType = {
|
||||
provider: IssueProvider;
|
||||
type: "github" | "gitlab";
|
||||
};
|
||||
|
||||
function detectProvider(repoPath: string): "gitlab" | "github" {
|
||||
try {
|
||||
const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd: repoPath, timeout: 5_000 }).toString().trim();
|
||||
return url.includes("github.com") ? "github" : "gitlab";
|
||||
} catch {
|
||||
return "gitlab";
|
||||
}
|
||||
}
|
||||
|
||||
export function createProvider(opts: ProviderOptions): ProviderWithType {
|
||||
const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
|
||||
if (!repoPath) throw new Error("Either repoPath or repo must be provided");
|
||||
const type = opts.provider ?? detectProvider(repoPath);
|
||||
const provider = type === "github" ? new GitHubProvider({ repoPath }) : new GitLabProvider({ repoPath });
|
||||
return { provider, type };
|
||||
}
|
||||
45
lib/providers/provider.ts
Normal file
45
lib/providers/provider.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* IssueProvider — Abstract interface for issue tracker operations.
|
||||
*
|
||||
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
|
||||
*/
|
||||
|
||||
export const STATE_LABELS = [
|
||||
"Planning", "To Do", "Doing", "To Test", "Testing", "Done", "To Improve", "Refining",
|
||||
] as const;
|
||||
|
||||
export type StateLabel = (typeof STATE_LABELS)[number];
|
||||
|
||||
export const LABEL_COLORS: Record<StateLabel, string> = {
|
||||
Planning: "#6699cc", "To Do": "#428bca", Doing: "#f0ad4e", "To Test": "#5bc0de",
|
||||
Testing: "#9b59b6", Done: "#5cb85c", "To Improve": "#d9534f", Refining: "#f39c12",
|
||||
};
|
||||
|
||||
export type Issue = {
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
labels: string[];
|
||||
state: string;
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export interface IssueProvider {
|
||||
ensureLabel(name: string, color: string): Promise<void>;
|
||||
ensureAllStateLabels(): Promise<void>;
|
||||
createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue>;
|
||||
listIssuesByLabel(label: StateLabel): Promise<Issue[]>;
|
||||
getIssue(issueId: number): Promise<Issue>;
|
||||
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
|
||||
closeIssue(issueId: number): Promise<void>;
|
||||
reopenIssue(issueId: number): Promise<void>;
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||
hasMergedMR(issueId: number): Promise<boolean>;
|
||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||
addComment(issueId: number, body: string): Promise<void>;
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** @deprecated Use IssueProvider */
|
||||
export type TaskManager = IssueProvider;
|
||||
146
lib/services/health.ts
Normal file
146
lib/services/health.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Health service — worker health checks and auto-fix.
|
||||
*
|
||||
* Detects: active_no_session, zombie_session, stale_worker, inactive_with_issue.
|
||||
* Used by both `status` (read-only) and `auto_pickup` (auto-fix).
|
||||
*/
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import {
|
||||
getSessionForModel,
|
||||
getWorker,
|
||||
updateWorker,
|
||||
type Project,
|
||||
} from "../projects.js";
|
||||
|
||||
export type HealthIssue = {
|
||||
type: "active_no_session" | "zombie_session" | "stale_worker" | "inactive_with_issue";
|
||||
severity: "critical" | "warning";
|
||||
project: string;
|
||||
groupId: string;
|
||||
role: "dev" | "qa";
|
||||
message: string;
|
||||
model?: string | null;
|
||||
sessionKey?: string | null;
|
||||
hoursActive?: number;
|
||||
issueId?: string | null;
|
||||
};
|
||||
|
||||
export type HealthFix = {
|
||||
issue: HealthIssue;
|
||||
fixed: boolean;
|
||||
labelReverted?: string;
|
||||
labelRevertFailed?: boolean;
|
||||
};
|
||||
|
||||
export async function checkWorkerHealth(opts: {
|
||||
workspaceDir: string;
|
||||
groupId: string;
|
||||
project: Project;
|
||||
role: "dev" | "qa";
|
||||
activeSessions: string[];
|
||||
autoFix: boolean;
|
||||
provider: {
|
||||
transitionLabel(id: number, from: StateLabel, to: StateLabel): Promise<void>;
|
||||
};
|
||||
}): Promise<HealthFix[]> {
|
||||
const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts;
|
||||
const fixes: HealthFix[] = [];
|
||||
const worker = getWorker(project, role);
|
||||
const sessionKey = worker.model ? getSessionForModel(worker, worker.model) : null;
|
||||
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
async function revertIssueLabel(fix: HealthFix) {
|
||||
if (!worker.issueId) return;
|
||||
try {
|
||||
const id = Number(worker.issueId.split(",")[0]);
|
||||
await provider.transitionLabel(id, currentLabel, revertLabel);
|
||||
fix.labelReverted = `${currentLabel} → ${revertLabel}`;
|
||||
} catch {
|
||||
fix.labelRevertFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1: Active but no session key for current model
|
||||
if (worker.active && !sessionKey) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "active_no_session", severity: "critical",
|
||||
project: project.name, groupId, role,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} active but no session for model "${worker.model}"`,
|
||||
},
|
||||
fixed: false,
|
||||
};
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null });
|
||||
fix.fixed = true;
|
||||
}
|
||||
fixes.push(fix);
|
||||
}
|
||||
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (worker.active && sessionKey && activeSessions.length > 0 && !activeSessions.includes(sessionKey)) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "zombie_session", severity: "critical",
|
||||
project: project.name, groupId, role,
|
||||
sessionKey, model: worker.model,
|
||||
message: `${role.toUpperCase()} session not in active sessions list`,
|
||||
},
|
||||
fixed: false,
|
||||
};
|
||||
if (autoFix) {
|
||||
await revertIssueLabel(fix);
|
||||
const sessions = { ...worker.sessions };
|
||||
if (worker.model) sessions[worker.model] = null;
|
||||
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, sessions });
|
||||
fix.fixed = true;
|
||||
}
|
||||
fixes.push(fix);
|
||||
}
|
||||
|
||||
// Check 3: Inactive but still has issueId
|
||||
if (!worker.active && worker.issueId) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "inactive_with_issue", severity: "warning",
|
||||
project: project.name, groupId, role,
|
||||
issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} inactive but still has issueId "${worker.issueId}"`,
|
||||
},
|
||||
fixed: false,
|
||||
};
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, { issueId: null });
|
||||
fix.fixed = true;
|
||||
}
|
||||
fixes.push(fix);
|
||||
}
|
||||
|
||||
// Check 4: Active for >2 hours (stale)
|
||||
if (worker.active && worker.startTime && sessionKey) {
|
||||
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
||||
if (hours > 2) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "stale_worker", severity: "warning",
|
||||
project: project.name, groupId, role,
|
||||
hoursActive: Math.round(hours * 10) / 10,
|
||||
sessionKey, issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} active for ${Math.round(hours * 10) / 10}h — may need attention`,
|
||||
},
|
||||
fixed: false,
|
||||
};
|
||||
if (autoFix) {
|
||||
await revertIssueLabel(fix);
|
||||
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null });
|
||||
fix.fixed = true;
|
||||
}
|
||||
fixes.push(fix);
|
||||
}
|
||||
}
|
||||
|
||||
return fixes;
|
||||
}
|
||||
123
lib/services/pipeline.ts
Normal file
123
lib/services/pipeline.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Pipeline service — declarative completion rules.
|
||||
*
|
||||
* Replaces 7 if-blocks with a data-driven lookup table.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { StateLabel, IssueProvider } from "../providers/provider.js";
|
||||
import { deactivateWorker } from "../projects.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type CompletionRule = {
|
||||
from: StateLabel;
|
||||
to: StateLabel;
|
||||
gitPull?: boolean;
|
||||
detectPr?: boolean;
|
||||
closeIssue?: boolean;
|
||||
reopenIssue?: boolean;
|
||||
};
|
||||
|
||||
export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
||||
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
|
||||
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
|
||||
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
|
||||
"qa:refine": { from: "Testing", to: "Refining" },
|
||||
"dev:blocked": { from: "Doing", to: "To Do" },
|
||||
"qa:blocked": { from: "Testing", to: "To Test" },
|
||||
};
|
||||
|
||||
export const NEXT_STATE: Record<string, string> = {
|
||||
"dev:done": "QA queue",
|
||||
"dev:blocked": "returned to queue",
|
||||
"qa:pass": "Done!",
|
||||
"qa:fail": "back to DEV",
|
||||
"qa:refine": "awaiting human decision",
|
||||
"qa:blocked": "returned to QA queue",
|
||||
};
|
||||
|
||||
const EMOJI: Record<string, string> = {
|
||||
"dev:done": "✅",
|
||||
"qa:pass": "🎉",
|
||||
"qa:fail": "❌",
|
||||
"qa:refine": "🤔",
|
||||
"dev:blocked": "🚫",
|
||||
"qa:blocked": "🚫",
|
||||
};
|
||||
|
||||
export type CompletionOutput = {
|
||||
labelTransition: string;
|
||||
announcement: string;
|
||||
nextState: string;
|
||||
prUrl?: string;
|
||||
issueUrl?: string;
|
||||
issueClosed?: boolean;
|
||||
issueReopened?: boolean;
|
||||
};
|
||||
|
||||
export function getRule(role: string, result: string): CompletionRule | undefined {
|
||||
return COMPLETION_RULES[`${role}:${result}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the completion side-effects for a role:result pair.
|
||||
*/
|
||||
export async function executeCompletion(opts: {
|
||||
workspaceDir: string;
|
||||
groupId: string;
|
||||
role: "dev" | "qa";
|
||||
result: string;
|
||||
issueId: number;
|
||||
summary?: string;
|
||||
prUrl?: string;
|
||||
provider: IssueProvider;
|
||||
repoPath: string;
|
||||
}): Promise<CompletionOutput> {
|
||||
const { workspaceDir, groupId, role, result, issueId, summary, provider, repoPath } = opts;
|
||||
const key = `${role}:${result}`;
|
||||
const rule = COMPLETION_RULES[key];
|
||||
if (!rule) throw new Error(`No completion rule for ${key}`);
|
||||
|
||||
let prUrl = opts.prUrl;
|
||||
|
||||
// Git pull (dev:done)
|
||||
if (rule.gitPull) {
|
||||
try {
|
||||
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Auto-detect PR URL (dev:done)
|
||||
if (rule.detectPr && !prUrl) {
|
||||
try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Deactivate worker + transition label
|
||||
await deactivateWorker(workspaceDir, groupId, role);
|
||||
await provider.transitionLabel(issueId, rule.from, rule.to);
|
||||
|
||||
// Close/reopen
|
||||
if (rule.closeIssue) await provider.closeIssue(issueId);
|
||||
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
||||
|
||||
// Build announcement
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const emoji = EMOJI[key] ?? "📋";
|
||||
const label = key.replace(":", " ").toUpperCase();
|
||||
let announcement = `${emoji} ${label} #${issueId}`;
|
||||
if (summary) announcement += ` — ${summary}`;
|
||||
announcement += `\n📋 Issue: ${issue.web_url}`;
|
||||
if (prUrl) announcement += `\n🔗 PR: ${prUrl}`;
|
||||
announcement += `\n${NEXT_STATE[key]}.`;
|
||||
|
||||
return {
|
||||
labelTransition: `${rule.from} → ${rule.to}`,
|
||||
announcement,
|
||||
nextState: NEXT_STATE[key],
|
||||
prUrl,
|
||||
issueUrl: issue.web_url,
|
||||
issueClosed: rule.closeIssue,
|
||||
issueReopened: rule.reopenIssue,
|
||||
};
|
||||
}
|
||||
247
lib/services/queue.ts
Normal file
247
lib/services/queue.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Queue service — task sequencing and priority logic.
|
||||
*
|
||||
* Pure functions for scanning issue queues, building execution sequences,
|
||||
* and formatting output. No tool registration or I/O concerns.
|
||||
*/
|
||||
import type { Issue } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import type { Project } from "../projects.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type QueueLabel = "To Improve" | "To Test" | "To Do";
|
||||
export type Role = "dev" | "qa";
|
||||
|
||||
export interface SequencedTask {
|
||||
sequence: number;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
role: Role;
|
||||
issueId: number;
|
||||
title: string;
|
||||
label: QueueLabel;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectTrack {
|
||||
name: string;
|
||||
role: Role;
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
export interface ProjectExecutionConfig {
|
||||
name: string;
|
||||
groupId: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
devActive: boolean;
|
||||
qaActive: boolean;
|
||||
devIssueId: string | null;
|
||||
qaIssueId: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectTaskSequence {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
tracks: ProjectTrack[];
|
||||
}
|
||||
|
||||
export interface GlobalTaskSequence {
|
||||
mode: "sequential";
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
export interface ProjectQueues {
|
||||
projectId: string;
|
||||
project: Project;
|
||||
queues: Record<QueueLabel, Issue[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const QUEUE_PRIORITY: Record<QueueLabel, number> = {
|
||||
"To Improve": 3,
|
||||
"To Test": 2,
|
||||
"To Do": 1,
|
||||
};
|
||||
|
||||
export function getTaskPriority(label: QueueLabel, issue: Issue): number {
|
||||
return QUEUE_PRIORITY[label] * 10000 - issue.iid;
|
||||
}
|
||||
|
||||
export function getRoleForLabel(label: QueueLabel): Role {
|
||||
return label === "To Test" ? "qa" : "dev";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchProjectQueues(project: Project): Promise<Record<QueueLabel, Issue[]>> {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
const labels: QueueLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const queues: Record<QueueLabel, Issue[]> = { "To Improve": [], "To Test": [], "To Do": [] };
|
||||
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
queues[label] = issues.sort((a, b) => getTaskPriority(label, b) - getTaskPriority(label, a));
|
||||
} catch {
|
||||
queues[label] = [];
|
||||
}
|
||||
}
|
||||
return queues;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildProjectTrack(
|
||||
projectId: string, projectName: string, role: Role,
|
||||
queues: Record<QueueLabel, Issue[]>,
|
||||
isActive: boolean, activeIssueId: string | null,
|
||||
startSeq: number,
|
||||
): { track: ProjectTrack; nextSequence: number } {
|
||||
const tasks: SequencedTask[] = [];
|
||||
let seq = startSeq;
|
||||
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
if (getRoleForLabel(label) !== role) continue;
|
||||
for (const issue of queues[label]) {
|
||||
tasks.push({
|
||||
sequence: seq++, projectId, projectName, role,
|
||||
issueId: issue.iid, title: issue.title, label,
|
||||
active: isActive && activeIssueId === String(issue.iid),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { track: { name: role === "dev" ? "DEV Track" : "QA Track", role, tasks }, nextSequence: seq };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sequence building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildParallelProjectSequences(projectQueues: ProjectQueues[]): ProjectTaskSequence[] {
|
||||
return projectQueues.map(({ projectId, project, queues }) => {
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
const tracks: ProjectTrack[] = [];
|
||||
|
||||
if (roleExecution === "sequential") {
|
||||
// Build alternating DEV/QA sequence
|
||||
const alternating = buildAlternatingTrack(projectId, project, queues);
|
||||
if (alternating.tasks.length > 0) tracks.push(alternating);
|
||||
} else {
|
||||
const dev = buildProjectTrack(projectId, project.name, "dev", queues, project.dev.active, project.dev.issueId, 1);
|
||||
const qa = buildProjectTrack(projectId, project.name, "qa", queues, project.qa.active, project.qa.issueId, 1);
|
||||
if (dev.track.tasks.length > 0) tracks.push(dev.track);
|
||||
if (qa.track.tasks.length > 0) tracks.push(qa.track);
|
||||
}
|
||||
|
||||
return { projectId, projectName: project.name, roleExecution, tracks };
|
||||
});
|
||||
}
|
||||
|
||||
function buildAlternatingTrack(
|
||||
projectId: string, project: Project, queues: Record<QueueLabel, Issue[]>,
|
||||
): ProjectTrack {
|
||||
const tasks: SequencedTask[] = [];
|
||||
const added = new Set<number>();
|
||||
let seq = 1;
|
||||
|
||||
const nextForRole = (role: Role): SequencedTask | null => {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
if (getRoleForLabel(label) !== role) continue;
|
||||
for (const issue of queues[label]) {
|
||||
if (added.has(issue.iid)) continue;
|
||||
const isActive =
|
||||
(role === "dev" && project.dev.active && project.dev.issueId === String(issue.iid)) ||
|
||||
(role === "qa" && project.qa.active && project.qa.issueId === String(issue.iid));
|
||||
return { sequence: 0, projectId, projectName: project.name, role, issueId: issue.iid, title: issue.title, label, active: isActive };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Start with active task
|
||||
for (const role of ["dev", "qa"] as Role[]) {
|
||||
const w = project[role];
|
||||
if (w.active && w.issueId) {
|
||||
const t = nextForRole(role);
|
||||
if (t) { t.sequence = seq++; t.active = true; tasks.push(t); added.add(t.issueId); break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Alternate
|
||||
let lastRole: Role | null = tasks[0]?.role ?? null;
|
||||
while (true) {
|
||||
const next = nextForRole(lastRole === "dev" ? "qa" : "dev");
|
||||
if (!next) break;
|
||||
next.sequence = seq++;
|
||||
tasks.push(next);
|
||||
added.add(next.issueId);
|
||||
lastRole = next.role;
|
||||
}
|
||||
|
||||
return { name: "DEV/QA Alternating", role: "dev", tasks };
|
||||
}
|
||||
|
||||
export function buildGlobalTaskSequence(projectQueues: ProjectQueues[]): GlobalTaskSequence {
|
||||
const all: Array<{ projectId: string; projectName: string; role: Role; label: QueueLabel; issue: Issue; priority: number }> = [];
|
||||
|
||||
for (const { projectId, project, queues } of projectQueues) {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
for (const issue of queues[label]) {
|
||||
all.push({ projectId, projectName: project.name, role: getRoleForLabel(label), label, issue, priority: getTaskPriority(label, issue) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.priority !== a.priority ? b.priority - a.priority : a.issue.iid - b.issue.iid);
|
||||
|
||||
const tasks: SequencedTask[] = [];
|
||||
const added = new Set<string>();
|
||||
let seq = 1;
|
||||
|
||||
// Active task first
|
||||
const active = projectQueues.find(({ project }) => project.dev.active || project.qa.active);
|
||||
if (active) {
|
||||
const { project, projectId } = active;
|
||||
for (const [role, w] of [["dev", project.dev], ["qa", project.qa]] as const) {
|
||||
if (w.active && w.issueId) {
|
||||
const t = all.find((t) => t.projectId === projectId && t.role === role && String(t.issue.iid) === w.issueId);
|
||||
if (t) {
|
||||
const key = `${t.projectId}:${t.issue.iid}`;
|
||||
tasks.push({ sequence: seq++, projectId: t.projectId, projectName: t.projectName, role: t.role, issueId: t.issue.iid, title: t.issue.title, label: t.label, active: true });
|
||||
added.add(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of all) {
|
||||
const key = `${t.projectId}:${t.issue.iid}`;
|
||||
if (added.has(key)) continue;
|
||||
tasks.push({ sequence: seq++, projectId: t.projectId, projectName: t.projectName, role: t.role, issueId: t.issue.iid, title: t.issue.title, label: t.label, active: false });
|
||||
added.add(key);
|
||||
}
|
||||
|
||||
return { mode: "sequential", tasks };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatProjectQueues(queues: Record<QueueLabel, Issue[]>) {
|
||||
const fmt = (label: QueueLabel) => queues[label].map((i) => ({ id: i.iid, title: i.title, priority: QUEUE_PRIORITY[label] }));
|
||||
return { toImprove: fmt("To Improve"), toTest: fmt("To Test"), toDo: fmt("To Do") };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* setup.ts — Shared setup logic for DevClaw onboarding.
|
||||
*
|
||||
* Used by both the `devclaw_setup` tool and the `openclaw devclaw setup` CLI command.
|
||||
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
||||
* Handles: agent creation, model configuration, workspace file writes.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -328,7 +328,7 @@ async function writePluginConfig(
|
||||
|
||||
// Add tool restrictions to the agent
|
||||
// Workers shouldn't spawn sub-agents or message other sessions directly
|
||||
// All coordination should go through DevClaw tools (task_pickup, task_complete, etc.)
|
||||
// All coordination should go through DevClaw tools (work_start, work_finish, etc.)
|
||||
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
|
||||
if (agent) {
|
||||
if (!agent.tools) {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
/**
|
||||
* GitHubProvider — IssueProvider implementation using gh CLI.
|
||||
*
|
||||
* Wraps gh commands for label management, issue operations, and PR checks.
|
||||
* ensureLabel is idempotent — catches "already exists" errors gracefully.
|
||||
*
|
||||
* Note: gh CLI JSON output uses different field names than GitLab:
|
||||
* number (not iid), body (not description), url (not web_url),
|
||||
* labels are objects with { name } (not plain strings).
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
type TaskManager,
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
STATE_LABELS,
|
||||
LABEL_COLORS,
|
||||
} from "./task-manager.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type GitHubProviderOptions = {
|
||||
repoPath: string;
|
||||
};
|
||||
|
||||
type GhIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
labels: Array<{ name: string }>;
|
||||
state: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/** Convert gh JSON issue to the common Issue type. */
|
||||
function toIssue(gh: GhIssue): Issue {
|
||||
return {
|
||||
iid: gh.number,
|
||||
title: gh.title,
|
||||
description: gh.body ?? "",
|
||||
labels: gh.labels.map((l) => l.name),
|
||||
state: gh.state,
|
||||
web_url: gh.url,
|
||||
};
|
||||
}
|
||||
|
||||
export class GitHubProvider implements TaskManager {
|
||||
private repoPath: string;
|
||||
|
||||
constructor(opts: GitHubProviderOptions) {
|
||||
this.repoPath = opts.repoPath;
|
||||
}
|
||||
|
||||
private async gh(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
cwd: this.repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
// gh expects color without # prefix
|
||||
const hex = color.replace(/^#/, "");
|
||||
try {
|
||||
await this.gh(["label", "create", name, "--color", hex]);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (msg.includes("already exists")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureAllStateLabels(): Promise<void> {
|
||||
for (const label of STATE_LABELS) {
|
||||
await this.ensureLabel(label, LABEL_COLORS[label]);
|
||||
}
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
title: string,
|
||||
description: string,
|
||||
label: StateLabel,
|
||||
assignees?: string[],
|
||||
): Promise<Issue> {
|
||||
// Write description to temp file to preserve newlines
|
||||
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
|
||||
await writeFile(tempFile, description, "utf-8");
|
||||
|
||||
try {
|
||||
const args = [
|
||||
"issue", "create",
|
||||
"--title", title,
|
||||
"--body-file", tempFile,
|
||||
"--label", label,
|
||||
];
|
||||
if (assignees && assignees.length > 0) {
|
||||
args.push("--assignee", assignees.join(","));
|
||||
}
|
||||
// gh issue create returns the URL of the created issue
|
||||
const url = await this.gh(args);
|
||||
// Extract issue number from URL (e.g., https://github.com/owner/repo/issues/42)
|
||||
const match = url.match(/\/issues\/(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse issue number from created issue URL: ${url}`);
|
||||
}
|
||||
const issueId = parseInt(match[1], 10);
|
||||
// Fetch the full issue details
|
||||
return this.getIssue(issueId);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||
try {
|
||||
const raw = await this.gh([
|
||||
"issue", "list",
|
||||
"--label", label,
|
||||
"--state", "open",
|
||||
"--json", "number,title,body,labels,state,url",
|
||||
]);
|
||||
const issues = JSON.parse(raw) as GhIssue[];
|
||||
return issues.map(toIssue);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getIssue(issueId: number): Promise<Issue> {
|
||||
const raw = await this.gh([
|
||||
"issue", "view", String(issueId),
|
||||
"--json", "number,title,body,labels,state,url",
|
||||
]);
|
||||
return toIssue(JSON.parse(raw) as GhIssue);
|
||||
}
|
||||
|
||||
async transitionLabel(
|
||||
issueId: number,
|
||||
from: StateLabel,
|
||||
to: StateLabel,
|
||||
): Promise<void> {
|
||||
// Fetch current issue to get all labels
|
||||
const issue = await this.getIssue(issueId);
|
||||
|
||||
// Find all state labels currently on the issue
|
||||
const currentStateLabels = issue.labels.filter((label) =>
|
||||
STATE_LABELS.includes(label as StateLabel),
|
||||
);
|
||||
|
||||
// If no state labels to remove, just add the new one
|
||||
if (currentStateLabels.length === 0) {
|
||||
await this.gh([
|
||||
"issue", "edit", String(issueId),
|
||||
"--add-label", to,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all state labels and add the new one in a single operation
|
||||
// This ensures clean transitions: "removed X, added Y" instead of messy multi-label operations
|
||||
const args = [
|
||||
"issue", "edit", String(issueId),
|
||||
];
|
||||
|
||||
// Add all current state labels to remove
|
||||
for (const label of currentStateLabels) {
|
||||
args.push("--remove-label", label);
|
||||
}
|
||||
|
||||
// Add the new state label
|
||||
args.push("--add-label", to);
|
||||
|
||||
await this.gh(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> {
|
||||
await this.gh(["issue", "close", String(issueId)]);
|
||||
}
|
||||
|
||||
async reopenIssue(issueId: number): Promise<void> {
|
||||
await this.gh(["issue", "reopen", String(issueId)]);
|
||||
}
|
||||
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
|
||||
return issue.labels.includes(expected);
|
||||
}
|
||||
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||
for (const label of STATE_LABELS) {
|
||||
if (issue.labels.includes(label)) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.gh([
|
||||
"pr", "list",
|
||||
"--state", "merged",
|
||||
"--json", "title,body",
|
||||
]);
|
||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
|
||||
const pattern = `#${issueId}`;
|
||||
return prs.some(
|
||||
(pr) =>
|
||||
pr.title.includes(pattern) || (pr.body ?? "").includes(pattern),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
try {
|
||||
const raw = await this.gh([
|
||||
"pr", "list",
|
||||
"--state", "merged",
|
||||
"--json", "number,title,body,url,mergedAt",
|
||||
"--limit", "20",
|
||||
]);
|
||||
const prs = JSON.parse(raw) as Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
mergedAt: string;
|
||||
}>;
|
||||
|
||||
const pattern = `#${issueId}`;
|
||||
|
||||
// Find the most recently merged PR that references this issue
|
||||
// PRs are returned in reverse chronological order by default
|
||||
const matchingPr = prs.find(
|
||||
(pr) =>
|
||||
pr.title.includes(pattern) || (pr.body ?? "").includes(pattern),
|
||||
);
|
||||
|
||||
return matchingPr?.url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
// Write body to temp file to preserve newlines
|
||||
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
|
||||
await writeFile(tempFile, body, "utf-8");
|
||||
|
||||
try {
|
||||
await this.gh([
|
||||
"issue", "comment", String(issueId),
|
||||
"--body-file", tempFile,
|
||||
]);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.gh(["auth", "status"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* GitLabProvider — IssueProvider implementation using glab CLI.
|
||||
*
|
||||
* Wraps glab commands for label management, issue operations, and MR checks.
|
||||
* ensureLabel is idempotent — catches "already exists" errors gracefully.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
type TaskManager,
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
STATE_LABELS,
|
||||
LABEL_COLORS,
|
||||
} from "./task-manager.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type GitLabProviderOptions = {
|
||||
repoPath: string;
|
||||
};
|
||||
|
||||
export class GitLabProvider implements TaskManager {
|
||||
private repoPath: string;
|
||||
|
||||
constructor(opts: GitLabProviderOptions) {
|
||||
this.repoPath = opts.repoPath;
|
||||
}
|
||||
|
||||
private async glab(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("glab", args, {
|
||||
cwd: this.repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
try {
|
||||
await this.glab(["label", "create", "--name", name, "--color", color]);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
// Idempotent: ignore "already exists" errors
|
||||
if (msg.includes("already exists") || msg.includes("409")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureAllStateLabels(): Promise<void> {
|
||||
for (const label of STATE_LABELS) {
|
||||
await this.ensureLabel(label, LABEL_COLORS[label]);
|
||||
}
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
title: string,
|
||||
description: string,
|
||||
label: StateLabel,
|
||||
assignees?: string[],
|
||||
): Promise<Issue> {
|
||||
// Write description to temp file to preserve newlines
|
||||
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
|
||||
await writeFile(tempFile, description, "utf-8");
|
||||
|
||||
try {
|
||||
// Use shell to read file content into description
|
||||
const { exec } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
let cmd = `glab issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}"`;
|
||||
if (assignees && assignees.length > 0) {
|
||||
cmd += ` --assignee "${assignees.join(",")}"`;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
cwd: this.repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
// glab issue create returns the issue URL (e.g. https://gitlab.com/owner/repo/-/issues/42)
|
||||
const match = stdout.trim().match(/\/issues\/(\d+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse issue number from created issue URL: ${stdout.trim()}`);
|
||||
}
|
||||
const issueId = parseInt(match[1], 10);
|
||||
return this.getIssue(issueId);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||
try {
|
||||
const raw = await this.glab([
|
||||
"issue", "list", "--label", label, "--output", "json",
|
||||
]);
|
||||
return JSON.parse(raw) as Issue[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getIssue(issueId: number): Promise<Issue> {
|
||||
const raw = await this.glab([
|
||||
"issue", "view", String(issueId), "--output", "json",
|
||||
]);
|
||||
return JSON.parse(raw) as Issue;
|
||||
}
|
||||
|
||||
async transitionLabel(
|
||||
issueId: number,
|
||||
from: StateLabel,
|
||||
to: StateLabel,
|
||||
): Promise<void> {
|
||||
// Fetch current issue to get all labels
|
||||
const issue = await this.getIssue(issueId);
|
||||
|
||||
// Find all state labels currently on the issue
|
||||
const currentStateLabels = issue.labels.filter((label) =>
|
||||
STATE_LABELS.includes(label as StateLabel),
|
||||
);
|
||||
|
||||
// If no state labels to remove, just add the new one
|
||||
if (currentStateLabels.length === 0) {
|
||||
await this.glab([
|
||||
"issue", "update", String(issueId),
|
||||
"--label", to,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all state labels and add the new one in a single operation
|
||||
// This ensures clean transitions: "removed X, added Y" instead of messy multi-label operations
|
||||
const args = [
|
||||
"issue", "update", String(issueId),
|
||||
];
|
||||
|
||||
// Add all current state labels to remove
|
||||
for (const label of currentStateLabels) {
|
||||
args.push("--unlabel", label);
|
||||
}
|
||||
|
||||
// Add the new state label
|
||||
args.push("--label", to);
|
||||
|
||||
await this.glab(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> {
|
||||
await this.glab(["issue", "close", String(issueId)]);
|
||||
}
|
||||
|
||||
async reopenIssue(issueId: number): Promise<void> {
|
||||
await this.glab(["issue", "reopen", String(issueId)]);
|
||||
}
|
||||
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
|
||||
return issue.labels.includes(expected);
|
||||
}
|
||||
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||
for (const label of STATE_LABELS) {
|
||||
if (issue.labels.includes(label)) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.glab([
|
||||
"mr", "list", "--output", "json", "--state", "merged",
|
||||
]);
|
||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
|
||||
const pattern = `#${issueId}`;
|
||||
return mrs.some(
|
||||
(mr) =>
|
||||
mr.title.includes(pattern) || (mr.description ?? "").includes(pattern),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
try {
|
||||
const raw = await this.glab([
|
||||
"mr", "list", "--output", "json", "--state", "merged",
|
||||
]);
|
||||
const mrs = JSON.parse(raw) as Array<{
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
web_url: string;
|
||||
merged_at: string;
|
||||
}>;
|
||||
|
||||
const pattern = `#${issueId}`;
|
||||
|
||||
// Find the most recently merged MR that references this issue
|
||||
// Sort by merged_at to get the most recent first
|
||||
const matchingMr = mrs
|
||||
.filter(
|
||||
(mr) =>
|
||||
mr.title.includes(pattern) || (mr.description ?? "").includes(pattern),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime()
|
||||
)[0];
|
||||
|
||||
return matchingMr?.web_url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
// Write body to temp file to preserve newlines
|
||||
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
|
||||
await writeFile(tempFile, body, "utf-8");
|
||||
|
||||
try {
|
||||
const { exec } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const cmd = `glab issue note ${issueId} --message "$(cat ${tempFile})"`;
|
||||
await execAsync(cmd, {
|
||||
cwd: this.repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.glab(["auth", "status"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Provider factory — creates the appropriate IssueProvider for a repository.
|
||||
*
|
||||
* Auto-detects provider from git remote URL:
|
||||
* - github.com → GitHubProvider (gh CLI)
|
||||
* - Everything else → GitLabProvider (glab CLI)
|
||||
*
|
||||
* Can be overridden with explicit `provider` option.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { TaskManager } from "./task-manager.js";
|
||||
import { GitLabProvider } from "./gitlab.js";
|
||||
import { GitHubProvider } from "./github.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
|
||||
export type ProviderOptions = {
|
||||
provider?: "gitlab" | "github";
|
||||
repo?: string;
|
||||
repoPath?: string;
|
||||
};
|
||||
|
||||
function detectProvider(repoPath: string): "gitlab" | "github" {
|
||||
try {
|
||||
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
||||
cwd: repoPath,
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
if (url.includes("github.com")) return "github";
|
||||
return "gitlab";
|
||||
} catch {
|
||||
return "gitlab";
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderWithType = {
|
||||
provider: TaskManager;
|
||||
type: "github" | "gitlab";
|
||||
};
|
||||
|
||||
export function createProvider(opts: ProviderOptions): ProviderWithType {
|
||||
const repoPath =
|
||||
opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
|
||||
if (!repoPath) {
|
||||
throw new Error(
|
||||
"Either repoPath or repo must be provided to createProvider",
|
||||
);
|
||||
}
|
||||
|
||||
const type = opts.provider ?? detectProvider(repoPath);
|
||||
|
||||
if (type === "github") {
|
||||
return {
|
||||
provider: new GitHubProvider({ repoPath }),
|
||||
type: "github",
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: new GitLabProvider({ repoPath }),
|
||||
type: "gitlab",
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* TaskManager — Abstract interface for issue tracker operations.
|
||||
*
|
||||
* GitHub (via gh CLI) and GitLab (via glab CLI) are the current implementations.
|
||||
* Future providers: Jira (via API).
|
||||
*
|
||||
* All DevClaw tools operate through this interface, making it possible
|
||||
* to swap issue trackers without changing tool logic.
|
||||
*/
|
||||
|
||||
export const STATE_LABELS = [
|
||||
"Planning",
|
||||
"To Do",
|
||||
"Doing",
|
||||
"To Test",
|
||||
"Testing",
|
||||
"Done",
|
||||
"To Improve",
|
||||
"Refining",
|
||||
] as const;
|
||||
|
||||
export type StateLabel = (typeof STATE_LABELS)[number];
|
||||
|
||||
export const LABEL_COLORS: Record<StateLabel, string> = {
|
||||
Planning: "#6699cc",
|
||||
"To Do": "#428bca",
|
||||
Doing: "#f0ad4e",
|
||||
"To Test": "#5bc0de",
|
||||
Testing: "#9b59b6",
|
||||
Done: "#5cb85c",
|
||||
"To Improve": "#d9534f",
|
||||
Refining: "#f39c12",
|
||||
};
|
||||
|
||||
export type Issue = {
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
labels: string[];
|
||||
state: string;
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
export interface TaskManager {
|
||||
/** Create a label if it doesn't exist (idempotent). */
|
||||
ensureLabel(name: string, color: string): Promise<void>;
|
||||
|
||||
/** Create all 8 state labels (idempotent). */
|
||||
ensureAllStateLabels(): Promise<void>;
|
||||
|
||||
/** Create a new issue. */
|
||||
createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue>;
|
||||
|
||||
/** List issues with a specific state label. */
|
||||
listIssuesByLabel(label: StateLabel): Promise<Issue[]>;
|
||||
|
||||
/** Fetch a single issue by ID. */
|
||||
getIssue(issueId: number): Promise<Issue>;
|
||||
|
||||
/** Transition an issue from one state label to another (atomic unlabel + label). */
|
||||
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
|
||||
|
||||
/** Close an issue. */
|
||||
closeIssue(issueId: number): Promise<void>;
|
||||
|
||||
/** Reopen an issue. */
|
||||
reopenIssue(issueId: number): Promise<void>;
|
||||
|
||||
/** Check if an issue has a specific state label. */
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
|
||||
|
||||
/** Get the current state label of an issue. */
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||
|
||||
/** Check if any merged MR/PR exists for a specific issue. */
|
||||
hasMergedMR(issueId: number): Promise<boolean>;
|
||||
|
||||
/** Get the URL of the most recently merged MR/PR for a specific issue. Returns null if not found. */
|
||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||
|
||||
/** Add a comment to an issue. */
|
||||
addComment(issueId: number, body: string): Promise<void>;
|
||||
|
||||
/** Verify the task manager is working (CLI available, auth valid, repo accessible). */
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility alias for backward compatibility.
|
||||
* @deprecated Use TaskManager instead.
|
||||
*/
|
||||
export type IssueProvider = TaskManager;
|
||||
108
lib/templates.ts
108
lib/templates.ts
@@ -19,15 +19,14 @@ Read the comments carefully — they often contain clarifications, decisions, or
|
||||
|
||||
## Your Job
|
||||
|
||||
- Work in a git worktree at \`~/git/<project>.worktrees/<issue-number>/\` (never switch branches in the main repo)
|
||||
- Example: \`git worktree add ~/git/myproject.worktrees/42 fix/42-bug-name\`
|
||||
- Work in a git worktree (never switch branches in the main repo)
|
||||
- Run tests before completing
|
||||
- Create an MR/PR to the base branch and merge it
|
||||
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X", "Fixes issue #X"). Instead use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state via task_complete - auto-closing bypasses QA validation.
|
||||
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
||||
- Clean up the worktree after merging
|
||||
- When done, call task_complete with role "dev", result "done", and a brief summary
|
||||
- When done, call work_finish with role "dev", result "done", and a brief summary
|
||||
- If you discover unrelated bugs, call task_create to file them
|
||||
- Do NOT call task_pickup, queue_status, session_health, or project_register
|
||||
- Do NOT call work_start, status, or project_register
|
||||
`;
|
||||
|
||||
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
||||
@@ -36,12 +35,13 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
||||
- Run tests and linting
|
||||
- Verify the changes address the issue requirements
|
||||
- Check for regressions in related functionality
|
||||
- When done, call task_complete with role "qa" and one of:
|
||||
- **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked
|
||||
- When done, call work_finish with role "qa" and one of:
|
||||
- result "pass" if everything looks good
|
||||
- result "fail" with specific issues if problems found
|
||||
- result "refine" if you need human input to decide
|
||||
- If you discover unrelated bugs, call task_create to file them
|
||||
- Do NOT call task_pickup, queue_status, session_health, or project_register
|
||||
- Do NOT call work_start, status, or project_register
|
||||
`;
|
||||
|
||||
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
|
||||
@@ -57,18 +57,19 @@ Skip the orchestrator section. Follow your task message and role instructions (a
|
||||
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
|
||||
- **DEV always works in a git worktree** (never switch branches in the main repo)
|
||||
- **DEV must merge to base branch** before announcing completion
|
||||
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Instead use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state via task_complete — auto-closing bypasses QA validation.
|
||||
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA.
|
||||
- **QA tests on the deployed version** and inspects code on the base branch
|
||||
- **QA always calls task_comment** with review findings before completing
|
||||
- Always run tests before completing
|
||||
|
||||
### Completing Your Task
|
||||
|
||||
When you are done, **call \`task_complete\` yourself** — do not just announce in text.
|
||||
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
||||
|
||||
- **DEV done:** \`task_complete({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA pass:** \`task_complete({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA fail:** \`task_complete({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||
- **QA refine:** \`task_complete({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||
- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||
- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||
|
||||
The \`projectGroupId\` is included in your task message.
|
||||
|
||||
@@ -81,34 +82,7 @@ If you discover unrelated bugs or needed improvements during your work, call \`t
|
||||
### Tools You Should NOT Use
|
||||
|
||||
These are orchestrator-only tools. Do not call them:
|
||||
- \`task_pickup\`, \`queue_status\`, \`session_health\`, \`project_register\`
|
||||
|
||||
---
|
||||
|
||||
## Worker Task Templates (Reference)
|
||||
|
||||
These templates show the expected workflow for DEV and QA workers. Your actual task message will include specific issue details and project context.
|
||||
|
||||
### DEV Worker Workflow
|
||||
|
||||
1. **Setup**: Create worktree: \`git worktree add ~/git/<project>.worktrees/<issue-id>/ -b fix/<issue-id>-<slug>\`
|
||||
2. **Implement**: Make changes, run tests locally
|
||||
3. **Commit**: Use conventional commits with issue number: \`feat: add feature (#12)\`
|
||||
4. **Push**: \`git push -u origin fix/<issue-id>-<slug>\`
|
||||
5. **Create PR/MR**:
|
||||
- **CRITICAL**: Do NOT use closing keywords (no "Closes #X", "Fixes #X", "Resolves #X")
|
||||
- Use: "As described in issue #X" or "Addresses issue #X" or "Related to issue #X"
|
||||
- Example title: \`feat: add user auth (#12)\`
|
||||
6. **Merge**: Merge the PR/MR to base branch
|
||||
7. **Cleanup**: Remove worktree: \`git worktree remove ~/git/<project>.worktrees/<issue-id>/\`
|
||||
8. **Complete**: Call \`task_complete({ role: "dev", result: "done", ... })\`
|
||||
|
||||
### QA Worker Workflow
|
||||
|
||||
1. **Pull latest**: \`git pull\` on base branch
|
||||
2. **Verify deployment**: Check the deployed version shows the changes
|
||||
3. **Run tests**: Execute test suite, check for regressions
|
||||
4. **Report**: Call \`task_complete\` with result "pass", "fail", or "refine"
|
||||
- \`work_start\`, \`status\`, \`auto_pickup\`, \`project_register\`
|
||||
|
||||
---
|
||||
|
||||
@@ -124,10 +98,10 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
|
||||
|---|---|
|
||||
| \`project_register\` | One-time project setup: creates labels, scaffolds role files, adds to projects.json |
|
||||
| \`task_create\` | Create issues from chat (bugs, features, tasks) |
|
||||
| \`queue_status\` | Scans issue queue (To Do, To Test, To Improve) + shows worker state |
|
||||
| \`task_pickup\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions, state update |
|
||||
| \`task_complete\` | End-to-end: label transition, state update, issue close/reopen. Auto-chains if enabled. |
|
||||
| \`session_health\` | Detects zombie workers, stale sessions. Can auto-fix. |
|
||||
| \`task_update\` | Update issue title, description, or labels |
|
||||
| \`status\` | Scans issue queue + worker state + health checks |
|
||||
| \`work_start\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions |
|
||||
| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-chains if enabled. |
|
||||
|
||||
### Pipeline Flow
|
||||
|
||||
@@ -143,7 +117,7 @@ Issue labels are the single source of truth for task state.
|
||||
|
||||
### Developer Assignment
|
||||
|
||||
Evaluate each task and pass the appropriate developer tier to \`task_pickup\`:
|
||||
Evaluate each task and pass the appropriate developer tier to \`work_start\`:
|
||||
|
||||
- **junior** — trivial: typos, single-file fix, quick change
|
||||
- **medior** — standard: features, bug fixes, multi-file changes
|
||||
@@ -152,15 +126,15 @@ Evaluate each task and pass the appropriate developer tier to \`task_pickup\`:
|
||||
|
||||
### Picking Up Work
|
||||
|
||||
1. Use \`queue_status\` to see what's available
|
||||
1. Use \`status\` to see what's available
|
||||
2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work)
|
||||
3. Evaluate complexity, choose developer tier
|
||||
4. Call \`task_pickup\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name)
|
||||
4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name)
|
||||
5. Post the \`announcement\` from the tool response to Telegram
|
||||
|
||||
### When Work Completes
|
||||
|
||||
Workers call \`task_complete\` themselves — the label transition, state update, and audit log happen atomically.
|
||||
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically.
|
||||
|
||||
**If \`autoChain\` is enabled on the project:**
|
||||
- DEV "done" → QA is dispatched automatically (qa tier)
|
||||
@@ -168,7 +142,7 @@ Workers call \`task_complete\` themselves — the label transition, state update
|
||||
- QA "pass" / "refine" → pipeline done or needs human input, no chaining
|
||||
|
||||
**If \`autoChain\` is disabled:**
|
||||
- The \`task_complete\` response includes a \`nextAction\` hint
|
||||
- The \`work_finish\` response includes a \`nextAction\` hint
|
||||
- \`"qa_pickup"\` → pick up QA for this issue
|
||||
- \`"dev_fix"\` → pick up DEV to fix
|
||||
- absent → pipeline done or needs human input
|
||||
@@ -181,7 +155,7 @@ Workers receive role-specific instructions appended to their task message. These
|
||||
|
||||
### Heartbeats
|
||||
|
||||
On heartbeat, follow \`HEARTBEAT.md\`.
|
||||
On heartbeat, call \`auto_pickup\` — it runs health checks and picks up available work automatically.
|
||||
|
||||
### Safety
|
||||
|
||||
@@ -193,31 +167,15 @@ On heartbeat, follow \`HEARTBEAT.md\`.
|
||||
|
||||
export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
|
||||
|
||||
On each heartbeat, run these checks using DevClaw tools:
|
||||
On each heartbeat, call \`auto_pickup\` (no parameters needed for a full sweep).
|
||||
|
||||
## 1. Health Check
|
||||
It will automatically:
|
||||
1. Run health checks (zombie workers, stale sessions)
|
||||
2. Scan queues across all projects
|
||||
3. Pick up available work by priority: To Improve > To Test > To Do
|
||||
4. Choose appropriate developer tier based on task complexity
|
||||
|
||||
Call \`session_health\` with \`projectGroupId\` and \`autoFix: true\`.
|
||||
- Detects zombie workers (active but session dead)
|
||||
- Auto-fixes stale state in projects.json
|
||||
If you want to target a single project, pass \`projectGroupId\`.
|
||||
|
||||
## 2. Queue Scan
|
||||
|
||||
Call \`queue_status\` with \`projectGroupId\`.
|
||||
- Shows issues in To Do, To Test, To Improve
|
||||
- Shows current worker state (active/idle)
|
||||
|
||||
## 3. Pick Up Work (if slots free)
|
||||
|
||||
If a worker slot is free (DEV or QA not active), pick up work by priority:
|
||||
|
||||
1. \`To Improve\` issues → \`task_pickup\` with role \`dev\`
|
||||
2. \`To Test\` issues → \`task_pickup\` with role \`qa\`
|
||||
3. \`To Do\` issues → \`task_pickup\` with role \`dev\`
|
||||
|
||||
Choose the developer tier based on task complexity (see AGENTS.md developer assignment guide).
|
||||
|
||||
## 4. Nothing to do?
|
||||
|
||||
If no issues in queue and no active workers → reply \`HEARTBEAT_OK\`.
|
||||
If nothing needs attention, it reports HEARTBEAT_OK.
|
||||
`;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* analyze_channel_bindings — Check channel availability and detect binding conflicts.
|
||||
*
|
||||
* Returns analysis of the current channel binding state, including:
|
||||
* - Whether the channel is configured and enabled
|
||||
* - Existing channel-wide bindings (potential conflicts)
|
||||
* - Existing group-specific bindings (no conflicts)
|
||||
* - Recommendations for what to do
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
analyzeChannelBindings,
|
||||
type ChannelType,
|
||||
} from "../binding-manager.js";
|
||||
|
||||
export function createAnalyzeChannelBindingsTool(api: OpenClawPluginApi) {
|
||||
return (_ctx: ToolContext) => ({
|
||||
name: "analyze_channel_bindings",
|
||||
label: "Analyze Channel Bindings",
|
||||
description:
|
||||
"Check if a channel (telegram/whatsapp) is configured and analyze existing bindings. Use this during onboarding when the user selects a channel binding (telegram/whatsapp) to: detect if the channel is configured and enabled, identify existing channel-wide bindings that would conflict, and provide smart recommendations (migrate binding, skip binding, or proceed). Call this BEFORE devclaw_setup when creating a new agent with channel binding.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channel: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "The channel to analyze (telegram or whatsapp)",
|
||||
},
|
||||
},
|
||||
required: ["channel"],
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channel = params.channel as ChannelType;
|
||||
|
||||
const analysis = await analyzeChannelBindings(channel);
|
||||
|
||||
const lines = [`**${channel.charAt(0).toUpperCase() + channel.slice(1)} Binding Analysis**`, ``];
|
||||
|
||||
if (!analysis.channelConfigured) {
|
||||
lines.push(`❌ Channel not configured`);
|
||||
} else if (!analysis.channelEnabled) {
|
||||
lines.push(`⚠️ Channel configured but disabled`);
|
||||
} else {
|
||||
lines.push(`✅ Channel configured and enabled`);
|
||||
}
|
||||
|
||||
lines.push(``);
|
||||
|
||||
if (analysis.existingChannelWideBinding) {
|
||||
lines.push(
|
||||
`**Existing Channel-Wide Binding:**`,
|
||||
` Agent: ${analysis.existingChannelWideBinding.agentName} (${analysis.existingChannelWideBinding.agentId})`,
|
||||
` ⚠️ This agent receives ALL ${channel} messages`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.groupSpecificBindings.length > 0) {
|
||||
lines.push(
|
||||
`**Group-Specific Bindings:**`,
|
||||
...analysis.groupSpecificBindings.map(
|
||||
(b) => ` • ${b.agentName} (${b.agentId}) → group ${b.groupId}`,
|
||||
),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`**Recommendation:**`, analysis.recommendation);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
channel,
|
||||
...analysis,
|
||||
summary: lines.join("\n"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
186
lib/tools/auto-pickup.ts
Normal file
186
lib/tools/auto-pickup.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* auto_pickup — Automated task pickup (heartbeat handler).
|
||||
*
|
||||
* Health checks → queue scan → fill free worker slots.
|
||||
* Optional projectGroupId for single-project or all-project sweep.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { getProject, getWorker, getSessionForModel, readProjects, type Project } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { type Tier } from "../tiers.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
import { checkWorkerHealth, type HealthFix } from "../services/health.js";
|
||||
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
type PickupAction = { project: string; groupId: string; issueId: number; issueTitle: string; role: "dev" | "qa"; model: string; sessionAction: "spawn" | "send"; announcement: string };
|
||||
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
return TIER_LABELS.find((t) => lower.includes(t)) ?? null;
|
||||
}
|
||||
|
||||
async function findNextIssueForRole(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role === "dev"
|
||||
? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAutoPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "auto_pickup",
|
||||
label: "Auto Pickup",
|
||||
description: `Automated task pickup. With projectGroupId: targets one project. Without: sweeps all projects. Runs health checks, then fills free worker slots by priority.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectGroupId: { type: "string", description: "Target a single project. Omit to sweep all." },
|
||||
dryRun: { type: "boolean", description: "Report only, don't dispatch. Default: false." },
|
||||
maxPickups: { type: "number", description: "Max pickups per tick." },
|
||||
activeSessions: { type: "array", items: { type: "string" }, description: "Active session IDs for zombie detection." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const targetGroupId = params.projectGroupId as string | undefined;
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const maxPickups = params.maxPickups as number | undefined;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution: ExecutionMode = (pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectEntries = targetGroupId
|
||||
? [[targetGroupId, data.projects[targetGroupId]] as const].filter(([, p]) => p)
|
||||
: Object.entries(data.projects);
|
||||
|
||||
if (projectEntries.length === 0) {
|
||||
return jsonResult({ success: true, dryRun, healthFixes: [], pickups: [], skipped: [{ project: "(none)", reason: "No projects" }] });
|
||||
}
|
||||
|
||||
const healthFixes: Array<HealthFix & { project: string; role: string }> = [];
|
||||
const pickups: PickupAction[] = [];
|
||||
const skipped: Array<{ project: string; role?: string; reason: string }> = [];
|
||||
let globalActiveDev = 0, globalActiveQa = 0, activeProjectCount = 0, pickupCount = 0;
|
||||
|
||||
// Pass 1: health checks
|
||||
for (const [groupId, project] of projectEntries) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkWorkerHealth({ workspaceDir, groupId, project, role, activeSessions, autoFix: !dryRun, provider });
|
||||
healthFixes.push(...fixes.map((f) => ({ ...f, project: project.name, role })));
|
||||
}
|
||||
const refreshed = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (refreshed) {
|
||||
if (refreshed.dev.active) globalActiveDev++;
|
||||
if (refreshed.qa.active) globalActiveQa++;
|
||||
if (refreshed.dev.active || refreshed.qa.active) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: pick up tasks
|
||||
for (const [groupId] of projectEntries) {
|
||||
const current = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (!current) continue;
|
||||
const { provider } = createProvider({ repo: current.repo });
|
||||
const roleExecution: ExecutionMode = current.roleExecution ?? "parallel";
|
||||
const projectActive = current.dev.active || current.qa.active;
|
||||
|
||||
if (projectExecution === "sequential" && !projectActive && activeProjectCount >= 1) {
|
||||
skipped.push({ project: current.name, reason: "Sequential: another project active" });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
if (maxPickups !== undefined && pickupCount >= maxPickups) { skipped.push({ project: current.name, role, reason: `Max pickups reached` }); continue; }
|
||||
const worker = getWorker(current, role);
|
||||
if (worker.active) { skipped.push({ project: current.name, role, reason: `Already active (#${worker.issueId})` }); continue; }
|
||||
if (roleExecution === "sequential" && getWorker(current, role === "dev" ? "qa" : "dev").active) {
|
||||
skipped.push({ project: current.name, role, reason: `Sequential: other role active` }); continue;
|
||||
}
|
||||
|
||||
const next = await findNextIssueForRole(provider, role);
|
||||
if (!next) continue;
|
||||
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
// Model selection
|
||||
let modelAlias: string;
|
||||
const tier = detectTierFromLabels(issue.labels);
|
||||
if (tier) {
|
||||
if (role === "qa" && tier !== "qa") modelAlias = "qa";
|
||||
else if (role === "dev" && tier === "qa") modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
|
||||
else modelAlias = tier;
|
||||
} else {
|
||||
modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
pickups.push({ project: current.name, groupId, issueId: issue.iid, issueTitle: issue.title, role, model: modelAlias, sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn", announcement: `[DRY RUN] Would pick up #${issue.iid}` });
|
||||
} else {
|
||||
try {
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project: current, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
pickups.push({ project: current.name, groupId, issueId: issue.iid, issueTitle: issue.title, role, model: dr.modelAlias, sessionAction: dr.sessionAction, announcement: dr.announcement });
|
||||
} catch (err) {
|
||||
skipped.push({ project: current.name, role, reason: `Dispatch failed: ${(err as Error).message}` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++; else globalActiveQa++;
|
||||
if (!projectActive) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog(workspaceDir, "auto_pickup", {
|
||||
dryRun, projectExecution, projectsScanned: projectEntries.length,
|
||||
healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length,
|
||||
});
|
||||
|
||||
// Notify
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "heartbeat", projectsScanned: projectEntries.length, healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length, dryRun, pickupDetails: pickups.map((p) => ({ project: p.project, issueId: p.issueId, role: p.role })) },
|
||||
{ workspaceDir, config: notifyConfig, orchestratorDm: context.type === "direct" ? context.chatId : undefined, channel: "channel" in context ? context.channel : undefined },
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true, dryRun, projectExecution, healthFixes, pickups, skipped,
|
||||
globalState: { activeProjects: activeProjectCount, activeDev: globalActiveDev, activeQa: globalActiveQa },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* context_test — Debug tool to test context detection.
|
||||
*
|
||||
* Call this from different contexts (DM, group, via another agent) to see
|
||||
* what context is detected and what guardrails are generated.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createContextTestTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "context_test",
|
||||
label: "Context Test (Debug)",
|
||||
description:
|
||||
"Debug tool: Shows detected context and guardrails. Use this to verify context detection works correctly in different scenarios (DM, group, via another agent).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
|
||||
async execute(_id: string, _params: Record<string, unknown>) {
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
const guardrails = generateGuardrails(context);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
debug: {
|
||||
toolContext: {
|
||||
agentId: ctx.agentId,
|
||||
messageChannel: ctx.messageChannel,
|
||||
sessionKey: ctx.sessionKey,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
agentAccountId: ctx.agentAccountId,
|
||||
sandboxed: ctx.sandboxed,
|
||||
},
|
||||
devClawAgentIds,
|
||||
},
|
||||
detectedContext: context,
|
||||
guardrails,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* devclaw_onboard — Explicit tool for triggering DevClaw onboarding.
|
||||
*
|
||||
* Provides discoverable, tool-based onboarding that doesn't rely on
|
||||
* keyword detection. Returns conversational context as a tool result.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
isPluginConfigured,
|
||||
hasWorkspaceFiles,
|
||||
buildOnboardToolContext,
|
||||
buildReconfigContext,
|
||||
} from "../onboarding.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_onboard",
|
||||
label: "DevClaw Onboarding",
|
||||
description:
|
||||
"Start DevClaw onboarding workflow. Use this tool when the user wants to: set up DevClaw, install DevClaw, onboard DevClaw, configure DevClaw, get started with DevClaw, or asks questions like 'can we install devclaw?', 'how do I set up devclaw?', 'let's onboard devclaw'. Returns step-by-step QA-style guidance. Call this FIRST before calling devclaw_setup to provide conversational setup experience.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["first-run", "reconfigure"],
|
||||
description:
|
||||
"Whether this is first-time setup (first-run) or reconfiguration (reconfigure). Auto-detected if omitted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// Warn if called in wrong context (group chat)
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "DevClaw onboarding should not be done in project group chats.",
|
||||
recommendation:
|
||||
"Please discuss DevClaw setup in a direct message with the DevClaw agent or via another agent (like your main assistant).",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const configured = isPluginConfigured(
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
);
|
||||
const hasWorkspace = await hasWorkspaceFiles(ctx.workspaceDir);
|
||||
|
||||
const mode = params.mode
|
||||
? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace
|
||||
? "reconfigure"
|
||||
: "first-run";
|
||||
|
||||
const instructions =
|
||||
mode === "first-run"
|
||||
? buildOnboardToolContext()
|
||||
: buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
mode,
|
||||
configured,
|
||||
instructions,
|
||||
contextGuidance: generateGuardrails(context),
|
||||
nextSteps: [
|
||||
"Follow the instructions above",
|
||||
"Call devclaw_setup with your collected answers",
|
||||
mode === "first-run" ? "Optional: register a project afterward" : null,
|
||||
].filter(Boolean),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* devclaw_setup — Agent-driven setup tool.
|
||||
*
|
||||
* Creates a new agent (optional), configures model tiers,
|
||||
* and writes workspace files (AGENTS.md, HEARTBEAT.md, roles, memory).
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup } from "../setup.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_setup",
|
||||
label: "DevClaw Setup",
|
||||
description: `Execute DevClaw setup with collected configuration. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent with channel binding and migration support. Backs up existing files before overwriting. This tool is typically called AFTER devclaw_onboard guides the conversation, but can be called directly if the user provides explicit configuration parameters.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newAgentName: {
|
||||
type: "string",
|
||||
description: "Create a new agent with this name. If omitted, configures the current agent's workspace.",
|
||||
},
|
||||
channelBinding: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "Channel to bind the new agent to (optional). Only used when newAgentName is specified. If omitted, no binding is created.",
|
||||
},
|
||||
migrateFrom: {
|
||||
type: "string",
|
||||
description: "Agent ID to migrate channel binding from (optional). Use when replacing an existing agent's channel-wide binding. Call analyze_channel_bindings first to detect conflicts.",
|
||||
},
|
||||
models: {
|
||||
type: "object",
|
||||
description: `Model overrides per tier. Missing tiers use defaults. Example: { "junior": "anthropic/claude-haiku-4-5", "senior": "anthropic/claude-opus-4-5" }`,
|
||||
properties: {
|
||||
junior: { type: "string", description: `Junior dev model (default: ${DEFAULT_MODELS.junior})` },
|
||||
medior: { type: "string", description: `Medior dev model (default: ${DEFAULT_MODELS.medior})` },
|
||||
senior: { type: "string", description: `Senior dev model (default: ${DEFAULT_MODELS.senior})` },
|
||||
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
|
||||
},
|
||||
},
|
||||
projectExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
description: "Plugin-level project execution mode: parallel (each project independent) or sequential (work on one project at a time). Default: parallel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const newAgentName = params.newAgentName as string | undefined;
|
||||
const channelBinding = params.channelBinding as "telegram" | "whatsapp" | undefined;
|
||||
const migrateFrom = params.migrateFrom as string | undefined;
|
||||
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
|
||||
const projectExecution = params.projectExecution as "parallel" | "sequential" | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
const result = await runSetup({
|
||||
newAgentName,
|
||||
channelBinding: channelBinding ?? null,
|
||||
migrateFrom,
|
||||
// If no new agent name, use the current agent's workspace
|
||||
agentId: newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: newAgentName ? undefined : workspaceDir,
|
||||
models: modelsParam,
|
||||
projectExecution,
|
||||
});
|
||||
|
||||
const lines = [
|
||||
result.agentCreated
|
||||
? `Agent "${result.agentId}" created`
|
||||
: `Configured workspace for agent "${result.agentId}"`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (result.bindingMigrated) {
|
||||
lines.push(
|
||||
`✅ Channel binding migrated:`,
|
||||
` ${result.bindingMigrated.channel} (from "${result.bindingMigrated.from}" → "${result.agentId}")`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Models:`,
|
||||
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
|
||||
``,
|
||||
`Files written:`,
|
||||
...result.filesWritten.map((f) => ` ${f}`),
|
||||
);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push(``, `Warnings:`, ...result.warnings.map((w) => ` ${w}`));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
`Next steps:`,
|
||||
` 1. Add bot to a Telegram/WhatsApp group`,
|
||||
` 2. Register a project: "Register project <name> at <repo> for group <id>"`,
|
||||
` 3. Create your first issue and pick it up`,
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
...result,
|
||||
summary: lines.join("\n"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
/**
|
||||
* heartbeat_tick — Automated task pickup across all projects.
|
||||
*
|
||||
* Runs on heartbeat/cron context:
|
||||
* 1. Clean zombie sessions (session_health logic)
|
||||
* 2. Loop over all projects
|
||||
* 3. Check worker slots per project
|
||||
* 4. Pick up tasks by priority (To Improve > To Test > To Do)
|
||||
* 5. Respect two-level work mode:
|
||||
* - projectExecution (plugin-level): parallel/sequential for projects
|
||||
* - roleExecution (project-level): parallel/sequential for roles within a project
|
||||
* 6. Return summary of actions taken
|
||||
*
|
||||
* Context guard: Only allows from DM/cron context, blocks project groups.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type Issue, type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import {
|
||||
getProject,
|
||||
getWorker,
|
||||
getSessionForModel,
|
||||
readProjects,
|
||||
updateWorker,
|
||||
type Project,
|
||||
} from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { type Tier } from "../tiers.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
|
||||
/** Labels that map to QA role */
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
|
||||
/** All pickable labels, in priority order (highest first) */
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
|
||||
/** Tier labels that can appear on issues */
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
|
||||
type PickupAction = {
|
||||
project: string;
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
issueTitle: string;
|
||||
role: "dev" | "qa";
|
||||
model: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
announcement: string;
|
||||
};
|
||||
|
||||
type HealthFix = {
|
||||
project: string;
|
||||
role: "dev" | "qa";
|
||||
type: string;
|
||||
fixed: boolean;
|
||||
};
|
||||
|
||||
type TickResult = {
|
||||
success: boolean;
|
||||
dryRun: boolean;
|
||||
projectExecution: ExecutionMode;
|
||||
healthFixes: HealthFix[];
|
||||
pickups: PickupAction[];
|
||||
skipped: Array<{ project: string; role?: "dev" | "qa"; reason: string }>;
|
||||
globalState?: { activeProjects: number; activeDev: number; activeQa: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect role from issue's current state label.
|
||||
*/
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tier from issue labels (e.g., "junior", "senior").
|
||||
*/
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lowerLabels = labels.map((l) => l.toLowerCase());
|
||||
for (const tier of TIER_LABELS) {
|
||||
if (lowerLabels.includes(tier)) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next issue to pick up by priority for a specific role.
|
||||
*/
|
||||
async function findNextIssueForRole(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labelsToCheck =
|
||||
role === "dev"
|
||||
? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
|
||||
for (const label of labelsToCheck) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) {
|
||||
// Return oldest issue first (FIFO)
|
||||
const oldest = issues[issues.length - 1];
|
||||
return { issue: oldest, label };
|
||||
}
|
||||
} catch {
|
||||
// Continue to next label on error
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check logic for a single project/role.
|
||||
* Returns fixes applied (simplified version of session_health).
|
||||
*/
|
||||
async function checkAndFixWorkerHealth(
|
||||
workspaceDir: string,
|
||||
groupId: string,
|
||||
project: Project,
|
||||
role: "dev" | "qa",
|
||||
activeSessions: string[],
|
||||
autoFix: boolean,
|
||||
provider: { transitionLabel(id: number, from: StateLabel, to: StateLabel): Promise<void> },
|
||||
): Promise<HealthFix[]> {
|
||||
const fixes: HealthFix[] = [];
|
||||
const worker = project[role];
|
||||
const currentSessionKey = worker.model
|
||||
? getSessionForModel(worker, worker.model)
|
||||
: null;
|
||||
|
||||
// Check 1: Active but no session key for current model
|
||||
if (worker.active && !currentSessionKey) {
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "active_no_session",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (
|
||||
worker.active &&
|
||||
currentSessionKey &&
|
||||
activeSessions.length > 0 &&
|
||||
!activeSessions.includes(currentSessionKey)
|
||||
) {
|
||||
if (autoFix) {
|
||||
// Revert issue 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 provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort label revert
|
||||
}
|
||||
|
||||
// Clear the dead session
|
||||
const updatedSessions = { ...worker.sessions };
|
||||
if (worker.model) {
|
||||
updatedSessions[worker.model] = null;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
sessions: updatedSessions,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "zombie_session",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 3: Inactive but still has issueId
|
||||
if (!worker.active && worker.issueId) {
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "inactive_with_issue",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max workers for a role from project config (with defaults).
|
||||
*/
|
||||
function getMaxWorkers(project: Project, role: "dev" | "qa"): number {
|
||||
const key = role === "dev" ? "maxDevWorkers" : "maxQaWorkers";
|
||||
const value = (project as Record<string, unknown>)[key];
|
||||
return typeof value === "number" ? value : 1;
|
||||
}
|
||||
|
||||
export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "heartbeat_tick",
|
||||
label: "Heartbeat Tick",
|
||||
description: `Automated task pickup across all projects. Runs session health checks, then picks up tasks by priority (To Improve > To Test > To Do). Respects two-level work mode: plugin-level projectExecution (parallel/sequential for projects) and project-level roleExecution (parallel/sequential for roles within a project). Only works from DM/cron context, not project groups.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "Report what would happen without actually picking up tasks. Default: false.",
|
||||
},
|
||||
maxPickups: {
|
||||
type: "number",
|
||||
description: "Maximum number of task pickups per tick. Default: unlimited.",
|
||||
},
|
||||
activeSessions: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of currently alive session IDs from sessions_list. Used for zombie detection.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const maxPickups = params.maxPickups as number | undefined;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// Only allow from DM or direct context (not project groups)
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "heartbeat_tick cannot be used in project group chats.",
|
||||
recommendation: "Use this tool from a DM or cron context to manage all projects.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Get plugin-level projectExecution mode from plugin config
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution: ExecutionMode =
|
||||
(pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
|
||||
|
||||
const result: TickResult = {
|
||||
success: true,
|
||||
dryRun,
|
||||
projectExecution,
|
||||
healthFixes: [],
|
||||
pickups: [],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
// Read all projects
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectEntries = Object.entries(data.projects);
|
||||
|
||||
if (projectEntries.length === 0) {
|
||||
return jsonResult({
|
||||
...result,
|
||||
skipped: [{ project: "(none)", reason: "No projects registered" }],
|
||||
});
|
||||
}
|
||||
|
||||
// Track global worker counts for sequential mode
|
||||
let globalActiveDev = 0;
|
||||
let globalActiveQa = 0;
|
||||
let activeProjectCount = 0;
|
||||
let pickupCount = 0;
|
||||
|
||||
// First pass: count active workers and run health checks
|
||||
for (const [groupId, project] of projectEntries) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Health check for both roles
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkAndFixWorkerHealth(
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
role,
|
||||
activeSessions,
|
||||
!dryRun, // autoFix when not dryRun
|
||||
provider,
|
||||
);
|
||||
result.healthFixes.push(...fixes);
|
||||
}
|
||||
|
||||
// Re-read project after health fixes
|
||||
const refreshedData = await readProjects(workspaceDir);
|
||||
const refreshedProject = refreshedData.projects[groupId];
|
||||
if (refreshedProject) {
|
||||
const devActive = refreshedProject.dev.active;
|
||||
const qaActive = refreshedProject.qa.active;
|
||||
if (devActive) globalActiveDev++;
|
||||
if (qaActive) globalActiveQa++;
|
||||
if (devActive || qaActive) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: pick up tasks
|
||||
for (const [groupId, _project] of projectEntries) {
|
||||
// Re-read to get post-health-fix state
|
||||
const currentData = await readProjects(workspaceDir);
|
||||
const project = currentData.projects[groupId];
|
||||
if (!project) continue;
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Get project-level roleExecution mode (default: parallel)
|
||||
const roleExecution: ExecutionMode = project.roleExecution ?? "parallel";
|
||||
|
||||
// Check if this project has any active workers
|
||||
const projectHasActiveWorker = project.dev.active || project.qa.active;
|
||||
|
||||
// Plugin-level projectExecution check: if sequential, only one project can have workers
|
||||
if (projectExecution === "sequential" && !projectHasActiveWorker && activeProjectCount >= 1) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
reason: "Sequential projectExecution: another project has active workers",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check each role
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
// Check max pickups limit
|
||||
if (maxPickups !== undefined && pickupCount >= maxPickups) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Max pickups (${maxPickups}) reached`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if worker slot is available
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `${role.toUpperCase()} already active (issue #${worker.issueId})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check max workers per project
|
||||
const maxWorkers = getMaxWorkers(project, role);
|
||||
// For now we only support 1 worker per role, but structure supports more
|
||||
if (maxWorkers < 1) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `${role.toUpperCase()} disabled (maxWorkers=0)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Project-level roleExecution check: if sequential, only one role can be active
|
||||
if (roleExecution === "sequential") {
|
||||
const otherRole = role === "dev" ? "qa" : "dev";
|
||||
const otherWorker = getWorker(project, otherRole);
|
||||
if (otherWorker.active) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Sequential roleExecution: ${otherRole.toUpperCase()} already active`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find next issue for this role
|
||||
const next = await findNextIssueForRole(provider, role);
|
||||
if (!next) {
|
||||
// No tasks available - not a skip, just nothing to do
|
||||
continue;
|
||||
}
|
||||
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
// Select model
|
||||
let modelAlias: string;
|
||||
const tierFromLabels = detectTierFromLabels(issue.labels);
|
||||
|
||||
if (tierFromLabels) {
|
||||
// Validate tier matches role
|
||||
if (role === "qa" && tierFromLabels !== "qa") {
|
||||
modelAlias = "qa";
|
||||
} else if (role === "dev" && tierFromLabels === "qa") {
|
||||
const selected = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = selected.tier;
|
||||
} else {
|
||||
modelAlias = tierFromLabels;
|
||||
}
|
||||
} else {
|
||||
const selected = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = selected.tier;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
// In dry run, just report what would happen
|
||||
result.pickups.push({
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: modelAlias,
|
||||
sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn",
|
||||
announcement: `[DRY RUN] Would pick up #${issue.iid}: ${issue.title}`,
|
||||
});
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++;
|
||||
if (role === "qa") globalActiveQa++;
|
||||
if (!projectHasActiveWorker) activeProjectCount++;
|
||||
} else {
|
||||
// Actually dispatch
|
||||
try {
|
||||
const dispatchResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
modelAlias,
|
||||
fromLabel: currentLabel,
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
result.pickups.push({
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
announcement: dispatchResult.announcement,
|
||||
});
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++;
|
||||
if (role === "qa") globalActiveQa++;
|
||||
if (!projectHasActiveWorker) activeProjectCount++;
|
||||
} catch (err) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Dispatch failed for #${issue.iid}: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add global state for visibility
|
||||
result.globalState = {
|
||||
activeProjects: activeProjectCount,
|
||||
activeDev: globalActiveDev,
|
||||
activeQa: globalActiveQa,
|
||||
};
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||
dryRun,
|
||||
projectExecution,
|
||||
projectsScanned: projectEntries.length,
|
||||
healthFixes: result.healthFixes.length,
|
||||
pickups: result.pickups.length,
|
||||
skipped: result.skipped.length,
|
||||
});
|
||||
|
||||
// Send heartbeat notification back to whoever triggered it
|
||||
// Both channel and target are derived from context (DM sessionKey)
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
const orchestratorDm = context.type === "direct" ? context.chatId : undefined;
|
||||
|
||||
await notify(
|
||||
{
|
||||
type: "heartbeat",
|
||||
projectsScanned: projectEntries.length,
|
||||
healthFixes: result.healthFixes.length,
|
||||
pickups: result.pickups.length,
|
||||
skipped: result.skipped.length,
|
||||
dryRun,
|
||||
pickupDetails: result.pickups.map((p) => ({
|
||||
project: p.project,
|
||||
issueId: p.issueId,
|
||||
role: p.role,
|
||||
})),
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
orchestratorDm,
|
||||
channel: "channel" in context ? context.channel : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
50
lib/tools/onboard.ts
Normal file
50
lib/tools/onboard.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* onboard — Conversational DevClaw onboarding.
|
||||
*
|
||||
* Returns step-by-step guidance. Call this before setup.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { isPluginConfigured, hasWorkspaceFiles, buildOnboardToolContext, buildReconfigContext } from "../onboarding.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "onboard",
|
||||
label: "Onboard",
|
||||
description: "Start DevClaw onboarding workflow. Returns step-by-step QA-style guidance. Call this first, then setup with collected answers.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["first-run", "reconfigure"], description: "Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false, error: "Onboarding should not be done in group chats.",
|
||||
recommendation: "Use a direct message instead.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const configured = isPluginConfigured(api.pluginConfig as Record<string, unknown>);
|
||||
const hasWorkspace = await hasWorkspaceFiles(ctx.workspaceDir);
|
||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
||||
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
|
||||
return jsonResult({
|
||||
success: true, mode, configured, instructions,
|
||||
contextGuidance: generateGuardrails(context),
|
||||
nextSteps: ["Follow instructions above", "Call setup with collected answers", mode === "first-run" ? "Register a project afterward" : null].filter(Boolean),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { DEV_TIERS, QA_TIERS } from "../tiers.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
|
||||
@@ -146,7 +146,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
error: "Project registration can only be done from the Telegram/WhatsApp group you're registering.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw for the first time, use devclaw_onboard. Then go to the project's Telegram/WhatsApp group to register it."
|
||||
? "If you're setting up DevClaw for the first time, use onboard. Then go to the project's Telegram/WhatsApp group to register it."
|
||||
: "Please go to the Telegram/WhatsApp group you want to register and call project_register from there.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Tests for queue-status execution-aware sequencing logic
|
||||
* Tests for status tool execution-aware sequencing logic
|
||||
* Run with: node --test lib/tools/queue-status.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("queue_status execution-aware sequencing", () => {
|
||||
describe("status execution-aware sequencing", () => {
|
||||
describe("priority ordering", () => {
|
||||
it("should prioritize To Improve > To Test > To Do", () => {
|
||||
// To Improve has priority 3, To Test has 2, To Do has 1
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
/**
|
||||
* queue_status — Show task queue and worker status across projects.
|
||||
*
|
||||
* Enhanced with execution-aware task sequencing based on two-level work mode.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, getProject, type Project } from "../projects.js";
|
||||
import { type StateLabel, type Issue } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
/** Priority order for queue labels (higher = more urgent) */
|
||||
const QUEUE_PRIORITY: Record<QueueLabel, number> = {
|
||||
"To Improve": 3,
|
||||
"To Test": 2,
|
||||
"To Do": 1,
|
||||
};
|
||||
|
||||
type QueueLabel = "To Improve" | "To Test" | "To Do";
|
||||
type Role = "dev" | "qa";
|
||||
|
||||
/** A task in the sequence with metadata */
|
||||
interface SequencedTask {
|
||||
/** Sequence number (1-based) */
|
||||
sequence: number;
|
||||
/** Project group ID */
|
||||
projectId: string;
|
||||
/** Project name */
|
||||
projectName: string;
|
||||
/** Role (dev or qa) */
|
||||
role: Role;
|
||||
/** Issue ID */
|
||||
issueId: number;
|
||||
/** Issue title */
|
||||
title: string;
|
||||
/** Queue label */
|
||||
label: QueueLabel;
|
||||
/** Whether this task is currently active */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** A track of tasks for a specific role within a project */
|
||||
interface ProjectTrack {
|
||||
/** Track name */
|
||||
name: string;
|
||||
/** Role for this track */
|
||||
role: Role;
|
||||
/** Tasks in this track */
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
/** Execution configuration for a project */
|
||||
interface ProjectExecutionConfig {
|
||||
name: string;
|
||||
groupId: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
devActive: boolean;
|
||||
qaActive: boolean;
|
||||
devIssueId: string | null;
|
||||
qaIssueId: string | null;
|
||||
}
|
||||
|
||||
/** Task sequence for a project in parallel mode */
|
||||
interface ProjectTaskSequence {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
/** For sequential: single track, for parallel: multiple tracks */
|
||||
tracks: ProjectTrack[];
|
||||
}
|
||||
|
||||
/** Global task sequence for sequential mode */
|
||||
interface GlobalTaskSequence {
|
||||
mode: "sequential";
|
||||
/** Interleaved tasks across all projects */
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
/** Project queues cache entry */
|
||||
interface ProjectQueues {
|
||||
projectId: string;
|
||||
project: Project;
|
||||
queues: Record<QueueLabel, Issue[]>;
|
||||
}
|
||||
|
||||
/** Result structure for the enhanced queue status */
|
||||
interface QueueStatusResult {
|
||||
execution: {
|
||||
plugin: {
|
||||
projectExecution: "parallel" | "sequential";
|
||||
};
|
||||
projects: ProjectExecutionConfig[];
|
||||
};
|
||||
sequences: {
|
||||
mode: "parallel" | "sequential";
|
||||
/** For sequential mode: global task list */
|
||||
global?: GlobalTaskSequence;
|
||||
/** For parallel mode: per-project tracks */
|
||||
projects?: ProjectTaskSequence[];
|
||||
};
|
||||
projects: Array<{
|
||||
name: string;
|
||||
groupId: string;
|
||||
dev: {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
model: string | null;
|
||||
sessions: Record<string, string | null>;
|
||||
};
|
||||
qa: {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
model: string | null;
|
||||
sessions: Record<string, string | null>;
|
||||
};
|
||||
queue: {
|
||||
toImprove: Array<{ id: number; title: string; priority: number }>;
|
||||
toTest: Array<{ id: number; title: string; priority: number }>;
|
||||
toDo: Array<{ id: number; title: string; priority: number }>;
|
||||
};
|
||||
}>;
|
||||
context: {
|
||||
type: string;
|
||||
projectName?: string;
|
||||
autoFiltered?: boolean;
|
||||
};
|
||||
contextGuidance: string;
|
||||
}
|
||||
|
||||
/** Build task priority score (higher = more urgent) */
|
||||
function getTaskPriority(label: QueueLabel, issue: Issue): number {
|
||||
const basePriority = QUEUE_PRIORITY[label] * 10000;
|
||||
// Secondary sort by creation date (older = higher priority)
|
||||
// Use issue ID as proxy for creation order (lower ID = older)
|
||||
return basePriority - issue.iid;
|
||||
}
|
||||
|
||||
/** Determine role based on queue label */
|
||||
function getRoleForLabel(label: QueueLabel): Role {
|
||||
switch (label) {
|
||||
case "To Do":
|
||||
case "To Improve":
|
||||
return "dev";
|
||||
case "To Test":
|
||||
return "qa";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch and sort all queueable issues for a project */
|
||||
async function fetchProjectQueues(
|
||||
project: Project,
|
||||
): Promise<Record<QueueLabel, Issue[]>> {
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
const queueLabels: QueueLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const queues: Record<QueueLabel, Issue[]> = {
|
||||
"To Improve": [],
|
||||
"To Test": [],
|
||||
"To Do": [],
|
||||
};
|
||||
|
||||
for (const label of queueLabels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
// Sort by priority (higher first) then by ID (lower first = older first)
|
||||
queues[label] = issues.sort((a, b) => {
|
||||
const priorityA = getTaskPriority(label, a);
|
||||
const priorityB = getTaskPriority(label, b);
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
} catch {
|
||||
queues[label] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return queues;
|
||||
}
|
||||
|
||||
/** Build a project track for a specific role */
|
||||
function buildProjectTrack(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
role: Role,
|
||||
queues: Record<QueueLabel, Issue[]>,
|
||||
isActive: boolean,
|
||||
activeIssueId: string | null,
|
||||
startingSequence: number,
|
||||
): { track: ProjectTrack; nextSequence: number } {
|
||||
const tasks: SequencedTask[] = [];
|
||||
let sequence = startingSequence;
|
||||
|
||||
// Helper to add tasks from a queue for this role
|
||||
const addTasksFromQueue = (label: QueueLabel, issues: Issue[]) => {
|
||||
// Only add tasks that match this role
|
||||
if (getRoleForLabel(label) !== role) return;
|
||||
|
||||
for (const issue of issues) {
|
||||
const taskActive = isActive && activeIssueId === String(issue.iid);
|
||||
tasks.push({
|
||||
sequence: sequence++,
|
||||
projectId,
|
||||
projectName,
|
||||
role,
|
||||
issueId: issue.iid,
|
||||
title: issue.title,
|
||||
label,
|
||||
active: taskActive,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add in priority order
|
||||
addTasksFromQueue("To Improve", queues["To Improve"]);
|
||||
addTasksFromQueue("To Test", queues["To Test"]);
|
||||
addTasksFromQueue("To Do", queues["To Do"]);
|
||||
|
||||
return {
|
||||
track: {
|
||||
name: role === "dev" ? "DEV Track" : "QA Track",
|
||||
role,
|
||||
tasks,
|
||||
},
|
||||
nextSequence: sequence,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build project sequences for parallel mode */
|
||||
function buildParallelProjectSequences(
|
||||
projectQueues: ProjectQueues[],
|
||||
): ProjectTaskSequence[] {
|
||||
const sequences: ProjectTaskSequence[] = [];
|
||||
|
||||
for (const { projectId, project, queues } of projectQueues) {
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
const tracks: ProjectTrack[] = [];
|
||||
|
||||
if (roleExecution === "sequential") {
|
||||
// Sequential within project: show alternating DEV/QA sequence
|
||||
const devActive = project.dev.active;
|
||||
const qaActive = project.qa.active;
|
||||
const alternatingTasks: SequencedTask[] = [];
|
||||
let sequence = 1;
|
||||
|
||||
// Get next task for each role
|
||||
const getNextTaskForRole = (role: Role): SequencedTask | null => {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
if (getRoleForLabel(label) !== role) continue;
|
||||
const issues = queues[label];
|
||||
for (const issue of issues) {
|
||||
// Check if already added
|
||||
if (alternatingTasks.some((t) => t.issueId === issue.iid)) continue;
|
||||
const isActive =
|
||||
(role === "dev" && devActive && project.dev.issueId === String(issue.iid)) ||
|
||||
(role === "qa" && qaActive && project.qa.issueId === String(issue.iid));
|
||||
return {
|
||||
sequence: 0, // Will be set later
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
role,
|
||||
issueId: issue.iid,
|
||||
title: issue.title,
|
||||
label,
|
||||
active: isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Build alternating sequence
|
||||
let lastRole: Role | null = null;
|
||||
if (devActive && !qaActive) lastRole = "dev";
|
||||
else if (qaActive && !devActive) lastRole = "qa";
|
||||
|
||||
// Add active task first if any
|
||||
if (devActive && project.dev.issueId) {
|
||||
const activeDevTask = getNextTaskForRole("dev");
|
||||
if (activeDevTask) {
|
||||
activeDevTask.sequence = sequence++;
|
||||
activeDevTask.active = true;
|
||||
alternatingTasks.push(activeDevTask);
|
||||
}
|
||||
} else if (qaActive && project.qa.issueId) {
|
||||
const activeQaTask = getNextTaskForRole("qa");
|
||||
if (activeQaTask) {
|
||||
activeQaTask.sequence = sequence++;
|
||||
activeQaTask.active = true;
|
||||
alternatingTasks.push(activeQaTask);
|
||||
}
|
||||
}
|
||||
|
||||
// Build future alternating sequence
|
||||
while (true) {
|
||||
const nextRole: Role = lastRole === "dev" ? "qa" : "dev";
|
||||
const task = getNextTaskForRole(nextRole);
|
||||
if (!task) break;
|
||||
task.sequence = sequence++;
|
||||
alternatingTasks.push(task);
|
||||
lastRole = nextRole;
|
||||
}
|
||||
|
||||
if (alternatingTasks.length > 0) {
|
||||
tracks.push({
|
||||
name: "DEV/QA Alternating",
|
||||
role: "dev", // Mixed track
|
||||
tasks: alternatingTasks,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Parallel within project: separate tracks for DEV and QA
|
||||
const devTrack = buildProjectTrack(
|
||||
projectId,
|
||||
project.name,
|
||||
"dev",
|
||||
queues,
|
||||
project.dev.active,
|
||||
project.dev.issueId,
|
||||
1,
|
||||
);
|
||||
const qaTrack = buildProjectTrack(
|
||||
projectId,
|
||||
project.name,
|
||||
"qa",
|
||||
queues,
|
||||
project.qa.active,
|
||||
project.qa.issueId,
|
||||
1,
|
||||
);
|
||||
|
||||
if (devTrack.track.tasks.length > 0) {
|
||||
tracks.push(devTrack.track);
|
||||
}
|
||||
if (qaTrack.track.tasks.length > 0) {
|
||||
tracks.push(qaTrack.track);
|
||||
}
|
||||
}
|
||||
|
||||
sequences.push({
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
roleExecution,
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
||||
return sequences;
|
||||
}
|
||||
|
||||
/** Build global task sequence for sequential mode */
|
||||
function buildGlobalTaskSequence(
|
||||
projectQueues: ProjectQueues[],
|
||||
): GlobalTaskSequence {
|
||||
const allTasks: Array<{
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
role: Role;
|
||||
label: QueueLabel;
|
||||
issue: Issue;
|
||||
priority: number;
|
||||
}> = [];
|
||||
|
||||
// Collect all tasks from all projects
|
||||
for (const { projectId, project, queues } of projectQueues) {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
for (const issue of queues[label]) {
|
||||
allTasks.push({
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
role: getRoleForLabel(label),
|
||||
label,
|
||||
issue,
|
||||
priority: getTaskPriority(label, issue),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (higher first), then by project order, then by ID
|
||||
allTasks.sort((a, b) => {
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
return a.issue.iid - b.issue.iid;
|
||||
});
|
||||
|
||||
// For global sequential mode, we need to interleave based on active workers
|
||||
// Find which project has an active worker
|
||||
const activeProject = projectQueues.find(
|
||||
({ project }) => project.dev.active || project.qa.active,
|
||||
);
|
||||
|
||||
const sequencedTasks: SequencedTask[] = [];
|
||||
let sequence = 1;
|
||||
|
||||
if (activeProject) {
|
||||
// If there's an active project, start with its active task
|
||||
const { project, projectId } = activeProject;
|
||||
if (project.dev.active && project.dev.issueId) {
|
||||
const task = allTasks.find(
|
||||
(t) =>
|
||||
t.projectId === projectId &&
|
||||
t.role === "dev" &&
|
||||
String(t.issue.iid) === project.dev.issueId,
|
||||
);
|
||||
if (task) {
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} else if (project.qa.active && project.qa.issueId) {
|
||||
const task = allTasks.find(
|
||||
(t) =>
|
||||
t.projectId === projectId &&
|
||||
t.role === "qa" &&
|
||||
String(t.issue.iid) === project.qa.issueId,
|
||||
);
|
||||
if (task) {
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining tasks in priority order
|
||||
for (const task of allTasks) {
|
||||
// Skip if already added
|
||||
if (
|
||||
sequencedTasks.some(
|
||||
(t) => t.projectId === task.projectId && t.issueId === task.issue.iid,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "sequential",
|
||||
tasks: sequencedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert project queues to the output format */
|
||||
function formatProjectQueues(
|
||||
queues: Record<QueueLabel, Issue[]>,
|
||||
): QueueStatusResult["projects"][0]["queue"] {
|
||||
return {
|
||||
toImprove: queues["To Improve"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Improve"],
|
||||
})),
|
||||
toTest: queues["To Test"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Test"],
|
||||
})),
|
||||
toDo: queues["To Do"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Do"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "queue_status",
|
||||
label: "Queue Status",
|
||||
description: `Show task queue and worker status with execution-aware task sequencing. Context-aware: In group chats, auto-filters to that project. In direct messages, shows all projects. Best for status checks, not during setup.`,
|
||||
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 workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// If via another agent (setup mode), suggest devclaw_onboard instead
|
||||
if (context.type === "via-agent") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
warning: "queue_status is for operational use, not setup.",
|
||||
recommendation: "If you're setting up DevClaw, use devclaw_onboard instead.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-filter to current project in group context
|
||||
let groupId = params.projectGroupId as string | undefined;
|
||||
if (context.type === "group" && !groupId) {
|
||||
groupId = context.groupId;
|
||||
}
|
||||
|
||||
// Get plugin-level execution setting
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId
|
||||
? [groupId]
|
||||
: Object.keys(data.projects);
|
||||
|
||||
// Build execution configs and fetch all project data
|
||||
const executionConfigs: ProjectExecutionConfig[] = [];
|
||||
const projectList: Array<{ id: string; project: Project }> = [];
|
||||
|
||||
for (const pid of projectIds) {
|
||||
const project = getProject(data, pid);
|
||||
if (!project) continue;
|
||||
|
||||
projectList.push({ id: pid, project });
|
||||
executionConfigs.push({
|
||||
name: project.name,
|
||||
groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
devActive: project.dev.active,
|
||||
qaActive: project.qa.active,
|
||||
devIssueId: project.dev.issueId,
|
||||
qaIssueId: project.qa.issueId,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all queues in parallel
|
||||
const projectQueues: ProjectQueues[] = await Promise.all(
|
||||
projectList.map(async ({ id, project }) => ({
|
||||
projectId: id,
|
||||
project,
|
||||
queues: await fetchProjectQueues(project),
|
||||
})),
|
||||
);
|
||||
|
||||
// Build sequences based on execution mode
|
||||
let sequences: QueueStatusResult["sequences"];
|
||||
|
||||
if (projectExecution === "sequential") {
|
||||
const globalSequence = buildGlobalTaskSequence(projectQueues);
|
||||
sequences = {
|
||||
mode: "sequential",
|
||||
global: globalSequence,
|
||||
};
|
||||
} else {
|
||||
const projectSequences = buildParallelProjectSequences(projectQueues);
|
||||
sequences = {
|
||||
mode: "parallel",
|
||||
projects: projectSequences,
|
||||
};
|
||||
}
|
||||
|
||||
// Build project details with queues
|
||||
const projects: QueueStatusResult["projects"] = projectQueues.map(
|
||||
({ projectId, project, queues }) => ({
|
||||
name: project.name,
|
||||
groupId: projectId,
|
||||
dev: {
|
||||
active: project.dev.active,
|
||||
issueId: project.dev.issueId,
|
||||
model: project.dev.model,
|
||||
sessions: project.dev.sessions,
|
||||
},
|
||||
qa: {
|
||||
active: project.qa.active,
|
||||
issueId: project.qa.issueId,
|
||||
model: project.qa.model,
|
||||
sessions: project.qa.sessions,
|
||||
},
|
||||
queue: formatProjectQueues(queues),
|
||||
}),
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "queue_status", {
|
||||
projectCount: projects.length,
|
||||
totalToImprove: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toImprove.length,
|
||||
0,
|
||||
),
|
||||
totalToTest: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toTest.length,
|
||||
0,
|
||||
),
|
||||
totalToDo: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toDo.length,
|
||||
0,
|
||||
),
|
||||
projectExecution,
|
||||
});
|
||||
|
||||
const result: QueueStatusResult = {
|
||||
execution: {
|
||||
plugin: {
|
||||
projectExecution,
|
||||
},
|
||||
projects: executionConfigs,
|
||||
},
|
||||
sequences,
|
||||
projects,
|
||||
context: {
|
||||
type: context.type,
|
||||
...(context.type === "group" && {
|
||||
projectName: context.projectName,
|
||||
autoFiltered: !params.projectGroupId,
|
||||
}),
|
||||
},
|
||||
contextGuidance: generateGuardrails(context),
|
||||
};
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* session_health — Check and fix session state consistency.
|
||||
*
|
||||
* Detects zombie sessions (active=true but session dead) and stale workers.
|
||||
* Checks the sessions map for each worker's current model.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
|
||||
import { type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "session_health",
|
||||
label: "Session Health",
|
||||
description: `Check session state consistency across all projects. Detects: active workers with no session in their sessions map, 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 issues: Array<Record<string, unknown>> = [];
|
||||
let fixesApplied = 0;
|
||||
|
||||
for (const [groupId, project] of Object.entries(data.projects)) {
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const worker = project[role];
|
||||
const currentSessionKey = worker.model
|
||||
? getSessionForModel(worker, worker.model)
|
||||
: null;
|
||||
|
||||
// Check 1: Active but no session key for current model
|
||||
if (worker.active && !currentSessionKey) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "active_no_session",
|
||||
severity: "critical",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (
|
||||
worker.active &&
|
||||
currentSessionKey &&
|
||||
activeSessions.length > 0 &&
|
||||
!activeSessions.includes(currentSessionKey)
|
||||
) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "zombie_session",
|
||||
severity: "critical",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
sessionKey: currentSessionKey,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
// Revert issue 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 provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
issue.labelReverted = `${currentLabel} → ${revertLabel}`;
|
||||
}
|
||||
} catch {
|
||||
issue.labelRevertFailed = true;
|
||||
}
|
||||
|
||||
// Clear the dead session from the sessions map
|
||||
const updatedSessions = { ...worker.sessions };
|
||||
if (worker.model) {
|
||||
updatedSessions[worker.model] = null;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
sessions: updatedSessions,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "stale_worker",
|
||||
severity: "warning",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
hoursActive: Math.round(hoursActive * 10) / 10,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
62
lib/tools/setup.ts
Normal file
62
lib/tools/setup.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* setup — Agent-driven DevClaw setup.
|
||||
*
|
||||
* Creates agent, configures model tiers, writes workspace files.
|
||||
* Thin wrapper around lib/setup.ts.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup } from "../setup.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "setup",
|
||||
label: "Setup",
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, roles, memory/projects.json, and model tier config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newAgentName: { type: "string", description: "Create a new agent. Omit to configure current workspace." },
|
||||
channelBinding: { type: "string", enum: ["telegram", "whatsapp"], description: "Channel to bind (optional, with newAgentName only)." },
|
||||
migrateFrom: { type: "string", description: "Agent ID to migrate channel binding from. Check openclaw.json bindings first." },
|
||||
models: {
|
||||
type: "object", description: "Model overrides per tier.",
|
||||
properties: {
|
||||
junior: { type: "string", description: `Default: ${DEFAULT_MODELS.junior}` },
|
||||
medior: { type: "string", description: `Default: ${DEFAULT_MODELS.medior}` },
|
||||
senior: { type: "string", description: `Default: ${DEFAULT_MODELS.senior}` },
|
||||
qa: { type: "string", description: `Default: ${DEFAULT_MODELS.qa}` },
|
||||
},
|
||||
},
|
||||
projectExecution: { type: "string", enum: ["parallel", "sequential"], description: "Project execution mode. Default: parallel." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const result = await runSetup({
|
||||
newAgentName: params.newAgentName as string | undefined,
|
||||
channelBinding: (params.channelBinding as "telegram" | "whatsapp") ?? null,
|
||||
migrateFrom: params.migrateFrom as string | undefined,
|
||||
agentId: params.newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
models: params.models as Partial<Record<Tier, string>> | undefined,
|
||||
projectExecution: params.projectExecution as "parallel" | "sequential" | undefined,
|
||||
});
|
||||
|
||||
const lines = [
|
||||
result.agentCreated ? `Agent "${result.agentId}" created` : `Configured "${result.agentId}"`,
|
||||
"",
|
||||
];
|
||||
if (result.bindingMigrated) {
|
||||
lines.push(`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from} → ${result.agentId})`, "");
|
||||
}
|
||||
lines.push("Models:", ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), "", "Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
||||
if (result.warnings.length > 0) lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`));
|
||||
lines.push("", "Next: register a project, then create issues and pick them up.");
|
||||
|
||||
return jsonResult({ success: true, ...result, summary: lines.join("\n") });
|
||||
},
|
||||
});
|
||||
}
|
||||
136
lib/tools/status.ts
Normal file
136
lib/tools/status.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* status — Unified queue + health overview.
|
||||
*
|
||||
* Merges queue_status + session_health into a single tool.
|
||||
* Context-aware: auto-filters to project in group chats.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, getProject, type Project } from "../projects.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { checkWorkerHealth } from "../services/health.js";
|
||||
import {
|
||||
fetchProjectQueues, buildParallelProjectSequences, buildGlobalTaskSequence,
|
||||
formatProjectQueues, type ProjectQueues, type ProjectExecutionConfig,
|
||||
} from "../services/queue.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
|
||||
export function createStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: `Show task queue, worker status, and health across projects. Context-aware: auto-filters in group chats. Pass activeSessions for zombie detection.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectGroupId: { type: "string", description: "Filter to specific project. Omit for all." },
|
||||
includeHealth: { type: "boolean", description: "Run health checks. Default: true." },
|
||||
activeSessions: { type: "array", items: { type: "string" }, description: "Active session IDs for zombie detection." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
const includeHealth = (params.includeHealth as boolean) ?? true;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
|
||||
// Context detection
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
if (context.type === "via-agent") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
warning: "status is for operational use, not setup.",
|
||||
recommendation: "Use onboard instead for DevClaw setup.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-filter in group context
|
||||
let groupId = params.projectGroupId as string | undefined;
|
||||
if (context.type === "group" && !groupId) groupId = context.groupId;
|
||||
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
||||
|
||||
// Build execution configs + fetch queues
|
||||
const configs: ProjectExecutionConfig[] = [];
|
||||
const projectList: Array<{ id: string; project: Project }> = [];
|
||||
|
||||
for (const pid of projectIds) {
|
||||
const project = getProject(data, pid);
|
||||
if (!project) continue;
|
||||
projectList.push({ id: pid, project });
|
||||
configs.push({
|
||||
name: project.name, groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
devActive: project.dev.active, qaActive: project.qa.active,
|
||||
devIssueId: project.dev.issueId, qaIssueId: project.qa.issueId,
|
||||
});
|
||||
}
|
||||
|
||||
// Health checks (read-only — never auto-fix from status)
|
||||
const healthIssues: Array<Record<string, unknown>> = [];
|
||||
if (includeHealth) {
|
||||
for (const { id, project } of projectList) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkWorkerHealth({
|
||||
workspaceDir, groupId: id, project, role, activeSessions,
|
||||
autoFix: false, provider,
|
||||
});
|
||||
for (const f of fixes) healthIssues.push({ ...f.issue, fixed: f.fixed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch queues
|
||||
const projectQueues: ProjectQueues[] = await Promise.all(
|
||||
projectList.map(async ({ id, project }) => ({
|
||||
projectId: id, project,
|
||||
queues: await fetchProjectQueues(project),
|
||||
})),
|
||||
);
|
||||
|
||||
// Build sequences
|
||||
const sequences = projectExecution === "sequential"
|
||||
? { mode: "sequential" as const, global: buildGlobalTaskSequence(projectQueues) }
|
||||
: { mode: "parallel" as const, projects: buildParallelProjectSequences(projectQueues) };
|
||||
|
||||
// Build project details
|
||||
const projects = projectQueues.map(({ projectId, project, queues }) => ({
|
||||
name: project.name, groupId: projectId,
|
||||
dev: { active: project.dev.active, issueId: project.dev.issueId, model: project.dev.model, sessions: project.dev.sessions },
|
||||
qa: { active: project.qa.active, issueId: project.qa.issueId, model: project.qa.model, sessions: project.qa.sessions },
|
||||
queue: formatProjectQueues(queues),
|
||||
}));
|
||||
|
||||
await auditLog(workspaceDir, "status", {
|
||||
projectCount: projects.length,
|
||||
totalToImprove: projects.reduce((s, p) => s + p.queue.toImprove.length, 0),
|
||||
totalToTest: projects.reduce((s, p) => s + p.queue.toTest.length, 0),
|
||||
totalToDo: projects.reduce((s, p) => s + p.queue.toDo.length, 0),
|
||||
healthIssues: healthIssues.length,
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
execution: { plugin: { projectExecution }, projects: configs },
|
||||
sequences, projects,
|
||||
health: includeHealth ? { issues: healthIssues, note: activeSessions.length === 0 ? "No activeSessions — zombie detection skipped." : undefined } : undefined,
|
||||
context: {
|
||||
type: context.type,
|
||||
...(context.type === "group" && { projectName: context.projectName, autoFiltered: !params.projectGroupId }),
|
||||
},
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
/** Valid author roles for attribution */
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
|
||||
*
|
||||
* Handles: validation, label transition, projects.json state update,
|
||||
* issue close/reopen, audit logging, and optional auto-chaining.
|
||||
*
|
||||
* When project.autoChain is true:
|
||||
* - DEV "done" → automatically dispatches QA (qa tier)
|
||||
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV tier)
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
import {
|
||||
deactivateWorker,
|
||||
getProject,
|
||||
getSessionForModel,
|
||||
getWorker,
|
||||
readProjects,
|
||||
} from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_complete",
|
||||
label: "Task Complete",
|
||||
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"],
|
||||
properties: {
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["dev", "qa"],
|
||||
description: "Worker role completing the task",
|
||||
},
|
||||
result: {
|
||||
type: "string",
|
||||
enum: ["done", "pass", "fail", "refine", "blocked"],
|
||||
description:
|
||||
'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input), "blocked" (cannot complete, needs escalation)',
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram/WhatsApp group ID (key in projects.json)",
|
||||
},
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Brief summary for group announcement",
|
||||
},
|
||||
prUrl: {
|
||||
type: "string",
|
||||
description: "Pull Request or Merge Request URL (optional, auto-detected if not provided)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa";
|
||||
const result = params.result as "done" | "pass" | "fail" | "refine" | "blocked";
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
let prUrl = params.prUrl 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" && result !== "blocked") {
|
||||
throw new Error(
|
||||
`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", "refine", or "blocked".`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId,
|
||||
role,
|
||||
result,
|
||||
};
|
||||
|
||||
// === DEV DONE ===
|
||||
if (role === "dev" && result === "done") {
|
||||
try {
|
||||
await execFileAsync("git", ["pull"], {
|
||||
cwd: repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
output.gitPull = "success";
|
||||
} catch (err) {
|
||||
output.gitPull = `warning: ${(err as Error).message}`;
|
||||
}
|
||||
|
||||
// Fetch issue to get URL
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const issueUrl = issue.web_url;
|
||||
|
||||
// Auto-detect PR/MR URL if not provided
|
||||
if (!prUrl) {
|
||||
try {
|
||||
prUrl = await provider.getMergedMRUrl(issueId) ?? undefined;
|
||||
} catch (err) {
|
||||
// Ignore errors in PR URL detection
|
||||
}
|
||||
}
|
||||
|
||||
await deactivateWorker(workspaceDir, groupId, "dev");
|
||||
await provider.transitionLabel(issueId, "Doing", "To Test");
|
||||
|
||||
output.labelTransition = "Doing → To Test";
|
||||
|
||||
// Build announcement with URLs
|
||||
let announcement = `✅ DEV done #${issueId}`;
|
||||
if (summary) {
|
||||
announcement += ` — ${summary}`;
|
||||
}
|
||||
announcement += `\n📋 Issue: ${issueUrl}`;
|
||||
if (prUrl) {
|
||||
announcement += `\n🔗 PR: ${prUrl}`;
|
||||
}
|
||||
announcement += `\nMoved to QA queue.`;
|
||||
|
||||
output.announcement = announcement;
|
||||
output.issueUrl = issueUrl;
|
||||
if (prUrl) {
|
||||
output.prUrl = prUrl;
|
||||
}
|
||||
|
||||
if (project.autoChain) {
|
||||
try {
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role: "qa",
|
||||
modelAlias: "qa",
|
||||
fromLabel: "To Test",
|
||||
toLabel: "Testing",
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(
|
||||
id,
|
||||
from as StateLabel,
|
||||
to as StateLabel,
|
||||
),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
output.autoChain = {
|
||||
dispatched: true,
|
||||
role: "qa",
|
||||
model: chainResult.modelAlias,
|
||||
sessionAction: chainResult.sessionAction,
|
||||
announcement: chainResult.announcement,
|
||||
};
|
||||
} catch (err) {
|
||||
output.autoChain = {
|
||||
dispatched: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
output.nextAction = "qa_pickup";
|
||||
}
|
||||
}
|
||||
|
||||
// === QA PASS ===
|
||||
if (role === "qa" && result === "pass") {
|
||||
// Fetch issue to get URL
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const issueUrl = issue.web_url;
|
||||
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "Done");
|
||||
await provider.closeIssue(issueId);
|
||||
|
||||
output.labelTransition = "Testing → Done";
|
||||
output.issueClosed = true;
|
||||
output.issueUrl = issueUrl;
|
||||
output.announcement = `🎉 QA PASS #${issueId}${summary ? ` — ${summary}` : ""}\n📋 Issue: ${issueUrl}\nIssue closed.`;
|
||||
}
|
||||
|
||||
// === QA FAIL ===
|
||||
if (role === "qa" && result === "fail") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "To Improve");
|
||||
await provider.reopenIssue(issueId);
|
||||
|
||||
const devWorker = getWorker(project, "dev");
|
||||
const devModel = devWorker.model;
|
||||
const devSessionKey = devModel
|
||||
? getSessionForModel(devWorker, devModel)
|
||||
: null;
|
||||
|
||||
output.labelTransition = "Testing → To Improve";
|
||||
output.issueReopened = true;
|
||||
output.announcement = `❌ QA FAIL #${issueId}${summary ? ` — ${summary}` : ""}. Sent back to DEV.`;
|
||||
output.devSessionAvailable = !!devSessionKey;
|
||||
if (devModel) output.devModel = devModel;
|
||||
|
||||
if (project.autoChain && devModel) {
|
||||
try {
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role: "dev",
|
||||
modelAlias: devModel,
|
||||
fromLabel: "To Improve",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(
|
||||
id,
|
||||
from as StateLabel,
|
||||
to as StateLabel,
|
||||
),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
output.autoChain = {
|
||||
dispatched: true,
|
||||
role: "dev",
|
||||
model: chainResult.modelAlias,
|
||||
sessionAction: chainResult.sessionAction,
|
||||
announcement: chainResult.announcement,
|
||||
};
|
||||
} catch (err) {
|
||||
output.autoChain = {
|
||||
dispatched: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
output.nextAction = "dev_fix";
|
||||
}
|
||||
}
|
||||
|
||||
// === QA REFINE ===
|
||||
if (role === "qa" && result === "refine") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "Refining");
|
||||
|
||||
output.labelTransition = "Testing → Refining";
|
||||
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);
|
||||
|
||||
// Determine next state for the notification
|
||||
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(
|
||||
{
|
||||
type: "workerComplete",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId,
|
||||
role,
|
||||
result,
|
||||
summary,
|
||||
nextState,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: project.channel ?? "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "task_complete", {
|
||||
project: project.name,
|
||||
groupId,
|
||||
issue: issueId,
|
||||
role,
|
||||
result,
|
||||
summary: summary ?? null,
|
||||
labelTransition: output.labelTransition,
|
||||
autoChain: output.autoChain ?? null,
|
||||
});
|
||||
|
||||
return jsonResult(output);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../task-managers/task-manager.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
|
||||
const STATE_LABELS: StateLabel[] = [
|
||||
"Planning",
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* task_pickup — Atomically pick up a task from the issue queue.
|
||||
*
|
||||
* Auto-detects:
|
||||
* - projectGroupId: from message context (group chat)
|
||||
* - role: from issue label (To Do/To Improve → dev, To Test → qa)
|
||||
* - model: from tier labels on issue → heuristics → default
|
||||
* - issueId: if omitted, picks next by priority (To Improve > To Test > To Do)
|
||||
*
|
||||
* Handles: validation, model selection, then delegates to dispatchTask()
|
||||
* for label transition, session creation/reuse, task dispatch, state update,
|
||||
* and audit logging.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type Issue, type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { activateWorker, getProject, getWorker, readProjects } from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { isDevTier, isTier, type Tier } from "../tiers.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
|
||||
/** Labels that map to QA role */
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
|
||||
/** All pickable labels, in priority order (highest first) */
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
|
||||
/** Tier labels that can appear on issues */
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
/**
|
||||
* Detect role from issue's current state label.
|
||||
*/
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tier from issue labels (e.g., "junior", "senior").
|
||||
*/
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lowerLabels = labels.map((l) => l.toLowerCase());
|
||||
for (const tier of TIER_LABELS) {
|
||||
if (lowerLabels.includes(tier)) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next issue to pick up by priority.
|
||||
* Priority: To Improve > To Test > To Do
|
||||
*/
|
||||
async function findNextIssue(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role?: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
// Filter priority order by role if specified
|
||||
let labelsToCheck = PRIORITY_ORDER;
|
||||
if (role === "dev") {
|
||||
labelsToCheck = PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l));
|
||||
} else if (role === "qa") {
|
||||
labelsToCheck = PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
}
|
||||
|
||||
for (const label of labelsToCheck) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) {
|
||||
// Return oldest issue first (FIFO)
|
||||
const oldest = issues[issues.length - 1];
|
||||
return { issue: oldest, label };
|
||||
}
|
||||
} catch {
|
||||
// Continue to next label on error
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_pickup",
|
||||
label: "Task Pickup",
|
||||
description: `Pick up a task from the issue queue. Context-aware: ONLY works in project group chats, not in DMs or during setup. Handles label transition, tier assignment, session creation, task dispatch, and audit logging. Returns an announcement for posting in the group.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: [],
|
||||
properties: {
|
||||
issueId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Issue ID to pick up. If omitted, picks next by priority (To Improve > To Test > To Do).",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["dev", "qa"],
|
||||
description:
|
||||
"Worker role: dev or qa. If omitted, auto-detected from issue label (To Do/To Improve → dev, To Test → qa).",
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Telegram/WhatsApp group ID (key in projects.json). If omitted, auto-detected from current group chat context.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
description:
|
||||
"Developer tier (junior, medior, senior, qa). If omitted, detected from issue tier labels, then heuristics.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
||||
const groupIdParam = params.projectGroupId as string | undefined;
|
||||
const modelParam = params.model as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// ONLY allow in group context
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "task_pickup can only be used in project group chats.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw, use devclaw_onboard instead."
|
||||
: "To pick up tasks, please use the relevant project's Telegram/WhatsApp group.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Auto-detect projectGroupId from context if not provided
|
||||
const groupId = groupIdParam ?? context.groupId;
|
||||
|
||||
// 2. 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(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Create provider for issue operations
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
// 4. Find issue (by ID or auto-pick)
|
||||
let issue: Issue;
|
||||
let currentLabel: StateLabel;
|
||||
|
||||
if (issueIdParam !== undefined) {
|
||||
// Explicit issue ID provided
|
||||
issue = await provider.getIssue(issueIdParam);
|
||||
const label = provider.getCurrentStateLabel(issue);
|
||||
if (!label) {
|
||||
throw new Error(
|
||||
`Issue #${issueIdParam} has no recognized state label. Expected one of: ${PRIORITY_ORDER.join(", ")}`,
|
||||
);
|
||||
}
|
||||
currentLabel = label;
|
||||
} else {
|
||||
// Auto-pick next issue by priority
|
||||
const next = await findNextIssue(provider, roleParam);
|
||||
if (!next) {
|
||||
const roleFilter = roleParam ? ` for ${roleParam.toUpperCase()}` : "";
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: `No issues available${roleFilter}. Queue is empty.`,
|
||||
checkedLabels: roleParam
|
||||
? PRIORITY_ORDER.filter((l) =>
|
||||
roleParam === "dev"
|
||||
? DEV_LABELS.includes(l)
|
||||
: QA_LABELS.includes(l),
|
||||
)
|
||||
: PRIORITY_ORDER,
|
||||
});
|
||||
}
|
||||
issue = next.issue;
|
||||
currentLabel = next.label;
|
||||
}
|
||||
|
||||
// 5. Auto-detect role from issue label if not provided
|
||||
const detectedRole = detectRoleFromLabel(currentLabel);
|
||||
if (!detectedRole) {
|
||||
throw new Error(
|
||||
`Issue #${issue.iid} has label "${currentLabel}" which doesn't map to dev or qa. Expected: ${[...DEV_LABELS, ...QA_LABELS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleParam ?? detectedRole;
|
||||
|
||||
// Verify role matches label (if role was explicitly provided)
|
||||
if (roleParam && roleParam !== detectedRole) {
|
||||
throw new Error(
|
||||
`Role mismatch: issue #${issue.iid} has label "${currentLabel}" (${detectedRole.toUpperCase()}) but role "${roleParam}" was requested.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 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}). Complete current task first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6b. Check project-level roleExecution
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
if (roleExecution === "sequential") {
|
||||
const otherRole = role === "dev" ? "qa" : "dev";
|
||||
const otherWorker = getWorker(project, otherRole);
|
||||
if (otherWorker.active) {
|
||||
throw new Error(
|
||||
`Project "${project.name}" has sequential roleExecution: ${otherRole.toUpperCase()} worker is active (issue: ${otherWorker.issueId}). Wait for it to complete first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Select model (priority: param > tier label > heuristic)
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let modelAlias: string;
|
||||
let modelReason: string;
|
||||
let modelSource: string;
|
||||
|
||||
if (modelParam) {
|
||||
// Explicit model param
|
||||
modelAlias = modelParam;
|
||||
modelReason = "LLM-selected by orchestrator";
|
||||
modelSource = "llm";
|
||||
} else {
|
||||
// Check for tier labels on the issue
|
||||
const tierFromLabels = detectTierFromLabels(issue.labels);
|
||||
|
||||
if (tierFromLabels) {
|
||||
// Validate tier matches role
|
||||
if (role === "qa" && tierFromLabels !== "qa") {
|
||||
// QA role should use qa tier, ignore dev tier labels
|
||||
modelAlias = "qa";
|
||||
modelReason = `QA role overrides tier label "${tierFromLabels}"`;
|
||||
modelSource = "role-override";
|
||||
} else if (role === "dev" && tierFromLabels === "qa") {
|
||||
// Dev role shouldn't use qa tier, fall back to heuristic
|
||||
const selected = selectModel(
|
||||
issue.title,
|
||||
issue.description ?? "",
|
||||
role,
|
||||
);
|
||||
modelAlias = selected.tier;
|
||||
modelReason = `Ignored "qa" tier label for DEV role; ${selected.reason}`;
|
||||
modelSource = "heuristic";
|
||||
} else {
|
||||
modelAlias = tierFromLabels;
|
||||
modelReason = `Tier label found on issue: "${tierFromLabels}"`;
|
||||
modelSource = "label";
|
||||
}
|
||||
} else {
|
||||
// Fall back to keyword heuristic
|
||||
const selected = selectModel(
|
||||
issue.title,
|
||||
issue.description ?? "",
|
||||
role,
|
||||
);
|
||||
modelAlias = selected.tier;
|
||||
modelReason = selected.reason;
|
||||
modelSource = "heuristic";
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Dispatch via shared logic
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const dispatchResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
modelAlias,
|
||||
fromLabel: currentLabel,
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
// 8b. Explicitly update worker state in projects.json
|
||||
// Defense in depth: ensure state is set even if dispatchTask had issues
|
||||
const now = new Date().toISOString();
|
||||
const stateUpdateParams: {
|
||||
issueId: string;
|
||||
model: string;
|
||||
sessionKey?: string;
|
||||
startTime?: string;
|
||||
} = {
|
||||
issueId: String(issue.iid),
|
||||
model: modelAlias,
|
||||
};
|
||||
|
||||
// Only set sessionKey and startTime on new spawn (not on reuse)
|
||||
if (dispatchResult.sessionAction === "spawn") {
|
||||
stateUpdateParams.sessionKey = dispatchResult.sessionKey;
|
||||
stateUpdateParams.startTime = now;
|
||||
}
|
||||
|
||||
await activateWorker(workspaceDir, groupId, role, stateUpdateParams);
|
||||
|
||||
// 9. Send notification to project group
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{
|
||||
type: "workerStart",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: context.channel,
|
||||
},
|
||||
);
|
||||
|
||||
// 10. Build result
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
fullModel: dispatchResult.fullModel,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
announcement: dispatchResult.announcement,
|
||||
labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelReason,
|
||||
modelSource,
|
||||
autoDetected: {
|
||||
projectGroupId: !groupIdParam,
|
||||
role: !roleParam,
|
||||
issueId: issueIdParam === undefined,
|
||||
model: !modelParam,
|
||||
},
|
||||
};
|
||||
|
||||
if (dispatchResult.sessionAction === "send") {
|
||||
result.tokensSavedEstimate = "~50K (session reuse)";
|
||||
}
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../task-managers/task-manager.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
|
||||
const STATE_LABELS: StateLabel[] = [
|
||||
"Planning",
|
||||
|
||||
120
lib/tools/work-finish.ts
Normal file
120
lib/tools/work-finish.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* work_finish — Complete a task (DEV done, QA pass/fail/refine/blocked).
|
||||
*
|
||||
* Delegates side-effects to pipeline service, then handles notifications,
|
||||
* audit, and optional auto-chain dispatch.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { resolveRepoPath, readProjects, getProject, getWorker, getSessionForModel } from "../projects.js";
|
||||
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_finish",
|
||||
label: "Work Finish",
|
||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit. With auto-scheduling, dispatches the next step automatically.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
properties: {
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||
projectGroupId: { type: "string", description: "Project group ID" },
|
||||
summary: { type: "string", description: "Brief summary" },
|
||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa";
|
||||
const result = params.result as string;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
const prUrl = params.prUrl as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
// Validate role:result
|
||||
if (role === "dev" && result !== "done" && result !== "blocked")
|
||||
throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`);
|
||||
if (role === "qa" && result === "done")
|
||||
throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`);
|
||||
if (!getRule(role, result))
|
||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||
|
||||
// Resolve project + worker
|
||||
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 not active on ${project.name}`);
|
||||
|
||||
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
|
||||
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
|
||||
// Execute completion (pipeline service)
|
||||
const completion = await executeCompletion({
|
||||
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
||||
});
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
success: true, project: project.name, groupId, issueId, role, result,
|
||||
...completion,
|
||||
};
|
||||
|
||||
// Auto-chain dispatch
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const scheduling = (pluginConfig?.scheduling as string) ?? "auto";
|
||||
|
||||
if (scheduling === "auto") {
|
||||
const chainRole = result === "done" ? "qa" : result === "fail" ? "dev" : null;
|
||||
if (chainRole) {
|
||||
const chainModel = chainRole === "qa" ? "qa" : (getWorker(project, "dev").model ?? "medior");
|
||||
try {
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project, issueId,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role: chainRole, modelAlias: chainModel,
|
||||
fromLabel: result === "done" ? "To Test" : "To Improve",
|
||||
toLabel: chainRole === "qa" ? "Testing" : "Doing",
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
output.autoChain = { dispatched: true, role: chainRole, model: chainResult.modelAlias, announcement: chainResult.announcement };
|
||||
} catch (err) {
|
||||
output.autoChain = { dispatched: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "workerComplete", project: project.name, groupId, issueId, role, result: result as "done" | "pass" | "fail" | "refine" | "blocked", summary, nextState: NEXT_STATE[`${role}:${result}`] },
|
||||
{ workspaceDir, config: notifyConfig, groupId, channel: project.channel ?? "telegram" },
|
||||
);
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "work_finish", {
|
||||
project: project.name, groupId, issue: issueId, role, result,
|
||||
summary: summary ?? null, labelTransition: completion.labelTransition,
|
||||
autoChain: output.autoChain ?? null,
|
||||
});
|
||||
|
||||
return jsonResult(output);
|
||||
},
|
||||
});
|
||||
}
|
||||
176
lib/tools/work-start.ts
Normal file
176
lib/tools/work-start.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* work_start — Pick up a task from the issue queue.
|
||||
*
|
||||
* Context-aware: ONLY works in project group chats.
|
||||
* Auto-detects: projectGroupId, role, model, issueId.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { activateWorker, getProject, getWorker, readProjects } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { isDevTier, isTier, type Tier } from "../tiers.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
return TIER_LABELS.find((t) => lower.includes(t)) ?? null;
|
||||
}
|
||||
|
||||
async function findNextIssue(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role?: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role === "dev" ? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: role === "qa" ? PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l))
|
||||
: PRIORITY_ORDER;
|
||||
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_start",
|
||||
label: "Work Start",
|
||||
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, and audit.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." },
|
||||
projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." },
|
||||
model: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
||||
const groupIdParam = params.projectGroupId as string | undefined;
|
||||
const modelParam = params.model as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
// Context guard: group only
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "work_start can only be used in project group chats.",
|
||||
recommendation: context.type === "via-agent" ? "Use onboard instead for setup." : "Use the relevant project group.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const groupId = groupIdParam ?? context.groupId;
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
if (!project) throw new Error(`Project not found for groupId: ${groupId}`);
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Find issue
|
||||
let issue: Issue;
|
||||
let currentLabel: StateLabel;
|
||||
if (issueIdParam !== undefined) {
|
||||
issue = await provider.getIssue(issueIdParam);
|
||||
const label = provider.getCurrentStateLabel(issue);
|
||||
if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`);
|
||||
currentLabel = label;
|
||||
} else {
|
||||
const next = await findNextIssue(provider, roleParam);
|
||||
if (!next) return jsonResult({ success: false, error: `No issues available. Queue is empty.` });
|
||||
issue = next.issue;
|
||||
currentLabel = next.label;
|
||||
}
|
||||
|
||||
// Detect role
|
||||
const detectedRole = detectRoleFromLabel(currentLabel);
|
||||
if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`);
|
||||
const role = roleParam ?? detectedRole;
|
||||
if (roleParam && roleParam !== detectedRole) throw new Error(`Role mismatch: "${currentLabel}" → ${detectedRole}, requested ${roleParam}`);
|
||||
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
||||
const other = role === "dev" ? "qa" : "dev";
|
||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
||||
}
|
||||
|
||||
// Select model
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let modelAlias: string, modelReason: string, modelSource: string;
|
||||
if (modelParam) {
|
||||
modelAlias = modelParam; modelReason = "LLM-selected"; modelSource = "llm";
|
||||
} else {
|
||||
const tier = detectTierFromLabels(issue.labels);
|
||||
if (tier) {
|
||||
if (role === "qa" && tier !== "qa") { modelAlias = "qa"; modelReason = `QA overrides "${tier}"`; modelSource = "role-override"; }
|
||||
else if (role === "dev" && tier === "qa") { const s = selectModel(issue.title, issue.description ?? "", role); modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic"; }
|
||||
else { modelAlias = tier; modelReason = `Label: "${tier}"`; modelSource = "label"; }
|
||||
} else {
|
||||
const s = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic";
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
// Ensure worker state
|
||||
const stateUpdate: { issueId: string; model: string; sessionKey?: string; startTime?: string } = {
|
||||
issueId: String(issue.iid), model: modelAlias,
|
||||
};
|
||||
if (dr.sessionAction === "spawn") {
|
||||
stateUpdate.sessionKey = dr.sessionKey;
|
||||
stateUpdate.startTime = new Date().toISOString();
|
||||
}
|
||||
await activateWorker(workspaceDir, groupId, role, stateUpdate);
|
||||
|
||||
// Notify
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, model: dr.modelAlias, sessionAction: dr.sessionAction },
|
||||
{ workspaceDir, config: notifyConfig, groupId, channel: context.channel },
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
||||
role, model: dr.modelAlias, fullModel: dr.fullModel, sessionAction: dr.sessionAction,
|
||||
announcement: dr.announcement, labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelReason, modelSource,
|
||||
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, model: !modelParam },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user