- Replaced raw model aliases with developer tiers (junior, medior, senior, qa) in dispatch and model selection logic. - Updated `dispatchTask` to resolve models based on tiers and plugin configuration. - Modified `selectModel` to return tier names instead of model aliases based on task description. - Implemented migration logic for transitioning from old model aliases to new tier names in worker state. - Added setup logic for agent creation and model configuration in `setup.ts`. - Created shared templates for workspace files and instructions for DEV/QA workers. - Enhanced project registration to scaffold role files based on developer tiers. - Updated task management tools to reflect changes in model selection and tier assignment. - Introduced a new `devclaw_setup` tool for agent-driven setup and configuration. - Updated plugin configuration schema to support model mapping per developer tier.
239 lines
6.7 KiB
TypeScript
239 lines
6.7 KiB
TypeScript
/**
|
|
* 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";
|
|
import { resolveModel, TIER_EMOJI, isTier } from "./tiers.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export type DispatchOpts = {
|
|
workspaceDir: string;
|
|
agentId: string;
|
|
groupId: string;
|
|
project: Project;
|
|
issueId: number;
|
|
issueTitle: string;
|
|
issueDescription: string;
|
|
issueUrl: string;
|
|
role: "dev" | "qa";
|
|
/** Developer tier (junior, medior, senior, qa) or raw model ID */
|
|
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>;
|
|
/** Plugin config for model resolution */
|
|
pluginConfig?: Record<string, unknown>;
|
|
};
|
|
|
|
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,
|
|
pluginConfig,
|
|
} = opts;
|
|
|
|
const fullModel = resolveModel(modelAlias, pluginConfig);
|
|
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,
|
|
tier: modelAlias,
|
|
sessionAction,
|
|
sessionKey,
|
|
labelTransition: `${fromLabel} → ${toLabel}`,
|
|
});
|
|
|
|
await auditLog(workspaceDir, "model_selection", {
|
|
issue: issueId,
|
|
role,
|
|
tier: modelAlias,
|
|
fullModel,
|
|
});
|
|
|
|
// Build announcement
|
|
const emoji = isTier(modelAlias)
|
|
? TIER_EMOJI[modelAlias]
|
|
: (role === "qa" ? "🔍" : "🔧");
|
|
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
|
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;
|
|
|
|
return {
|
|
sessionAction,
|
|
sessionKey: sessionKey!,
|
|
modelAlias,
|
|
fullModel,
|
|
announcement,
|
|
};
|
|
}
|