From 3188f88a97ed68b0c5ac2f1215a111e4c4d27585 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 4 Oct 2026 23:03:01 +0200 Subject: [PATCH] Fix what the review of Continue / Try again found - A chat on a runner can be picked up: retry is forwarded to the runner (which gets what it needs first). - An automation's run is run again by the automation (Run now), so its busy check, events and Needs you follow. - Messages waiting in a chat are sent or removed before a retry (they'd go along unasked). - A ticket's chat is recognised by its origin, also when the ticket is archived. - A VM that couldn't start gets its own advice; VMs that are off keep theirs. - Phone: the strip hides where the desktop does, one request at a time, a stale answer refreshes quietly; live step labels are plain words again; a run that already ended doesn't come back as working. - Quoted error text can't close Godmode's notes: tags are stripped until none is left, angle brackets neutralised. - The stop sentence comes from one place; the bar is announced to screen readers and shows when it is busy. --- apps/desktop/src/components/chat/turn-end.tsx | 45 +++++++++++++++---- apps/mobile/src/app/chat/[id].tsx | 40 ++++++++++++----- apps/mobile/src/components/message.tsx | 5 ++- apps/mobile/src/lib/format.ts | 4 +- apps/mobile/src/lib/live.ts | 7 ++- docs/ARCHITECTURE.md | 8 ++-- packages/core/src/remote/routing.ts | 5 ++- packages/core/src/runner/prompt.ts | 12 +++-- packages/core/src/runner/runner.ts | 3 +- packages/core/src/server/routes/chat.ts | 4 +- packages/core/src/services/questions.ts | 4 +- packages/core/src/services/retries.ts | 6 +++ packages/core/test/frame.test.ts | 29 +++++++++++- packages/core/test/remote-e2e.test.ts | 11 +++++ packages/shared/src/retry.ts | 7 ++- 15 files changed, 148 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/components/chat/turn-end.tsx b/apps/desktop/src/components/chat/turn-end.tsx index b861ad5c..8c6bc394 100644 --- a/apps/desktop/src/components/chat/turn-end.tsx +++ b/apps/desktop/src/components/chat/turn-end.tsx @@ -4,12 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { Agent, ConversationWithMessages, Message, RetryMode } from "@godmode/shared"; import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared"; -import { ArrowUpRight, MessageSquarePlus, Play, RotateCcw } from "lucide-react"; +import { ArrowUpRight, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useStartAgentChat } from "@/components/agents/agent-actions"; +import { useRunRoutine } from "@/components/agents/routine-item"; import { api, ApiRequestError, errorMessage } from "@/lib/api"; -import { useTasks } from "@/lib/hooks"; +import { useRoutines, useTasks } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; const PLATFORM_ORIGINS = new Set(["slack", "telegram", "teams"]); @@ -22,7 +23,9 @@ function fixFor(end: RunEnd, agentName: string): { text: string; to?: string; la case "cli": return { text: "Install Claude Code, then try again.", to: "/settings/system", label: "Open System" }; case "vm": - return { text: "Turn virtual machines on, or pick no VM below, then try again.", to: "/settings/vms", label: "VM settings" }; + return end.off + ? { text: "Turn virtual machines on, or pick no VM below, then try again.", to: "/settings/vms", label: "VM settings" } + : { text: "The VM couldn't be started — check it, or pick no VM below, then try again.", to: "/vms", label: "Open VMs" }; case "folder": return { text: `Pick another folder for ${agentName} below, then try again.` }; default: @@ -44,7 +47,9 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver retry: false, }); const { data: tasks = [] } = useTasks("all"); + const { data: routines = [] } = useRoutines(); const chat = useStartAgentChat(); + const runRoutine = useRunRoutine(); const name = agent?.name ?? "The agent"; const retry = useMutation({ mutationFn: () => api.conversations.retry(conversation.id, message.runId!), @@ -70,13 +75,14 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver if (!run || (run.status !== "failed" && run.status !== "cancelled")) return null; if (run.trigger === "dream" || run.trigger === "check" || PLATFORM_ORIGINS.has(conversation.origin)) return null; - const task = tasks.find((t) => t.conversationId === conversation.id); - if (task) { + // A ticket's chat (also one whose ticket is archived) goes on from the ticket. + if (conversation.origin === "task") { + const task = tasks.find((t) => t.conversationId === conversation.id); return ( - This chat works on ticket #{task.number} — continue it from there. + {task ? `This chat works on ticket #${task.number} — continue it from there.` : "This chat works on a ticket — continue it from there."} @@ -84,6 +90,27 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver ); } + // An automation's run is run again by the automation, so its own checks and history follow it. + if (run.trigger === "routine") { + const routine = routines.find((r) => r.id === run.routineId); + if (!routine) return null; + return ( + + This was “{routine.name}” — run the automation again. + + + + + + ); + } + const end = runEndOf(run.error ?? ""); if (!retryHelps(end)) { return ( @@ -101,7 +128,7 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver const mode: RetryMode = retryModeOf(message.blocks) === "continue" && conversation.claudeSessionId ? "continue" : "again"; const label = mode === "continue" ? "Continue" : "Try again"; const action = (variant: "outline" | "ghost") => ( - ); @@ -143,7 +170,7 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver function Row({ children }: { children: ReactNode }) { return ( -
+
{children}
); diff --git a/apps/mobile/src/app/chat/[id].tsx b/apps/mobile/src/app/chat/[id].tsx index 09d28602..7eb2510d 100644 --- a/apps/mobile/src/app/chat/[id].tsx +++ b/apps/mobile/src/app/chat/[id].tsx @@ -1,7 +1,7 @@ import { FlashList, type FlashListRef } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Pressable, StyleSheet, View } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -13,7 +13,7 @@ import { LiveStrip } from "@/components/live-strip"; import { AssistantMessage, UserMessage } from "@/components/message"; import { Icon } from "@/components/icon"; import { EmptyState, T, tap } from "@/components/ui"; -import { api, errorText } from "@/lib/api"; +import { ApiError, api, errorText } from "@/lib/api"; import { encodeFiles, type PendingFile } from "@/lib/attachments"; import { useAgents } from "@/lib/hooks"; import { useConversationRun, useLive } from "@/lib/live"; @@ -95,11 +95,13 @@ export default function Chat() { .catch((err) => Alert.alert("Couldn't continue", errorText(err))); }; - // The latest turn ended early (failed, stopped, cut off): one tap picks it up. - const ended = !run && !paused && conversation.data ? endedTurn(conversation.data) : null; + // The latest turn ended early (failed, stopped, cut off): one tap picks it up — when nothing else would happen first. + const [retrying, setRetrying] = useState(false); + const ended = !run && !paused && conversation.data && agent?.enabled !== false ? endedTurn(conversation.data) : null; const retry = () => { - if (!ended) return; + if (!ended || retrying) return; tap(); + setRetrying(true); api.conversations .retry(id, ended.runId) .then((result) => { @@ -108,7 +110,12 @@ export default function Chat() { ); useLive.getState().runStarted(result.run); }) - .catch((err) => Alert.alert("Couldn't pick this up", errorText(err))); + .catch((err) => { + // Something else moved the chat on (another tap, the computer): show that instead of an error. + if (err instanceof ApiError && err.code === "stale") void queryClient.invalidateQueries({ queryKey: qk.conversation(id) }); + else Alert.alert("Couldn't pick this up", errorText(err)); + }) + .finally(() => setRetrying(false)); }; const title = conversation.data?.title || "Chat"; @@ -151,7 +158,7 @@ export default function Chat() { onContinue={resume} /> ) : ended ? ( - + ) : null} 0) return null; const last = conv.messages[conv.messages.length - 1]; if (last?.role !== "assistant" || !last.runId) return null; const end = last.blocks[last.blocks.length - 1]; @@ -216,7 +226,7 @@ function endedTurn(conv: ConversationWithMessages): { runId: string; mode: Retry } /** Above the composer when the last turn ended early. */ -function EndedStrip({ mode, onRetry }: { mode: RetryMode; onRetry: () => void }) { +function EndedStrip({ mode, onRetry, busy }: { mode: RetryMode; onRetry: () => void; busy: boolean }) { const c = useColors(); return ( @@ -224,7 +234,15 @@ function EndedStrip({ mode, onRetry }: { mode: RetryMode; onRetry: () => void }) {mode === "continue" ? "Stopped before it was done" : "Didn't get through"} - + {mode === "continue" ? "Continue" : "Try again"} diff --git a/apps/mobile/src/components/message.tsx b/apps/mobile/src/components/message.tsx index 4fe06882..e323aad7 100644 --- a/apps/mobile/src/components/message.tsx +++ b/apps/mobile/src/components/message.tsx @@ -4,11 +4,12 @@ import { memo, useEffect, useState } from "react"; import { LayoutAnimation, Pressable, StyleSheet, View } from "react-native"; import Animated, { FadeIn, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated"; import type { Attachment, Message, MessageBlock } from "@godmode/shared"; +import { toolActivity } from "@godmode/shared"; import { FileChip } from "./attachments"; import { Icon } from "./icon"; import { Markdown } from "./markdown"; import { Row, T, tap } from "./ui"; -import { activityText, toolLabel } from "@/lib/format"; +import { toolLabel } from "@/lib/format"; import { radius, space, useColors } from "@/lib/theme"; type Step = Extract; @@ -209,7 +210,7 @@ function Steps({ steps, live }: { steps: Step[]; live?: boolean }) { const label = latest ? toolLabel(latest) : null; const failed = tools.some((t) => t.isError); const pending = live && latest && latest.result === undefined; - const summary = pending ? activityText(`Using ${latest.name.replace(/^mcp__.+?__/, "")}`) : label ? label.title : "Thought it through"; + const summary = pending ? toolActivity(latest.name, latest.input).replace(/…$/, "") : label ? label.title : "Thought it through"; const count = tools.length; return ( diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts index 503d6616..00f96c2e 100644 --- a/apps/mobile/src/lib/format.ts +++ b/apps/mobile/src/lib/format.ts @@ -160,8 +160,8 @@ const DOING: [RegExp, string][] = [ /** The core says "Using browser_navigate"; people read "Opening a page". */ export function activityText(label: string | null | undefined): string { if (!label) return "Thinking"; - // An older core sends "Using "; a current one sends plain words ("Using Linear…" among them). + // An older core sends "Using " (no "…"); a current one sends plain words ending in "…" ("Using Linear…"). const tool = /^Using ([\w.-]+)$/.exec(label)?.[1]; - if (!tool || !/[_.]/.test(tool)) return label.replace(/…$/, ""); + if (!tool) return label.replace(/…$/, ""); return DOING.find(([re]) => re.test(tool))?.[1] ?? "Working"; } diff --git a/apps/mobile/src/lib/live.ts b/apps/mobile/src/lib/live.ts index 7852ed1f..9902cabf 100644 --- a/apps/mobile/src/lib/live.ts +++ b/apps/mobile/src/lib/live.ts @@ -31,6 +31,8 @@ interface LiveState { /** Why it's offline in the cloud gateway's words (computer not connected, plan limit), when it said so. */ offlineReason: string | null; runs: Record; + /** Runs seen ending lately: an answer that arrives after their end doesn't bring them back as live. */ + ended: string[]; /** Latest activity per run; may arrive before the run itself is known. */ labels: Record; drafts: Record; @@ -49,6 +51,7 @@ export const useLive = create((set) => ({ status: "connecting", offlineReason: null, runs: {}, + ended: [], labels: {}, drafts: {}, frames: {}, @@ -62,7 +65,7 @@ export const useLive = create((set) => ({ drafts: Object.fromEntries(Object.entries(s.drafts).filter(([, d]) => ids.has(d.runId))), }; }), - runStarted: (run) => set((s) => ({ runs: { ...s.runs, [run.id]: { run, activity: s.labels[run.id] ?? null } } })), + runStarted: (run) => set((s) => (s.ended.includes(run.id) ? s : { runs: { ...s.runs, [run.id]: { run, activity: s.labels[run.id] ?? null } } })), runActivity: (runId, label) => set((s) => ({ labels: { ...s.labels, [runId]: label }, @@ -74,7 +77,7 @@ export const useLive = create((set) => ({ const { [run.id]: _label, ...labels } = s.labels; const drafts = { ...s.drafts }; if (drafts[run.conversationId]?.runId === run.id) delete drafts[run.conversationId]; - return { runs, labels, drafts }; + return { runs, labels, drafts, ended: [...s.ended.slice(-49), run.id] }; }), delta: (conversationId, draft) => set((s) => ({ drafts: { ...s.drafts, [conversationId]: draft } })), frame: (key, frame) => set((s) => ({ frames: { ...s.frames, [key]: frame } })), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 666eb206..1f3c94df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -298,9 +298,11 @@ queued, budgeted and reported like a message from the human: starting over. **Try again** otherwise: the run's own prompt is sent again (saved secrets stay masked; the marker says so). A turn an automation, follow-up or another agent started is answered to the human in this chat. * The chat gets a `retry` marker (a system message, `content` "Continue where you stopped" / "Try again"). -* Refused: a run that isn't the chat's latest (`stale`), one that didn't end early, a chat that works, stands still or - belongs to a ticket (`task_chat` — continued from the ticket) or a chat platform (`platform_chat` — the person asks - there), dreams and condition checks, and a chat too long to go on (`context`). Godmode's own end-of-turn sentences +* Refused: a run that isn't the chat's latest (`stale`), one that didn't end early, a chat that works, stands still, + has messages waiting (`queued` — they would go along unasked) or belongs to a ticket (`task_chat` — continued from the + ticket) or a chat platform (`platform_chat` — the person asks there), an automation's run (`automation` — the desktop + offers the automation's *Run now*, so its busy check, events and "Needs you" follow), dreams and condition checks, and + a chat too long to go on (`context`). A chat on a runner is picked up there (the request is forwarded). Godmode's own end-of-turn sentences live in `@godmode/shared` (`runEndOf`); for a sign-in, CLI, VM, folder or model error the desktop links to the fix and offers *Try again* next to it. diff --git a/packages/core/src/remote/routing.ts b/packages/core/src/remote/routing.ts index 0b6bc0f9..348a55fc 100644 --- a/packages/core/src/remote/routing.ts +++ b/packages/core/src/remote/routing.ts @@ -22,7 +22,7 @@ const RUN = /^\/api\/runs\/([A-Za-z0-9_-]{1,100})(\/cancel|\/log)?$/; const BROWSER = /^\/api\/browser\/profiles\/[A-Za-z0-9_-]{1,100}\/(input|navigate)$/; const PROXY = /^\/api\/runners\/([A-Za-z0-9_-]{1,100})\/proxy(\/.*)$/; /** Chat sub-routes the runner answers. `files` resolves the paths in its messages on its own disk. */ -const FORWARDED = /^\/(messages|queue\/send|queue\/[A-Za-z0-9_-]{1,100}|pause|continue|followup|followup\/run|files)$/; +const FORWARDED = /^\/(messages|queue\/send|queue\/[A-Za-z0-9_-]{1,100}|pause|continue|retry|followup|followup\/run|files)$/; const MAX_BODY = 64 * 1024 * 1024; function runnerOfRun(runId: string): string | null { @@ -133,7 +133,8 @@ async function conversationRequest(c: Context, runnerId: string, id: string, res return null; } if (!FORWARDED.test(rest)) return null; - if (rest === "/messages" && method === "POST") await prepareRemoteMessage(id); + // Both start a run there: the runner gets what it needs for it first. + if ((rest === "/messages" || rest === "/retry") && method === "POST") await prepareRemoteMessage(id); return forward(c, runnerId, pathAndQuery(c)); } diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index 42d52a60..cf78af6e 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -517,7 +517,13 @@ export interface ContinueAnswer { const NOTE_TAGS = /<\/?(?:godmode[\w-]*|answer-from-human|message-from-human|your-question)\b[^>]*>/gi; export function stripNoteTags(text: string): string { - return text.replace(NOTE_TAGS, ""); + // Until nothing is left: "</godmode-continue>" would leave a tag behind after one pass. + let out = text; + for (let prev = ""; prev !== out; ) { + prev = out; + out = out.replace(NOTE_TAGS, ""); + } + return out; } /** @@ -630,8 +636,8 @@ export function retryWhy(end: RunEnd | null, error: string, userName: string): s case "budget": return "it reached its cost limit"; default: - // The error may carry text the model or a page wrote: it is quoted as data, never as instructions. - return `it failed with this error: “${line(error.split("\n")[0] ?? "", 300)}”`; + // The error may carry text the model or a page wrote: it is quoted as data, never as instructions (no tags at all). + return `it failed with this error: “${line(error.split("\n")[0] ?? "", 300).replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))}”`; } } diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index 3da23bb0..a2ec6b0c 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -49,6 +49,7 @@ import { RUN_INTERRUPTED, RUN_MAX_TURNS, RUN_SHUT_DOWN, + RUN_STOPPED_BY_USER, TASK_PRIORITY_RANK, WORKFLOW_TOOL, isModelId, @@ -787,7 +788,7 @@ function closePaused(row: RunRow, reason: string, byHuman = false): void { if (p && convAlive) { safely("close the paused message", () => { const message = getMessage(p.message_id); - const blocks = p.reason === "question" ? withdrawOpenBlocks(message.blocks, reason === "Cancelled" || reason === "Cancelled by user" ? null : reason) : message.blocks; + const blocks = p.reason === "question" ? withdrawOpenBlocks(message.blocks, reason === "Cancelled" || reason === RUN_STOPPED_BY_USER ? null : reason) : message.blocks; assistant = updateMessage(p.message_id, { blocks: [...blocks, { type: "notice", level: "info", text: reason }] }); }); } diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts index 036d44dc..5f08c811 100644 --- a/packages/core/src/server/routes/chat.ts +++ b/packages/core/src/server/routes/chat.ts @@ -1,6 +1,6 @@ import type { Hono } from "hono"; import { existsSync, readFileSync } from "node:fs"; -import { EFFORT_OPTIONS, MAX_INSTRUCTIONS_LENGTH, isModelId } from "@godmode/shared"; +import { EFFORT_OPTIONS, MAX_INSTRUCTIONS_LENGTH, RUN_STOPPED_BY_USER, isModelId } from "@godmode/shared"; import { createConversation, deleteConversation, @@ -247,7 +247,7 @@ export function registerChatRoutes(app: Hono): void { }); app.post("/api/runs/:id/cancel", async (c) => { - await cancelRun(c.req.param("id"), "Cancelled by user", { byHuman: true }); + await cancelRun(c.req.param("id"), RUN_STOPPED_BY_USER, { byHuman: true }); return c.json({ ok: true as const }); }); diff --git a/packages/core/src/services/questions.ts b/packages/core/src/services/questions.ts index 4fa78493..c2427012 100644 --- a/packages/core/src/services/questions.ts +++ b/packages/core/src/services/questions.ts @@ -24,7 +24,7 @@ import type { Run, SendMessageInput, } from "@godmode/shared"; -import { parseSlashCommand } from "@godmode/shared"; +import { RUN_STOPPED_BY_USER, parseSlashCommand } from "@godmode/shared"; import { all, get, insert, run as sql } from "../db"; import { bus } from "../events/bus"; import { logger } from "../log"; @@ -520,7 +520,7 @@ export function withdrawOpenBlocks(blocks: MessageBlock[], reason: string | null } /** Cancel reasons that only say "it was stopped": the card says that itself. */ -const PLAIN_STOPS = new Set(["Cancelled", "Cancelled by user"]); +const PLAIN_STOPS = new Set(["Cancelled", RUN_STOPPED_BY_USER]); /** The run that waited for the question was stopped (runner.ts `closePaused`): the question is withdrawn. */ export function withdrawQuestion(runId: string, reason: string): void { diff --git a/packages/core/src/services/retries.ts b/packages/core/src/services/retries.ts index cc23d8e1..c3631549 100644 --- a/packages/core/src/services/retries.ts +++ b/packages/core/src/services/retries.ts @@ -27,10 +27,16 @@ export async function retryRun(conversationId: string, runId: string): Promise("SELECT id FROM runs WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1", conversationId)?.id; if (latest !== runId) throw new HttpError(409, "Something new happened in this chat since — scroll down to see it.", "stale"); if (activeRunForConversation(conversationId)) throw new HttpError(409, "The agent is already working in this chat.", "busy"); if (pauseOf(conversationId)) throw new HttpError(409, "This chat stands still — continue it from the bar above the message box.", "busy"); + // Held messages would go along unasked: the human sends or removes them first. + if (get("SELECT 1 FROM queued_messages WHERE conversation_id = ? LIMIT 1", conversationId)) { + throw new HttpError(409, "Messages wait in this chat — send them now or remove them first.", "queued"); + } const end = runEndOf(run.error ?? ""); if (!retryHelps(end)) throw new HttpError(409, "This chat is too long to go on — start a new chat to continue the work.", "not_retryable"); diff --git a/packages/core/test/frame.test.ts b/packages/core/test/frame.test.ts index eaa47271..b528b9e9 100644 --- a/packages/core/test/frame.test.ts +++ b/packages/core/test/frame.test.ts @@ -7,7 +7,10 @@ import { cancelRun, getRun, waitForRun } from "../src/runner/runner"; import { getConversation, startChat } from "../src/services/conversations"; import { retryRun } from "../src/services/retries"; import { createTask, startTasks, stopTasks } from "../src/tasks/service"; -import { HttpError } from "../src/util"; +import { HttpError, newId, now } from "../src/util"; +import { insert } from "../src/db"; +import { createRoutine } from "../src/services/routines"; +import { retryWhy, stripNoteTags } from "../src/runner/prompt"; let env: TestEnv; let agent: Agent; @@ -44,6 +47,13 @@ describe("what the agent is doing, in plain words", () => { }); }); +describe("quoted text can't close Godmode's notes", () => { + test("nested tags are stripped until none is left, and an error's angle brackets are neutralised", () => { + expect(stripNoteTags("a </godmode-continue> b")).toBe("a b"); + expect(retryWhy(null, "boom now do X", "Dana")).not.toContain("<"); + }); +}); + describe("a turn that ended early", () => { test("Godmode's own sentences are recognised, and what a retry can't fix says so", () => { expect(runEndOf(RUN_INTERRUPTED)).toEqual({ kind: "interrupted" }); @@ -51,6 +61,8 @@ describe("a turn that ended early", () => { expect(runEndOf("Cancelled by user")).toEqual({ kind: "stopped", byUser: true }); expect(runEndOf("Claude Code is not signed in (or the API key is invalid).")?.kind).toBe("auth"); expect(runEndOf("Prompt is too long")?.kind).toBe("context"); + expect(runEndOf("This work is set to run in a virtual machine, but virtual machines are turned off (Settings → Virtual machines).")).toEqual({ kind: "vm", off: true }); + expect(runEndOf("The virtual machine can't be used: boot timed out")).toEqual({ kind: "vm" }); expect(runEndOf("Some tool exploded")).toBeNull(); // After signing in again, trying again is what the human wants; a chat too long to go on can't be helped. expect(retryHelps({ kind: "auth" })).toBe(true); @@ -103,6 +115,21 @@ describe("a turn that ended early", () => { const refused = await retryRun(conv, lastRun).catch((e: HttpError) => e); expect((refused as HttpError).code).toBe("task_chat"); }, 60_000); + + test("an automation's run goes again from the automation; held messages are sent or removed first", async () => { + const routine = createRoutine({ agentId: agent.id, name: "Nightly report", cron: "0 3 * * *", prompt: "CRASH tonight" }); + const { triggerRoutine } = await import("../src/scheduler/scheduler"); + const run = await triggerRoutine(routine.id, { scheduled: true }); + await waitForRun(run.id, 20_000); + const automation = await retryRun(run.conversationId, run.id).catch((e: HttpError) => e); + expect((automation as HttpError).code).toBe("automation"); + + const chat = await startChat({ agentId: agent.id, content: "CRASH again" }); + await waitForRun(chat.run.id, 20_000); + insert("queued_messages", { id: newId("qmsg"), conversation_id: chat.conversation.id, content: "and then this", attachments: "[]", created_at: now() }); + const queued = await retryRun(chat.conversation.id, chat.run.id).catch((e: HttpError) => e); + expect((queued as HttpError).code).toBe("queued"); + }, 60_000); }); function getConversationIdOf(taskId: string): string { diff --git a/packages/core/test/remote-e2e.test.ts b/packages/core/test/remote-e2e.test.ts index 2b948ad4..62c96e72 100644 --- a/packages/core/test/remote-e2e.test.ts +++ b/packages/core/test/remote-e2e.test.ts @@ -184,6 +184,17 @@ describe("a runner, end to end", () => { expect(viaApi.messages.map((m) => m.id)).toContain(started.message.id); }, 60_000); + test("a turn that failed on the runner is tried again there, from here", async () => { + const res = await api("/api/chat", { method: "POST", body: JSON.stringify({ content: "CRASH on the runner", agentId, runnerId }) }); + const { conversation, run } = (await res.json()) as StartChatResult; + await until(() => get<{ status: string }>("SELECT status FROM runs WHERE id = ?", run.id)?.status === "failed", 30_000, "the run to fail"); + const retried = await api(`/api/conversations/${conversation.id}/retry`, { method: "POST", body: JSON.stringify({ runId: run.id }) }); + expect(retried.status).toBe(201); + const again = (await retried.json()) as { mode: string; run: { id: string } }; + expect(again.mode).toBe("again"); + await until(() => !!get<{ status: string }>("SELECT status FROM runs WHERE id = ? AND status IN ('failed', 'succeeded')", again.run.id), 30_000, "the retry to end"); + }, 60_000); + test("the fix-with-Claude chat runs here, gets the runner tools, and runner_exec runs on the runner", async () => { const started = await startAutofix(runnerId, { note: "It seems stuck" }); expect(started.conversation.runnerId).toBeNull(); diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 267aa386..a18f3ad3 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -14,6 +14,8 @@ export interface RunEnd { kind: RunEndKind; minutes?: number; byUser?: boolean; + /** `vm`: virtual machines are turned off (else the VM couldn't be started or used). */ + off?: boolean; } /** What one of Godmode's own end-of-turn sentences means; null for any other text (a plain error). */ @@ -25,11 +27,12 @@ export function runEndOf(text: string): RunEnd | null { if (timeout) return { kind: "timeout", minutes: Number(timeout[1]) }; if (t.startsWith(RUN_MAX_TURNS)) return { kind: "turns" }; if (/prompt is too long|context (?:length|window)|conversation is too long/i.test(t)) return { kind: "context" }; - if (t.startsWith(RUN_COST_LIMIT) || /^Stopped at its limit of \$[\d.]+ for one run/.test(t)) return { kind: "budget" }; + if (t.startsWith(RUN_COST_LIMIT)) return { kind: "budget" }; if (/^Claude Code is not signed in/.test(t) || /^(?:invalid api key|authentication_error|oauth token (?:has )?expired|credit balance is too low)/i.test(t)) return { kind: "auth" }; if (t.startsWith(RUN_CLI_MISSING)) return { kind: "cli" }; if (/Pick another folder for this chat\.|Change the default folder in /.test(t)) return { kind: "folder" }; - if (/virtual machines are turned off|virtual machine can't be used/i.test(t)) return { kind: "vm" }; + if (/virtual machines are turned off/i.test(t)) return { kind: "vm", off: true }; + if (/virtual machine can't be used/i.test(t)) return { kind: "vm" }; if (/^Invalid model id/.test(t)) return { kind: "model" }; if (t.startsWith(RUN_STOPPED_BY_USER)) return { kind: "stopped", byUser: true }; if (/^(Cancelled|Stopped)\b/.test(t) || t === "Restarted from the task board" || t.startsWith("Paused, but what it needed")) return { kind: "stopped" };