diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 1bc5b9f374..bb21029e1c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -858,6 +858,13 @@ const ChatPaneContent: React.FC = (props) => { } sendQueuedImmediatelyInFlightRef.current = queuedMessage.id; + // Release the duplicate-send guard only if it still points at this attempt; a + // newer queued message (or a clear) may have already reset it in the meantime. + const clearInFlightGuardIfCurrent = () => { + if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) { + sendQueuedImmediatelyInFlightRef.current = null; + } + }; try { // Set "interrupting" state immediately so UI shows "interrupting..." without flash. storeRaw.setInterrupting(workspaceId); @@ -865,16 +872,11 @@ const ChatPaneContent: React.FC = (props) => { workspaceId, options: { sendQueuedImmediately: true }, }); - if ( - !interruptResult.success && - sendQueuedImmediatelyInFlightRef.current === queuedMessage.id - ) { - sendQueuedImmediatelyInFlightRef.current = null; + if (!interruptResult.success) { + clearInFlightGuardIfCurrent(); } } catch (error) { - if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) { - sendQueuedImmediatelyInFlightRef.current = null; - } + clearInFlightGuardIfCurrent(); throw error; } }, [api, workspaceId, workspaceState?.queuedMessage, workspaceState?.canInterrupt, storeRaw]); diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 7d5213f57a..ae8d2136c7 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -232,6 +232,10 @@ function estimateBase64DataUrlBytes(dataUrl: string): number | null { } const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000; +// Shared so the three "blocked while editing a message" attachment guards surface identical copy +// and can't drift if one is reworded. +const EDIT_MODE_ATTACHMENT_ERROR_MESSAGE = "Attachments cannot be added while editing a message."; + export type { ChatInputProps, ChatInputAPI }; interface SendOverrides { @@ -2002,7 +2006,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } @@ -2041,7 +2045,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } @@ -2219,7 +2223,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } diff --git a/src/browser/features/Tools/Goal/goalToolUtils.tsx b/src/browser/features/Tools/Goal/goalToolUtils.tsx index 7e22685057..61f1b3f4b4 100644 --- a/src/browser/features/Tools/Goal/goalToolUtils.tsx +++ b/src/browser/features/Tools/Goal/goalToolUtils.tsx @@ -22,10 +22,15 @@ export function formatGoalElapsed(startedAtMs: number, nowMs: number = Date.now( return `${hours}h ${minutes % 60}m`; } +// Shared turn-count pluralization so the goal toolcards render "1 turn" / +// "N turns" consistently (was open-coded in formatGoalTurns and the set_goal +// toolcard's requested-turns formatter). +export function pluralizeTurns(count: number): string { + return `${count} turn${count === 1 ? "" : "s"}`; +} + export function formatGoalTurns(turnsUsed: number, turnCap: number | null): string { - return turnCap == null - ? `${turnsUsed} turn${turnsUsed === 1 ? "" : "s"}` - : `${turnsUsed} / ${turnCap} turns`; + return turnCap == null ? pluralizeTurns(turnsUsed) : `${turnsUsed} / ${turnCap} turns`; } export function formatGoalBudgetSummary(costCents: number, budgetCents: number | null): string { diff --git a/src/browser/features/Tools/SetGoalToolCall.tsx b/src/browser/features/Tools/SetGoalToolCall.tsx index a8fa242309..2b4853a3e5 100644 --- a/src/browser/features/Tools/SetGoalToolCall.tsx +++ b/src/browser/features/Tools/SetGoalToolCall.tsx @@ -20,6 +20,7 @@ import { extractGoalFromResult, formatGoalCents, formatGoalTurns, + pluralizeTurns, } from "./Goal/goalToolUtils"; interface SetGoalToolCallProps { @@ -43,7 +44,7 @@ function formatAppliedBudget(budgetCents: number | null): string { } function formatOptionalTurnCap(turnCap: number | null | undefined): string { - return turnCap == null ? "Workspace default" : `${turnCap} turn${turnCap === 1 ? "" : "s"}`; + return turnCap == null ? "Workspace default" : pluralizeTurns(turnCap); } export const SetGoalToolCall: React.FC = ({ diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index db7fd08810..15f3a10113 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -187,28 +187,34 @@ async function readSkillDescriptorFromDir( ): Promise { const skillFilePath = runtime.normalizePath("SKILL.md", skillDir); - let stat; - try { - stat = await runtime.stat(skillFilePath); - } catch { + // Every invalid-skill diagnostic for this directory shares the same identity + // (directoryName / scope / displayPath); only message + hint vary per failure. + const pushInvalidSkill = (message: string, hint: string): void => { options?.invalidSkills?.push({ directoryName, scope, displayPath: skillFilePath, - message: "SKILL.md is missing or unreadable.", - hint: "Create a SKILL.md file with YAML frontmatter (--- ... ---).", + message, + hint, }); + }; + + let stat; + try { + stat = await runtime.stat(skillFilePath); + } catch { + pushInvalidSkill( + "SKILL.md is missing or unreadable.", + "Create a SKILL.md file with YAML frontmatter (--- ... ---)." + ); return null; } if (stat.isDirectory) { - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: "SKILL.md is a directory (expected a file).", - hint: "Replace SKILL.md with a regular file.", - }); + pushInvalidSkill( + "SKILL.md is a directory (expected a file).", + "Replace SKILL.md with a regular file." + ); return null; } @@ -216,13 +222,7 @@ async function readSkillDescriptorFromDir( const sizeValidation = validateFileSize(stat); if (sizeValidation) { log.warn(`Skipping skill '${directoryName}' (${scope}): ${sizeValidation.error}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: sizeValidation.error, - hint: "Reduce SKILL.md size below 1MB.", - }); + pushInvalidSkill(sizeValidation.error, "Reduce SKILL.md size below 1MB."); return null; } @@ -232,13 +232,10 @@ async function readSkillDescriptorFromDir( } catch (err) { const message = getErrorMessage(err); log.warn(`Failed to read SKILL.md for ${directoryName}: ${message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: `Failed to read SKILL.md: ${message}`, - hint: "Check file permissions and ensure the file is UTF-8 text.", - }); + pushInvalidSkill( + `Failed to read SKILL.md: ${message}`, + "Check file permissions and ensure the file is UTF-8 text." + ); return null; } @@ -259,13 +256,10 @@ async function readSkillDescriptorFromDir( const validated = AgentSkillDescriptorSchema.safeParse(descriptor); if (!validated.success) { log.warn(`Invalid agent skill descriptor for ${directoryName}: ${validated.error.message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: `Invalid agent skill descriptor: ${validated.error.message}`, - hint: "Fix SKILL.md frontmatter fields to satisfy the skill schema.", - }); + pushInvalidSkill( + `Invalid agent skill descriptor: ${validated.error.message}`, + "Fix SKILL.md frontmatter fields to satisfy the skill schema." + ); return null; } @@ -273,13 +267,10 @@ async function readSkillDescriptorFromDir( } catch (err) { const message = err instanceof AgentSkillParseError ? err.message : getErrorMessage(err); log.warn(`Skipping invalid skill '${directoryName}' (${scope}): ${message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, + pushInvalidSkill( message, - hint: "Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name.", - }); + "Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name." + ); return null; } } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6b4cf734f2..ce4d18ae52 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1698,12 +1698,22 @@ export class TaskService { let config = this.config.loadConfigOrDefault(); let taskIndex = this.buildAgentTaskIndex(config); - let awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "awaiting_report" - ); - let runningTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "running" - ); + // Recompute the startup recovery candidate lists from a config snapshot. Hoisted into a + // closure so the post-interrupt refresh below reuses the exact same status filters. + const listStartupRecoveryCandidates = ( + sourceConfig: ProjectsConfig + ): { + awaitingReportTasks: AgentTaskWorkspaceEntry[]; + runningTasks: AgentTaskWorkspaceEntry[]; + } => ({ + awaitingReportTasks: this.listAgentTaskWorkspaces(sourceConfig).filter( + (t) => t.taskStatus === "awaiting_report" + ), + runningTasks: this.listAgentTaskWorkspaces(sourceConfig).filter( + (t) => t.taskStatus === "running" + ), + }); + let { awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config); let interruptedInactiveWorkflowOwnerAtStartup = false; for (const task of [...awaitingReportTasks, ...runningTasks]) { @@ -1725,10 +1735,7 @@ export class TaskService { // blocked by a child that this startup pass just interrupted. config = this.config.loadConfigOrDefault(); taskIndex = this.buildAgentTaskIndex(config); - awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "awaiting_report" - ); - runningTasks = this.listAgentTaskWorkspaces(config).filter((t) => t.taskStatus === "running"); + ({ awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config)); } let resumedAwaitingReportCount = 0; diff --git a/src/node/services/tools/heartbeat.ts b/src/node/services/tools/heartbeat.ts index 6330271b1a..376c5591e1 100644 --- a/src/node/services/tools/heartbeat.ts +++ b/src/node/services/tools/heartbeat.ts @@ -1,6 +1,10 @@ import { tool } from "ai"; import assert from "@/common/utils/assert"; -import type { ToolFactory, WorkspaceHeartbeatSettingsUpdate } from "@/common/utils/tools/tools"; +import type { + ToolFactory, + WorkspaceHeartbeatSettings, + WorkspaceHeartbeatSettingsUpdate, +} from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools"; import { getErrorMessage } from "@/common/utils/errors"; @@ -51,6 +55,22 @@ function summarize( return `Heartbeat is ${status} for this workspace at ${formatInterval(settings.intervalMs)}.`; } +// Build the shared success payload for every heartbeat action so the get/set/unset branches +// don't each re-assemble the same { action, configured, settings, summary } object. `configured` +// is derived from settings: null only for unset and an unconfigured get, non-null otherwise. +function buildSuccessResult( + action: HeartbeatToolArgs["action"], + settings: WorkspaceHeartbeatSettings | null +): HeartbeatToolResult { + return { + success: true, + action, + configured: settings != null, + settings, + summary: summarize({ action, settings }), + }; +} + export const createHeartbeatTool: ToolFactory = (config) => tool({ description: TOOL_DEFINITIONS.heartbeat.description, @@ -66,13 +86,7 @@ export const createHeartbeatTool: ToolFactory = (config) => if (args.action === "get") { const settings = heartbeatService.getHeartbeatSettings(workspaceId); - return { - success: true, - action: args.action, - configured: settings != null, - settings, - summary: summarize({ action: args.action, settings }), - }; + return buildSuccessResult(args.action, settings); } if (args.action === "unset") { @@ -80,13 +94,7 @@ export const createHeartbeatTool: ToolFactory = (config) => if (!unsetResult.success) { return { success: false, error: unsetResult.error }; } - return { - success: true, - action: args.action, - configured: false, - settings: null, - summary: summarize({ action: args.action, settings: null }), - }; + return buildSuccessResult(args.action, null); } const settingsUpdate: WorkspaceHeartbeatSettingsUpdate = {}; @@ -109,13 +117,7 @@ export const createHeartbeatTool: ToolFactory = (config) => } const settings = setResult.data; - return { - success: true, - action: args.action, - configured: true, - settings, - summary: summarize({ action: args.action, settings }), - }; + return buildSuccessResult(args.action, settings); } catch (error) { return { success: false, error: getErrorMessage(error) }; } diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 8c116bbf88..fc17296dad 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -36,6 +36,11 @@ import { import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { coerceNonEmptyString } from "@/node/services/taskUtils"; +// Plan agent is read-only: only `explore` sub-agent tasks may be spawned. Shared by both the +// workspace-turn guard and the per-launch agent-id guard so the message can't drift between them. +const PLAN_AGENT_EXPLORE_ONLY_ERROR = + 'In the plan agent you may only spawn agentId: "explore" tasks.'; + const BUILT_IN_TASK_TOOL_MARKER = Symbol("muxBuiltInTaskTool"); export function markBuiltInTaskTool( @@ -402,7 +407,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); if (config.planFileOnly && kind === "workspace") { - throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); + throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR); } if (kind === "workspace") { @@ -504,7 +509,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { // Plan agent is explicitly non-executing. Allow only read-only exploration tasks. if (config.planFileOnly && requestedAgentId !== "explore") { - throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); + throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR); } // Parent runtime model and thinking are forwarded as a low-priority fallback so diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index 81932237b2..da27555df5 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -38,6 +38,9 @@ import { const DEFAULT_TASK_AWAIT_TIMEOUT_MS = 600_000; const WORKFLOW_AWAIT_POLL_INTERVAL_MS = 250; +// Shared fallback used when a workspace-turn record errored without a specific message, +// so every error-status await result path reports identical text. +const WORKSPACE_TURN_DEFAULT_ERROR = "Workspace turn failed"; // Status values for which task_await still treats an agent task as live and // should surface the live status (plus an `elapsed_ms` field) instead of @@ -549,7 +552,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: snapshot.error ?? "Workspace turn failed", + error: snapshot.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } } @@ -620,7 +623,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: latest.error ?? "Workspace turn failed", + error: latest.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } return { @@ -654,7 +657,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: latest.error ?? "Workspace turn failed", + error: latest.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } if (latest.status === "interrupted") { diff --git a/src/node/services/workflows/WorkflowRunner.ts b/src/node/services/workflows/WorkflowRunner.ts index 5f2669cb59..ebf289cd10 100644 --- a/src/node/services/workflows/WorkflowRunner.ts +++ b/src/node/services/workflows/WorkflowRunner.ts @@ -952,6 +952,20 @@ export class WorkflowRunner { // Preserve the original child failure; workflow failure handling will surface that cause. } }; + // A child-task failure aborts the batch the same way regardless of which + // launch path raised it: a foreground-backgrounded wait flips the batch to + // backgrounded and aborts it (so the abort guards drain queued work), + // while any other failure interrupts the still-running siblings unless the + // batch was already backgrounded. Shared by the per-run and bulk-create + // catch blocks so the two stay in lockstep. + const applyChildFailureToBatch = async (error: unknown): Promise => { + if (isForegroundWaitBackgroundedError(error)) { + foregroundBackgrounded = true; + abortBatch(); + } else if (!foregroundBackgrounded) { + await interruptRemainingTasks(); + } + }; const batchWaitOptions: WorkflowAgentWaitOptions = { ...options.waitOptions, abortSignal: batchAbortController.signal, @@ -1029,12 +1043,7 @@ export class WorkflowRunner { return { runIndex, step, runResult }; } catch (error) { batchFailed = true; - if (isForegroundWaitBackgroundedError(error)) { - foregroundBackgrounded = true; - abortBatch(); - } else if (!foregroundBackgrounded) { - await interruptRemainingTasks(); - } + await applyChildFailureToBatch(error); return { runIndex, step, error }; } })(); @@ -1099,12 +1108,7 @@ export class WorkflowRunner { bulkCreatableSteps[index].taskId = createdTask.taskId; } } catch (error) { - if (isForegroundWaitBackgroundedError(error)) { - foregroundBackgrounded = true; - abortBatch(); - } else if (!foregroundBackgrounded) { - await interruptRemainingTasks(); - } + await applyChildFailureToBatch(error); throw error; } } diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 1bea338b4c..64e81ad702 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -583,14 +583,21 @@ export class WorkspaceGoalService { return true; } - private getFilePath(workspaceId: string): string { + // Shared resolver for the goal service's per-workspace session files + // (goal.json / goal-history.jsonl / goal-board.json). Centralizes the + // non-empty workspaceId guard and session-dir join so each file accessor + // doesn't re-assert and re-join the same way. + private resolveSessionFilePath(workspaceId: string, fileName: string): string { assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_FILE); + return path.join(this.config.getSessionDir(workspaceId), fileName); + } + + private getFilePath(workspaceId: string): string { + return this.resolveSessionFilePath(workspaceId, GOAL_FILE); } private getHistoryFilePath(workspaceId: string): string { - assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_HISTORY_FILE); + return this.resolveSessionFilePath(workspaceId, GOAL_HISTORY_FILE); } /** @@ -2928,8 +2935,7 @@ export class WorkspaceGoalService { // ─────────────────────────────────────────────────────────────────────── private getBoardFilePath(workspaceId: string): string { - assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_BOARD_FILE); + return this.resolveSessionFilePath(workspaceId, GOAL_BOARD_FILE); } private async readBoard(workspaceId: string): Promise {