feat: Implement GitLabProvider for issue management using glab CLI

- Add GitLabProvider class for handling issue operations, label management, and MR checks.
- Implement methods for ensuring labels, creating issues, listing issues by label, and transitioning labels.
- Introduce a provider factory to auto-detect GitLab or GitHub based on the repository URL.
- Create project registration tool to validate repositories, create state labels, and log project entries.
- Enhance queue status and session health tools to support new session management features.
- Update task completion and task creation tools to support auto-chaining and improved session handling.
- Refactor task pickup tool to streamline model selection and session management.
This commit is contained in:
Lauren ten Hoor
2026-02-09 12:54:50 +08:00
parent d921b5c7bb
commit 8a79755e4c
16 changed files with 1578 additions and 242 deletions

240
lib/dispatch.ts Normal file
View File

@@ -0,0 +1,240 @@
/**
* dispatch.ts — Core dispatch logic shared by task_pickup and task_complete (auto-chain).
*
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
* state update (activateWorker), and audit logging.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
type Project,
type WorkerState,
getWorker,
getSessionForModel,
activateWorker,
} from "./projects.js";
import { selectModel } from "./model-selector.js";
import { log as auditLog } from "./audit.js";
const execFileAsync = promisify(execFile);
export const MODEL_MAP: Record<string, string> = {
haiku: "anthropic/claude-haiku-4-5",
sonnet: "anthropic/claude-sonnet-4-5",
opus: "anthropic/claude-opus-4-5",
grok: "github-copilot/grok-code-fast-1",
};
export type DispatchOpts = {
workspaceDir: string;
agentId: string;
groupId: string;
project: Project;
issueId: number;
issueTitle: string;
issueDescription: string;
issueUrl: string;
role: "dev" | "qa";
modelAlias: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string;
/** Label to transition TO (e.g. "Doing", "Testing") */
toLabel: string;
/** Function to transition labels (injected to avoid gitlab.ts dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
};
export type DispatchResult = {
sessionAction: "spawn" | "send";
sessionKey: string;
modelAlias: string;
fullModel: string;
announcement: string;
};
/**
* Build the task message sent to a worker session.
* Reads role-specific instructions from workspace/roles/<project>/<role>.md
* with fallback to workspace/roles/default/<role>.md.
*/
async function buildTaskMessage(opts: {
workspaceDir: string;
projectName: string;
role: "dev" | "qa";
issueId: number;
issueTitle: string;
issueDescription: string;
issueUrl: string;
repo: string;
baseBranch: string;
groupId: string;
}): Promise<string> {
const { workspaceDir, projectName, role, issueId, issueTitle, issueDescription, issueUrl, repo, baseBranch, groupId } = opts;
// Read role-specific instructions
let roleInstructions = "";
const projectRoleFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
const defaultRoleFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
try {
roleInstructions = await fs.readFile(projectRoleFile, "utf-8");
} catch {
try {
roleInstructions = await fs.readFile(defaultRoleFile, "utf-8");
} catch {
// No role instructions — that's fine
}
}
const parts = [
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
``,
issueTitle,
issueDescription ? `\n${issueDescription}` : "",
``,
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
`Project group ID: ${groupId}`,
];
if (roleInstructions) {
parts.push(``, `---`, ``, roleInstructions.trim());
}
return parts.join("\n");
}
/**
* Dispatch a task to a worker session. Handles session spawn/reuse,
* CLI dispatch, state update, and audit logging.
*
* Returns dispatch result on success. Throws on dispatch failure
* (with label rollback). Logs warning on state update failure
* (dispatch succeeded, session IS running).
*/
export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult> {
const {
workspaceDir, agentId, groupId, project, issueId,
issueTitle, issueDescription, issueUrl,
role, modelAlias, fromLabel, toLabel, transitionLabel,
} = opts;
const fullModel = MODEL_MAP[modelAlias] ?? modelAlias;
const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias);
const sessionAction = existingSessionKey ? "send" : "spawn";
// Build task message with role instructions
const taskMessage = await buildTaskMessage({
workspaceDir,
projectName: project.name,
role,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo: project.repo,
baseBranch: project.baseBranch,
groupId,
});
// Transition label
await transitionLabel(issueId, fromLabel, toLabel);
// Dispatch
let sessionKey = existingSessionKey;
let dispatched = false;
try {
if (sessionAction === "spawn") {
sessionKey = `agent:${agentId}:subagent:${randomUUID()}`;
await execFileAsync("openclaw", [
"gateway", "call", "sessions.patch",
"--data", JSON.stringify({ key: sessionKey, model: fullModel }),
], { timeout: 30_000 });
}
await execFileAsync("openclaw", [
"agent",
"--session-id", sessionKey!,
"--message", taskMessage,
], { timeout: 60_000 });
dispatched = true;
// Update state
const now = new Date().toISOString();
if (sessionAction === "spawn") {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
sessionKey: sessionKey!,
startTime: now,
});
} else {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
});
}
} catch (err) {
if (dispatched) {
// State update failed but session IS running — log warning, don't rollback
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
groupId,
issue: issueId,
role,
warning: "State update failed after successful dispatch",
error: (err as Error).message,
sessionKey,
});
} else {
// Dispatch failed — rollback label
try {
await transitionLabel(issueId, toLabel, fromLabel);
} catch {
// Best-effort rollback
}
throw new Error(
`Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`,
);
}
}
// Audit
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
groupId,
issue: issueId,
issueTitle,
role,
model: modelAlias,
sessionAction,
sessionKey,
labelTransition: `${fromLabel}${toLabel}`,
});
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: modelAlias,
fullModel,
});
// Build announcement
const emoji = role === "dev"
? (modelAlias === "haiku" ? "⚡" : modelAlias === "opus" ? "🧠" : "🔧")
: "🔍";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;
return {
sessionAction,
sessionKey: sessionKey!,
modelAlias,
fullModel,
announcement,
};
}

80
lib/issue-provider.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* IssueProvider — Abstract interface for issue tracker operations.
*
* GitLab is the first implementation (via glab CLI).
* Future providers: GitHub (via gh CLI), 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 IssueProvider {
/** 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>;
/** Verify the provider is working (CLI available, auth valid, repo accessible). */
healthCheck(): Promise<boolean>;
}

View File

@@ -7,10 +7,10 @@ import path from "node:path";
export type WorkerState = {
active: boolean;
sessionId: string | null;
issueId: string | null;
startTime: string | null;
model: string | null;
sessions: Record<string, string | null>;
};
export type Project = {
@@ -20,6 +20,7 @@ export type Project = {
deployUrl: string;
baseBranch: string;
deployBranch: string;
autoChain: boolean;
dev: WorkerState;
qa: WorkerState;
};
@@ -28,13 +29,84 @@ export type ProjectsData = {
projects: Record<string, Project>;
};
/**
* Migrate old WorkerState schema (sessionId field) to new sessions map.
* Called transparently on read — old data is converted in memory,
* persisted on next write.
*/
function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Already migrated — has sessions map
if (worker.sessions && typeof worker.sessions === "object") {
return worker as unknown as WorkerState;
}
// Old schema: { sessionId, model, ... }
const sessionId = worker.sessionId as string | null;
const model = worker.model as string | null;
const sessions: Record<string, string | null> = {};
if (sessionId && model) {
sessions[model] = sessionId;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model,
sessions,
};
}
/**
* Create a blank WorkerState with null sessions for given model aliases.
*/
export function emptyWorkerState(aliases: string[]): WorkerState {
const sessions: Record<string, string | null> = {};
for (const alias of aliases) {
sessions[alias] = null;
}
return {
active: false,
issueId: null,
startTime: null,
model: null,
sessions,
};
}
/**
* Get session key for a specific model alias from a worker's sessions map.
*/
export function getSessionForModel(
worker: WorkerState,
modelAlias: string,
): string | null {
return worker.sessions[modelAlias] ?? null;
}
function projectsPath(workspaceDir: string): string {
return path.join(workspaceDir, "memory", "projects.json");
}
export async function readProjects(workspaceDir: string): Promise<ProjectsData> {
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
return JSON.parse(raw) as ProjectsData;
const data = JSON.parse(raw) as ProjectsData;
// Migrate any old-schema or missing fields transparently
for (const project of Object.values(data.projects)) {
project.dev = project.dev
? migrateWorkerState(project.dev as unknown as Record<string, unknown>)
: emptyWorkerState([]);
project.qa = project.qa
? migrateWorkerState(project.qa as unknown as Record<string, unknown>)
: emptyWorkerState([]);
if (project.autoChain === undefined) {
project.autoChain = false;
}
}
return data;
}
export async function writeProjects(
@@ -79,6 +151,10 @@ export async function updateWorker(
}
const worker = project[role];
// Merge sessions maps if both exist
if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions };
}
project[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data);
@@ -87,7 +163,7 @@ export async function updateWorker(
/**
* Mark a worker as active with a new task.
* Sets active=true, issueId, model. Preserves sessionId and startTime if reusing.
* Sets active=true, issueId, model. Stores session key in sessions[model].
*/
export async function activateWorker(
workspaceDir: string,
@@ -96,7 +172,7 @@ export async function activateWorker(
params: {
issueId: string;
model: string;
sessionId?: string;
sessionKey?: string;
startTime?: string;
},
): Promise<ProjectsData> {
@@ -105,9 +181,9 @@ export async function activateWorker(
issueId: params.issueId,
model: params.model,
};
// Only set sessionId and startTime if provided (new spawn)
if (params.sessionId !== undefined) {
updates.sessionId = params.sessionId;
// Store session key in the sessions map for this model
if (params.sessionKey !== undefined) {
updates.sessions = { [params.model]: params.sessionKey };
}
if (params.startTime !== undefined) {
updates.startTime = params.startTime;
@@ -117,7 +193,7 @@ export async function activateWorker(
/**
* Mark a worker as inactive after task completion.
* Clears issueId and active, PRESERVES sessionId, model, startTime for reuse.
* Clears issueId and active, PRESERVES sessions map, model, startTime for reuse.
*/
export async function deactivateWorker(
workspaceDir: string,

211
lib/providers/github.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* 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 IssueProvider,
type Issue,
type StateLabel,
STATE_LABELS,
LABEL_COLORS,
} from "../issue-provider.js";
const execFileAsync = promisify(execFile);
export type GitHubProviderOptions = {
ghPath?: string;
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 IssueProvider {
private ghPath: string;
private repoPath: string;
constructor(opts: GitHubProviderOptions) {
this.ghPath = opts.ghPath ?? "gh";
this.repoPath = opts.repoPath;
}
private async gh(args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.ghPath, 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> {
await this.gh([
"issue", "edit", String(issueId),
"--remove-label", from,
"--add-label", to,
]);
}
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 healthCheck(): Promise<boolean> {
try {
await this.gh(["auth", "status"]);
return true;
} catch {
return false;
}
}
}

174
lib/providers/gitlab.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* 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 IssueProvider,
type Issue,
type StateLabel,
STATE_LABELS,
LABEL_COLORS,
} from "../issue-provider.js";
const execFileAsync = promisify(execFile);
export type GitLabProviderOptions = {
glabPath?: string;
repoPath: string;
};
export class GitLabProvider implements IssueProvider {
private glabPath: string;
private repoPath: string;
constructor(opts: GitLabProviderOptions) {
this.glabPath = opts.glabPath ?? "glab";
this.repoPath = opts.repoPath;
}
private async glab(args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.glabPath, 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 = `${this.glabPath} issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}" --output json`;
if (assignees && assignees.length > 0) {
cmd += ` --assignee "${assignees.join(",")}"`;
}
const { stdout } = await execAsync(cmd, {
cwd: this.repoPath,
timeout: 30_000,
});
return JSON.parse(stdout.trim()) as Issue;
} 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> {
await this.glab([
"issue", "update", String(issueId),
"--unlabel", from,
"--label", to,
]);
}
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 healthCheck(): Promise<boolean> {
try {
await this.glab(["auth", "status"]);
return true;
} catch {
return false;
}
}
}

54
lib/providers/index.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* 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 { IssueProvider } from "../issue-provider.js";
import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js";
export type ProviderOptions = {
provider?: "gitlab" | "github";
glabPath?: string;
ghPath?: 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: IssueProvider;
type: "github" | "gitlab";
};
export function createProvider(opts: ProviderOptions): ProviderWithType {
const type = opts.provider ?? detectProvider(opts.repoPath);
if (type === "github") {
return {
provider: new GitHubProvider({ ghPath: opts.ghPath, repoPath: opts.repoPath }),
type: "github",
};
}
return {
provider: new GitLabProvider({ glabPath: opts.glabPath, repoPath: opts.repoPath }),
type: "gitlab",
};
}

View File

@@ -0,0 +1,230 @@
/**
* project_register — Register a new project with DevClaw.
*
* Atomically: validates repo, detects GitHub/GitLab provider, creates all 8 state labels (idempotent),
* adds project entry to projects.json, and logs the event.
*
* Replaces the manual steps of running glab/gh label create + editing projects.json.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import fs from "node:fs/promises";
import path from "node:path";
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
/**
* Ensure default role files exist, then copy them into the project's role directory.
* Returns true if files were created, false if they already existed.
*/
async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Promise<boolean> {
const defaultDir = path.join(workspaceDir, "roles", "default");
const projectDir = path.join(workspaceDir, "roles", projectName);
// Ensure default role files exist
await fs.mkdir(defaultDir, { recursive: true });
const defaultDev = path.join(defaultDir, "dev.md");
const defaultQa = path.join(defaultDir, "qa.md");
try {
await fs.access(defaultDev);
} catch {
await fs.writeFile(defaultDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
}
try {
await fs.access(defaultQa);
} catch {
await fs.writeFile(defaultQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
}
// Create project-specific role files (copy from default if not exist)
await fs.mkdir(projectDir, { recursive: true });
const projectDev = path.join(projectDir, "dev.md");
const projectQa = path.join(projectDir, "qa.md");
let created = false;
try {
await fs.access(projectDev);
} catch {
await fs.copyFile(defaultDev, projectDev);
created = true;
}
try {
await fs.access(projectQa);
} catch {
await fs.copyFile(defaultQa, projectQa);
created = true;
}
return created;
}
const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
- 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
- Clean up the worktree after merging
- When done, call task_complete 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
`;
const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Pull latest from the base branch
- 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:
- 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
`;
export function createProjectRegisterTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "project_register",
description: `Register a new project with DevClaw. Creates all required state labels (idempotent) and adds the project to projects.json. One-time setup per project. Auto-detects GitHub/GitLab from git remote.`,
parameters: {
type: "object",
required: ["projectGroupId", "name", "repo", "groupName", "baseBranch"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram group ID (will be the key in projects.json)",
},
name: {
type: "string",
description: "Short project name (e.g. 'my-webapp')",
},
repo: {
type: "string",
description: "Path to git repo (e.g. '~/git/my-project')",
},
groupName: {
type: "string",
description: "Telegram group display name (e.g. 'Dev - My Project')",
},
baseBranch: {
type: "string",
description: "Base branch for development (e.g. 'development', 'main')",
},
deployBranch: {
type: "string",
description: "Branch that triggers deployment. Defaults to baseBranch.",
},
deployUrl: {
type: "string",
description: "Deployment URL for the project",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const name = params.name as string;
const repo = params.repo as string;
const groupName = params.groupName as string;
const baseBranch = params.baseBranch as string;
const deployBranch = (params.deployBranch as string) ?? baseBranch;
const deployUrl = (params.deployUrl as string) ?? "";
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Check project not already registered (allow re-register if incomplete)
const data = await readProjects(workspaceDir);
const existing = data.projects[groupId];
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
throw new Error(
`Project already registered for groupId ${groupId}: "${existing.name}". Use a different group ID or remove the existing entry first.`,
);
}
// 2. Resolve repo path
const repoPath = resolveRepoPath(repo);
// 3. Create provider and verify it works
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const ghPath = (api.pluginConfig as Record<string, unknown>)?.ghPath as string | undefined;
const { provider, type: providerType } = createProvider({ glabPath, ghPath, repoPath });
const healthy = await provider.healthCheck();
if (!healthy) {
const cliName = providerType === "github" ? "gh" : "glab";
const cliInstallUrl = providerType === "github"
? "https://cli.github.com"
: "https://gitlab.com/gitlab-org/cli";
throw new Error(
`${providerType.toUpperCase()} health check failed for ${repoPath}. ` +
`Detected provider: ${providerType}. ` +
`Ensure '${cliName}' CLI is installed, authenticated (${cliName} auth status), ` +
`and the repo has a ${providerType.toUpperCase()} remote. ` +
`Install ${cliName} from: ${cliInstallUrl}`
);
}
// 4. Create all 8 state labels (idempotent)
await provider.ensureAllStateLabels();
// 5. Add project to projects.json
data.projects[groupId] = {
name,
repo,
groupName,
deployUrl,
baseBranch,
deployBranch,
autoChain: false,
dev: emptyWorkerState(["haiku", "sonnet", "opus"]),
qa: emptyWorkerState(["grok"]),
};
await writeProjects(workspaceDir, data);
// 6. Scaffold role files
const rolesCreated = await scaffoldRoleFiles(workspaceDir, name);
// 7. Audit log
await auditLog(workspaceDir, "project_register", {
project: name,
groupId,
repo,
baseBranch,
deployBranch,
deployUrl: deployUrl || null,
});
// 8. Return announcement
const rolesNote = rolesCreated ? " Role files scaffolded." : "";
const announcement = `📋 Project "${name}" registered for group ${groupName}. Labels created.${rolesNote} Ready for tasks.`;
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
project: name,
groupId,
repo,
baseBranch,
deployBranch,
labelsCreated: 8,
rolesScaffolded: rolesCreated,
announcement,
}, null, 2),
}],
};
},
});
}

View File

@@ -63,15 +63,15 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
groupId: pid,
dev: {
active: project.dev.active,
sessionId: project.dev.sessionId,
issueId: project.dev.issueId,
model: project.dev.model,
sessions: project.dev.sessions,
},
qa: {
active: project.qa.active,
sessionId: project.qa.sessionId,
issueId: project.qa.issueId,
model: project.qa.model,
sessions: project.qa.sessions,
},
queue: {
toImprove: queue["To Improve"],

View File

@@ -2,21 +2,17 @@
* session_health — Check and fix session state consistency.
*
* Detects zombie sessions (active=true but session dead) and stale workers.
* Replaces manual HEARTBEAT.md step 1.
*
* NOTE: This tool checks projects.json state only. The agent should verify
* session liveness via sessions_list and pass the results. The tool cannot
* call sessions_list directly (it's an agent-level tool).
* Checks the sessions map for each worker's current model.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, updateWorker } from "../projects.js";
import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
import { log as auditLog } from "../audit.js";
export function createSessionHealthTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "session_health",
description: `Check session state consistency across all projects. Detects: active workers with dead sessions, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`,
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: {
@@ -53,16 +49,20 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
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 sessionId
if (worker.active && !worker.sessionId) {
// 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,
message: `${role.toUpperCase()} marked active but has no sessionId`,
model: worker.model,
message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`,
};
if (autoFix) {
@@ -76,12 +76,12 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
issues.push(issue);
}
// Check 2: Active with sessionId but session is dead (zombie)
// Check 2: Active with session but session is dead (zombie)
if (
worker.active &&
worker.sessionId &&
currentSessionKey &&
activeSessions.length > 0 &&
!activeSessions.includes(worker.sessionId)
!activeSessions.includes(currentSessionKey)
) {
const issue: Record<string, unknown> = {
type: "zombie_session",
@@ -89,8 +89,9 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
project: project.name,
groupId,
role,
sessionId: worker.sessionId,
message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`,
sessionKey: currentSessionKey,
model: worker.model,
message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`,
};
if (autoFix) {
@@ -107,9 +108,16 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
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++;
@@ -131,7 +139,7 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
groupId,
role,
hoursActive: Math.round(hoursActive * 10) / 10,
sessionId: worker.sessionId,
sessionKey: currentSessionKey,
issueId: worker.issueId,
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
});

View File

@@ -1,16 +1,20 @@
/**
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
*
* Handles: validation, GitLab label transition, projects.json state update,
* issue close/reopen, and audit logging.
* 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 (default model: grok)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV model)
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
readProjects,
getProject,
getWorker,
getSessionForModel,
deactivateWorker,
activateWorker,
} from "../projects.js";
import {
getIssue,
@@ -20,8 +24,8 @@ import {
resolveRepoPath,
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -30,7 +34,7 @@ const execFileAsync = promisify(execFile);
export function createTaskCompleteTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_complete",
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. For QA fail, also prepares DEV session instructions for the fix cycle.`,
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix).`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
@@ -101,7 +105,6 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === DEV DONE ===
if (role === "dev" && result === "done") {
// Pull latest on the project repo
try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
output.gitPull = "success";
@@ -109,22 +112,49 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
output.gitPull = `warning: ${(err as Error).message}`;
}
// Deactivate DEV (preserves sessionId, model, startTime)
await deactivateWorker(workspaceDir, groupId, "dev");
// Transition label: Doing → To Test
await transitionLabel(issueId, "Doing", "To Test", glabOpts);
output.labelTransition = "Doing → To Test";
output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`;
if (project.autoChain) {
try {
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
groupId,
project,
issueId,
issueTitle: issue.title,
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role: "qa",
modelAlias: "grok",
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
});
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") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Done, close issue
await transitionLabel(issueId, "Testing", "Done", glabOpts);
await closeIssue(issueId, glabOpts);
@@ -135,44 +165,57 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === QA FAIL ===
if (role === "qa" && result === "fail") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → To Improve, reopen issue
await transitionLabel(issueId, "Testing", "To Improve", glabOpts);
await reopenIssue(issueId, glabOpts);
// Prepare DEV fix cycle
const issue = await getIssue(issueId, glabOpts);
const devModel = selectModel(issue.title, issue.description ?? "", "dev");
const devWorker = getWorker(project, "dev");
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 DEV session exists, prepare reuse instructions
if (devWorker.sessionId) {
output.devFixInstructions =
`Send QA feedback to existing DEV session ${devWorker.sessionId}. ` +
`If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` +
`Then sessions_send with QA failure details. ` +
`DEV will pick up from To Improve → Doing automatically.`;
output.devSessionId = devWorker.sessionId;
output.devModel = devModel.alias;
if (project.autoChain && devModel) {
try {
const issue = await getIssue(issueId, glabOpts);
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) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
});
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.devFixInstructions =
`No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`;
output.devModel = devModel.alias;
output.nextAction = "dev_fix";
}
}
// === QA REFINE ===
if (role === "qa" && result === "refine") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Refining
await transitionLabel(issueId, "Testing", "Refining", glabOpts);
output.labelTransition = "Testing → Refining";
@@ -188,6 +231,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
result,
summary: summary ?? null,
labelTransition: output.labelTransition,
autoChain: output.autoChain ?? null,
});
return {

143
lib/tools/task-create.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* task_create — Create a new task (issue) in the project's issue tracker.
*
* Atomically: creates an issue with the specified title, description, and label.
* Returns the created issue for immediate pickup if desired.
*
* Use this when:
* - You want to create work items from chat
* - A sub-agent finds a bug and needs to file a follow-up issue
* - Breaking down an epic into smaller tasks
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../issue-provider.js";
const STATE_LABELS: StateLabel[] = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
];
export function createTaskCreateTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_create",
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
Examples:
- Simple: { title: "Fix login bug" }
- With body: { title: "Add dark mode", description: "## Why\nUsers want dark mode...\n\n## Acceptance Criteria\n- [ ] Toggle in settings" }
- Ready for dev: { title: "Implement auth", description: "...", label: "To Do", pickup: true }
The issue is created with a state label (defaults to "Planning"). Returns the created issue for immediate pickup.`,
parameters: {
type: "object",
required: ["projectGroupId", "title"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram group ID for the project",
},
title: {
type: "string",
description: "Short, descriptive issue title (e.g., 'Fix login timeout bug')",
},
description: {
type: "string",
description: "Full issue body in markdown. Use for detailed context, acceptance criteria, reproduction steps, links. Supports GitHub-flavored markdown.",
},
label: {
type: "string",
description: `State label for the issue. One of: ${STATE_LABELS.join(", ")}. Defaults to "Planning".`,
enum: STATE_LABELS,
},
assignees: {
type: "array",
items: { type: "string" },
description: "GitHub/GitLab usernames to assign (optional)",
},
pickup: {
type: "boolean",
description: "If true, immediately pick up this issue for DEV after creation. Defaults to false.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const title = params.title as string;
const description = (params.description as string) ?? "";
const label = (params.label as StateLabel) ?? "Planning";
const assignees = (params.assignees as string[] | undefined) ?? [];
const pickup = (params.pickup as boolean) ?? false;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(`Project not found for groupId ${groupId}. Run project_register first.`);
}
// 2. Create provider
const repoPath = resolveRepoPath(project.repo);
const config = api.pluginConfig as Record<string, unknown> | undefined;
const { provider, type: providerType } = createProvider({
glabPath: config?.glabPath as string | undefined,
ghPath: config?.ghPath as string | undefined,
repoPath,
});
// 3. Create the issue
const issue = await provider.createIssue(title, description, label, assignees);
// 4. Audit log
await auditLog(workspaceDir, "task_create", {
project: project.name,
groupId,
issueId: issue.iid,
title,
label,
provider: providerType,
pickup,
});
// 5. Build response
const hasBody = description && description.trim().length > 0;
const result = {
success: true,
issue: {
id: issue.iid,
title: issue.title,
body: hasBody ? description : null,
url: issue.web_url,
label,
},
project: project.name,
provider: providerType,
pickup,
announcement: pickup
? `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Picking up for DEV...`
: `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Ready for pickup when needed.`,
};
return {
content: [{
type: "text" as const,
text: JSON.stringify(result, null, 2),
}],
};
},
});
}

View File

@@ -1,18 +1,15 @@
/**
* task_pickup — Atomically pick up a task from the GitLab queue.
* task_pickup — Atomically pick up a task from the issue queue.
*
* Handles: validation, model selection, GitLab label transition,
* projects.json state update, and audit logging.
* Handles: validation, model selection, then delegates to dispatchTask()
* for label transition, session creation/reuse, task dispatch, state update,
* and audit logging.
*
* Returns structured instructions for the agent to spawn/send a session.
* Model selection is LLM-based: the orchestrator passes a `model` param.
* A keyword heuristic is used as fallback if no model is specified.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
readProjects,
getProject,
getWorker,
activateWorker,
} from "../projects.js";
import { readProjects, getProject, getWorker } from "../projects.js";
import {
getIssue,
getCurrentStateLabel,
@@ -21,25 +18,25 @@ import {
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js";
export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_pickup",
description: `Pick up a task from the GitLab queue for a DEV or QA worker. Atomically handles: label transition, model selection, projects.json update, and audit logging. Returns session action instructions (spawn or send) for the agent to execute.`,
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, model selection, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate model. Returns an announcement for the agent to post — no further session actions needed.`,
parameters: {
type: "object",
required: ["issueId", "role", "projectGroupId"],
properties: {
issueId: { type: "number", description: "GitLab issue ID to pick up" },
issueId: { type: "number", description: "Issue ID to pick up" },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" },
projectGroupId: {
type: "string",
description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
},
modelOverride: {
model: {
type: "string",
description: "Force a specific model alias (e.g. haiku, sonnet, opus, grok). Overrides automatic selection.",
description: "Model alias to use (e.g. haiku, sonnet, opus, grok). The orchestrator should analyze the issue complexity and choose. Falls back to keyword heuristic if omitted.",
},
},
},
@@ -48,7 +45,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const issueId = params.issueId as number;
const role = params.role as "dev" | "qa";
const groupId = params.projectGroupId as string;
const modelOverride = params.modelOverride as string | undefined;
const modelParam = params.model as string | undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
@@ -68,11 +65,11 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const worker = getWorker(project, role);
if (worker.active) {
throw new Error(
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}, session: ${worker.sessionId}). Complete current task first.`,
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}). Complete current task first.`,
);
}
// 3. Fetch issue from GitLab and verify state
// 3. Fetch issue and verify state
const repoPath = resolveRepoPath(project.repo);
const glabOpts = {
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
@@ -82,7 +79,6 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const issue = await getIssue(issueId, glabOpts);
const currentLabel = getCurrentStateLabel(issue);
// Validate label matches expected state for the role
const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"];
const validLabelsForQa: StateLabel[] = ["To Test"];
const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa;
@@ -95,70 +91,40 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
// 4. Select model
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let selectedModel = selectModel(issue.title, issue.description ?? "", role);
if (modelOverride) {
selectedModel = {
model: modelOverride,
alias: modelOverride,
reason: `User override: ${modelOverride}`,
};
}
let modelAlias: string;
let modelReason: string;
let modelSource: string;
// 5. Determine session action (spawn vs reuse)
const existingSessionId = worker.sessionId;
const sessionAction = existingSessionId ? "send" : "spawn";
// 6. Transition GitLab label
await transitionLabel(issueId, currentLabel, targetLabel, glabOpts);
// 7. Update projects.json
const now = new Date().toISOString();
if (sessionAction === "spawn") {
// New spawn — agent will provide sessionId after spawning
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: selectedModel.alias,
startTime: now,
});
if (modelParam) {
modelAlias = modelParam;
modelReason = "LLM-selected by orchestrator";
modelSource = "llm";
} else {
// Reuse existing session — preserve sessionId and startTime
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: selectedModel.alias,
});
const selected = selectModel(issue.title, issue.description ?? "", role);
modelAlias = selected.alias;
modelReason = selected.reason;
modelSource = "heuristic";
}
// 8. Audit log
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
// 5. Dispatch via shared logic
const dispatchResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
groupId,
issue: issueId,
project,
issueId,
issueTitle: issue.title,
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role,
model: selectedModel.alias,
modelReason: selectedModel.reason,
sessionAction,
sessionId: existingSessionId,
labelTransition: `${currentLabel}${targetLabel}`,
modelAlias,
fromLabel: currentLabel,
toLabel: targetLabel,
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
});
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: selectedModel.alias,
fullModel: selectedModel.model,
reason: selectedModel.reason,
override: modelOverride ?? null,
});
// 9. Build announcement and session instructions
const emoji = role === "dev"
? (selectedModel.alias === "haiku" ? "⚡" : selectedModel.alias === "opus" ? "🧠" : "🔧")
: "🔍";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${selectedModel.alias}) for #${issueId}: ${issue.title}`;
// 6. Build result
const result: Record<string, unknown> = {
success: true,
project: project.name,
@@ -166,26 +132,17 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
issueId,
issueTitle: issue.title,
role,
model: selectedModel.alias,
fullModel: selectedModel.model,
modelReason: selectedModel.reason,
sessionAction,
announcement,
model: dispatchResult.modelAlias,
fullModel: dispatchResult.fullModel,
sessionAction: dispatchResult.sessionAction,
announcement: dispatchResult.announcement,
labelTransition: `${currentLabel}${targetLabel}`,
modelReason,
modelSource,
};
if (sessionAction === "send") {
result.sessionId = existingSessionId;
result.instructions =
`Session reuse: send new task to existing session ${existingSessionId}. ` +
`If model "${selectedModel.alias}" differs from current session model, call sessions.patch first to update the model. ` +
`Then call sessions_send with the task description. ` +
`After spawning/sending, update projects.json sessionId if it changed.`;
if (dispatchResult.sessionAction === "send") {
result.tokensSavedEstimate = "~50K (session reuse)";
} else {
result.instructions =
`New session: call sessions_spawn with model "${selectedModel.model}" for this ${role.toUpperCase()} task. ` +
`After spawn completes, call task_pickup_confirm with the returned sessionId to update projects.json.`;
}
return {