Files
devclaw-gitea/lib/dispatch.ts
Lauren ten Hoor aa8e8dbd1b feat: refactor model selection to use developer tiers
- 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.
2026-02-09 13:41:22 +08:00

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,
};
}