fix(workspace): a running room turn can be stopped, and a stop is not a failure (#2795) - #2798
Conversation
… 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
…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
ad5d80a to
c67d83d
Compare
…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>
|
merge-train: two mechanical changes pushed to this branch.
Validation notes for the record: no critical findings; One follow-up that is yours, not the train's: no feature flow documents the room Stop path or |
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
PortalWorkCardhas always rendered a Stop button —PortalRoom.vuesimply never handed it:can-stop/@stop. Now wired to the Work tab's own store action:stopItemre-checks the server's verdict, calls the same portal terminate route, treats a 404 as the lost race rather than a refusal, and refetches soCANCELLEDcomes back from the server instead of being written optimistically here. Two surfaces, one cancel path.2. The server said those rows were unstoppable
can_stopgated onkind in ("turn", "delegated"), and a room wake projects asroom— 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)andexecution_belongs_to_caller(agent match +source_user_emailmatch), andshared_sessions.service._wake_agentsatisfies both by construction: every wake runs throughexecute_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_agreenow 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 asotherand must stay unstoppable.loopis 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_agenttreatedCANCELLEDexactly likeFAILED: 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.CANCELLEDnow 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
soleStoppableItemstops 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 failureshouldCancelOnEscapeis 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 throughshouldCancelOnEscapewith the typeahead and add-agent popups asoverlays, 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-ifchain, silently repointing the "…is thinking…" fallback atstopError. 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), matchingroomComposerChain.spec.jsone region down.Acceptance criteria
can_stoptrue server-side, and the terminate route verified to accept the call.working(the existingfinally), the tile drops out on the refetch, and the transcript says stopped, not could not respond.shouldCancelOnEscape, with a room-specific "exactly one" rule that is documented and tested.can_stopon a room-driven row; the room card emitsstopand calls the same store action as the Work tab.Verification
Fixes #2795
🤖 Generated with Claude Code
https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf