feat(tasks): non-terminal task/interrupt + INTERRUPTED status (AGX1-391)#365
Conversation
✱ Stainless preview buildsThis PR will update the openapi python typescript
|
9d96106 to
9426983
Compare
…X1-391)
Adds a `task/interrupt` RPC method and `POST /tasks/{task_id}/interrupt` route
that stop an in-flight agent turn without terminating the task, plus a new
non-terminal `INTERRUPTED` task status. Mirrors the `task/cancel` paths but never
transitions the task to a terminal state, so the conversation stays continuable.
- RPC `task/interrupt` across the api/domain method enums, params, union, validators
- widen ACP_TYPE_TO_ALLOWED_RPC_METHODS to SYNC / ASYNC / AGENTIC
- authorize as `update`; dispatch + service forward the RPC to the agent (async)
and best-effort for sync; never calls _transition_to_terminal
- INTERRUPTED status + state machine: RUNNING<->INTERRUPTED and terminal
transitions accept INTERRUPTED as a source
- alembic migration adding the INTERRUPTED enum value
- regenerated openapi.yaml (drives SDK regeneration)
- unit + integration tests mirroring task/cancel
Part of the Stop-mid-stream + queue-at-boundary effort; see the design doc at
teams/sgp/agents/golden_agent/docs/interrupt-and-queue-design.md in agentex-agents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9426983 to
db293d5
Compare
- interrupt_task (service): use a RUNNING->INTERRUPTED compare-and-swap instead of an unconditional update, so a task the agent terminated during the ACP call window is not un-terminated (mirrors resume_interrupted_task) - restore x-acting-user-authorization to BLOCKED_HEADERS (unintentionally dropped) - fix stale 'only running tasks' error text in _transition_to_terminal now that INTERRUPTED is a valid transition source Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xt (AGX1-391) The _transition_to_terminal error message changed (Only running -> 'Only running or interrupted') per review; assert on the stable 'Only running' substring, which matches both the transition and interrupt guard messages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n (AGX1-391) cancel_task (RPC path) used an unconditional repository update that could clobber a task the agent terminated during the ACP-forward window. Mirror interrupt_task: CAS to CANCELED on the observed non-terminal source (RUNNING/INTERRUPTED), no-op if the task already reached a terminal status. Addresses the Greptile 4/5 consistency note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| # (the agent completed/failed it, or it was concurrently canceled); leave | ||
| # that status intact rather than clobbering a terminal status with the | ||
| # non-terminal INTERRUPTED. | ||
| updated = await self.transition_task_status( |
There was a problem hiding this comment.
is it worth have the acp_client.interrupt_task happen after this CAS since right now a second RPC interrupt sees INTERRUPTED, but still forwards another interrupt before its CAS misses?
|
it also looks like the stainless diagnostic reports the new interrupt endpoint is not configured so just leaving this as a reminder to make sure the code for it gets generated |
| # _transition_to_terminal. | ||
| if task is not None and task.status == TaskStatus.INTERRUPTED: | ||
| resumed = await self.task_service.resume_interrupted_task(task.id) | ||
| if resumed is not None: |
There was a problem hiding this comment.
small race, but if resumed is None here it might be because the CAS lost to an e.g. concurrent cancel or complete so I think it would make sense to refetch the task and reject if the latest status is terminal
smoreinis
left a comment
There was a problem hiding this comment.
just a couple comments on the code here plus a reminder to add the new path to the stainless config, everything else LGTM! i think this will be a great help for folks working on voice too since they (will) want to interrupt a turn when a user barges in
…NTERRUPTED (AGX1-391)
Interrupt is cooperative, not control-plane-authoritative. The task/interrupt RPC now only forwards to the agent (like event/send) and writes no status; the agent stops its own turn and records INTERRUPTED by calling the REST POST /tasks/{id}/interrupt route (like the other task-state routes). The control plane still owns RUNNING (task creation + auto-resume on the next turn). An agent that does not implement interrupt stays RUNNING, keeping status honest.
Also (per @smoreinis review): in _get_or_create_task, when the INTERRUPTED->RUNNING resume CAS misses, refetch and reject with ClientError if the task is now terminal, instead of starting a turn against a terminal task.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…apture (AGX1-391) Hand-editable SDK support for the platform's non-terminal task/interrupt verb (the generated client.tasks.interrupt + param/response types + RPC unions regenerate from the scale-agentex OpenAPI change via Stainless; see the TODO in protocol/acp.py). - protocol/acp.py: RPCMethod.TASK_INTERRUPT, InterruptTaskParams, PARAMS_MODEL_BY_METHOD - Temporal ACP layer: SignalName.INTERRUPT_TURN and an overridable BaseWorkflow.on_interrupt hook (no-op default so existing agents are unaffected), TemporalTaskService.interrupt() that signals interrupt_turn (never cancels/terminates), on_task_interrupt ACP-server decorator + handle_interrupt forwarding - resume fix: capture session_id from the early system/init (claude) and thread.started (codex) envelopes with a cancellation-safe finally, so a turn interrupted before completion stays resumable - unit tests Part of the Stop-mid-stream + queue-at-boundary effort (AGX1-391). Pairs with scaleapi/scale-agentex#365. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Platform half of AGX1-391 (stop an agent run mid-stream). Adds a non-terminal
INTERRUPTEDtask status and atask/interruptoperation so a running turn can be stopped without terminating the task or locking the chat.Ownership model (important)
Interrupt is cooperative, not control-plane-authoritative like
cancel:task/interruptRPC = courier only. It is forwarded to the agent (exactly likeevent/send) and writes no status. Only the agent can actually stop an in-flight turn.INTERRUPTED. After stopping its turn, the agent calls the RESTPOST /tasks/{task_id}/interruptroute (like the other task-state routes: complete/fail/cancel/timeout) to set its status. An agent that does not implement interrupt simply staysRUNNING, so status stays honest.RUNNING. It setsRUNNINGon task creation and auto-resumesINTERRUPTED -> RUNNINGon the next turn (_get_or_create_task).Contrast with
cancel, which is authoritative: the control plane setsCANCELEDitself (and the RPCcancel_taskalso CAS-writesCANCELED), because cancel does not depend on the agent cooperating.What's included
task/interruptRPC method (forward-only) + RESTPOST /tasks/{task_id}/interrupt(authorizedupdate, the agent's write path). Allowed forSYNC/ASYNC/AGENTIC.INTERRUPTEDstatus + alembic migration; terminal transitions acceptINTERRUPTEDas a valid source (so an interrupted task can still be canceled/completed).INTERRUPTED -> RUNNINGon the next turn; if that CAS loses to a concurrent terminal transition, the turn is rejected instead of running against a terminal task.openapi.yaml(drives SDK regeneration).task/cancelsuite.Review follow-ups (in this PR)
task/interruptRPC reworked to forward-only per design discussion (agent ownsINTERRUPTED); control plane keeps ownership ofRUNNING/auto-resume.ClientErrorif the resume CAS lost to a concurrent cancel/complete (@smoreinis).cancel_task(RPC path) hardened with a compare-and-swap so it cannot clobber a task the agent terminated during the ACP call (independent correctness fix; @greptile).x-acting-user-authorizationtoBLOCKED_HEADERS(had been dropped inadvertently)._transition_to_terminalerror text now thatINTERRUPTEDis a valid source.Not in this PR (tracked separately)
on_interrupt(stops the turn and calls the REST interrupt route to recordINTERRUPTED) and the agentex SDK interrupt hook (interrupt_turnsignal /on_interruptbase workflow method).openapi.yamllands in Stainless — this includes adding thetask/interruptpath to the Stainless config sotasks.interruptis generated (per @smoreinis).Testing
Unit + integration tests pass (authz mapping, non-terminal interrupt via REST,
INTERRUPTED -> RUNNINGresume, terminal-from-INTERRUPTED, SYNC now permits the method, fail-closed unchanged).🤖 Generated with Claude Code
Greptile Summary
This PR introduces a non-terminal
task/interruptoperation and anINTERRUPTEDtask status as a structural sibling oftask/cancel. It allows a running turn to be stopped without terminating the task, enabling the user to resume via the next message or event.INTERRUPTEDenum value with a guarded Alembic migration, state machine transitions (RUNNING → INTERRUPTED,INTERRUPTED → RUNNING, and terminal transitions fromINTERRUPTED), and compare-and-swap logic throughout to prevent concurrent-status clobbering.POST /tasks/{task_id}/interruptendpoint (authorized asupdate) and atask/interruptRPC method (allowed for SYNC, ASYNC, and AGENTIC agents), with matching unit and integration tests.cancel_taskinAgentTaskServiceto the same CAS pattern for consistency, and restoresx-acting-user-authorizationtoBLOCKED_HEADERS.Confidence Score: 5/5
Safe to merge; the CAS-based state transitions are correct, the authorization mapping is consistent, and the migration is guarded with IF NOT EXISTS.
The INTERRUPTED state machine is cleanly implemented with compare-and-swap throughout — no path can overwrite a terminal status. Authorization for interrupt correctly maps to the less-privileged update operation. The one notable issue is a docstring on _handle_task_interrupt that claims the returned task carries INTERRUPTED status when the cooperative design intentionally leaves the task RUNNING until the agent acknowledges; this does not affect runtime correctness.
agentex/src/domain/use_cases/agents_acp_use_case.py — the _handle_task_interrupt return-value docstring should be corrected to reflect that the returned task retains RUNNING status until the agent-side callback writes INTERRUPTED.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant UI as Client / UI participant TasksRoute as REST /tasks/{id}/interrupt participant AgentsRoute as RPC task/interrupt participant UseCase as TasksUseCase participant ACPUseCase as AgentsACPUseCase participant TaskSvc as AgentTaskService participant DB as Task Repository participant Agent as Agent Pod note over UI,Agent: Path A — REST endpoint (direct status write) UI->>TasksRoute: "POST /tasks/{id}/interrupt" TasksRoute->>UseCase: interrupt_task(id, reason) UseCase->>DB: CAS RUNNING → INTERRUPTED DB-->>UseCase: TaskEntity (INTERRUPTED) TasksRoute-->>UI: "200 OK — Task{status: INTERRUPTED}" note over UI,Agent: Path B — RPC endpoint (agent signal, no status write) UI->>AgentsRoute: "POST /agents/{id}/rpc method=task/interrupt" AgentsRoute->>ACPUseCase: _handle_task_interrupt ACPUseCase->>TaskSvc: interrupt_task(agent, task, url) TaskSvc->>Agent: JSON-RPC task/interrupt (best-effort) TaskSvc->>DB: get task (no write) ACPUseCase-->>UI: "200 OK — Task{status: RUNNING}" note over UI,DB: Resume on next turn UI->>AgentsRoute: message/send or task/create AgentsRoute->>ACPUseCase: _get_or_create_task ACPUseCase->>TaskSvc: resume_interrupted_task (CAS INTERRUPTED→RUNNING) TaskSvc-->>ACPUseCase: TaskEntity (RUNNING)%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant UI as Client / UI participant TasksRoute as REST /tasks/{id}/interrupt participant AgentsRoute as RPC task/interrupt participant UseCase as TasksUseCase participant ACPUseCase as AgentsACPUseCase participant TaskSvc as AgentTaskService participant DB as Task Repository participant Agent as Agent Pod note over UI,Agent: Path A — REST endpoint (direct status write) UI->>TasksRoute: "POST /tasks/{id}/interrupt" TasksRoute->>UseCase: interrupt_task(id, reason) UseCase->>DB: CAS RUNNING → INTERRUPTED DB-->>UseCase: TaskEntity (INTERRUPTED) TasksRoute-->>UI: "200 OK — Task{status: INTERRUPTED}" note over UI,Agent: Path B — RPC endpoint (agent signal, no status write) UI->>AgentsRoute: "POST /agents/{id}/rpc method=task/interrupt" AgentsRoute->>ACPUseCase: _handle_task_interrupt ACPUseCase->>TaskSvc: interrupt_task(agent, task, url) TaskSvc->>Agent: JSON-RPC task/interrupt (best-effort) TaskSvc->>DB: get task (no write) ACPUseCase-->>UI: "200 OK — Task{status: RUNNING}" note over UI,DB: Resume on next turn UI->>AgentsRoute: message/send or task/create AgentsRoute->>ACPUseCase: _get_or_create_task ACPUseCase->>TaskSvc: resume_interrupted_task (CAS INTERRUPTED→RUNNING) TaskSvc-->>ACPUseCase: TaskEntity (RUNNING)Reviews (5): Last reviewed commit: "refactor(tasks): make task/interrupt RPC..." | Re-trigger Greptile