Refactor setup and tool helpers for improved modularity and clarity

- Moved setup logic into dedicated files: agent.ts, config.ts, index.ts, workspace.ts.
- Introduced tool-helpers.ts for shared functions across tools, reducing boilerplate.
- Updated tools (status, task-comment, task-create, task-update, work-finish, work-start) to utilize new helper functions for workspace resolution and provider creation.
- Enhanced error handling and context detection in tools.
- Improved project resolution logic to streamline tool execution.
- Added new functionality for agent creation and configuration management in setup.
This commit is contained in:
Lauren ten Hoor
2026-02-10 22:51:35 +08:00
parent 55b062ac76
commit 70af40e986
22 changed files with 768 additions and 953 deletions

View File

@@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js";
import {
type Project,
activateWorker,
getSessionForModel,
getSessionForTier,
getWorker,
} from "./projects.js";
import { TIER_EMOJI, isTier, resolveModel } from "./tiers.js";
import { TIER_EMOJI, isTier, resolveTierToModel } from "./tiers.js";
const execFileAsync = promisify(execFile);
@@ -30,12 +30,12 @@ export type DispatchOpts = {
issueUrl: string;
role: "dev" | "qa";
/** Developer tier (junior, medior, senior, qa) or raw model ID */
modelAlias: string;
tier: 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) */
/** Function to transition labels (injected to avoid provider dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
/** Plugin config for model resolution */
pluginConfig?: Record<string, unknown>;
@@ -46,8 +46,8 @@ export type DispatchOpts = {
export type DispatchResult = {
sessionAction: "spawn" | "send";
sessionKey: string;
modelAlias: string;
fullModel: string;
tier: string;
model: string;
announcement: string;
};
@@ -69,43 +69,12 @@ export async function buildTaskMessage(opts: {
groupId: string;
}): Promise<string> {
const {
workspaceDir,
projectName,
role,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo,
baseBranch,
groupId,
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 roleInstructions = await loadRoleInstructions(workspaceDir, projectName, role);
// Build available results based on role
const availableResults =
role === "dev"
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
@@ -125,11 +94,8 @@ export async function buildTaskMessage(opts: {
parts.push(``, `---`, ``, roleInstructions.trim());
}
// Mandatory completion contract
parts.push(
``,
`---`,
``,
``, `---`, ``,
`## MANDATORY: Task Completion`,
``,
`When you finish this task, you MUST call \`work_finish\` with:`,
@@ -147,188 +113,172 @@ export async function buildTaskMessage(opts: {
}
/**
* Dispatch a task to a worker session. Handles session spawn/reuse,
* CLI dispatch, state update, and audit logging.
* Dispatch a task to a worker session.
*
* Returns dispatch result on success. Throws on dispatch failure
* (with label rollback). Logs warning on state update failure
* (dispatch succeeded, session IS running).
* Flow: resolve model → build message → transition label → spawn/send session
* → update worker state → audit → build announcement.
*
* On dispatch failure: rolls back label transition.
* On state update failure after dispatch: logs warning (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,
workspaceDir, agentId, groupId, project, issueId, issueTitle,
issueDescription, issueUrl, role, tier, fromLabel, toLabel,
transitionLabel, pluginConfig,
} = opts;
const fullModel = resolveModel(modelAlias, pluginConfig);
const model = resolveTierToModel(tier, pluginConfig);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias);
const existingSessionKey = getSessionForTier(worker, tier);
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,
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 ?? "unknown"}:subagent:${project.name}-${role}-${modelAlias}`;
await execFileAsync(
"openclaw",
[
"gateway",
"call",
"sessions.patch",
"--params",
JSON.stringify({ key: sessionKey, model: fullModel }),
],
{ timeout: 30_000 },
);
}
// 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 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;
const gatewayParams = JSON.stringify({
idempotencyKey: `devclaw-${project.name}-${issueId}-${role}-${Date.now()}`,
agentId: agentId ?? "devclaw",
sessionKey: sessionKey!,
message: taskMessage,
deliver: false,
lane: "subagent",
...(orchestratorSessionKey
? { spawnedBy: orchestratorSessionKey }
: {}),
sessionKey = await ensureSession(sessionAction, sessionKey, {
agentId, projectName: project.name, role, tier, model,
});
await sendToAgent(sessionKey!, taskMessage, {
agentId, projectName: project.name, issueId, role,
orchestratorSessionKey: opts.sessionKey,
});
const child = spawn(
"openclaw",
[
"gateway",
"call",
"agent",
"--params",
gatewayParams,
"--expect-final",
"--json",
],
{ detached: true, stdio: "ignore" },
);
child.unref();
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,
});
}
await recordWorkerState(workspaceDir, groupId, role, {
issueId, tier, sessionKey: sessionKey!, sessionAction,
});
} catch (err) {
if (dispatched) {
// State update failed but session IS running — log warning, don't rollback
await auditLog(workspaceDir, "work_start", {
project: project.name,
groupId,
issue: issueId,
role,
project: project.name, groupId, issue: issueId, role,
warning: "State update failed after successful dispatch",
error: (err as Error).message,
sessionKey,
error: (err as Error).message, sessionKey,
});
// Re-throw so caller knows state update failed
throw new Error(
`State update failed after successful session dispatch: ${(err as Error).message}. Session is running but projects.json was not updated.`,
);
} 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}".`,
);
}
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, "work_start", {
project: project.name,
groupId,
issue: issueId,
issueTitle,
role,
tier: modelAlias,
sessionAction,
sessionKey,
labelTransition: `${fromLabel}${toLabel}`,
await auditDispatch(workspaceDir, {
project: project.name, groupId, issueId, issueTitle,
role, tier, model, sessionAction, sessionKey: sessionKey!,
fromLabel, toLabel,
});
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
tier: modelAlias,
fullModel,
});
const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl);
// 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}\n🔗 ${issueUrl}`;
return {
sessionAction,
sessionKey: sessionKey!,
modelAlias,
fullModel,
announcement,
};
return { sessionAction, sessionKey: sessionKey!, tier, model, announcement };
}
// ---------------------------------------------------------------------------
// Private helpers — exist so dispatchTask reads as a sequence of steps
// ---------------------------------------------------------------------------
async function loadRoleInstructions(
workspaceDir: string, projectName: string, role: "dev" | "qa",
): Promise<string> {
const projectFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
const defaultFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* fallback */ }
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
return "";
}
async function ensureSession(
action: "spawn" | "send",
existingKey: string | null,
opts: { agentId?: string; projectName: string; role: string; tier: string; model: string },
): Promise<string> {
if (action === "send") return existingKey!;
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.tier}`;
await execFileAsync(
"openclaw",
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })],
{ timeout: 30_000 },
);
return sessionKey;
}
function sendToAgent(
sessionKey: string, taskMessage: string,
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
): void {
const gatewayParams = JSON.stringify({
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
agentId: opts.agentId ?? "devclaw",
sessionKey,
message: taskMessage,
deliver: false,
lane: "subagent",
...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}),
});
const child = spawn(
"openclaw",
["gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
{ detached: true, stdio: "ignore" },
);
child.unref();
}
async function recordWorkerState(
workspaceDir: string, groupId: string, role: "dev" | "qa",
opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> {
const params: { issueId: string; tier: string; sessionKey?: string; startTime?: string } = {
issueId: String(opts.issueId), tier: opts.tier,
};
if (opts.sessionAction === "spawn") {
params.sessionKey = opts.sessionKey;
params.startTime = new Date().toISOString();
}
await activateWorker(workspaceDir, groupId, role, params);
}
async function auditDispatch(
workspaceDir: string,
opts: {
project: string; groupId: string; issueId: number; issueTitle: string;
role: string; tier: string; model: string; sessionAction: string;
sessionKey: string; fromLabel: string; toLabel: string;
},
): Promise<void> {
await auditLog(workspaceDir, "work_start", {
project: opts.project, groupId: opts.groupId,
issue: opts.issueId, issueTitle: opts.issueTitle,
role: opts.role, tier: opts.tier,
sessionAction: opts.sessionAction, sessionKey: opts.sessionKey,
labelTransition: `${opts.fromLabel}${opts.toLabel}`,
});
await auditLog(workspaceDir, "model_selection", {
issue: opts.issueId, role: opts.role, tier: opts.tier, model: opts.model,
});
}
function buildAnnouncement(
tier: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string,
): string {
const emoji = isTier(tier) ? TIER_EMOJI[tier] : role === "qa" ? "🔍" : "🔧";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
}