Skip to content

feat(tasks): non-terminal task/interrupt + INTERRUPTED status (AGX1-391)#365

Merged
declan-scale merged 5 commits into
mainfrom
declan-scale/agx1-391-task-interrupt
Jul 16, 2026
Merged

feat(tasks): non-terminal task/interrupt + INTERRUPTED status (AGX1-391)#365
declan-scale merged 5 commits into
mainfrom
declan-scale/agx1-391-task-interrupt

Conversation

@declan-scale

@declan-scale declan-scale commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Platform half of AGX1-391 (stop an agent run mid-stream). Adds a non-terminal INTERRUPTED task status and a task/interrupt operation 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/interrupt RPC = courier only. It is forwarded to the agent (exactly like event/send) and writes no status. Only the agent can actually stop an in-flight turn.
  • The agent records INTERRUPTED. After stopping its turn, the agent calls the REST POST /tasks/{task_id}/interrupt route (like the other task-state routes: complete/fail/cancel/timeout) to set its status. An agent that does not implement interrupt simply stays RUNNING, so status stays honest.
  • The control plane owns RUNNING. It sets RUNNING on task creation and auto-resumes INTERRUPTED -> RUNNING on the next turn (_get_or_create_task).

Contrast with cancel, which is authoritative: the control plane sets CANCELED itself (and the RPC cancel_task also CAS-writes CANCELED), because cancel does not depend on the agent cooperating.

What's included

  • task/interrupt RPC method (forward-only) + REST POST /tasks/{task_id}/interrupt (authorized update, the agent's write path). Allowed for SYNC / ASYNC / AGENTIC.
  • New non-terminal INTERRUPTED status + alembic migration; terminal transitions accept INTERRUPTED as a valid source (so an interrupted task can still be canceled/completed).
  • Auto-resume INTERRUPTED -> RUNNING on the next turn; if that CAS loses to a concurrent terminal transition, the turn is rejected instead of running against a terminal task.
  • Regenerated openapi.yaml (drives SDK regeneration).
  • Unit + integration tests mirroring the task/cancel suite.

Review follow-ups (in this PR)

  • task/interrupt RPC reworked to forward-only per design discussion (agent owns INTERRUPTED); control plane keeps ownership of RUNNING/auto-resume.
  • Auto-resume rejects with ClientError if 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).
  • Restored x-acting-user-authorization to BLOCKED_HEADERS (had been dropped inadvertently).
  • Corrected the _transition_to_terminal error text now that INTERRUPTED is a valid source.

Not in this PR (tracked separately)

  • Agent-side behavior: golden agent on_interrupt (stops the turn and calls the REST interrupt route to record INTERRUPTED) and the agentex SDK interrupt hook (interrupt_turn signal / on_interrupt base workflow method).
  • SDK regeneration (scale-agentex-python / agentex-typescript) once this openapi.yaml lands in Stainless — this includes adding the task/interrupt path to the Stainless config so tasks.interrupt is generated (per @smoreinis).
  • Golden-agent Stop + queue-at-boundary UI (scaleapi egp-annotation). The generic agentex-ui Stop button was intentionally dropped: a generic Stop is misleading for agents that do not implement self-interruption.

Testing

Unit + integration tests pass (authz mapping, non-terminal interrupt via REST, INTERRUPTED -> RUNNING resume, terminal-from-INTERRUPTED, SYNC now permits the method, fail-closed unchanged).

🤖 Generated with Claude Code

Greptile Summary

This PR introduces a non-terminal task/interrupt operation and an INTERRUPTED task status as a structural sibling of task/cancel. It allows a running turn to be stopped without terminating the task, enabling the user to resume via the next message or event.

  • Adds INTERRUPTED enum value with a guarded Alembic migration, state machine transitions (RUNNING → INTERRUPTED, INTERRUPTED → RUNNING, and terminal transitions from INTERRUPTED), and compare-and-swap logic throughout to prevent concurrent-status clobbering.
  • Exposes a REST POST /tasks/{task_id}/interrupt endpoint (authorized as update) and a task/interrupt RPC method (allowed for SYNC, ASYNC, and AGENTIC agents), with matching unit and integration tests.
  • Aligns cancel_task in AgentTaskService to the same CAS pattern for consistency, and restores x-acting-user-authorization to BLOCKED_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

Filename Overview
agentex/database/migrations/alembic/versions/2026_07_16_1200_add_interrupted_task_status_a1b2c3d4e5f6.py Adds INTERRUPTED enum value with IF NOT EXISTS guard; downgrade is a documented no-op consistent with prior enum migrations.
agentex/src/domain/services/task_service.py Adds cooperative interrupt_task (courier-only, no DB write) and resume_interrupted_task (CAS INTERRUPTED→RUNNING); cancel_task aligned to same CAS guard pattern.
agentex/src/domain/use_cases/tasks_use_case.py New interrupt_task method with CAS RUNNING→INTERRUPTED; _transition_to_terminal expanded to accept INTERRUPTED as a valid source status.
agentex/src/domain/use_cases/agents_acp_use_case.py _handle_task_interrupt wired correctly; INTERRUPTED→RUNNING resume in _get_or_create_task is sound; docstring on return type of _handle_task_interrupt is inaccurate (returns RUNNING, not INTERRUPTED).
agentex/src/api/routes/tasks.py New interrupt_task REST endpoint authorized as update (not cancel); delegates to use-case CAS correctly.
agentex/src/domain/entities/agents_rpc.py InterruptTaskParams, InterruptTaskRequestEntity, and ACP_TYPE_TO_ALLOWED_RPC_METHODS all updated consistently; SYNC now permits task/interrupt.
agentex/tests/unit/use_cases/test_tasks_use_case.py Comprehensive tests for interrupt/resume/terminal-from-INTERRUPTED paths; error message match strings updated to reflect new wording.
agentex/tests/integration/api/tasks/test_tasks_api.py Integration tests cover interrupt, default reason, terminal-from-INTERRUPTED, and rejection of interrupt on completed task.
agentex/tests/unit/api/test_tasks_authz.py Authorization tests confirm interrupt maps to update (not cancel) for both task_id and task_name resolution paths, and SYNC/ASYNC/AGENTIC allowlists.

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)
Loading
%%{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)
Loading

Reviews (5): Last reviewed commit: "refactor(tasks): make task/interrupt RPC..." | Re-trigger Greptile

@declan-scale
declan-scale requested a review from a team as a code owner July 16, 2026 15:04
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add interrupt endpoint, InterruptTaskRequest model, INTERRUPTED status to tasks

python

feat(api): add task/interrupt method and INTERRUPTED status to agents/tasks

typescript

feat(api): add task/interrupt method and INTERRUPTED status to agents/tasks
agentex-sdk-openapi studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅

⚠️ agentex-sdk-typescript studio · code

Your SDK build had a failure in the build CI job, which is a regression from the base state.
generate ⚠️build ❗lint ❗test ✅

⚠️ agentex-sdk-python studio · code

Your SDK build had at least one "warning" diagnostic.
generate ⚠️build ✅lint ✅test ✅

pip install https://pkg.stainless.com/s/agentex-sdk-python/4f1c09348c2ddffeb8c78738fb9b7aa14ed4b752/agentex_client-0.19.0-py3-none-any.whl

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-16 19:19:02 UTC

…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>
Comment thread agentex/src/domain/services/task_service.py Outdated
declan-scale and others added 3 commits July 16, 2026 11:22
- 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(

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.

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?

@smoreinis

Copy link
Copy Markdown
Contributor

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:

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.

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 smoreinis 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.

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>
@declan-scale
declan-scale merged commit a92081a into main Jul 16, 2026
34 checks passed
@declan-scale
declan-scale deleted the declan-scale/agx1-391-task-interrupt branch July 16, 2026 19:16
declan-scale added a commit to scaleapi/scale-agentex-python that referenced this pull request Jul 16, 2026
…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>
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