Skip to content

[bug] Stop on a loop looks like it does nothing — the server records the cancel and no UI ever reads cancelRequested #376

Closed

Description

@serge-ivo

Reported

"I can't stop the loop for Heartfull."

The server accepted the stop. Nothing in the UI ever says so.

Live state of the run right now (f8ddc272, run 252061dc):

status:          running
cancelRequested: true      ← the stop WAS recorded
iteration:       32 / 50
startedAt:       2026-08-07T07:58:02Z
lastProgressAt:  2026-08-07T09:06:31Z

So requestCancel worked: agent_loop_runs.cancel_requested = 1. The API returns it — it is right
there in check_instance_loop's payload as cancelRequested.

No UI reads it. git grep cancelRequested across store/console/src and
agents/coder/web/src returns nothing.

The consequence, in LoopRunsSection.tsx:

  • pressing Stop sets a transient local message — "Stopping — the current step will finish first."
    (:87), which is honest but is component state and vanishes on the next poll, re-render or
    navigation;
  • the row itself renders from r.status === "running" alone (:108, :125), so it keeps showing
    · running and keeps offering a live Stop button.

So: press Stop → a message flashes → the row still says running → press Stop again → same. The user
correctly concludes it does not work. The flag is set, re-set, and re-set; the UI is simply not
looking at it.

Why the wait is long enough to matter

Cancellation is cooperative by design and the reasoning is sound (agent-loop-store.ts:160-166):
the flag is read at the top of each iteration (workflows/agent-loop.ts:77) so the in-flight
step settles its spend, because killing mid-step strands a budget reservation against the shared
tree pool.

But the in-flight step is act-${iteration} — handing an instruction to a Coder that drives Claude
Code through a real task. That is minutes, not seconds. This run has been 6m45s past its last
recorded progress and is presumably still inside iteration 32.

A cooperative cancel with a multi-minute settling window is fine. A cooperative cancel with a
multi-minute window and no persistent acknowledgement is indistinguishable from a broken button.

Fix

  1. Render the pending state. When cancelRequested && status === "running", show Stopping…
    instead of running, and disable the Stop button. The data is already on the wire; this is a
    read, not a new endpoint.
  2. Say what it is waiting for — "finishing the current step" — so a multi-minute wait reads as
    the documented behaviour rather than a hang. work-report.ts:87 already composes this sentence
    for the work report ("a cancel has been requested"); the console just never got it.
  3. Consider a second press meaning something. Once a cancel is pending, a further Stop could
    offer a hard abort. Not obviously right — it reintroduces the stranded-reservation problem the
    cooperative design exists to avoid — so this needs a decision, not just an implementation.

Also worth noting from the same data

Three of this instance's six runs died with WorkflowInternalError: Attempt failed due to internal workflows error, and two more with the $50.00 daily limit (#343). The run list is mostly failures.
Not this issue, but the same instance is the evidence for several open ones.

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Audit (picked this up for implementation and found it already delivered): shipped in e9e6bd4, which is on main and deployed. No further code was needed.

    Both implementable parts are present, and the third was correctly left as a decision:

    1. The pending state renders. store/console/src/lib/loopStopState.ts — loopStopControl({status:"running", cancelRequested:true}) returns phase:"stopping", statusLabel:"Stopping…", canStop:false. LoopRunsSection.tsx:124 routes the row through it (:132 disables the button, :147 renders the label).
    2. It says what it is waiting for. STOPPING_HINT = "Stopping — the current step has to finish first. This can take a few minutes.", rendered persistently at :150 and as the button title at :134 — component state that vanished on the next poll is exactly what the ticket was about, and this is derived from the server field on every render instead.
    3. Second press as hard abort — not implemented, which matches the ticket calling it "not obviously right"; it reintroduces the stranded-reservation problem the cooperative design exists to avoid.

    Two edge cases in loopStopState.test.ts worth noting because they are the ones that would have bitten:

    • cancel_requested is never cleared, so every cancelled run carries it forever — reading the flag without the status would have pinned "Stopping…" on ended rows permanently.
    • undefined/null read as no cancel pending, so an older server omitting the field cannot freeze the Stop button on every run.

    There is also a source-scanning guard asserting that LoopRunsSection.tsx and InstanceDetail.tsx actually route through the resolver — the right shape here, since the defect was never a wrong resolver but that no component asked one, and a unit test of a pure function cannot catch a function nobody calls.

    Closing as delivered. Not verified live in WebKit at 320/390px — this is a text-and-disabled-state change inside an existing row rather than new layout, so the risk is low, but I did not check it and am saying so.

    The tail observation in the ticket (three runs lost to WorkflowInternalError, two to the $50.00 daily limit) is untouched by this and belongs to #343.

  2. added a commit that references this issue on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfrontendFrontend / UI work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions