Skip to content

[Bug]: TaskRepository.cancelTask cannot stop an already-running async subagent #2790

Description

@sailor-smile

Describe the bug

When a parent agent is cancelled, async subagents spawned through SubagentsMiddleware
(background task mode) keep running: they continue issuing LLM requests and tool calls until
they finish naturally, then push their results to the inbox and wake the parent up.

TaskRepository.cancelTask(ctx, sessionId, taskId) looks like the API intended for this, but it
has no effect on a local task that has already started executing. It can only mark the task
as cancelled at task boundaries; it cannot interrupt the agent loop that is currently running.

Two independent causes, both verifiable from the bytecode of agentscope-harness-2.0.0.jar.

Cause 1 — BackgroundTask.cancel(boolean) does not interrupt the worker thread.

public boolean cancel(boolean);
   0: aload_0
   1: iconst_1
   2: putfield      cancelled:Z
   5: aload_0
   6: getfield      future:Ljava/util/concurrent/CompletableFuture;
   9: iload_1
  10: invokevirtual java/util/concurrent/CompletableFuture.cancel:(Z)Z
  13: ireturn

The whole method body is "set the cancelled flag" plus CompletableFuture.cancel. Per the JDK
contract, CompletableFuture.cancel(mayInterruptIfRunning) ignores mayInterruptIfRunning
(that implementation does not use interrupts to control processing). A supplier already running
on the ws-task-* executor is therefore never interrupted.

Cause 2 — TaskRecord.cancelRequested has no checkpoint inside the agent loop.

cancelTask does call setCancelRequested(true) and persists the record, and that part works.
But scanning every class in the jar (running javap -c on each and searching for
isCancelRequested call sites), only two classes ever read the flag:

  • io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.class
  • io/agentscope/harness/agent/subagent/task/TaskRecord.class

The checkpoints inside WorkspaceTaskRepository are:

Location When
runLocalSupplier entry before the supplier starts
runLocalSupplier after supplier returns after the supplier ends
runRemoteTask entry before the remote call
pollRemoteUntilDone loop remote tasks, every poll
updateStatus prevents overwriting CANCELLED

For local tasks (LocalTaskRunSpec), every checkpoint sits outside the supplier. The supplier
body is the entire agent loop (many rounds of LLM calls and tool calls), so once execution enters
it, the flag is never consulted again.

Net effect:

  • queued, not-yet-started tasks — entry checkpoint works, genuinely skipped
  • currently executing tasks — cannot be interrupted at all, run to completion
  • remote tasks (RemoteTaskRunSpec) — polling checkpoint exists, better than local

To Reproduce

  1. You code
// 1. A supervisor agent that can spawn subagents
HarnessAgent supervisor = HarnessAgent.builder()
        .model(model)
        .messageBus(messageBus)
        .subagentFactory("worker", name -> workerAgent)
        .build();

// 2. Obtaining the TaskRepository currently requires reflection:
//    HarnessAgent.subagentMiddleware is private and has no public getter.
Field mwField = HarnessAgent.class.getDeclaredField("subagentMiddleware");
mwField.setAccessible(true);
DynamicSubagentsMiddleware dsm = (DynamicSubagentsMiddleware) mwField.get(supervisor);
TaskRepository repo = dsm.getTaskRepository();

// 3. Ask the supervisor to spawn an async subagent whose work spans several tool calls,
//    e.g. a prompt that makes it call a tool repeatedly in background mode.
supervisor.streamEvents(new UserMessage(promptThatSpawnsBackgroundTask), ctx).subscribe();

// 4. Once the subagent is genuinely executing (tool calls visible on a ws-task-* thread),
//    cancel every non-terminal task of that session.
RuntimeContext rc = RuntimeContext.builder().sessionId(sessionId).build();
for (BackgroundTask t : repo.listTasks(rc, sessionId, null)) {
    TaskStatus st = t.getTaskStatus();
    if (st == null || !st.isTerminal()) {
        boolean requested = repo.cancelTask(rc, sessionId, t.getTaskId());
        System.out.println("cancelTask returned " + requested + " for " + t.getTaskId());
    }
}
  1. How to execute

Run the flow, wait until the subagent has started calling tools, then trigger the
cancelTask loop above. Keep watching the logs after the call returns.

  1. See error

cancelTask returns true, but the subagent is unaffected and keeps executing.

Expected behavior

After cancelTask returns, the subagent should stop at the next interruptible point
(for example before the next reasoning round or the next tool call) and issue no further
LLM requests or tool calls.

Error messages

There is no exception — that is the problem. cancelTask reports success and execution
continues silently. The only visible evidence is that the task later completes normally:

[ws-task-307] INFO  SubagentsMiddleware - Subagent task task_<id> completed, pushed to inbox and enqueued wakeup: session=<sessionId>

Observed in production: the parent run was cancelled at 11:24:08; four subagents kept logging
POST_ACTING ... state=SUCCESS at 11:24:09 and 11:24:10, and only reached completed at
11:24:40 — 32 seconds of LLM and tool work after cancellation.

Environment (please complete the following information):

  • AgentScope-Java Version: 2.0.0 (agentscope-harness 2.0.0)
  • Java Version: 17
  • OS: macos

Additional context

Relation to #1577: that issue covers cancel-propagation breakage for synchronous subagents
(stream() mode), and its impact table explicitly marks "async subagent (timeout=0)" as
"not on the chain, unaffected (needs task_cancel)". This issue is exactly that gap: the
task_cancel capability exists as an API, but does not achieve stop-execution semantics for
already-running local tasks.

Suggested fix. Everything needed already exists inside the SDK; only the wiring is missing.
ReActAgent already has per-session interruption:

public void interrupt(String userId, String sessionId, Msg msg)
// -> getAgentState(userId, sessionId).interruptControl().trigger(InterruptSource.USER, msg)

and ReActAgent$CallExecution already has private Mono<Void> checkInterrupted() with 4 call
sites in the reasoning/acting loop — precisely the interruptible points needed.

Proposal: make cancelTask reach the InterruptControl of the executing subagent. For example,
have WorkspaceTaskRepository.putTask register the task's agent instance (or its AgentState /
InterruptControl) alongside the taskId, and have cancelTask additionally call
interruptControl().trigger(...). Cancellation granularity then matches the existing
checkInterrupted density, with no new checking logic required.

Alternative: inside runLocalSupplier, compose the supplier's reactive chain with
cancelRequested (e.g. takeUntilOther, or periodic checks followed by Mono.error) so the flag
also takes effect while the supplier is running.

Why this cannot be worked around from application code. Subagent instances are created fresh on
every spawn by the subagentFactory and are not cached, so callers cannot obtain a reference to
the running instance and cannot call interrupt themselves. GracefulShutdownManager is a global
singleton with no session dimension, so using it would affect every session in the process. The
only remaining option is for each integrator to add an onActing middleware to the subagent's own
chain that checks an external cancellation flag and throws — duplicated work for everyone, with
granularity limited to tool-call boundaries.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions