Skip to content

fix(tools): keep a message interrupt from muting the Tasks it spares - #424

Open
kevin9327 wants to merge 1 commit into
leookun:mainfrom
kevin9327:fix/interrupt-mutes-kept-running-task
Open

kevin9327 wants to merge 1 commit into
leookun:mainfrom
kevin9327:fix/interrupt-mutes-kept-running-task

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

The bug

interrupt_for_message runs when the user sends a message mid-turn. It deliberately spares background Task execs — they are kept in the map and no abort is emitted for them — while aborting everything else:

entries.retain(|id, entry| {
    interrupted_ids.push(*id);                                    // <- every id, unconditionally
    let keep_running = entry.call.name.eq_ignore_ascii_case("Task");
    if !keep_running {
        abort_ids.push(*id);
    }
    keep_running
});

The push happens before the keep_running decision, so the spared Tasks land in interrupted_ids alongside the aborted ones and are added to self.interrupted. Nothing ever removes an individual id from that set (interrupted_rounds, its neighbour in the conversation layer, is pruned per round; this one is not — only the wholesale drain_running clears it).

That set gates the client event path:

if pending.is_interrupted(message.id).await {
    if message.message.as_ref().is_some_and(is_terminal) {
        pending.discard_exec(message.id).await;
    }
    return Ok(ClientExecEvent::Pending);
}

Failure mode

The Task keeps running on the client — correctly, no abort is emitted for it — and the server then:

  1. drops every stdout/stderr delta it sends (ClientExecEvent::Pending), and
  2. discards the exec outright when its terminal result arrives.

So the result never becomes a ToolCompletion, and the tool round goes on waiting for a completion that can no longer arrive. The keep_running branch was dead intent: sparing the Task from the abort only changed how it dies.

The fix

Mark an exec interrupted only when it is also aborted — one moved line. Interaction ids are unaffected: they are cleared wholesale a few lines below and are still all marked, which the second test pins.

Verification

cargo +1.95 test --package cursor-server --lib cursor::tools::runtime

Before (interrupted_ids.push(*id) moved back above the decision, tests kept):

---- a_message_interrupt_does_not_mute_the_tasks_it_keeps_running stdout ----
assertion failed: !runtime.is_interrupted(task).await

test result: FAILED. 1 passed; 1 failed

After:

test cursor::tools::runtime::tests::a_message_interrupt_does_not_mute_the_tasks_it_keeps_running ... ok
test cursor::tools::runtime::tests::a_message_interrupt_still_mutes_and_aborts_everything_else ... ok

test result: ok. 2 passed; 0 failed

The second test is the guard on the other side: a non-Task exec is still aborted and muted, and a pending interaction is still muted.

Gates (make check, Rust half):

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean on CI's stable; see note
  • cargo test --workspace --all-targets — all suites green

Toolchain note: this machine's stable is broken, so the gates ran with +1.95. Clippy 1.95 reports a collapsible_match error at server/src/cursor/compile/model.rs:109 on unmodified main; that is a 1.95-only false positive (its own suggestion, "fast" if parse_bool(parameter)? =>, does not compile — ? is not allowed in a match guard) and CI's 1.98.1 does not emit it, so main is green. Clippy here therefore ran with -A clippy::collapsible_match; this diff is unaffected either way.

Related

This is the shape reported in #340 (Multitask: the subtask stops when it completes and the parent never continues), but I have not reproduced that report end to end, so treat it as a lead rather than a claim.

`interrupt_for_message` deliberately keeps background `Task` execs running
when the user sends a message mid-turn: `retain` returns `keep_running` for
them, and only the others are aborted. But every id was pushed into
`interrupted_ids` before that decision, so the spared Tasks were marked
interrupted too.

Nothing ever clears that set for an individual id, and it gates the client
event path:

    if pending.is_interrupted(message.id).await {
        if message.message.as_ref().is_some_and(is_terminal) {
            pending.discard_exec(message.id).await;
        }
        return Ok(ClientExecEvent::Pending);
    }

So the Task keeps running on the client, no abort is emitted for it, and the
server then drops every delta it sends and discards the exec outright when
its terminal result arrives. The result never becomes a `ToolCompletion`,
and the tool round waits on a completion that can no longer arrive. The
`keep_running` branch was dead intent.

Mark an exec interrupted only when it is also aborted.

Co-Authored-By: Claude Opus 5 <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.

1 participant