From f5d9a575c0db4b822ac713c2bd25c665dee030e4 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 17:09:24 +0000 Subject: [PATCH 1/2] start a resumed CoS task immediately instead of leaving it pending for a manual Run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume and Relaunch both requeued the paused agent's task and stopped there. The requeue only makes a task ELIGIBLE — what spawns it is the automatic dequeue `completeAgent` schedules, and that path admits pending user tasks plus auto-approved system tasks under CoS auto-run in `execute` mode. So on an install with auto-run off, or for a task still awaiting approval, the task sat `pending` until the user opened the task list and pressed Run. One click became two, on the screen the user had just acted from. `resumeAgent` now force-spawns the task it requeued, through `forceSpawnTask` — the same door "Run now" uses — so it inherits those refusals rather than restating them. A stopped/paused daemon, an unreachable runner, a task needing approval, and a full agent pool all still mean "leave it queued"; the refusal comes back as `spawnHold` so the dialog names it instead of toasting a resume that silently didn't start. Relaunch is a pause plus a resume, so it inherits the dispatch through that composition. Deliberately not pushed deeper into `reviveBlockedTask` or the `tasks:changed` unblock listener: those are shared with the autonomous revival paths (investigation retry, orphan cooldown, completion cleanup), where force-spawning would strip the auto-run gate that withholds unattended spawns. Both doors into the new dispatch are a human clicking a button. Both dialogs render the outcome through one shared helper so neither can say "queued" for a run that already started, or omit the reason it didn't. --- client/src/components/cos/tabs/AgentsTab.jsx | 22 ++++--- .../components/cos/tabs/AgentsTab.test.jsx | 30 +++++++++ .../cos/tabs/RelaunchAgentModal.jsx | 29 ++++++--- .../cos/tabs/RelaunchAgentModal.test.jsx | 24 +++++++ client/src/lib/README.md | 1 + client/src/lib/agentResumeOutcome.js | 25 +++++++ client/src/lib/index.js | 1 + server/services/agentManagement.js | 61 ++++++++++++++++- server/services/agentManagement.test.js | 65 ++++++++++++++++++- 9 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 client/src/lib/agentResumeOutcome.js diff --git a/client/src/components/cos/tabs/AgentsTab.jsx b/client/src/components/cos/tabs/AgentsTab.jsx index d03645e10f..25eb2782ea 100644 --- a/client/src/components/cos/tabs/AgentsTab.jsx +++ b/client/src/components/cos/tabs/AgentsTab.jsx @@ -8,16 +8,22 @@ import ResumeAgentModal from './ResumeAgentModal'; import RelaunchAgentModal from './RelaunchAgentModal'; import BrailleSpinner from '../../BrailleSpinner'; import InlineConfirmRow from '../../ui/InlineConfirmRow'; +import { agentResumeMessage } from '../../../lib/agentResumeOutcome'; // What each `resumeAgent` outcome actually did (server modes, agentManagement.js). // `already-active` and `superseded` deliberately queue NOTHING — the task is already -// in flight, or a later pause owns it — so an unmapped mode must NOT fall through to -// "created a resume task". The server's `created` flag decides that (see below); this -// map only supplies the specific wording. +// in flight, or a later pause owns it — so they carry no `running` wording, and an +// unmapped mode must NOT fall through to "created a resume task". The server's +// `created` flag decides that (see below); this map only supplies the specific +// wording. `requeued` has both variants because the server force-spawns the resumed +// task when a slot is free — see `agentResumeMessage` for that contract. const RESUME_MESSAGES = { - requeued: 'Resumed — the paused task is queued on its preserved worktree', - 'already-active': 'Its task is already queued or running — nothing new was created', - superseded: 'A later agent now holds this task paused — that pause was left intact', + requeued: { + queued: 'Resumed — the paused task is queued on its preserved worktree', + running: 'Resumed — the paused task is running again on its preserved worktree', + }, + 'already-active': { queued: 'Its task is already queued or running — nothing new was created' }, + superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' }, }; // Only agents from a manually-filled task form ask for a rating — scheduled/ @@ -165,8 +171,8 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a // A resume that created nothing (`created: false`) never claims it did, even for // a mode this build has no wording for — the completed-agent branch above has no // `created` field at all and did queue a task, so it keeps the default. - toast.success(RESUME_MESSAGES[result.mode] - || (result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`)); + toast.success(agentResumeMessage(result, RESUME_MESSAGES, + result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`)); setResumingAgent(null); onRefresh(); }; diff --git a/client/src/components/cos/tabs/AgentsTab.test.jsx b/client/src/components/cos/tabs/AgentsTab.test.jsx index ce4c6c6f2b..01a781332c 100644 --- a/client/src/components/cos/tabs/AgentsTab.test.jsx +++ b/client/src/components/cos/tabs/AgentsTab.test.jsx @@ -191,6 +191,36 @@ describe('AgentsTab resume routing', () => { expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/resume task/i)); }); + // The server force-spawns the resumed task when a slot is free, so "queued" is the + // exception, not the rule — and a "queued" toast for a run that already started + // reads as the Resume click not having taken. + it('says the resumed task is running when the server started it', async () => { + const user = userEvent.setup(); + api.resumeCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true }); + renderTab([pausedAgent]); + await act(async () => {}); + + await user.click(screen.getByRole('button', { name: 'Resume agent-paused' })); + await user.click(screen.getByRole('button', { name: 'Submit resume' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i))); + }); + + it('names why a resumed task stayed queued instead of leaving the user to hunt for it', async () => { + const user = userEvent.setup(); + api.resumeCosAgent.mockResolvedValue({ + success: true, taskId: 'task-abc', mode: 'requeued', + spawned: false, spawnHold: 'No available agent slots (3/3)', + }); + renderTab([pausedAgent]); + await act(async () => {}); + + await user.click(screen.getByRole('button', { name: 'Resume agent-paused' })); + await user.click(screen.getByRole('button', { name: 'Submit resume' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/))); + }); + // The default has to be safe by construction, not by keeping a copy of the server's // mode enum in sync — a future non-creating mode this build has no wording for must // not regress to announcing a task that was never queued. diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.jsx index 44820877bd..1c9a1b4db1 100644 --- a/client/src/components/cos/tabs/RelaunchAgentModal.jsx +++ b/client/src/components/cos/tabs/RelaunchAgentModal.jsx @@ -9,15 +9,25 @@ import { FormField } from '../../ui/FormField'; import CollapsibleText from '../../ui/CollapsibleText'; import { useAsyncAction } from '../../../hooks/useAsyncAction'; import { effortAwareModelOptions, seedModelEffort } from '../../../utils/providers'; +import { agentResumeMessage } from '../../../lib/agentResumeOutcome'; // What each relaunch outcome actually did. The server reuses `resumeAgent`'s -// modes (agentManagement.js): only `requeued` restarts the work — `already-active` -// and `superseded` deliberately queue NOTHING, so an unmapped mode must not fall -// through to a message claiming the task was relaunched. +// modes (agentManagement.js): only `requeued` and `new-task` put work back on the +// queue — `already-active` and `superseded` deliberately queue NOTHING, so those +// carry no `running` wording and an unmapped mode falls through to the plain +// fallback rather than a message claiming the task was relaunched. See +// `agentResumeMessage` for the queued-vs-running contract. const RELAUNCH_MESSAGES = { - requeued: 'Relaunched — the task is queued again on its preserved worktree', - 'already-active': 'Its task is already queued or running — nothing new was created', - superseded: 'A later agent now holds this task paused — that pause was left intact', + requeued: { + queued: 'Relaunched — the task is queued again on its preserved worktree', + running: 'Relaunched — the task is running again on its preserved worktree', + }, + 'new-task': { + queued: 'Relaunched — a replacement task is queued', + running: 'Relaunched — a replacement task is running', + }, + 'already-active': { queued: 'Its task is already queued or running — nothing new was created' }, + superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' }, }; /** @@ -72,7 +82,7 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC app: formData.app || undefined, context: formData.note.trim() || undefined }, { silent: true }); - toast.success(RELAUNCH_MESSAGES[result?.mode] || 'Relaunched'); + toast.success(agentResumeMessage(result, RELAUNCH_MESSAGES, 'Relaunched')); onDone?.(result); onClose(); return result; @@ -120,8 +130,9 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC expandedClassName="max-h-48 overflow-y-auto whitespace-pre-wrap" />
- This stops the running agent and requeues the same task on the worktree it leaves - behind — no second agent, and nothing to clean up afterward. + This stops the running agent and restarts the same task on the worktree it leaves + behind — no second agent, and nothing to clean up afterward. It starts right away + when an agent slot is free, and stays queued until one is otherwise.
diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx index 1c3c314acf..ceb1a582bf 100644 --- a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx +++ b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx @@ -123,6 +123,30 @@ describe('RelaunchAgentModal', () => { expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument(); }); + it('says the task is running when the server started it, not that it is queued', async () => { + const user = userEvent.setup(); + api.relaunchCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true }); + renderModal(); + + await user.click(screen.getByRole('button', { name: 'Relaunch Agent' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i))); + expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/queued/i)); + }); + + it('names why a relaunched task stayed queued instead of leaving the user to hunt for it', async () => { + const user = userEvent.setup(); + api.relaunchCosAgent.mockResolvedValue({ + success: true, taskId: 'task-abc', mode: 'requeued', + spawned: false, spawnHold: 'No available agent slots (3/3)', + }); + renderModal(); + + await user.click(screen.getByRole('button', { name: 'Relaunch Agent' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/))); + }); + it('keeps the dialog open and surfaces the error when the relaunch fails', async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/client/src/lib/README.md b/client/src/lib/README.md index bbea8b4bce..d7e1cd4fe3 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -99,6 +99,7 @@ grep -i "what you want to do" client/src/lib/README.md | Module | Purpose | |---|---| | `a11yKeyboard.js` | `clickableProps(handler, { role?, disabled? })` → `{ role, tabIndex, onKeyDown }` and `onActivateKeyDown(handler)` — make a non-`