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:
330
lib/dispatch.ts
330
lib/dispatch.ts
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user