refactor: reorganize task management imports and update task handling tools
- Updated import paths for task management providers in task-comment, task-create, and task-update tools. - Removed deprecated task-complete and task-pickup tools, replacing them with work-finish and work-start tools for improved task handling. - Enhanced work-finish and work-start tools to streamline task completion and pickup processes, including context-aware detection and auto-scheduling features. - Updated package.json to include build scripts and main entry point. - Modified tsconfig.json to enable output directory, declaration files, and source maps for better TypeScript support.
This commit is contained in:
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* analyze_channel_bindings — Check channel availability and detect binding conflicts.
|
||||
*
|
||||
* Returns analysis of the current channel binding state, including:
|
||||
* - Whether the channel is configured and enabled
|
||||
* - Existing channel-wide bindings (potential conflicts)
|
||||
* - Existing group-specific bindings (no conflicts)
|
||||
* - Recommendations for what to do
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
analyzeChannelBindings,
|
||||
type ChannelType,
|
||||
} from "../binding-manager.js";
|
||||
|
||||
export function createAnalyzeChannelBindingsTool(api: OpenClawPluginApi) {
|
||||
return (_ctx: ToolContext) => ({
|
||||
name: "analyze_channel_bindings",
|
||||
label: "Analyze Channel Bindings",
|
||||
description:
|
||||
"Check if a channel (telegram/whatsapp) is configured and analyze existing bindings. Use this during onboarding when the user selects a channel binding (telegram/whatsapp) to: detect if the channel is configured and enabled, identify existing channel-wide bindings that would conflict, and provide smart recommendations (migrate binding, skip binding, or proceed). Call this BEFORE devclaw_setup when creating a new agent with channel binding.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channel: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "The channel to analyze (telegram or whatsapp)",
|
||||
},
|
||||
},
|
||||
required: ["channel"],
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channel = params.channel as ChannelType;
|
||||
|
||||
const analysis = await analyzeChannelBindings(channel);
|
||||
|
||||
const lines = [`**${channel.charAt(0).toUpperCase() + channel.slice(1)} Binding Analysis**`, ``];
|
||||
|
||||
if (!analysis.channelConfigured) {
|
||||
lines.push(`❌ Channel not configured`);
|
||||
} else if (!analysis.channelEnabled) {
|
||||
lines.push(`⚠️ Channel configured but disabled`);
|
||||
} else {
|
||||
lines.push(`✅ Channel configured and enabled`);
|
||||
}
|
||||
|
||||
lines.push(``);
|
||||
|
||||
if (analysis.existingChannelWideBinding) {
|
||||
lines.push(
|
||||
`**Existing Channel-Wide Binding:**`,
|
||||
` Agent: ${analysis.existingChannelWideBinding.agentName} (${analysis.existingChannelWideBinding.agentId})`,
|
||||
` ⚠️ This agent receives ALL ${channel} messages`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.groupSpecificBindings.length > 0) {
|
||||
lines.push(
|
||||
`**Group-Specific Bindings:**`,
|
||||
...analysis.groupSpecificBindings.map(
|
||||
(b) => ` • ${b.agentName} (${b.agentId}) → group ${b.groupId}`,
|
||||
),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`**Recommendation:**`, analysis.recommendation);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
channel,
|
||||
...analysis,
|
||||
summary: lines.join("\n"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
186
lib/tools/auto-pickup.ts
Normal file
186
lib/tools/auto-pickup.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* auto_pickup — Automated task pickup (heartbeat handler).
|
||||
*
|
||||
* Health checks → queue scan → fill free worker slots.
|
||||
* Optional projectGroupId for single-project or all-project sweep.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { getProject, getWorker, getSessionForModel, readProjects, type Project } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { type Tier } from "../tiers.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
import { checkWorkerHealth, type HealthFix } from "../services/health.js";
|
||||
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
type PickupAction = { project: string; groupId: string; issueId: number; issueTitle: string; role: "dev" | "qa"; model: string; sessionAction: "spawn" | "send"; announcement: string };
|
||||
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
return TIER_LABELS.find((t) => lower.includes(t)) ?? null;
|
||||
}
|
||||
|
||||
async function findNextIssueForRole(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role === "dev"
|
||||
? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAutoPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "auto_pickup",
|
||||
label: "Auto Pickup",
|
||||
description: `Automated task pickup. With projectGroupId: targets one project. Without: sweeps all projects. Runs health checks, then fills free worker slots by priority.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectGroupId: { type: "string", description: "Target a single project. Omit to sweep all." },
|
||||
dryRun: { type: "boolean", description: "Report only, don't dispatch. Default: false." },
|
||||
maxPickups: { type: "number", description: "Max pickups per tick." },
|
||||
activeSessions: { type: "array", items: { type: "string" }, description: "Active session IDs for zombie detection." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const targetGroupId = params.projectGroupId as string | undefined;
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const maxPickups = params.maxPickups as number | undefined;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution: ExecutionMode = (pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectEntries = targetGroupId
|
||||
? [[targetGroupId, data.projects[targetGroupId]] as const].filter(([, p]) => p)
|
||||
: Object.entries(data.projects);
|
||||
|
||||
if (projectEntries.length === 0) {
|
||||
return jsonResult({ success: true, dryRun, healthFixes: [], pickups: [], skipped: [{ project: "(none)", reason: "No projects" }] });
|
||||
}
|
||||
|
||||
const healthFixes: Array<HealthFix & { project: string; role: string }> = [];
|
||||
const pickups: PickupAction[] = [];
|
||||
const skipped: Array<{ project: string; role?: string; reason: string }> = [];
|
||||
let globalActiveDev = 0, globalActiveQa = 0, activeProjectCount = 0, pickupCount = 0;
|
||||
|
||||
// Pass 1: health checks
|
||||
for (const [groupId, project] of projectEntries) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkWorkerHealth({ workspaceDir, groupId, project, role, activeSessions, autoFix: !dryRun, provider });
|
||||
healthFixes.push(...fixes.map((f) => ({ ...f, project: project.name, role })));
|
||||
}
|
||||
const refreshed = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (refreshed) {
|
||||
if (refreshed.dev.active) globalActiveDev++;
|
||||
if (refreshed.qa.active) globalActiveQa++;
|
||||
if (refreshed.dev.active || refreshed.qa.active) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: pick up tasks
|
||||
for (const [groupId] of projectEntries) {
|
||||
const current = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (!current) continue;
|
||||
const { provider } = createProvider({ repo: current.repo });
|
||||
const roleExecution: ExecutionMode = current.roleExecution ?? "parallel";
|
||||
const projectActive = current.dev.active || current.qa.active;
|
||||
|
||||
if (projectExecution === "sequential" && !projectActive && activeProjectCount >= 1) {
|
||||
skipped.push({ project: current.name, reason: "Sequential: another project active" });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
if (maxPickups !== undefined && pickupCount >= maxPickups) { skipped.push({ project: current.name, role, reason: `Max pickups reached` }); continue; }
|
||||
const worker = getWorker(current, role);
|
||||
if (worker.active) { skipped.push({ project: current.name, role, reason: `Already active (#${worker.issueId})` }); continue; }
|
||||
if (roleExecution === "sequential" && getWorker(current, role === "dev" ? "qa" : "dev").active) {
|
||||
skipped.push({ project: current.name, role, reason: `Sequential: other role active` }); continue;
|
||||
}
|
||||
|
||||
const next = await findNextIssueForRole(provider, role);
|
||||
if (!next) continue;
|
||||
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
// Model selection
|
||||
let modelAlias: string;
|
||||
const tier = detectTierFromLabels(issue.labels);
|
||||
if (tier) {
|
||||
if (role === "qa" && tier !== "qa") modelAlias = "qa";
|
||||
else if (role === "dev" && tier === "qa") modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
|
||||
else modelAlias = tier;
|
||||
} else {
|
||||
modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
pickups.push({ project: current.name, groupId, issueId: issue.iid, issueTitle: issue.title, role, model: modelAlias, sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn", announcement: `[DRY RUN] Would pick up #${issue.iid}` });
|
||||
} else {
|
||||
try {
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project: current, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
pickups.push({ project: current.name, groupId, issueId: issue.iid, issueTitle: issue.title, role, model: dr.modelAlias, sessionAction: dr.sessionAction, announcement: dr.announcement });
|
||||
} catch (err) {
|
||||
skipped.push({ project: current.name, role, reason: `Dispatch failed: ${(err as Error).message}` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++; else globalActiveQa++;
|
||||
if (!projectActive) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog(workspaceDir, "auto_pickup", {
|
||||
dryRun, projectExecution, projectsScanned: projectEntries.length,
|
||||
healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length,
|
||||
});
|
||||
|
||||
// Notify
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "heartbeat", projectsScanned: projectEntries.length, healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length, dryRun, pickupDetails: pickups.map((p) => ({ project: p.project, issueId: p.issueId, role: p.role })) },
|
||||
{ workspaceDir, config: notifyConfig, orchestratorDm: context.type === "direct" ? context.chatId : undefined, channel: "channel" in context ? context.channel : undefined },
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true, dryRun, projectExecution, healthFixes, pickups, skipped,
|
||||
globalState: { activeProjects: activeProjectCount, activeDev: globalActiveDev, activeQa: globalActiveQa },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* context_test — Debug tool to test context detection.
|
||||
*
|
||||
* Call this from different contexts (DM, group, via another agent) to see
|
||||
* what context is detected and what guardrails are generated.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createContextTestTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "context_test",
|
||||
label: "Context Test (Debug)",
|
||||
description:
|
||||
"Debug tool: Shows detected context and guardrails. Use this to verify context detection works correctly in different scenarios (DM, group, via another agent).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
|
||||
async execute(_id: string, _params: Record<string, unknown>) {
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
const guardrails = generateGuardrails(context);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
debug: {
|
||||
toolContext: {
|
||||
agentId: ctx.agentId,
|
||||
messageChannel: ctx.messageChannel,
|
||||
sessionKey: ctx.sessionKey,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
agentAccountId: ctx.agentAccountId,
|
||||
sandboxed: ctx.sandboxed,
|
||||
},
|
||||
devClawAgentIds,
|
||||
},
|
||||
detectedContext: context,
|
||||
guardrails,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* devclaw_onboard — Explicit tool for triggering DevClaw onboarding.
|
||||
*
|
||||
* Provides discoverable, tool-based onboarding that doesn't rely on
|
||||
* keyword detection. Returns conversational context as a tool result.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
isPluginConfigured,
|
||||
hasWorkspaceFiles,
|
||||
buildOnboardToolContext,
|
||||
buildReconfigContext,
|
||||
} from "../onboarding.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_onboard",
|
||||
label: "DevClaw Onboarding",
|
||||
description:
|
||||
"Start DevClaw onboarding workflow. Use this tool when the user wants to: set up DevClaw, install DevClaw, onboard DevClaw, configure DevClaw, get started with DevClaw, or asks questions like 'can we install devclaw?', 'how do I set up devclaw?', 'let's onboard devclaw'. Returns step-by-step QA-style guidance. Call this FIRST before calling devclaw_setup to provide conversational setup experience.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["first-run", "reconfigure"],
|
||||
description:
|
||||
"Whether this is first-time setup (first-run) or reconfiguration (reconfigure). Auto-detected if omitted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// Warn if called in wrong context (group chat)
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "DevClaw onboarding should not be done in project group chats.",
|
||||
recommendation:
|
||||
"Please discuss DevClaw setup in a direct message with the DevClaw agent or via another agent (like your main assistant).",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const configured = isPluginConfigured(
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
);
|
||||
const hasWorkspace = await hasWorkspaceFiles(ctx.workspaceDir);
|
||||
|
||||
const mode = params.mode
|
||||
? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace
|
||||
? "reconfigure"
|
||||
: "first-run";
|
||||
|
||||
const instructions =
|
||||
mode === "first-run"
|
||||
? buildOnboardToolContext()
|
||||
: buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
mode,
|
||||
configured,
|
||||
instructions,
|
||||
contextGuidance: generateGuardrails(context),
|
||||
nextSteps: [
|
||||
"Follow the instructions above",
|
||||
"Call devclaw_setup with your collected answers",
|
||||
mode === "first-run" ? "Optional: register a project afterward" : null,
|
||||
].filter(Boolean),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* devclaw_setup — Agent-driven setup tool.
|
||||
*
|
||||
* Creates a new agent (optional), configures model tiers,
|
||||
* and writes workspace files (AGENTS.md, HEARTBEAT.md, roles, memory).
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup } from "../setup.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_setup",
|
||||
label: "DevClaw Setup",
|
||||
description: `Execute DevClaw setup with collected configuration. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent with channel binding and migration support. Backs up existing files before overwriting. This tool is typically called AFTER devclaw_onboard guides the conversation, but can be called directly if the user provides explicit configuration parameters.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newAgentName: {
|
||||
type: "string",
|
||||
description: "Create a new agent with this name. If omitted, configures the current agent's workspace.",
|
||||
},
|
||||
channelBinding: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "Channel to bind the new agent to (optional). Only used when newAgentName is specified. If omitted, no binding is created.",
|
||||
},
|
||||
migrateFrom: {
|
||||
type: "string",
|
||||
description: "Agent ID to migrate channel binding from (optional). Use when replacing an existing agent's channel-wide binding. Call analyze_channel_bindings first to detect conflicts.",
|
||||
},
|
||||
models: {
|
||||
type: "object",
|
||||
description: `Model overrides per tier. Missing tiers use defaults. Example: { "junior": "anthropic/claude-haiku-4-5", "senior": "anthropic/claude-opus-4-5" }`,
|
||||
properties: {
|
||||
junior: { type: "string", description: `Junior dev model (default: ${DEFAULT_MODELS.junior})` },
|
||||
medior: { type: "string", description: `Medior dev model (default: ${DEFAULT_MODELS.medior})` },
|
||||
senior: { type: "string", description: `Senior dev model (default: ${DEFAULT_MODELS.senior})` },
|
||||
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
|
||||
},
|
||||
},
|
||||
projectExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
description: "Plugin-level project execution mode: parallel (each project independent) or sequential (work on one project at a time). Default: parallel.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const newAgentName = params.newAgentName as string | undefined;
|
||||
const channelBinding = params.channelBinding as "telegram" | "whatsapp" | undefined;
|
||||
const migrateFrom = params.migrateFrom as string | undefined;
|
||||
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
|
||||
const projectExecution = params.projectExecution as "parallel" | "sequential" | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
const result = await runSetup({
|
||||
newAgentName,
|
||||
channelBinding: channelBinding ?? null,
|
||||
migrateFrom,
|
||||
// If no new agent name, use the current agent's workspace
|
||||
agentId: newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: newAgentName ? undefined : workspaceDir,
|
||||
models: modelsParam,
|
||||
projectExecution,
|
||||
});
|
||||
|
||||
const lines = [
|
||||
result.agentCreated
|
||||
? `Agent "${result.agentId}" created`
|
||||
: `Configured workspace for agent "${result.agentId}"`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (result.bindingMigrated) {
|
||||
lines.push(
|
||||
`✅ Channel binding migrated:`,
|
||||
` ${result.bindingMigrated.channel} (from "${result.bindingMigrated.from}" → "${result.agentId}")`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Models:`,
|
||||
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
|
||||
``,
|
||||
`Files written:`,
|
||||
...result.filesWritten.map((f) => ` ${f}`),
|
||||
);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push(``, `Warnings:`, ...result.warnings.map((w) => ` ${w}`));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
`Next steps:`,
|
||||
` 1. Add bot to a Telegram/WhatsApp group`,
|
||||
` 2. Register a project: "Register project <name> at <repo> for group <id>"`,
|
||||
` 3. Create your first issue and pick it up`,
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
...result,
|
||||
summary: lines.join("\n"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
/**
|
||||
* heartbeat_tick — Automated task pickup across all projects.
|
||||
*
|
||||
* Runs on heartbeat/cron context:
|
||||
* 1. Clean zombie sessions (session_health logic)
|
||||
* 2. Loop over all projects
|
||||
* 3. Check worker slots per project
|
||||
* 4. Pick up tasks by priority (To Improve > To Test > To Do)
|
||||
* 5. Respect two-level work mode:
|
||||
* - projectExecution (plugin-level): parallel/sequential for projects
|
||||
* - roleExecution (project-level): parallel/sequential for roles within a project
|
||||
* 6. Return summary of actions taken
|
||||
*
|
||||
* Context guard: Only allows from DM/cron context, blocks project groups.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type Issue, type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import {
|
||||
getProject,
|
||||
getWorker,
|
||||
getSessionForModel,
|
||||
readProjects,
|
||||
updateWorker,
|
||||
type Project,
|
||||
} from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { type Tier } from "../tiers.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
|
||||
/** Labels that map to QA role */
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
|
||||
/** All pickable labels, in priority order (highest first) */
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
|
||||
/** Tier labels that can appear on issues */
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
|
||||
type PickupAction = {
|
||||
project: string;
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
issueTitle: string;
|
||||
role: "dev" | "qa";
|
||||
model: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
announcement: string;
|
||||
};
|
||||
|
||||
type HealthFix = {
|
||||
project: string;
|
||||
role: "dev" | "qa";
|
||||
type: string;
|
||||
fixed: boolean;
|
||||
};
|
||||
|
||||
type TickResult = {
|
||||
success: boolean;
|
||||
dryRun: boolean;
|
||||
projectExecution: ExecutionMode;
|
||||
healthFixes: HealthFix[];
|
||||
pickups: PickupAction[];
|
||||
skipped: Array<{ project: string; role?: "dev" | "qa"; reason: string }>;
|
||||
globalState?: { activeProjects: number; activeDev: number; activeQa: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect role from issue's current state label.
|
||||
*/
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tier from issue labels (e.g., "junior", "senior").
|
||||
*/
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lowerLabels = labels.map((l) => l.toLowerCase());
|
||||
for (const tier of TIER_LABELS) {
|
||||
if (lowerLabels.includes(tier)) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next issue to pick up by priority for a specific role.
|
||||
*/
|
||||
async function findNextIssueForRole(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labelsToCheck =
|
||||
role === "dev"
|
||||
? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
|
||||
for (const label of labelsToCheck) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) {
|
||||
// Return oldest issue first (FIFO)
|
||||
const oldest = issues[issues.length - 1];
|
||||
return { issue: oldest, label };
|
||||
}
|
||||
} catch {
|
||||
// Continue to next label on error
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check logic for a single project/role.
|
||||
* Returns fixes applied (simplified version of session_health).
|
||||
*/
|
||||
async function checkAndFixWorkerHealth(
|
||||
workspaceDir: string,
|
||||
groupId: string,
|
||||
project: Project,
|
||||
role: "dev" | "qa",
|
||||
activeSessions: string[],
|
||||
autoFix: boolean,
|
||||
provider: { transitionLabel(id: number, from: StateLabel, to: StateLabel): Promise<void> },
|
||||
): Promise<HealthFix[]> {
|
||||
const fixes: HealthFix[] = [];
|
||||
const worker = project[role];
|
||||
const currentSessionKey = worker.model
|
||||
? getSessionForModel(worker, worker.model)
|
||||
: null;
|
||||
|
||||
// Check 1: Active but no session key for current model
|
||||
if (worker.active && !currentSessionKey) {
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "active_no_session",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (
|
||||
worker.active &&
|
||||
currentSessionKey &&
|
||||
activeSessions.length > 0 &&
|
||||
!activeSessions.includes(currentSessionKey)
|
||||
) {
|
||||
if (autoFix) {
|
||||
// Revert issue label
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
try {
|
||||
if (worker.issueId) {
|
||||
const primaryIssueId = Number(worker.issueId.split(",")[0]);
|
||||
await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort label revert
|
||||
}
|
||||
|
||||
// Clear the dead session
|
||||
const updatedSessions = { ...worker.sessions };
|
||||
if (worker.model) {
|
||||
updatedSessions[worker.model] = null;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
sessions: updatedSessions,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "zombie_session",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 3: Inactive but still has issueId
|
||||
if (!worker.active && worker.issueId) {
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "inactive_with_issue",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 4: Active for >2 hours (stale watchdog)
|
||||
// A stale worker likely crashed or ran out of context without calling task_complete.
|
||||
// Auto-fix reverts the label back to queue so the issue can be picked up again.
|
||||
if (worker.active && worker.startTime && currentSessionKey) {
|
||||
const startMs = new Date(worker.startTime).getTime();
|
||||
const nowMs = Date.now();
|
||||
const hoursActive = (nowMs - startMs) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursActive > 2) {
|
||||
if (autoFix) {
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
try {
|
||||
if (worker.issueId) {
|
||||
const primaryIssueId = Number(worker.issueId.split(",")[0]);
|
||||
await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort label revert
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
}
|
||||
fixes.push({
|
||||
project: project.name,
|
||||
role,
|
||||
type: "stale_worker",
|
||||
fixed: autoFix,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max workers for a role from project config (with defaults).
|
||||
*/
|
||||
function getMaxWorkers(project: Project, role: "dev" | "qa"): number {
|
||||
const key = role === "dev" ? "maxDevWorkers" : "maxQaWorkers";
|
||||
const value = (project as Record<string, unknown>)[key];
|
||||
return typeof value === "number" ? value : 1;
|
||||
}
|
||||
|
||||
export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "heartbeat_tick",
|
||||
label: "Heartbeat Tick",
|
||||
description: `Automated task pickup across all projects. Runs session health checks, then picks up tasks by priority (To Improve > To Test > To Do). Respects two-level work mode: plugin-level projectExecution (parallel/sequential for projects) and project-level roleExecution (parallel/sequential for roles within a project). Only works from DM/cron context, not project groups.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "Report what would happen without actually picking up tasks. Default: false.",
|
||||
},
|
||||
maxPickups: {
|
||||
type: "number",
|
||||
description: "Maximum number of task pickups per tick. Default: unlimited.",
|
||||
},
|
||||
activeSessions: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of currently alive session IDs from sessions_list. Used for zombie detection.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const maxPickups = params.maxPickups as number | undefined;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// Only allow from DM or direct context (not project groups)
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "heartbeat_tick cannot be used in project group chats.",
|
||||
recommendation: "Use this tool from a DM or cron context to manage all projects.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Get plugin-level projectExecution mode from plugin config
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution: ExecutionMode =
|
||||
(pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
|
||||
|
||||
const result: TickResult = {
|
||||
success: true,
|
||||
dryRun,
|
||||
projectExecution,
|
||||
healthFixes: [],
|
||||
pickups: [],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
// Read all projects
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectEntries = Object.entries(data.projects);
|
||||
|
||||
if (projectEntries.length === 0) {
|
||||
return jsonResult({
|
||||
...result,
|
||||
skipped: [{ project: "(none)", reason: "No projects registered" }],
|
||||
});
|
||||
}
|
||||
|
||||
// Track global worker counts for sequential mode
|
||||
let globalActiveDev = 0;
|
||||
let globalActiveQa = 0;
|
||||
let activeProjectCount = 0;
|
||||
let pickupCount = 0;
|
||||
|
||||
// First pass: count active workers and run health checks
|
||||
for (const [groupId, project] of projectEntries) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Health check for both roles
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkAndFixWorkerHealth(
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
role,
|
||||
activeSessions,
|
||||
!dryRun, // autoFix when not dryRun
|
||||
provider,
|
||||
);
|
||||
result.healthFixes.push(...fixes);
|
||||
}
|
||||
|
||||
// Re-read project after health fixes
|
||||
const refreshedData = await readProjects(workspaceDir);
|
||||
const refreshedProject = refreshedData.projects[groupId];
|
||||
if (refreshedProject) {
|
||||
const devActive = refreshedProject.dev.active;
|
||||
const qaActive = refreshedProject.qa.active;
|
||||
if (devActive) globalActiveDev++;
|
||||
if (qaActive) globalActiveQa++;
|
||||
if (devActive || qaActive) activeProjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: pick up tasks
|
||||
for (const [groupId, _project] of projectEntries) {
|
||||
// Re-read to get post-health-fix state
|
||||
const currentData = await readProjects(workspaceDir);
|
||||
const project = currentData.projects[groupId];
|
||||
if (!project) continue;
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Get project-level roleExecution mode (default: parallel)
|
||||
const roleExecution: ExecutionMode = project.roleExecution ?? "parallel";
|
||||
|
||||
// Check if this project has any active workers
|
||||
const projectHasActiveWorker = project.dev.active || project.qa.active;
|
||||
|
||||
// Plugin-level projectExecution check: if sequential, only one project can have workers
|
||||
if (projectExecution === "sequential" && !projectHasActiveWorker && activeProjectCount >= 1) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
reason: "Sequential projectExecution: another project has active workers",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check each role
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
// Check max pickups limit
|
||||
if (maxPickups !== undefined && pickupCount >= maxPickups) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Max pickups (${maxPickups}) reached`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if worker slot is available
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `${role.toUpperCase()} already active (issue #${worker.issueId})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check max workers per project
|
||||
const maxWorkers = getMaxWorkers(project, role);
|
||||
// For now we only support 1 worker per role, but structure supports more
|
||||
if (maxWorkers < 1) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `${role.toUpperCase()} disabled (maxWorkers=0)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Project-level roleExecution check: if sequential, only one role can be active
|
||||
if (roleExecution === "sequential") {
|
||||
const otherRole = role === "dev" ? "qa" : "dev";
|
||||
const otherWorker = getWorker(project, otherRole);
|
||||
if (otherWorker.active) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Sequential roleExecution: ${otherRole.toUpperCase()} already active`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find next issue for this role
|
||||
const next = await findNextIssueForRole(provider, role);
|
||||
if (!next) {
|
||||
// No tasks available - not a skip, just nothing to do
|
||||
continue;
|
||||
}
|
||||
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
// Select model
|
||||
let modelAlias: string;
|
||||
const tierFromLabels = detectTierFromLabels(issue.labels);
|
||||
|
||||
if (tierFromLabels) {
|
||||
// Validate tier matches role
|
||||
if (role === "qa" && tierFromLabels !== "qa") {
|
||||
modelAlias = "qa";
|
||||
} else if (role === "dev" && tierFromLabels === "qa") {
|
||||
const selected = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = selected.tier;
|
||||
} else {
|
||||
modelAlias = tierFromLabels;
|
||||
}
|
||||
} else {
|
||||
const selected = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = selected.tier;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
// In dry run, just report what would happen
|
||||
result.pickups.push({
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: modelAlias,
|
||||
sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn",
|
||||
announcement: `[DRY RUN] Would pick up #${issue.iid}: ${issue.title}`,
|
||||
});
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++;
|
||||
if (role === "qa") globalActiveQa++;
|
||||
if (!projectHasActiveWorker) activeProjectCount++;
|
||||
} else {
|
||||
// Actually dispatch
|
||||
try {
|
||||
const dispatchResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
modelAlias,
|
||||
fromLabel: currentLabel,
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
result.pickups.push({
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
announcement: dispatchResult.announcement,
|
||||
});
|
||||
pickupCount++;
|
||||
if (role === "dev") globalActiveDev++;
|
||||
if (role === "qa") globalActiveQa++;
|
||||
if (!projectHasActiveWorker) activeProjectCount++;
|
||||
} catch (err) {
|
||||
result.skipped.push({
|
||||
project: project.name,
|
||||
role,
|
||||
reason: `Dispatch failed for #${issue.iid}: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add global state for visibility
|
||||
result.globalState = {
|
||||
activeProjects: activeProjectCount,
|
||||
activeDev: globalActiveDev,
|
||||
activeQa: globalActiveQa,
|
||||
};
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||
dryRun,
|
||||
projectExecution,
|
||||
projectsScanned: projectEntries.length,
|
||||
healthFixes: result.healthFixes.length,
|
||||
pickups: result.pickups.length,
|
||||
skipped: result.skipped.length,
|
||||
});
|
||||
|
||||
// Send heartbeat notification back to whoever triggered it
|
||||
// Both channel and target are derived from context (DM sessionKey)
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
const orchestratorDm = context.type === "direct" ? context.chatId : undefined;
|
||||
|
||||
await notify(
|
||||
{
|
||||
type: "heartbeat",
|
||||
projectsScanned: projectEntries.length,
|
||||
healthFixes: result.healthFixes.length,
|
||||
pickups: result.pickups.length,
|
||||
skipped: result.skipped.length,
|
||||
dryRun,
|
||||
pickupDetails: result.pickups.map((p) => ({
|
||||
project: p.project,
|
||||
issueId: p.issueId,
|
||||
role: p.role,
|
||||
})),
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
orchestratorDm,
|
||||
channel: "channel" in context ? context.channel : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
50
lib/tools/onboard.ts
Normal file
50
lib/tools/onboard.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* onboard — Conversational DevClaw onboarding.
|
||||
*
|
||||
* Returns step-by-step guidance. Call this before setup.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { isPluginConfigured, hasWorkspaceFiles, buildOnboardToolContext, buildReconfigContext } from "../onboarding.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "onboard",
|
||||
label: "Onboard",
|
||||
description: "Start DevClaw onboarding workflow. Returns step-by-step QA-style guidance. Call this first, then setup with collected answers.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["first-run", "reconfigure"], description: "Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false, error: "Onboarding should not be done in group chats.",
|
||||
recommendation: "Use a direct message instead.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const configured = isPluginConfigured(api.pluginConfig as Record<string, unknown>);
|
||||
const hasWorkspace = await hasWorkspaceFiles(ctx.workspaceDir);
|
||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
||||
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
|
||||
return jsonResult({
|
||||
success: true, mode, configured, instructions,
|
||||
contextGuidance: generateGuardrails(context),
|
||||
nextSteps: ["Follow instructions above", "Call setup with collected answers", mode === "first-run" ? "Register a project afterward" : null].filter(Boolean),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { DEV_TIERS, QA_TIERS } from "../tiers.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
|
||||
@@ -146,7 +146,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
error: "Project registration can only be done from the Telegram/WhatsApp group you're registering.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw for the first time, use devclaw_onboard. Then go to the project's Telegram/WhatsApp group to register it."
|
||||
? "If you're setting up DevClaw for the first time, use onboard. Then go to the project's Telegram/WhatsApp group to register it."
|
||||
: "Please go to the Telegram/WhatsApp group you want to register and call project_register from there.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Tests for queue-status execution-aware sequencing logic
|
||||
* Tests for status tool execution-aware sequencing logic
|
||||
* Run with: node --test lib/tools/queue-status.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("queue_status execution-aware sequencing", () => {
|
||||
describe("status execution-aware sequencing", () => {
|
||||
describe("priority ordering", () => {
|
||||
it("should prioritize To Improve > To Test > To Do", () => {
|
||||
// To Improve has priority 3, To Test has 2, To Do has 1
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
/**
|
||||
* queue_status — Show task queue and worker status across projects.
|
||||
*
|
||||
* Enhanced with execution-aware task sequencing based on two-level work mode.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, getProject, type Project } from "../projects.js";
|
||||
import { type StateLabel, type Issue } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
/** Priority order for queue labels (higher = more urgent) */
|
||||
const QUEUE_PRIORITY: Record<QueueLabel, number> = {
|
||||
"To Improve": 3,
|
||||
"To Test": 2,
|
||||
"To Do": 1,
|
||||
};
|
||||
|
||||
type QueueLabel = "To Improve" | "To Test" | "To Do";
|
||||
type Role = "dev" | "qa";
|
||||
|
||||
/** A task in the sequence with metadata */
|
||||
interface SequencedTask {
|
||||
/** Sequence number (1-based) */
|
||||
sequence: number;
|
||||
/** Project group ID */
|
||||
projectId: string;
|
||||
/** Project name */
|
||||
projectName: string;
|
||||
/** Role (dev or qa) */
|
||||
role: Role;
|
||||
/** Issue ID */
|
||||
issueId: number;
|
||||
/** Issue title */
|
||||
title: string;
|
||||
/** Queue label */
|
||||
label: QueueLabel;
|
||||
/** Whether this task is currently active */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** A track of tasks for a specific role within a project */
|
||||
interface ProjectTrack {
|
||||
/** Track name */
|
||||
name: string;
|
||||
/** Role for this track */
|
||||
role: Role;
|
||||
/** Tasks in this track */
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
/** Execution configuration for a project */
|
||||
interface ProjectExecutionConfig {
|
||||
name: string;
|
||||
groupId: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
devActive: boolean;
|
||||
qaActive: boolean;
|
||||
devIssueId: string | null;
|
||||
qaIssueId: string | null;
|
||||
}
|
||||
|
||||
/** Task sequence for a project in parallel mode */
|
||||
interface ProjectTaskSequence {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
roleExecution: "parallel" | "sequential";
|
||||
/** For sequential: single track, for parallel: multiple tracks */
|
||||
tracks: ProjectTrack[];
|
||||
}
|
||||
|
||||
/** Global task sequence for sequential mode */
|
||||
interface GlobalTaskSequence {
|
||||
mode: "sequential";
|
||||
/** Interleaved tasks across all projects */
|
||||
tasks: SequencedTask[];
|
||||
}
|
||||
|
||||
/** Project queues cache entry */
|
||||
interface ProjectQueues {
|
||||
projectId: string;
|
||||
project: Project;
|
||||
queues: Record<QueueLabel, Issue[]>;
|
||||
}
|
||||
|
||||
/** Result structure for the enhanced queue status */
|
||||
interface QueueStatusResult {
|
||||
execution: {
|
||||
plugin: {
|
||||
projectExecution: "parallel" | "sequential";
|
||||
};
|
||||
projects: ProjectExecutionConfig[];
|
||||
};
|
||||
sequences: {
|
||||
mode: "parallel" | "sequential";
|
||||
/** For sequential mode: global task list */
|
||||
global?: GlobalTaskSequence;
|
||||
/** For parallel mode: per-project tracks */
|
||||
projects?: ProjectTaskSequence[];
|
||||
};
|
||||
projects: Array<{
|
||||
name: string;
|
||||
groupId: string;
|
||||
dev: {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
model: string | null;
|
||||
sessions: Record<string, string | null>;
|
||||
};
|
||||
qa: {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
model: string | null;
|
||||
sessions: Record<string, string | null>;
|
||||
};
|
||||
queue: {
|
||||
toImprove: Array<{ id: number; title: string; priority: number }>;
|
||||
toTest: Array<{ id: number; title: string; priority: number }>;
|
||||
toDo: Array<{ id: number; title: string; priority: number }>;
|
||||
};
|
||||
}>;
|
||||
context: {
|
||||
type: string;
|
||||
projectName?: string;
|
||||
autoFiltered?: boolean;
|
||||
};
|
||||
contextGuidance: string;
|
||||
}
|
||||
|
||||
/** Build task priority score (higher = more urgent) */
|
||||
function getTaskPriority(label: QueueLabel, issue: Issue): number {
|
||||
const basePriority = QUEUE_PRIORITY[label] * 10000;
|
||||
// Secondary sort by creation date (older = higher priority)
|
||||
// Use issue ID as proxy for creation order (lower ID = older)
|
||||
return basePriority - issue.iid;
|
||||
}
|
||||
|
||||
/** Determine role based on queue label */
|
||||
function getRoleForLabel(label: QueueLabel): Role {
|
||||
switch (label) {
|
||||
case "To Do":
|
||||
case "To Improve":
|
||||
return "dev";
|
||||
case "To Test":
|
||||
return "qa";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch and sort all queueable issues for a project */
|
||||
async function fetchProjectQueues(
|
||||
project: Project,
|
||||
): Promise<Record<QueueLabel, Issue[]>> {
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
const queueLabels: QueueLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const queues: Record<QueueLabel, Issue[]> = {
|
||||
"To Improve": [],
|
||||
"To Test": [],
|
||||
"To Do": [],
|
||||
};
|
||||
|
||||
for (const label of queueLabels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
// Sort by priority (higher first) then by ID (lower first = older first)
|
||||
queues[label] = issues.sort((a, b) => {
|
||||
const priorityA = getTaskPriority(label, a);
|
||||
const priorityB = getTaskPriority(label, b);
|
||||
return priorityB - priorityA;
|
||||
});
|
||||
} catch {
|
||||
queues[label] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return queues;
|
||||
}
|
||||
|
||||
/** Build a project track for a specific role */
|
||||
function buildProjectTrack(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
role: Role,
|
||||
queues: Record<QueueLabel, Issue[]>,
|
||||
isActive: boolean,
|
||||
activeIssueId: string | null,
|
||||
startingSequence: number,
|
||||
): { track: ProjectTrack; nextSequence: number } {
|
||||
const tasks: SequencedTask[] = [];
|
||||
let sequence = startingSequence;
|
||||
|
||||
// Helper to add tasks from a queue for this role
|
||||
const addTasksFromQueue = (label: QueueLabel, issues: Issue[]) => {
|
||||
// Only add tasks that match this role
|
||||
if (getRoleForLabel(label) !== role) return;
|
||||
|
||||
for (const issue of issues) {
|
||||
const taskActive = isActive && activeIssueId === String(issue.iid);
|
||||
tasks.push({
|
||||
sequence: sequence++,
|
||||
projectId,
|
||||
projectName,
|
||||
role,
|
||||
issueId: issue.iid,
|
||||
title: issue.title,
|
||||
label,
|
||||
active: taskActive,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add in priority order
|
||||
addTasksFromQueue("To Improve", queues["To Improve"]);
|
||||
addTasksFromQueue("To Test", queues["To Test"]);
|
||||
addTasksFromQueue("To Do", queues["To Do"]);
|
||||
|
||||
return {
|
||||
track: {
|
||||
name: role === "dev" ? "DEV Track" : "QA Track",
|
||||
role,
|
||||
tasks,
|
||||
},
|
||||
nextSequence: sequence,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build project sequences for parallel mode */
|
||||
function buildParallelProjectSequences(
|
||||
projectQueues: ProjectQueues[],
|
||||
): ProjectTaskSequence[] {
|
||||
const sequences: ProjectTaskSequence[] = [];
|
||||
|
||||
for (const { projectId, project, queues } of projectQueues) {
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
const tracks: ProjectTrack[] = [];
|
||||
|
||||
if (roleExecution === "sequential") {
|
||||
// Sequential within project: show alternating DEV/QA sequence
|
||||
const devActive = project.dev.active;
|
||||
const qaActive = project.qa.active;
|
||||
const alternatingTasks: SequencedTask[] = [];
|
||||
let sequence = 1;
|
||||
|
||||
// Get next task for each role
|
||||
const getNextTaskForRole = (role: Role): SequencedTask | null => {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
if (getRoleForLabel(label) !== role) continue;
|
||||
const issues = queues[label];
|
||||
for (const issue of issues) {
|
||||
// Check if already added
|
||||
if (alternatingTasks.some((t) => t.issueId === issue.iid)) continue;
|
||||
const isActive =
|
||||
(role === "dev" && devActive && project.dev.issueId === String(issue.iid)) ||
|
||||
(role === "qa" && qaActive && project.qa.issueId === String(issue.iid));
|
||||
return {
|
||||
sequence: 0, // Will be set later
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
role,
|
||||
issueId: issue.iid,
|
||||
title: issue.title,
|
||||
label,
|
||||
active: isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Build alternating sequence
|
||||
let lastRole: Role | null = null;
|
||||
if (devActive && !qaActive) lastRole = "dev";
|
||||
else if (qaActive && !devActive) lastRole = "qa";
|
||||
|
||||
// Add active task first if any
|
||||
if (devActive && project.dev.issueId) {
|
||||
const activeDevTask = getNextTaskForRole("dev");
|
||||
if (activeDevTask) {
|
||||
activeDevTask.sequence = sequence++;
|
||||
activeDevTask.active = true;
|
||||
alternatingTasks.push(activeDevTask);
|
||||
}
|
||||
} else if (qaActive && project.qa.issueId) {
|
||||
const activeQaTask = getNextTaskForRole("qa");
|
||||
if (activeQaTask) {
|
||||
activeQaTask.sequence = sequence++;
|
||||
activeQaTask.active = true;
|
||||
alternatingTasks.push(activeQaTask);
|
||||
}
|
||||
}
|
||||
|
||||
// Build future alternating sequence
|
||||
while (true) {
|
||||
const nextRole: Role = lastRole === "dev" ? "qa" : "dev";
|
||||
const task = getNextTaskForRole(nextRole);
|
||||
if (!task) break;
|
||||
task.sequence = sequence++;
|
||||
alternatingTasks.push(task);
|
||||
lastRole = nextRole;
|
||||
}
|
||||
|
||||
if (alternatingTasks.length > 0) {
|
||||
tracks.push({
|
||||
name: "DEV/QA Alternating",
|
||||
role: "dev", // Mixed track
|
||||
tasks: alternatingTasks,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Parallel within project: separate tracks for DEV and QA
|
||||
const devTrack = buildProjectTrack(
|
||||
projectId,
|
||||
project.name,
|
||||
"dev",
|
||||
queues,
|
||||
project.dev.active,
|
||||
project.dev.issueId,
|
||||
1,
|
||||
);
|
||||
const qaTrack = buildProjectTrack(
|
||||
projectId,
|
||||
project.name,
|
||||
"qa",
|
||||
queues,
|
||||
project.qa.active,
|
||||
project.qa.issueId,
|
||||
1,
|
||||
);
|
||||
|
||||
if (devTrack.track.tasks.length > 0) {
|
||||
tracks.push(devTrack.track);
|
||||
}
|
||||
if (qaTrack.track.tasks.length > 0) {
|
||||
tracks.push(qaTrack.track);
|
||||
}
|
||||
}
|
||||
|
||||
sequences.push({
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
roleExecution,
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
||||
return sequences;
|
||||
}
|
||||
|
||||
/** Build global task sequence for sequential mode */
|
||||
function buildGlobalTaskSequence(
|
||||
projectQueues: ProjectQueues[],
|
||||
): GlobalTaskSequence {
|
||||
const allTasks: Array<{
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
role: Role;
|
||||
label: QueueLabel;
|
||||
issue: Issue;
|
||||
priority: number;
|
||||
}> = [];
|
||||
|
||||
// Collect all tasks from all projects
|
||||
for (const { projectId, project, queues } of projectQueues) {
|
||||
for (const label of ["To Improve", "To Test", "To Do"] as QueueLabel[]) {
|
||||
for (const issue of queues[label]) {
|
||||
allTasks.push({
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
role: getRoleForLabel(label),
|
||||
label,
|
||||
issue,
|
||||
priority: getTaskPriority(label, issue),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (higher first), then by project order, then by ID
|
||||
allTasks.sort((a, b) => {
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
return a.issue.iid - b.issue.iid;
|
||||
});
|
||||
|
||||
// For global sequential mode, we need to interleave based on active workers
|
||||
// Find which project has an active worker
|
||||
const activeProject = projectQueues.find(
|
||||
({ project }) => project.dev.active || project.qa.active,
|
||||
);
|
||||
|
||||
const sequencedTasks: SequencedTask[] = [];
|
||||
let sequence = 1;
|
||||
|
||||
if (activeProject) {
|
||||
// If there's an active project, start with its active task
|
||||
const { project, projectId } = activeProject;
|
||||
if (project.dev.active && project.dev.issueId) {
|
||||
const task = allTasks.find(
|
||||
(t) =>
|
||||
t.projectId === projectId &&
|
||||
t.role === "dev" &&
|
||||
String(t.issue.iid) === project.dev.issueId,
|
||||
);
|
||||
if (task) {
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} else if (project.qa.active && project.qa.issueId) {
|
||||
const task = allTasks.find(
|
||||
(t) =>
|
||||
t.projectId === projectId &&
|
||||
t.role === "qa" &&
|
||||
String(t.issue.iid) === project.qa.issueId,
|
||||
);
|
||||
if (task) {
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining tasks in priority order
|
||||
for (const task of allTasks) {
|
||||
// Skip if already added
|
||||
if (
|
||||
sequencedTasks.some(
|
||||
(t) => t.projectId === task.projectId && t.issueId === task.issue.iid,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sequencedTasks.push({
|
||||
sequence: sequence++,
|
||||
projectId: task.projectId,
|
||||
projectName: task.projectName,
|
||||
role: task.role,
|
||||
issueId: task.issue.iid,
|
||||
title: task.issue.title,
|
||||
label: task.label,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "sequential",
|
||||
tasks: sequencedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert project queues to the output format */
|
||||
function formatProjectQueues(
|
||||
queues: Record<QueueLabel, Issue[]>,
|
||||
): QueueStatusResult["projects"][0]["queue"] {
|
||||
return {
|
||||
toImprove: queues["To Improve"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Improve"],
|
||||
})),
|
||||
toTest: queues["To Test"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Test"],
|
||||
})),
|
||||
toDo: queues["To Do"].map((i) => ({
|
||||
id: i.iid,
|
||||
title: i.title,
|
||||
priority: QUEUE_PRIORITY["To Do"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "queue_status",
|
||||
label: "Queue Status",
|
||||
description: `Show task queue and worker status with execution-aware task sequencing. Context-aware: In group chats, auto-filters to that project. In direct messages, shows all projects. Best for status checks, not during setup.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Specific project group ID to check. Omit to check all projects.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// If via another agent (setup mode), suggest devclaw_onboard instead
|
||||
if (context.type === "via-agent") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
warning: "queue_status is for operational use, not setup.",
|
||||
recommendation: "If you're setting up DevClaw, use devclaw_onboard instead.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-filter to current project in group context
|
||||
let groupId = params.projectGroupId as string | undefined;
|
||||
if (context.type === "group" && !groupId) {
|
||||
groupId = context.groupId;
|
||||
}
|
||||
|
||||
// Get plugin-level execution setting
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId
|
||||
? [groupId]
|
||||
: Object.keys(data.projects);
|
||||
|
||||
// Build execution configs and fetch all project data
|
||||
const executionConfigs: ProjectExecutionConfig[] = [];
|
||||
const projectList: Array<{ id: string; project: Project }> = [];
|
||||
|
||||
for (const pid of projectIds) {
|
||||
const project = getProject(data, pid);
|
||||
if (!project) continue;
|
||||
|
||||
projectList.push({ id: pid, project });
|
||||
executionConfigs.push({
|
||||
name: project.name,
|
||||
groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
devActive: project.dev.active,
|
||||
qaActive: project.qa.active,
|
||||
devIssueId: project.dev.issueId,
|
||||
qaIssueId: project.qa.issueId,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all queues in parallel
|
||||
const projectQueues: ProjectQueues[] = await Promise.all(
|
||||
projectList.map(async ({ id, project }) => ({
|
||||
projectId: id,
|
||||
project,
|
||||
queues: await fetchProjectQueues(project),
|
||||
})),
|
||||
);
|
||||
|
||||
// Build sequences based on execution mode
|
||||
let sequences: QueueStatusResult["sequences"];
|
||||
|
||||
if (projectExecution === "sequential") {
|
||||
const globalSequence = buildGlobalTaskSequence(projectQueues);
|
||||
sequences = {
|
||||
mode: "sequential",
|
||||
global: globalSequence,
|
||||
};
|
||||
} else {
|
||||
const projectSequences = buildParallelProjectSequences(projectQueues);
|
||||
sequences = {
|
||||
mode: "parallel",
|
||||
projects: projectSequences,
|
||||
};
|
||||
}
|
||||
|
||||
// Build project details with queues
|
||||
const projects: QueueStatusResult["projects"] = projectQueues.map(
|
||||
({ projectId, project, queues }) => ({
|
||||
name: project.name,
|
||||
groupId: projectId,
|
||||
dev: {
|
||||
active: project.dev.active,
|
||||
issueId: project.dev.issueId,
|
||||
model: project.dev.model,
|
||||
sessions: project.dev.sessions,
|
||||
},
|
||||
qa: {
|
||||
active: project.qa.active,
|
||||
issueId: project.qa.issueId,
|
||||
model: project.qa.model,
|
||||
sessions: project.qa.sessions,
|
||||
},
|
||||
queue: formatProjectQueues(queues),
|
||||
}),
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "queue_status", {
|
||||
projectCount: projects.length,
|
||||
totalToImprove: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toImprove.length,
|
||||
0,
|
||||
),
|
||||
totalToTest: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toTest.length,
|
||||
0,
|
||||
),
|
||||
totalToDo: projects.reduce(
|
||||
(sum, p) => sum + p.queue.toDo.length,
|
||||
0,
|
||||
),
|
||||
projectExecution,
|
||||
});
|
||||
|
||||
const result: QueueStatusResult = {
|
||||
execution: {
|
||||
plugin: {
|
||||
projectExecution,
|
||||
},
|
||||
projects: executionConfigs,
|
||||
},
|
||||
sequences,
|
||||
projects,
|
||||
context: {
|
||||
type: context.type,
|
||||
...(context.type === "group" && {
|
||||
projectName: context.projectName,
|
||||
autoFiltered: !params.projectGroupId,
|
||||
}),
|
||||
},
|
||||
contextGuidance: generateGuardrails(context),
|
||||
};
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* session_health — Check and fix session state consistency.
|
||||
*
|
||||
* Detects zombie sessions (active=true but session dead) and stale workers.
|
||||
* Checks the sessions map for each worker's current model.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
|
||||
import { type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
export function createSessionHealthTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "session_health",
|
||||
label: "Session Health",
|
||||
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: {
|
||||
autoFix: {
|
||||
type: "boolean",
|
||||
description: "Automatically fix zombie sessions and stale active flags. Default: false.",
|
||||
},
|
||||
activeSessions: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of currently alive session IDs from sessions_list. Used to detect zombies.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const autoFix = (params.autoFix as boolean) ?? false;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
|
||||
const issues: Array<Record<string, unknown>> = [];
|
||||
let fixesApplied = 0;
|
||||
|
||||
for (const [groupId, project] of Object.entries(data.projects)) {
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
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 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,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// Check 2: Active with session but session is dead (zombie)
|
||||
if (
|
||||
worker.active &&
|
||||
currentSessionKey &&
|
||||
activeSessions.length > 0 &&
|
||||
!activeSessions.includes(currentSessionKey)
|
||||
) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "zombie_session",
|
||||
severity: "critical",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
sessionKey: currentSessionKey,
|
||||
model: worker.model,
|
||||
message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
// Revert issue label
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
try {
|
||||
if (worker.issueId) {
|
||||
const primaryIssueId = Number(worker.issueId.split(",")[0]);
|
||||
await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
issue.labelReverted = `${currentLabel} → ${revertLabel}`;
|
||||
}
|
||||
} catch {
|
||||
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++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
|
||||
// Check 3: Active for >2 hours (stale watchdog)
|
||||
// Worker likely crashed or ran out of context without calling task_complete.
|
||||
if (worker.active && worker.startTime) {
|
||||
const startMs = new Date(worker.startTime).getTime();
|
||||
const nowMs = Date.now();
|
||||
const hoursActive = (nowMs - startMs) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursActive > 2) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "stale_worker",
|
||||
severity: "warning",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
hoursActive: Math.round(hoursActive * 10) / 10,
|
||||
sessionKey: currentSessionKey,
|
||||
issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
// Revert issue label back to queue
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
try {
|
||||
if (worker.issueId) {
|
||||
const primaryIssueId = Number(worker.issueId.split(",")[0]);
|
||||
await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel);
|
||||
issue.labelReverted = `${currentLabel} → ${revertLabel}`;
|
||||
}
|
||||
} catch {
|
||||
issue.labelRevertFailed = true;
|
||||
}
|
||||
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
active: false,
|
||||
issueId: null,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Inactive but still has issueId (should have been cleared)
|
||||
if (!worker.active && worker.issueId) {
|
||||
const issue: Record<string, unknown> = {
|
||||
type: "inactive_with_issue",
|
||||
severity: "warning",
|
||||
project: project.name,
|
||||
groupId,
|
||||
role,
|
||||
issueId: worker.issueId,
|
||||
message: `${role.toUpperCase()} inactive but still has issueId "${worker.issueId}"`,
|
||||
};
|
||||
|
||||
if (autoFix) {
|
||||
await updateWorker(workspaceDir, groupId, role, {
|
||||
issueId: null,
|
||||
});
|
||||
issue.fixed = true;
|
||||
fixesApplied++;
|
||||
}
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "health_check", {
|
||||
projectsScanned: Object.keys(data.projects).length,
|
||||
issuesFound: issues.length,
|
||||
fixesApplied,
|
||||
autoFix,
|
||||
activeSessionsProvided: activeSessions.length > 0,
|
||||
});
|
||||
|
||||
const result = {
|
||||
healthy: issues.length === 0,
|
||||
issuesFound: issues.length,
|
||||
fixesApplied,
|
||||
issues,
|
||||
note: activeSessions.length === 0
|
||||
? "No activeSessions provided — zombie detection skipped. Call sessions_list and pass the result for full health check."
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
62
lib/tools/setup.ts
Normal file
62
lib/tools/setup.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* setup — Agent-driven DevClaw setup.
|
||||
*
|
||||
* Creates agent, configures model tiers, writes workspace files.
|
||||
* Thin wrapper around lib/setup.ts.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup } from "../setup.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "setup",
|
||||
label: "Setup",
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, roles, memory/projects.json, and model tier config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newAgentName: { type: "string", description: "Create a new agent. Omit to configure current workspace." },
|
||||
channelBinding: { type: "string", enum: ["telegram", "whatsapp"], description: "Channel to bind (optional, with newAgentName only)." },
|
||||
migrateFrom: { type: "string", description: "Agent ID to migrate channel binding from. Check openclaw.json bindings first." },
|
||||
models: {
|
||||
type: "object", description: "Model overrides per tier.",
|
||||
properties: {
|
||||
junior: { type: "string", description: `Default: ${DEFAULT_MODELS.junior}` },
|
||||
medior: { type: "string", description: `Default: ${DEFAULT_MODELS.medior}` },
|
||||
senior: { type: "string", description: `Default: ${DEFAULT_MODELS.senior}` },
|
||||
qa: { type: "string", description: `Default: ${DEFAULT_MODELS.qa}` },
|
||||
},
|
||||
},
|
||||
projectExecution: { type: "string", enum: ["parallel", "sequential"], description: "Project execution mode. Default: parallel." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const result = await runSetup({
|
||||
newAgentName: params.newAgentName as string | undefined,
|
||||
channelBinding: (params.channelBinding as "telegram" | "whatsapp") ?? null,
|
||||
migrateFrom: params.migrateFrom as string | undefined,
|
||||
agentId: params.newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
models: params.models as Partial<Record<Tier, string>> | undefined,
|
||||
projectExecution: params.projectExecution as "parallel" | "sequential" | undefined,
|
||||
});
|
||||
|
||||
const lines = [
|
||||
result.agentCreated ? `Agent "${result.agentId}" created` : `Configured "${result.agentId}"`,
|
||||
"",
|
||||
];
|
||||
if (result.bindingMigrated) {
|
||||
lines.push(`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from} → ${result.agentId})`, "");
|
||||
}
|
||||
lines.push("Models:", ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), "", "Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
||||
if (result.warnings.length > 0) lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`));
|
||||
lines.push("", "Next: register a project, then create issues and pick them up.");
|
||||
|
||||
return jsonResult({ success: true, ...result, summary: lines.join("\n") });
|
||||
},
|
||||
});
|
||||
}
|
||||
136
lib/tools/status.ts
Normal file
136
lib/tools/status.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* status — Unified queue + health overview.
|
||||
*
|
||||
* Merges queue_status + session_health into a single tool.
|
||||
* Context-aware: auto-filters to project in group chats.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects, getProject, type Project } from "../projects.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { checkWorkerHealth } from "../services/health.js";
|
||||
import {
|
||||
fetchProjectQueues, buildParallelProjectSequences, buildGlobalTaskSequence,
|
||||
formatProjectQueues, type ProjectQueues, type ProjectExecutionConfig,
|
||||
} from "../services/queue.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
|
||||
export function createStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: `Show task queue, worker status, and health across projects. Context-aware: auto-filters in group chats. Pass activeSessions for zombie detection.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectGroupId: { type: "string", description: "Filter to specific project. Omit for all." },
|
||||
includeHealth: { type: "boolean", description: "Run health checks. Default: true." },
|
||||
activeSessions: { type: "array", items: { type: "string" }, description: "Active session IDs for zombie detection." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
const includeHealth = (params.includeHealth as boolean) ?? true;
|
||||
const activeSessions = (params.activeSessions as string[]) ?? [];
|
||||
|
||||
// Context detection
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
if (context.type === "via-agent") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
warning: "status is for operational use, not setup.",
|
||||
recommendation: "Use onboard instead for DevClaw setup.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-filter in group context
|
||||
let groupId = params.projectGroupId as string | undefined;
|
||||
if (context.type === "group" && !groupId) groupId = context.groupId;
|
||||
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel";
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
||||
|
||||
// Build execution configs + fetch queues
|
||||
const configs: ProjectExecutionConfig[] = [];
|
||||
const projectList: Array<{ id: string; project: Project }> = [];
|
||||
|
||||
for (const pid of projectIds) {
|
||||
const project = getProject(data, pid);
|
||||
if (!project) continue;
|
||||
projectList.push({ id: pid, project });
|
||||
configs.push({
|
||||
name: project.name, groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
devActive: project.dev.active, qaActive: project.qa.active,
|
||||
devIssueId: project.dev.issueId, qaIssueId: project.qa.issueId,
|
||||
});
|
||||
}
|
||||
|
||||
// Health checks (read-only — never auto-fix from status)
|
||||
const healthIssues: Array<Record<string, unknown>> = [];
|
||||
if (includeHealth) {
|
||||
for (const { id, project } of projectList) {
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
for (const role of ["dev", "qa"] as const) {
|
||||
const fixes = await checkWorkerHealth({
|
||||
workspaceDir, groupId: id, project, role, activeSessions,
|
||||
autoFix: false, provider,
|
||||
});
|
||||
for (const f of fixes) healthIssues.push({ ...f.issue, fixed: f.fixed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch queues
|
||||
const projectQueues: ProjectQueues[] = await Promise.all(
|
||||
projectList.map(async ({ id, project }) => ({
|
||||
projectId: id, project,
|
||||
queues: await fetchProjectQueues(project),
|
||||
})),
|
||||
);
|
||||
|
||||
// Build sequences
|
||||
const sequences = projectExecution === "sequential"
|
||||
? { mode: "sequential" as const, global: buildGlobalTaskSequence(projectQueues) }
|
||||
: { mode: "parallel" as const, projects: buildParallelProjectSequences(projectQueues) };
|
||||
|
||||
// Build project details
|
||||
const projects = projectQueues.map(({ projectId, project, queues }) => ({
|
||||
name: project.name, groupId: projectId,
|
||||
dev: { active: project.dev.active, issueId: project.dev.issueId, model: project.dev.model, sessions: project.dev.sessions },
|
||||
qa: { active: project.qa.active, issueId: project.qa.issueId, model: project.qa.model, sessions: project.qa.sessions },
|
||||
queue: formatProjectQueues(queues),
|
||||
}));
|
||||
|
||||
await auditLog(workspaceDir, "status", {
|
||||
projectCount: projects.length,
|
||||
totalToImprove: projects.reduce((s, p) => s + p.queue.toImprove.length, 0),
|
||||
totalToTest: projects.reduce((s, p) => s + p.queue.toTest.length, 0),
|
||||
totalToDo: projects.reduce((s, p) => s + p.queue.toDo.length, 0),
|
||||
healthIssues: healthIssues.length,
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
execution: { plugin: { projectExecution }, projects: configs },
|
||||
sequences, projects,
|
||||
health: includeHealth ? { issues: healthIssues, note: activeSessions.length === 0 ? "No activeSessions — zombie detection skipped." : undefined } : undefined,
|
||||
context: {
|
||||
type: context.type,
|
||||
...(context.type === "group" && { projectName: context.projectName, autoFiltered: !params.projectGroupId }),
|
||||
},
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
/** Valid author roles for attribution */
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
|
||||
*
|
||||
* 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 (qa tier)
|
||||
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV tier)
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
import {
|
||||
deactivateWorker,
|
||||
getProject,
|
||||
getSessionForModel,
|
||||
getWorker,
|
||||
readProjects,
|
||||
} from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_complete",
|
||||
label: "Task Complete",
|
||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. 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). Use "blocked" when the worker cannot complete the task (errors, missing info, etc.).`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
properties: {
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["dev", "qa"],
|
||||
description: "Worker role completing the task",
|
||||
},
|
||||
result: {
|
||||
type: "string",
|
||||
enum: ["done", "pass", "fail", "refine", "blocked"],
|
||||
description:
|
||||
'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input), "blocked" (cannot complete, needs escalation)',
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram/WhatsApp group ID (key in projects.json)",
|
||||
},
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Brief summary for group announcement",
|
||||
},
|
||||
prUrl: {
|
||||
type: "string",
|
||||
description: "Pull Request or Merge Request URL (optional, auto-detected if not provided)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa";
|
||||
const result = params.result as "done" | "pass" | "fail" | "refine" | "blocked";
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
let prUrl = params.prUrl as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// Validate result matches role
|
||||
if (role === "dev" && result !== "done" && result !== "blocked") {
|
||||
throw new Error(
|
||||
`DEV can only complete with "done" or "blocked", got "${result}"`,
|
||||
);
|
||||
}
|
||||
if (role === "qa" && result === "done") {
|
||||
throw new Error(
|
||||
`QA cannot use result "done". Use "pass", "fail", "refine", or "blocked".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve project
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for groupId: ${groupId}`);
|
||||
}
|
||||
|
||||
const worker = getWorker(project, role);
|
||||
if (!worker.active) {
|
||||
throw new Error(
|
||||
`${role.toUpperCase()} worker is not active on ${project.name}. Nothing to complete.`,
|
||||
);
|
||||
}
|
||||
|
||||
const issueId = worker.issueId
|
||||
? Number(worker.issueId.split(",")[0])
|
||||
: null;
|
||||
if (!issueId) {
|
||||
throw new Error(
|
||||
`No issueId found for active ${role.toUpperCase()} worker on ${project.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId,
|
||||
role,
|
||||
result,
|
||||
};
|
||||
|
||||
// === DEV DONE ===
|
||||
if (role === "dev" && result === "done") {
|
||||
try {
|
||||
await execFileAsync("git", ["pull"], {
|
||||
cwd: repoPath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
output.gitPull = "success";
|
||||
} catch (err) {
|
||||
output.gitPull = `warning: ${(err as Error).message}`;
|
||||
}
|
||||
|
||||
// Fetch issue to get URL
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const issueUrl = issue.web_url;
|
||||
|
||||
// Auto-detect PR/MR URL if not provided
|
||||
if (!prUrl) {
|
||||
try {
|
||||
prUrl = await provider.getMergedMRUrl(issueId) ?? undefined;
|
||||
} catch (err) {
|
||||
// Ignore errors in PR URL detection
|
||||
}
|
||||
}
|
||||
|
||||
await deactivateWorker(workspaceDir, groupId, "dev");
|
||||
await provider.transitionLabel(issueId, "Doing", "To Test");
|
||||
|
||||
output.labelTransition = "Doing → To Test";
|
||||
|
||||
// Build announcement with URLs
|
||||
let announcement = `✅ DEV done #${issueId}`;
|
||||
if (summary) {
|
||||
announcement += ` — ${summary}`;
|
||||
}
|
||||
announcement += `\n📋 Issue: ${issueUrl}`;
|
||||
if (prUrl) {
|
||||
announcement += `\n🔗 PR: ${prUrl}`;
|
||||
}
|
||||
announcement += `\nMoved to QA queue.`;
|
||||
|
||||
output.announcement = announcement;
|
||||
output.issueUrl = issueUrl;
|
||||
if (prUrl) {
|
||||
output.prUrl = prUrl;
|
||||
}
|
||||
|
||||
if (project.autoChain) {
|
||||
try {
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role: "qa",
|
||||
modelAlias: "qa",
|
||||
fromLabel: "To Test",
|
||||
toLabel: "Testing",
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(
|
||||
id,
|
||||
from as StateLabel,
|
||||
to as StateLabel,
|
||||
),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
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") {
|
||||
// Fetch issue to get URL
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const issueUrl = issue.web_url;
|
||||
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "Done");
|
||||
await provider.closeIssue(issueId);
|
||||
|
||||
output.labelTransition = "Testing → Done";
|
||||
output.issueClosed = true;
|
||||
output.issueUrl = issueUrl;
|
||||
output.announcement = `🎉 QA PASS #${issueId}${summary ? ` — ${summary}` : ""}\n📋 Issue: ${issueUrl}\nIssue closed.`;
|
||||
}
|
||||
|
||||
// === QA FAIL ===
|
||||
if (role === "qa" && result === "fail") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "To Improve");
|
||||
await provider.reopenIssue(issueId);
|
||||
|
||||
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 (project.autoChain && devModel) {
|
||||
try {
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const issue = await provider.getIssue(issueId);
|
||||
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) =>
|
||||
provider.transitionLabel(
|
||||
id,
|
||||
from as StateLabel,
|
||||
to as StateLabel,
|
||||
),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
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.nextAction = "dev_fix";
|
||||
}
|
||||
}
|
||||
|
||||
// === QA REFINE ===
|
||||
if (role === "qa" && result === "refine") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "Refining");
|
||||
|
||||
output.labelTransition = "Testing → Refining";
|
||||
output.announcement = `🤔 QA REFINE #${issueId}${summary ? ` — ${summary}` : ""}. Awaiting human decision.`;
|
||||
}
|
||||
|
||||
// === DEV BLOCKED ===
|
||||
if (role === "dev" && result === "blocked") {
|
||||
await deactivateWorker(workspaceDir, groupId, "dev");
|
||||
await provider.transitionLabel(issueId, "Doing", "To Do");
|
||||
|
||||
output.labelTransition = "Doing → To Do";
|
||||
output.announcement = `🚫 DEV BLOCKED #${issueId}${summary ? ` — ${summary}` : ""}. Returned to queue.`;
|
||||
}
|
||||
|
||||
// === QA BLOCKED ===
|
||||
if (role === "qa" && result === "blocked") {
|
||||
await deactivateWorker(workspaceDir, groupId, "qa");
|
||||
await provider.transitionLabel(issueId, "Testing", "To Test");
|
||||
|
||||
output.labelTransition = "Testing → To Test";
|
||||
output.announcement = `🚫 QA BLOCKED #${issueId}${summary ? ` — ${summary}` : ""}. Returned to QA queue.`;
|
||||
}
|
||||
|
||||
// Send notification to project group
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
|
||||
// Determine next state for the notification
|
||||
let nextState: string | undefined;
|
||||
if (role === "dev" && result === "done") {
|
||||
nextState = "QA queue";
|
||||
} else if (role === "dev" && result === "blocked") {
|
||||
nextState = "returned to queue";
|
||||
} else if (role === "qa" && result === "pass") {
|
||||
nextState = "Done!";
|
||||
} else if (role === "qa" && result === "fail") {
|
||||
nextState = "back to DEV";
|
||||
} else if (role === "qa" && result === "refine") {
|
||||
nextState = "awaiting human decision";
|
||||
} else if (role === "qa" && result === "blocked") {
|
||||
nextState = "returned to QA queue";
|
||||
}
|
||||
|
||||
await notify(
|
||||
{
|
||||
type: "workerComplete",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId,
|
||||
role,
|
||||
result,
|
||||
summary,
|
||||
nextState,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: project.channel ?? "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "task_complete", {
|
||||
project: project.name,
|
||||
groupId,
|
||||
issue: issueId,
|
||||
role,
|
||||
result,
|
||||
summary: summary ?? null,
|
||||
labelTransition: output.labelTransition,
|
||||
autoChain: output.autoChain ?? null,
|
||||
});
|
||||
|
||||
return jsonResult(output);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../task-managers/task-manager.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
|
||||
const STATE_LABELS: StateLabel[] = [
|
||||
"Planning",
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* task_pickup — Atomically pick up a task from the issue queue.
|
||||
*
|
||||
* Auto-detects:
|
||||
* - projectGroupId: from message context (group chat)
|
||||
* - role: from issue label (To Do/To Improve → dev, To Test → qa)
|
||||
* - model: from tier labels on issue → heuristics → default
|
||||
* - issueId: if omitted, picks next by priority (To Improve > To Test > To Do)
|
||||
*
|
||||
* Handles: validation, model selection, then delegates to dispatchTask()
|
||||
* for label transition, session creation/reuse, task dispatch, state update,
|
||||
* and audit logging.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { type Issue, type StateLabel } from "../task-managers/task-manager.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { activateWorker, getProject, getWorker, readProjects } from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { isDevTier, isTier, type Tier } from "../tiers.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
|
||||
/** Labels that map to QA role */
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
|
||||
/** All pickable labels, in priority order (highest first) */
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
|
||||
/** Tier labels that can appear on issues */
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
/**
|
||||
* Detect role from issue's current state label.
|
||||
*/
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tier from issue labels (e.g., "junior", "senior").
|
||||
*/
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lowerLabels = labels.map((l) => l.toLowerCase());
|
||||
for (const tier of TIER_LABELS) {
|
||||
if (lowerLabels.includes(tier)) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next issue to pick up by priority.
|
||||
* Priority: To Improve > To Test > To Do
|
||||
*/
|
||||
async function findNextIssue(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role?: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
// Filter priority order by role if specified
|
||||
let labelsToCheck = PRIORITY_ORDER;
|
||||
if (role === "dev") {
|
||||
labelsToCheck = PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l));
|
||||
} else if (role === "qa") {
|
||||
labelsToCheck = PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l));
|
||||
}
|
||||
|
||||
for (const label of labelsToCheck) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) {
|
||||
// Return oldest issue first (FIFO)
|
||||
const oldest = issues[issues.length - 1];
|
||||
return { issue: oldest, label };
|
||||
}
|
||||
} catch {
|
||||
// Continue to next label on error
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_pickup",
|
||||
label: "Task Pickup",
|
||||
description: `Pick up a task from the issue queue. Context-aware: ONLY works in project group chats, not in DMs or during setup. Handles label transition, tier assignment, session creation, task dispatch, and audit logging. Returns an announcement for posting in the group.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: [],
|
||||
properties: {
|
||||
issueId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Issue ID to pick up. If omitted, picks next by priority (To Improve > To Test > To Do).",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["dev", "qa"],
|
||||
description:
|
||||
"Worker role: dev or qa. If omitted, auto-detected from issue label (To Do/To Improve → dev, To Test → qa).",
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Telegram/WhatsApp group ID (key in projects.json). If omitted, auto-detected from current group chat context.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
description:
|
||||
"Developer tier (junior, medior, senior, qa). If omitted, detected from issue tier labels, then heuristics.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
||||
const groupIdParam = params.projectGroupId as string | undefined;
|
||||
const modelParam = params.model as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// ONLY allow in group context
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "task_pickup can only be used in project group chats.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw, use devclaw_onboard instead."
|
||||
: "To pick up tasks, please use the relevant project's Telegram/WhatsApp group.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Auto-detect projectGroupId from context if not provided
|
||||
const groupId = groupIdParam ?? context.groupId;
|
||||
|
||||
// 2. Resolve project
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Project not found for groupId: ${groupId}. Available: ${Object.keys(data.projects).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Create provider for issue operations
|
||||
const { provider } = createProvider({
|
||||
repo: project.repo,
|
||||
});
|
||||
|
||||
// 4. Find issue (by ID or auto-pick)
|
||||
let issue: Issue;
|
||||
let currentLabel: StateLabel;
|
||||
|
||||
if (issueIdParam !== undefined) {
|
||||
// Explicit issue ID provided
|
||||
issue = await provider.getIssue(issueIdParam);
|
||||
const label = provider.getCurrentStateLabel(issue);
|
||||
if (!label) {
|
||||
throw new Error(
|
||||
`Issue #${issueIdParam} has no recognized state label. Expected one of: ${PRIORITY_ORDER.join(", ")}`,
|
||||
);
|
||||
}
|
||||
currentLabel = label;
|
||||
} else {
|
||||
// Auto-pick next issue by priority
|
||||
const next = await findNextIssue(provider, roleParam);
|
||||
if (!next) {
|
||||
const roleFilter = roleParam ? ` for ${roleParam.toUpperCase()}` : "";
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: `No issues available${roleFilter}. Queue is empty.`,
|
||||
checkedLabels: roleParam
|
||||
? PRIORITY_ORDER.filter((l) =>
|
||||
roleParam === "dev"
|
||||
? DEV_LABELS.includes(l)
|
||||
: QA_LABELS.includes(l),
|
||||
)
|
||||
: PRIORITY_ORDER,
|
||||
});
|
||||
}
|
||||
issue = next.issue;
|
||||
currentLabel = next.label;
|
||||
}
|
||||
|
||||
// 5. Auto-detect role from issue label if not provided
|
||||
const detectedRole = detectRoleFromLabel(currentLabel);
|
||||
if (!detectedRole) {
|
||||
throw new Error(
|
||||
`Issue #${issue.iid} has label "${currentLabel}" which doesn't map to dev or qa. Expected: ${[...DEV_LABELS, ...QA_LABELS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleParam ?? detectedRole;
|
||||
|
||||
// Verify role matches label (if role was explicitly provided)
|
||||
if (roleParam && roleParam !== detectedRole) {
|
||||
throw new Error(
|
||||
`Role mismatch: issue #${issue.iid} has label "${currentLabel}" (${detectedRole.toUpperCase()}) but role "${roleParam}" was requested.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Check no active worker for this role
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
throw new Error(
|
||||
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}). Complete current task first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6b. Check project-level roleExecution
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
if (roleExecution === "sequential") {
|
||||
const otherRole = role === "dev" ? "qa" : "dev";
|
||||
const otherWorker = getWorker(project, otherRole);
|
||||
if (otherWorker.active) {
|
||||
throw new Error(
|
||||
`Project "${project.name}" has sequential roleExecution: ${otherRole.toUpperCase()} worker is active (issue: ${otherWorker.issueId}). Wait for it to complete first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Select model (priority: param > tier label > heuristic)
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let modelAlias: string;
|
||||
let modelReason: string;
|
||||
let modelSource: string;
|
||||
|
||||
if (modelParam) {
|
||||
// Explicit model param
|
||||
modelAlias = modelParam;
|
||||
modelReason = "LLM-selected by orchestrator";
|
||||
modelSource = "llm";
|
||||
} else {
|
||||
// Check for tier labels on the issue
|
||||
const tierFromLabels = detectTierFromLabels(issue.labels);
|
||||
|
||||
if (tierFromLabels) {
|
||||
// Validate tier matches role
|
||||
if (role === "qa" && tierFromLabels !== "qa") {
|
||||
// QA role should use qa tier, ignore dev tier labels
|
||||
modelAlias = "qa";
|
||||
modelReason = `QA role overrides tier label "${tierFromLabels}"`;
|
||||
modelSource = "role-override";
|
||||
} else if (role === "dev" && tierFromLabels === "qa") {
|
||||
// Dev role shouldn't use qa tier, fall back to heuristic
|
||||
const selected = selectModel(
|
||||
issue.title,
|
||||
issue.description ?? "",
|
||||
role,
|
||||
);
|
||||
modelAlias = selected.tier;
|
||||
modelReason = `Ignored "qa" tier label for DEV role; ${selected.reason}`;
|
||||
modelSource = "heuristic";
|
||||
} else {
|
||||
modelAlias = tierFromLabels;
|
||||
modelReason = `Tier label found on issue: "${tierFromLabels}"`;
|
||||
modelSource = "label";
|
||||
}
|
||||
} else {
|
||||
// Fall back to keyword heuristic
|
||||
const selected = selectModel(
|
||||
issue.title,
|
||||
issue.description ?? "",
|
||||
role,
|
||||
);
|
||||
modelAlias = selected.tier;
|
||||
modelReason = selected.reason;
|
||||
modelSource = "heuristic";
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Dispatch via shared logic
|
||||
const pluginConfig = api.pluginConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const dispatchResult = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issue.description ?? "",
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
modelAlias,
|
||||
fromLabel: currentLabel,
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) =>
|
||||
provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig,
|
||||
sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
// 8b. Explicitly update worker state in projects.json
|
||||
// Defense in depth: ensure state is set even if dispatchTask had issues
|
||||
const now = new Date().toISOString();
|
||||
const stateUpdateParams: {
|
||||
issueId: string;
|
||||
model: string;
|
||||
sessionKey?: string;
|
||||
startTime?: string;
|
||||
} = {
|
||||
issueId: String(issue.iid),
|
||||
model: modelAlias,
|
||||
};
|
||||
|
||||
// Only set sessionKey and startTime on new spawn (not on reuse)
|
||||
if (dispatchResult.sessionAction === "spawn") {
|
||||
stateUpdateParams.sessionKey = dispatchResult.sessionKey;
|
||||
stateUpdateParams.startTime = now;
|
||||
}
|
||||
|
||||
await activateWorker(workspaceDir, groupId, role, stateUpdateParams);
|
||||
|
||||
// 9. Send notification to project group
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{
|
||||
type: "workerStart",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: context.channel,
|
||||
},
|
||||
);
|
||||
|
||||
// 10. Build result
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
fullModel: dispatchResult.fullModel,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
announcement: dispatchResult.announcement,
|
||||
labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelReason,
|
||||
modelSource,
|
||||
autoDetected: {
|
||||
projectGroupId: !groupIdParam,
|
||||
role: !roleParam,
|
||||
issueId: issueIdParam === undefined,
|
||||
model: !modelParam,
|
||||
},
|
||||
};
|
||||
|
||||
if (dispatchResult.sessionAction === "send") {
|
||||
result.tokensSavedEstimate = "~50K (session reuse)";
|
||||
}
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { createProvider } from "../task-managers/index.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import type { StateLabel } from "../task-managers/task-manager.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
|
||||
const STATE_LABELS: StateLabel[] = [
|
||||
"Planning",
|
||||
|
||||
120
lib/tools/work-finish.ts
Normal file
120
lib/tools/work-finish.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* work_finish — Complete a task (DEV done, QA pass/fail/refine/blocked).
|
||||
*
|
||||
* Delegates side-effects to pipeline service, then handles notifications,
|
||||
* audit, and optional auto-chain dispatch.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { resolveRepoPath, readProjects, getProject, getWorker, getSessionForModel } from "../projects.js";
|
||||
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_finish",
|
||||
label: "Work Finish",
|
||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit. With auto-scheduling, dispatches the next step automatically.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
properties: {
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||
projectGroupId: { type: "string", description: "Project group ID" },
|
||||
summary: { type: "string", description: "Brief summary" },
|
||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa";
|
||||
const result = params.result as string;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
const prUrl = params.prUrl as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
// Validate role:result
|
||||
if (role === "dev" && result !== "done" && result !== "blocked")
|
||||
throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`);
|
||||
if (role === "qa" && result === "done")
|
||||
throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`);
|
||||
if (!getRule(role, result))
|
||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||
|
||||
// Resolve project + worker
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
if (!project) throw new Error(`Project not found for groupId: ${groupId}`);
|
||||
|
||||
const worker = getWorker(project, role);
|
||||
if (!worker.active) throw new Error(`${role.toUpperCase()} worker not active on ${project.name}`);
|
||||
|
||||
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
|
||||
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
|
||||
// Execute completion (pipeline service)
|
||||
const completion = await executeCompletion({
|
||||
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
||||
});
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
success: true, project: project.name, groupId, issueId, role, result,
|
||||
...completion,
|
||||
};
|
||||
|
||||
// Auto-chain dispatch
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const scheduling = (pluginConfig?.scheduling as string) ?? "auto";
|
||||
|
||||
if (scheduling === "auto") {
|
||||
const chainRole = result === "done" ? "qa" : result === "fail" ? "dev" : null;
|
||||
if (chainRole) {
|
||||
const chainModel = chainRole === "qa" ? "qa" : (getWorker(project, "dev").model ?? "medior");
|
||||
try {
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const chainResult = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project, issueId,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role: chainRole, modelAlias: chainModel,
|
||||
fromLabel: result === "done" ? "To Test" : "To Improve",
|
||||
toLabel: chainRole === "qa" ? "Testing" : "Doing",
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
output.autoChain = { dispatched: true, role: chainRole, model: chainResult.modelAlias, announcement: chainResult.announcement };
|
||||
} catch (err) {
|
||||
output.autoChain = { dispatched: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "workerComplete", project: project.name, groupId, issueId, role, result: result as "done" | "pass" | "fail" | "refine" | "blocked", summary, nextState: NEXT_STATE[`${role}:${result}`] },
|
||||
{ workspaceDir, config: notifyConfig, groupId, channel: project.channel ?? "telegram" },
|
||||
);
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "work_finish", {
|
||||
project: project.name, groupId, issue: issueId, role, result,
|
||||
summary: summary ?? null, labelTransition: completion.labelTransition,
|
||||
autoChain: output.autoChain ?? null,
|
||||
});
|
||||
|
||||
return jsonResult(output);
|
||||
},
|
||||
});
|
||||
}
|
||||
176
lib/tools/work-start.ts
Normal file
176
lib/tools/work-start.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* work_start — Pick up a task from the issue queue.
|
||||
*
|
||||
* Context-aware: ONLY works in project group chats.
|
||||
* Auto-detects: projectGroupId, role, model, issueId.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { activateWorker, getProject, getWorker, readProjects } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { isDevTier, isTier, type Tier } from "../tiers.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||
|
||||
function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
if (DEV_LABELS.includes(label)) return "dev";
|
||||
if (QA_LABELS.includes(label)) return "qa";
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
return TIER_LABELS.find((t) => lower.includes(t)) ?? null;
|
||||
}
|
||||
|
||||
async function findNextIssue(
|
||||
provider: { listIssuesByLabel(label: StateLabel): Promise<Issue[]> },
|
||||
role?: "dev" | "qa",
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role === "dev" ? PRIORITY_ORDER.filter((l) => DEV_LABELS.includes(l))
|
||||
: role === "qa" ? PRIORITY_ORDER.filter((l) => QA_LABELS.includes(l))
|
||||
: PRIORITY_ORDER;
|
||||
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_start",
|
||||
label: "Work Start",
|
||||
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, and audit.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." },
|
||||
projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." },
|
||||
model: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
||||
const groupIdParam = params.projectGroupId as string | undefined;
|
||||
const modelParam = params.model as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
if (!workspaceDir) throw new Error("No workspace directory available");
|
||||
|
||||
// Context guard: group only
|
||||
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "work_start can only be used in project group chats.",
|
||||
recommendation: context.type === "via-agent" ? "Use onboard instead for setup." : "Use the relevant project group.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const groupId = groupIdParam ?? context.groupId;
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
if (!project) throw new Error(`Project not found for groupId: ${groupId}`);
|
||||
|
||||
const { provider } = createProvider({ repo: project.repo });
|
||||
|
||||
// Find issue
|
||||
let issue: Issue;
|
||||
let currentLabel: StateLabel;
|
||||
if (issueIdParam !== undefined) {
|
||||
issue = await provider.getIssue(issueIdParam);
|
||||
const label = provider.getCurrentStateLabel(issue);
|
||||
if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`);
|
||||
currentLabel = label;
|
||||
} else {
|
||||
const next = await findNextIssue(provider, roleParam);
|
||||
if (!next) return jsonResult({ success: false, error: `No issues available. Queue is empty.` });
|
||||
issue = next.issue;
|
||||
currentLabel = next.label;
|
||||
}
|
||||
|
||||
// Detect role
|
||||
const detectedRole = detectRoleFromLabel(currentLabel);
|
||||
if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`);
|
||||
const role = roleParam ?? detectedRole;
|
||||
if (roleParam && roleParam !== detectedRole) throw new Error(`Role mismatch: "${currentLabel}" → ${detectedRole}, requested ${roleParam}`);
|
||||
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
||||
const other = role === "dev" ? "qa" : "dev";
|
||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
||||
}
|
||||
|
||||
// Select model
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let modelAlias: string, modelReason: string, modelSource: string;
|
||||
if (modelParam) {
|
||||
modelAlias = modelParam; modelReason = "LLM-selected"; modelSource = "llm";
|
||||
} else {
|
||||
const tier = detectTierFromLabels(issue.labels);
|
||||
if (tier) {
|
||||
if (role === "qa" && tier !== "qa") { modelAlias = "qa"; modelReason = `QA overrides "${tier}"`; modelSource = "role-override"; }
|
||||
else if (role === "dev" && tier === "qa") { const s = selectModel(issue.title, issue.description ?? "", role); modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic"; }
|
||||
else { modelAlias = tier; modelReason = `Label: "${tier}"`; modelSource = "label"; }
|
||||
} else {
|
||||
const s = selectModel(issue.title, issue.description ?? "", role);
|
||||
modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic";
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
|
||||
// Ensure worker state
|
||||
const stateUpdate: { issueId: string; model: string; sessionKey?: string; startTime?: string } = {
|
||||
issueId: String(issue.iid), model: modelAlias,
|
||||
};
|
||||
if (dr.sessionAction === "spawn") {
|
||||
stateUpdate.sessionKey = dr.sessionKey;
|
||||
stateUpdate.startTime = new Date().toISOString();
|
||||
}
|
||||
await activateWorker(workspaceDir, groupId, role, stateUpdate);
|
||||
|
||||
// Notify
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, model: dr.modelAlias, sessionAction: dr.sessionAction },
|
||||
{ workspaceDir, config: notifyConfig, groupId, channel: context.channel },
|
||||
);
|
||||
|
||||
return jsonResult({
|
||||
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
||||
role, model: dr.modelAlias, fullModel: dr.fullModel, sessionAction: dr.sessionAction,
|
||||
announcement: dr.announcement, labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
modelReason, modelSource,
|
||||
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, model: !modelParam },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user