From 55b062ac7662df3bf7f058d43c6a4d79291fc135 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Tue, 10 Feb 2026 21:46:11 +0800 Subject: [PATCH] refactor: replace autoChain with projectTick queue scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove hard-coded auto-chain dispatch (DEV done→QA, QA fail→DEV) and replace with a general-purpose projectTick service that scans the queue and fills free worker slots after every state transition. - Create lib/services/tick.ts: consolidates shared helpers and core projectTick() function from duplicated code in work-start/auto-pickup - work_finish: replaces auto-chain block with projectTick call - work_start: adds projectTick after dispatch to fill parallel slots - auto_pickup: delegates per-project loop to projectTick - Remove autoChain from Project type, migration code, and project-register - Remove scheduling config dependency from work_finish - Net -112 lines: simpler, self-healing pipeline Co-Authored-By: Claude Opus 4.6 --- lib/dispatch.ts | 2 +- lib/projects.ts | 4 - lib/services/tick.ts | 187 ++++++++++++++++++++++++++++++++++ lib/templates.ts | 20 ++-- lib/tools/auto-pickup.ts | 100 ++++-------------- lib/tools/project-register.ts | 1 - lib/tools/work-finish.ts | 45 +++----- lib/tools/work-start.ts | 58 ++++------- 8 files changed, 246 insertions(+), 171 deletions(-) create mode 100644 lib/services/tick.ts diff --git a/lib/dispatch.ts b/lib/dispatch.ts index ff1e305..124f544 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -1,5 +1,5 @@ /** - * dispatch.ts — Core dispatch logic shared by work_start and work_finish (auto-chain). + * dispatch.ts — Core dispatch logic shared by work_start, auto_pickup, and projectTick. * * Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI, * state update (activateWorker), and audit logging. diff --git a/lib/projects.ts b/lib/projects.ts index 5808e04..566a866 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -22,7 +22,6 @@ export type Project = { deployUrl: string; baseBranch: string; deployBranch: string; - autoChain: boolean; /** Messaging channel for this project's group (e.g. "telegram", "whatsapp", "discord", "slack"). Stored at registration time. */ channel?: string; /** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */ @@ -132,9 +131,6 @@ export async function readProjects(workspaceDir: string): Promise project.qa = project.qa ? migrateWorkerState(project.qa as unknown as Record) : emptyWorkerState([]); - if (project.autoChain === undefined) { - project.autoChain = false; - } if (!project.channel) { project.channel = "telegram"; } diff --git a/lib/services/tick.ts b/lib/services/tick.ts new file mode 100644 index 0000000..8d7facb --- /dev/null +++ b/lib/services/tick.ts @@ -0,0 +1,187 @@ +/** + * tick.ts — Project-level queue scan + dispatch. + * + * Core function: projectTick() scans one project's queue and fills free worker slots. + * Called by: work_start (fill parallel slot), work_finish (next pipeline step), auto_pickup (sweep). + */ +import type { Issue, StateLabel } from "../providers/provider.js"; +import type { IssueProvider } from "../providers/provider.js"; +import { createProvider } from "../providers/index.js"; +import { selectModel } from "../model-selector.js"; +import { getWorker, getSessionForModel, readProjects } from "../projects.js"; +import { dispatchTask } from "../dispatch.js"; +import type { Tier } from "../tiers.js"; + +// --------------------------------------------------------------------------- +// Shared constants + helpers (used by tick, work-start, auto-pickup) +// --------------------------------------------------------------------------- + +export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; +export const QA_LABELS: StateLabel[] = ["To Test"]; +export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"]; + +const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"]; + +export function detectTierFromLabels(labels: string[]): Tier | null { + const lower = labels.map((l) => l.toLowerCase()); + return TIER_LABELS.find((t) => lower.includes(t)) ?? null; +} + +export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null { + if (DEV_LABELS.includes(label)) return "dev"; + if (QA_LABELS.includes(label)) return "qa"; + return null; +} + +export async function findNextIssueForRole( + provider: Pick, + 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; +} + +/** + * Find next issue for any role (optional filter). Used by work_start for auto-detection. + */ +export async function findNextIssue( + provider: Pick, + 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; +} + +// --------------------------------------------------------------------------- +// projectTick +// --------------------------------------------------------------------------- + +export type TickAction = { + project: string; + groupId: string; + issueId: number; + issueTitle: string; + role: "dev" | "qa"; + model: string; + sessionAction: "spawn" | "send"; + announcement: string; +}; + +export type TickResult = { + pickups: TickAction[]; + skipped: Array<{ role?: string; reason: string }>; +}; + +/** + * Scan one project's queue and fill free worker slots. + * + * Does NOT run health checks (that's auto_pickup's job). + * Non-destructive: only dispatches if slots are free and issues are queued. + */ +export async function projectTick(opts: { + workspaceDir: string; + groupId: string; + agentId?: string; + sessionKey?: string; + pluginConfig?: Record; + dryRun?: boolean; + maxPickups?: number; + /** Only attempt this role. Used by work_start to fill the other slot. */ + targetRole?: "dev" | "qa"; +}): Promise { + const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole } = opts; + + const project = (await readProjects(workspaceDir)).projects[groupId]; + if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] }; + + const { provider } = createProvider({ repo: project.repo }); + const roleExecution = project.roleExecution ?? "parallel"; + const roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"]; + + const pickups: TickAction[] = []; + const skipped: TickResult["skipped"] = []; + let pickupCount = 0; + + for (const role of roles) { + if (maxPickups !== undefined && pickupCount >= maxPickups) { + skipped.push({ role, reason: "Max pickups reached" }); + continue; + } + + // Re-read fresh state (previous dispatch may have changed it) + const fresh = (await readProjects(workspaceDir)).projects[groupId]; + if (!fresh) break; + + const worker = getWorker(fresh, role); + if (worker.active) { + skipped.push({ role, reason: `Already active (#${worker.issueId})` }); + continue; + } + if (roleExecution === "sequential" && getWorker(fresh, role === "dev" ? "qa" : "dev").active) { + skipped.push({ 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"; + + // Tier selection: label → heuristic + 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: 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}`, + }); + } else { + try { + const dr = await dispatchTask({ + workspaceDir, agentId, groupId, project: fresh, 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, + }); + pickups.push({ + project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, + role, model: dr.modelAlias, sessionAction: dr.sessionAction, announcement: dr.announcement, + }); + } catch (err) { + skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` }); + continue; + } + } + pickupCount++; + } + + return { pickups, skipped }; +} diff --git a/lib/templates.ts b/lib/templates.ts index 3f5802b..2447fc2 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -101,7 +101,7 @@ All orchestration goes through these tools. You do NOT manually manage sessions, | \`task_update\` | Update issue title, description, or labels | | \`status\` | Scans issue queue + worker state + health checks | | \`work_start\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions | -| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-chains if enabled. | +| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-ticks queue after completion. | ### Pipeline Flow @@ -134,20 +134,14 @@ Evaluate each task and pass the appropriate developer tier to \`work_start\`: ### When Work Completes -Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. +Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. After completion, \`work_finish\` auto-ticks the queue to fill free slots: -**If \`autoChain\` is enabled on the project:** -- DEV "done" → QA is dispatched automatically (qa tier) -- QA "fail" → DEV fix is dispatched automatically (reuses previous DEV tier) -- QA "pass" / "refine" → pipeline done or needs human input, no chaining +- DEV "done" → issue moves to "To Test" → tick dispatches QA +- QA "fail" → issue moves to "To Improve" → tick dispatches DEV +- QA "pass" → Done, no further dispatch +- QA "refine" / blocked → needs human input -**If \`autoChain\` is disabled:** -- The \`work_finish\` response includes a \`nextAction\` hint -- \`"qa_pickup"\` → pick up QA for this issue -- \`"dev_fix"\` → pick up DEV to fix -- absent → pipeline done or needs human input - -Post the \`announcement\` from the tool response to Telegram. +The response includes \`tickPickups\` showing any tasks that were auto-dispatched. Post announcements from the tool response to Telegram. ### Role Instructions diff --git a/lib/tools/auto-pickup.ts b/lib/tools/auto-pickup.ts index a88519a..97ef7b4 100644 --- a/lib/tools/auto-pickup.ts +++ b/lib/tools/auto-pickup.ts @@ -1,51 +1,21 @@ /** * auto_pickup — Automated task pickup (heartbeat handler). * - * Health checks → queue scan → fill free worker slots. + * Health checks → projectTick per project → notify. * 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 { readProjects } from "../projects.js"; +import { detectContext } from "../context-guard.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"]; +import { projectTick, type TickAction } from "../services/tick.js"; 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 }, - 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) => ({ @@ -83,7 +53,7 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { } const healthFixes: Array = []; - const pickups: PickupAction[] = []; + const pickups: Array = []; const skipped: Array<{ project: string; role?: string; reason: string }> = []; let globalActiveDev = 0, globalActiveQa = 0, activeProjectCount = 0, pickupCount = 0; @@ -102,65 +72,31 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { } } - // Pass 2: pick up tasks + // Pass 2: projectTick per project 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; + // Sequential project guard (needs global state) 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 remaining = maxPickups !== undefined ? maxPickups - pickupCount : undefined; + const result = await projectTick({ + workspaceDir, groupId, agentId: ctx.agentId, pluginConfig, sessionKey: ctx.sessionKey, + dryRun, maxPickups: remaining, + }); - 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++; + pickups.push(...result.pickups.map((p) => ({ ...p, project: current.name }))); + skipped.push(...result.skipped.map((s) => ({ project: current.name, ...s }))); + pickupCount += result.pickups.length; + for (const p of result.pickups) { + if (p.role === "dev") globalActiveDev++; else globalActiveQa++; } + if (result.pickups.length > 0 && !projectActive) activeProjectCount++; } await auditLog(workspaceDir, "auto_pickup", { diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 9b3382b..e304905 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -204,7 +204,6 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) { deployUrl, baseBranch, deployBranch, - autoChain: false, channel: context.channel, roleExecution, dev: emptyWorkerState([...DEV_TIERS]), diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index f6c79d4..5c768f8 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -1,17 +1,16 @@ /** * 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. + * Delegates side-effects to pipeline service, then ticks the project queue + * to fill free slots, sends notifications, and logs to audit. */ 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 { readProjects, getProject, getWorker, resolveRepoPath } from "../projects.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 { projectTick, type TickResult } from "../services/tick.js"; import { log as auditLog } from "../audit.js"; import { notify, getNotificationConfig } from "../notify.js"; @@ -19,7 +18,7 @@ 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.`, + description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, audit, and auto-ticks the queue to fill free slots.`, parameters: { type: "object", required: ["role", "result", "projectGroupId"], @@ -74,31 +73,15 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { ...completion, }; - // Auto-chain dispatch + // Tick: fill free slots after completion const pluginConfig = api.pluginConfig as Record | 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 }; - } - } - } + let tickResult: TickResult | null = null; + try { + tickResult = await projectTick({ + workspaceDir, groupId, agentId: ctx.agentId, pluginConfig, sessionKey: ctx.sessionKey, + }); + } catch { /* non-fatal: tick failure shouldn't break work_finish */ } + if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups; // Notify const notifyConfig = getNotificationConfig(pluginConfig); @@ -111,7 +94,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { await auditLog(workspaceDir, "work_finish", { project: project.name, groupId, issue: issueId, role, result, summary: summary ?? null, labelTransition: completion.labelTransition, - autoChain: output.autoChain ?? null, + tickPickups: tickResult?.pickups.length ?? 0, }); return jsonResult(output); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 8eed9a3..0e90729 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -3,57 +3,25 @@ * * Context-aware: ONLY works in project group chats. * Auto-detects: projectGroupId, role, model, issueId. + * After dispatch, ticks the project queue to fill parallel slots. */ 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 type { 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 }, - 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; -} +import { findNextIssue, detectRoleFromLabel, detectTierFromLabels, projectTick, type TickResult } from "../services/tick.js"; 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.`, + description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`, parameters: { type: "object", properties: { @@ -92,7 +60,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const { provider } = createProvider({ repo: project.repo }); // Find issue - let issue: Issue; + let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; let currentLabel: StateLabel; if (issueIdParam !== undefined) { issue = await provider.getIssue(issueIdParam); @@ -164,13 +132,25 @@ export function createWorkStartTool(api: OpenClawPluginApi) { { workspaceDir, config: notifyConfig, groupId, channel: context.channel }, ); - return jsonResult({ + // Tick: fill parallel slots + let tickResult: TickResult | null = null; + try { + tickResult = await projectTick({ + workspaceDir, groupId, agentId: ctx.agentId, pluginConfig, sessionKey: ctx.sessionKey, + targetRole: role === "dev" ? "qa" : "dev", + }); + } catch { /* non-fatal */ } + + const output: Record = { 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 }, - }); + }; + if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups; + + return jsonResult(output); }, }); }