From ff83c25e8cf51daf71fa9f14733d609a4aae7b35 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Tue, 10 Feb 2026 23:14:12 +0800 Subject: [PATCH] feat: implement workerStart notifications for tick pickups and enhance tick handling --- lib/notify.ts | 38 ++++++++++++++++++++++++++++++++++++++ lib/services/tick.ts | 5 +++-- lib/tool-helpers.ts | 40 ++++++++++++++++++++++++++++++++++++++++ lib/tools/auto-pickup.ts | 8 +++++++- lib/tools/work-finish.ts | 23 ++++++++++------------- lib/tools/work-start.ts | 20 +++++++++----------- 6 files changed, 107 insertions(+), 27 deletions(-) diff --git a/lib/notify.ts b/lib/notify.ts index a01e628..447e0df 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -12,6 +12,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { log as auditLog } from "./audit.js"; +import type { TickAction } from "./services/tick.js"; const execFileAsync = promisify(execFile); @@ -226,6 +227,43 @@ export async function notify( return sendMessage(target, message, channel, opts.workspaceDir); } +/** + * Send workerStart notifications for each tick pickup. + * + * Called after projectTick() returns pickups — callers pass the array + * so each dispatched task gets a visible start notification in the project group. + */ +export async function notifyTickPickups( + pickups: TickAction[], + opts: { + workspaceDir: string; + config?: NotificationConfig; + channel?: string; + }, +): Promise { + for (const pickup of pickups) { + await notify( + { + type: "workerStart", + project: pickup.project, + groupId: pickup.groupId, + issueId: pickup.issueId, + issueTitle: pickup.issueTitle, + issueUrl: pickup.issueUrl, + role: pickup.role, + tier: pickup.tier, + sessionAction: pickup.sessionAction, + }, + { + workspaceDir: opts.workspaceDir, + config: opts.config, + groupId: pickup.groupId, + channel: opts.channel, + }, + ); + } +} + /** * Get notification config from plugin config. */ diff --git a/lib/services/tick.ts b/lib/services/tick.ts index b4cf6bb..06932fd 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -75,6 +75,7 @@ export type TickAction = { groupId: string; issueId: number; issueTitle: string; + issueUrl: string; role: "dev" | "qa"; tier: string; sessionAction: "spawn" | "send"; @@ -147,7 +148,7 @@ export async function projectTick(opts: { if (dryRun) { pickups.push({ - project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, + project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: selectedTier, sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn", announcement: `[DRY RUN] Would pick up #${issue.iid}`, @@ -162,7 +163,7 @@ export async function projectTick(opts: { pluginConfig, sessionKey, }); pickups.push({ - project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, + project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: dr.tier, sessionAction: dr.sessionAction, announcement: dr.announcement, }); } catch (err) { diff --git a/lib/tool-helpers.ts b/lib/tool-helpers.ts index 7bd29fb..3f54aaf 100644 --- a/lib/tool-helpers.ts +++ b/lib/tool-helpers.ts @@ -11,6 +11,8 @@ import type { InteractionContext } from "./context-guard.js"; import { detectContext, generateGuardrails } from "./context-guard.js"; import { readProjects, getProject, type Project, type ProjectsData } from "./projects.js"; import { createProvider, type ProviderWithType } from "./providers/index.js"; +import { projectTick, type TickAction } from "./services/tick.js"; +import { notifyTickPickups, getNotificationConfig } from "./notify.js"; /** * Require workspaceDir from context or throw a clear error. @@ -77,3 +79,41 @@ export function groupOnlyError(toolName: string, context: InteractionContext) { export function getPluginConfig(api: OpenClawPluginApi): Record | undefined { return api.pluginConfig as Record | undefined; } + +/** + * Run projectTick (non-fatal) and send workerStart notifications for any pickups. + * Returns the pickups array (empty on failure). + */ +export async function tickAndNotify(opts: { + workspaceDir: string; + groupId: string; + agentId?: string; + pluginConfig?: Record; + sessionKey?: string; + targetRole?: "dev" | "qa"; + channel?: string; +}): Promise { + let pickups: TickAction[] = []; + try { + const result = await projectTick({ + workspaceDir: opts.workspaceDir, + groupId: opts.groupId, + agentId: opts.agentId, + pluginConfig: opts.pluginConfig, + sessionKey: opts.sessionKey, + targetRole: opts.targetRole, + }); + pickups = result.pickups; + } catch { /* non-fatal: tick failure shouldn't break the caller */ } + + if (pickups.length) { + const notifyConfig = getNotificationConfig(opts.pluginConfig); + await notifyTickPickups(pickups, { + workspaceDir: opts.workspaceDir, + config: notifyConfig, + channel: opts.channel, + }); + } + + return pickups; +} diff --git a/lib/tools/auto-pickup.ts b/lib/tools/auto-pickup.ts index e59435d..58e058c 100644 --- a/lib/tools/auto-pickup.ts +++ b/lib/tools/auto-pickup.ts @@ -9,7 +9,7 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { readProjects } from "../projects.js"; import { log as auditLog } from "../audit.js"; -import { notify, getNotificationConfig } from "../notify.js"; +import { notify, notifyTickPickups, getNotificationConfig } from "../notify.js"; import { checkWorkerHealth, type HealthFix } from "../services/health.js"; import { projectTick, type TickAction } from "../services/tick.js"; import { requireWorkspaceDir, resolveContext, resolveProvider, getPluginConfig } from "../tool-helpers.js"; @@ -91,6 +91,12 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { pickups.push(...result.pickups.map((p) => ({ ...p, project: current.name }))); skipped.push(...result.skipped.map((s) => ({ project: current.name, ...s }))); pickupCount += result.pickups.length; + + // Send workerStart notifications for each pickup in this project + if (!dryRun && result.pickups.length > 0) { + const notifyConfig = getNotificationConfig(pluginConfig); + await notifyTickPickups(result.pickups, { workspaceDir, config: notifyConfig, channel: current.channel ?? "telegram" }); + } for (const p of result.pickups) { if (p.role === "dev") globalActiveDev++; else globalActiveQa++; } diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index b3858b9..d48f9e0 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -9,10 +9,9 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { getWorker, resolveRepoPath } from "../projects.js"; import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js"; -import { projectTick, type TickResult } from "../services/tick.js"; import { log as auditLog } from "../audit.js"; import { notify, getNotificationConfig } from "../notify.js"; -import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; +import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig, tickAndNotify } from "../tool-helpers.js"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -68,28 +67,26 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { ...completion, }; - // Tick: fill free slots after completion + // Notify completion const pluginConfig = getPluginConfig(api); - 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); 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" }, ); + // Tick: fill free slots + notify starts + const tickPickups = await tickAndNotify({ + workspaceDir, groupId, agentId: ctx.agentId, pluginConfig, sessionKey: ctx.sessionKey, + channel: project.channel ?? "telegram", + }); + if (tickPickups.length) output.tickPickups = tickPickups; + // Audit await auditLog(workspaceDir, "work_finish", { project: project.name, groupId, issue: issueId, role, result, summary: summary ?? null, labelTransition: completion.labelTransition, - tickPickups: tickResult?.pickups.length ?? 0, + tickPickups: tickPickups.length, }); return jsonResult(output); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index f8c26c1..7237a29 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -13,8 +13,8 @@ import { selectTier } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { notify, getNotificationConfig } from "../notify.js"; -import { findNextIssue, detectRoleFromLabel, detectTierFromLabels, projectTick, type TickResult } from "../services/tick.js"; -import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig } from "../tool-helpers.js"; +import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js"; +import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -109,14 +109,12 @@ export function createWorkStartTool(api: OpenClawPluginApi) { { workspaceDir, config: notifyConfig, groupId, channel: context.channel }, ); - // 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 */ } + // Tick: fill parallel slots + notify starts + const tickPickups = await tickAndNotify({ + workspaceDir, groupId, agentId: ctx.agentId, pluginConfig, sessionKey: ctx.sessionKey, + targetRole: role === "dev" ? "qa" : "dev", + channel: context.channel, + }); const output: Record = { success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, @@ -125,7 +123,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { tierReason, tierSource, autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam }, }; - if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups; + if (tickPickups.length) output.tickPickups = tickPickups; return jsonResult(output); },