Skip to content

fix(workspace): a running room turn can be stopped, and a stop is not a failure (#2795) - #2798

Merged
vybe merged 4 commits into
devfrom
fix/2795-room-stop-executions
Sep 15, 2026
Merged

vybe merged 4 commits into
devfrom
fix/2795-room-stop-executions

Conversation

@dolho

@dolho dolho commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Once a room fanned a message out to several agents there was no way to interrupt any of them short of waiting for the turn timeout. Two independent gaps stacked up.

1. The room's tiles never offered Stop

PortalWorkCard has always rendered a Stop button — PortalRoom.vue simply never handed it :can-stop / @stop. Now wired to the Work tab's own store action: stopItem re-checks the server's verdict, calls the same portal terminate route, treats a 404 as the lost race rather than a refusal, and refetches so CANCELLED comes back from the server instead of being written optimistically here. Two surfaces, one cancel path.

2. The server said those rows were unstoppable

can_stop gated on kind in ("turn", "delegated"), and a room wake projects as room — so the Work tab listed the run and hid the only control that would have ended it.

The widening is not cosmetic. The terminate route's own gates are _require_roster(agent) and execution_belongs_to_caller (agent match + source_user_email match), and shared_sessions.service._wake_agent satisfies both by construction: every wake runs through execute_task(..., source_user_email=<the poster>) on an agent that is a room participant, which on the Workspace can only be an agent already on the poster's roster. The route accepted these rows all along. test_the_projection_and_the_terminate_route_agree now evaluates both predicates against one row so that claim cannot rot.

The kind list became a named allowlist (STOPPABLE_KINDS) rather than gaining a third literal: an unrecognised trigger projects as other and must stay unstoppable. loop is still excluded — a loop is stopped from the Loops tab, where stopping the loop is what the person means; cancelling one iteration just leaves the runner to start the next.

Nothing else about the gate moves. "Only the person who started the run may stop it" is untouched, and stopping one participant's execution leaves the others alone (the fan-out is sequential, so the next agent is woken after the cancel returns).

3. A cancel read as a fault

_wake_agent treated CANCELLED exactly like FAILED: it posted "<agent> could not respond (no response)." — the surface blaming the agent for something the reader themselves just asked for — and dropped the cached resume handle. That drop exists for a dead handle; a cancel is no evidence of one, and dropping it makes the next turn pay for a cold context rebuild.

CANCELLED now posts "<agent>'s turn was stopped." and keeps the handle. The read cursor is still not advanced, so the delta the stopped turn never answered is re-delivered on the next wake.

Escape — ruled, not scoped out

AC 5 allowed either. A room fans out to several agents, so soleStoppableItem stops the turn only when there is exactly one to stop, and is a no-op otherwise: guessing by position destroys work somebody is still waiting for, which is the failure shouldCancelOnEscape is written to avoid. That is not as narrow as it sounds — the fan-out is sequential, so a room normally has exactly one live row and Escape behaves as it does in a 1:1. It runs through shouldCancelOnEscape with the typeahead and add-agent popups as overlays, so ent#155's "anything nearer the keystroke wins" rule is unchanged.

A guard for the mistake this PR made

The first draft inserted the stop-error line between two arms of the live-work v-if/v-else-if chain, silently repointing the "…is thinking…" fallback at stopError. The SFC compiled and every other test passed — i.e. the #2794 defect, committed inside its own sibling fix. There is now an AST guard (negative-tested: reintroducing the defect fails it), matching roomComposerChain.spec.js one region down.

Acceptance criteria

  • Each live room tile offers Stop while running/queued, with the same "Stopping…" state and refused-cancel surfacing as the 1:1 (ent#155).
  • The Work tab offers Stop for room-driven executions started by the current user — can_stop true server-side, and the terminate route verified to accept the call.
  • Stopping one participant's execution does not affect the others.
  • After a stop the room is honest: the agent is no longer working (the existing finally), the tile drops out on the refetch, and the transcript says stopped, not could not respond.
  • Esc-to-stop follows shouldCancelOnEscape, with a room-specific "exactly one" rule that is documented and tested.
  • Tests: can_stop on a room-driven row; the room card emits stop and calls the same store action as the Work tab.

Verification

tests/unit/test_2795_room_stop.py ......................... 17 passed
tests/unit/test_ent525_portal_work.py + the room suites .... 152 passed
src/frontend vitest run ................................... 130 files, 2922 passed

Fixes #2795


🤖 Generated with Claude Code

https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf

… a failure (#2795)

Two independent gaps stacked up, so once a room fanned a message out there was
no way to interrupt any agent short of waiting for the turn timeout.

**1. The room's tiles never offered Stop.** `PortalWorkCard` has always
rendered a Stop button; `PortalRoom.vue` simply never handed it `:can-stop` /
`@stop`. Wired to the Work tab's own store action — `stopItem` re-checks the
server's verdict, calls the same portal terminate route, treats a 404 as the
lost race rather than a refusal, and refetches so CANCELLED comes back from the
server instead of being written optimistically. Two surfaces, one cancel path.

**2. The server said those rows were unstoppable.** `can_stop` gated on
`kind in ("turn", "delegated")`, and a room wake projects as `room` — so the
Work tab listed the run and hid the only control that would have ended it.

That widening is not cosmetic: the terminate route's own gates are
`_require_roster(agent)` and `execution_belongs_to_caller` (agent match +
`source_user_email` match), and `_wake_agent` satisfies both by construction —
every wake runs through `execute_task(..., source_user_email=<the poster>)` on
an agent that is a room participant, which on the Workspace can only be an
agent already on the poster's roster. The route accepted these rows all along.
`test_the_projection_and_the_terminate_route_agree` now evaluates both
predicates against one row so the claim cannot rot. The kind list became a
named ALLOWLIST (`STOPPABLE_KINDS`) rather than gaining a third literal: an
unrecognised trigger projects as `other` and must stay unstoppable. `loop` is
still excluded — a loop is stopped from the Loops tab, where stopping the LOOP
is what the person means.

Nothing else about the gate moves: "only the person who started the run may
stop it" is untouched, and stopping one participant's execution leaves the
others alone (the fan-out is sequential, so the next agent is woken after the
cancel returns).

**3. A cancel read as a fault.** `_wake_agent` treated CANCELLED exactly like
FAILED: it posted "<agent> could not respond (no response)." — the surface
blaming the agent for something the reader themselves asked for — and dropped
the cached resume handle. That drop exists for a DEAD handle; a cancel is no
evidence of one, and dropping it makes the next turn pay for a cold context
rebuild. CANCELLED now posts "<agent>'s turn was stopped." and keeps the
handle. The read cursor is still not advanced, so the delta the stopped turn
never answered is re-delivered on the next wake.

**Escape** gets a rule of its own rather than being scoped out: a room fans out
to several agents, so `soleStoppableItem` stops the turn only when there is
exactly one to stop, and is a no-op otherwise — guessing by position destroys
work somebody is still waiting for. In practice the fan-out is sequential, so a
room normally has one live row and Escape behaves as it does in a 1:1. It goes
through `shouldCancelOnEscape` with the typeahead and add-agent popups as
overlays, so ent#155's "anything nearer the keystroke wins" rule is unchanged.

Also guards the live-work `v-if`/`v-else-if` chain with an AST test. Not
hypothetical: the first draft of this change inserted the stop-error line
between two of its arms and silently repointed the "…is thinking…" fallback at
`stopError`. The SFC compiled and every other test passed — the #2794 defect,
committed inside its own sibling fix. `roomComposerChain.spec.js` pins the same
hazard one region down.

Tests: `tests/unit/test_2795_room_stop.py` (17) and
`src/frontend/tests/unit/roomStopWork.spec.js` (17, incl. a negative-tested
chain guard). Full frontend suite 2922 green; the room/work backend suites 152
green.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
dolho and others added 2 commits September 15, 2026 11:03
…2795)

/review finding on this branch. `_wake_agent` tells a user cancel from a
failure by reading `execute_task`'s returned status — exact on a current agent
image, which relabels its own 504/502/500 to a `cancelled` 200 when its process
registry says the turn was terminated (#679 F3). An OLDER image re-raises:
`execute_task` writes FAILED, that write loses the CAS to the CANCELLED the
terminate route already wrote, and returns FAILED anyway. The room would then
post "<agent> could not respond (no response)." for a stop the reader had just
asked for — the exact AC #4 violation this PR exists to fix — and drop a resume
handle that was never bad.

The 1:1 is immune for a reason worth copying carefully: it never trusted the
return value either, it remembers the cancel client-side
(`cancelledExecutionIds`). A room has no such memory, so it asks the row.

Three properties: the re-read is scoped to the branch where it can change the
answer (the first draft fired on every terminal — a test now pins the
successful-reply path at zero reads); it is fail-OPEN, so an unreadable row
leaves the returned status in force; and it only runs on a path that has
already lost an LLM turn.

Tests: 24 in `tests/unit/test_2795_room_stop.py` (was 17), covering the
old-image cancel, a genuine failure, both no-read paths, and both fail-open
paths.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2795)

Found reviewing this branch: `_wake_agent` read `execute_task`'s returned
status to tell a cancel from a failure, which is exact only while the agent
image relabels its own cancel terminals. On an older image the FAILED write
loses the CAS to the terminate route's CANCELLED and returns FAILED anyway.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
@dolho
dolho force-pushed the fix/2795-room-stop-executions branch from ad5d80a to c67d83d Compare September 15, 2026 08:04
@dolho
dolho requested a review from vybe September 15, 2026 08:19
…rolled p (#2795)

A failed verb surfaces an `InlineError` next to its control and persists until
dismissed (design-system contract, principle 18). The refused-cancel line was a
hand-rolled `<p role="status">` with no dismiss; the sibling surface for the
same verb already does it right (`PortalWork.vue:43`, same `stopError` ref).

`role="alert"` comes with the primitive, which is the correct semantic for a
problem the person must notice.

The AST guard locates the element by its static `data-testid`, which the
component node still carries, so `roomStopWork.spec.js`'s "the stop-error line
sits OUTSIDE the chain" is unchanged and still bites.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vybe

vybe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

merge-train: two mechanical changes pushed to this branch.

  1. 7582772 — the refused-cancel line now uses the InlineError primitive instead of a hand-rolled <p role="status">. The design-system contract (principle 18) says a failed verb surfaces an InlineError next to its control and persists until dismissed, and the sibling surface for this exact verb already does it that way (PortalWork.vue:43, same stopError ref). The person also gets a dismiss control and role="alert", which is the right politeness for "your stop was refused and the turn is still spending".

    The AST guard in roomStopWork.spec.js locates the element by its static data-testid, which the component node still carries, so "the stop-error line sits OUTSIDE the chain" is unchanged and still bites. Full frontend suite green: 130 files, 2922 tests.

  2. The PR body said Related to #2795, which GitHub does not parse as a closing keyword — the issue would have stranded in status-in-progress after merge. Patched to Fixes #2795.

Validation notes for the record: no critical findings; test_2795_room_stop.py executes _wake_agent for real and the can_stop widening agrees with the terminate route's own execution_belongs_to_caller. AC4 is delivered differently than written (the tile drops on refetch and the transcript carries "…turn was stopped." instead of the tile showing a stopped outcome) — your PR body states this, and it satisfies the AC's intent, so it is recorded as CHANGED rather than missing.

One follow-up that is yours, not the train's: no feature flow documents the room Stop path or _wake_agent's third terminal. Nothing currently says anything false, so it is stale-by-omission — worth a /sync-feature-flows pass when convenient.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260915-1011 (#2808) — full suite green across all five members together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants