Skip to content

fix(core): cancel a session's background shell jobs on interrupt - #60119

Open
sulthonzh wants to merge 2 commits into
anomalyco:devfrom
sulthonzh:cancel-session-jobs
Open

sulthonzh wants to merge 2 commits into
anomalyco:devfrom
sulthonzh:cancel-session-jobs

Conversation

@sulthonzh

@sulthonzh sulthonzh commented Oct 29, 2026 •

Copy link
Copy Markdown

Issue for this PR

#60107 (fixes the interrupt half; see below)

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Interrupting a session only cancelled the job keyed by the session's own id, which covers a session running as a subagent job. Background shell and image jobs are keyed by their shell/image id and only carry the owning session in job metadata, so jobs.cancel(sessionID) missed them: the work kept running and its completion notice later woke the interrupted session for a new turn.

This adds Job.cancelAll({ sessionID, type? }), which cancels running jobs whose metadata.sessionID matches (same shape as backgroundAll, but keyed on ownership rather than blocking sessions, which are cleared once work is backgrounded), and calls it next to the existing jobs.cancel(sessionID) when the run coordinator settles on a non-shutdown interrupt.

Scope note: this fixes expected behavior 1 from the report in full. For expected behavior 2, jobs cancelled through this path end cancelled rather than error, so they follow the existing not-admitted notification handling and should not wake the session; the DELETE /api/shell/:id → Shell.NotFoundError notice path is separate and not changed here.

How did you verify your code works?

  • Two new unit tests in packages/core/test/job.test.ts: owned-by-session cancellation leaves foreign and unowned running jobs alone (and lets them complete normally after), and the optional type filter. Red before the change, green after.
  • job.test.ts 17/17; related suites (session-execution, tool-shell, subagent-lifetime, tool-subagent, session-shell) 127 pass / 0 fail.
  • Full packages/core suite: 6,993 pass; the only remaining failures are four root-in-container artifacts (two flock "unwritable lock root" tests, global-home isolation, one external-plugin spawn) reproduced identically without this change.
  • bun run typecheck clean (1,738 files).

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #60107, #36424, #34366, #54658, #60111

Multiplexer panes re-composite pane output at the multiplexer's own
cadence, so the TUI's animated repaint stream (tab pulse, marquee,
shimmer, tween ticks) turns into continuous full-region recomposites
on the multiplexer client, visible as flicker and shaking on remote
or slow links (anomalyco#57462, anomalyco#57676).

Resolve animations in resolve() the same way tabs.enabled is already
environment-aware: default off when HERDR_ENV=1, on otherwise; an
explicit animations setting always wins.

The mini footer queue test pinned its expected statusline glyph via
the test harness coercing unresolved animations to false; resolve()
now always defines the field, so the test declares animations: false
explicitly for its still-glyph assertion.
Interrupting a session only cancelled the job keyed by the session's own
id, which covers a session running as a subagent job. Background shell
and image jobs are keyed by their shell/image id and only carry the
owning session in job metadata, so jobs.cancel(sessionID) missed them
and the work kept running; the completion notice then woke the
interrupted session for a new turn (anomalyco#60107).

Add Job.cancelAll({ sessionID, type? }) that cancels running jobs owned
by the session (metadata.sessionID match, mirroring backgroundAll's
shape) and call it next to the existing cancel when the run coordinator
settles on a non-shutdown interrupt.
@sulthonzh
sulthonzh force-pushed the cancel-session-jobs branch from ad19971 to b1a0615 Compare October 29, 2026 05:39

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interrupt now kills the session's background shell, but the interrupted session still starts a new turn, so the user-visible part of #60107 isn't fixed yet.

I checked this with a simulated-model drive script that follows the #60107 reproduction (background sleep 8 && touch marker, then interrupt during the next turn):

  • Base: the shell keeps running, the marker is written, and the state="completed" notice starts a new model turn.
  • This PR: the shell is cancelled and no marker is written. But a <shell … state="cancelled">Cancelled</shell> notice is delivered right away and immediately starts a new model turn on the interrupted session.

The description says cancelled jobs follow a "not-admitted" path and won't wake the session, but nothing in the shell notice path does that (see the inline comment). job.test.ts and session-execution.test.ts pass, and tsc -b passes.

Public API: adds cancelAll to the internal Job.Interface in core only. The HTTP API, SDK, plugin API and events are unchanged. The bundled herdr change (below) adds an environment-dependent default for the TUI animations setting.

Other concerns:

  • Unrelated commit: this PR still includes fix(tui): default animations off inside herdr panes (packages/tui/src/config/index.tsx and two TUI tests), which is the same change as #60111. Please drop it from this PR.
  • Behaviour change for maintainers: the TUI's double-Esc stop and the web composer both call interrupt with resume: true. With this change, every stop also kills background shells the model started, including long-running ones like dev servers. Background subagents the session started are still left running, because their job metadata has no sessionID. Interrupting an idle session (no active run) also cancels nothing, since this runs only when an active run settles.

Related: #60107, #36424, #34366, #54658, #60111

// A session running as a subagent job is keyed by its own id; owned
// background work (shell, image) carries the session in job metadata.
yield* jobs.cancel(sessionID)
yield* jobs.cancelAll({ sessionID })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cancels the shell, but notifyWhenDone in tool/plugin/shell.ts then posts the state="cancelled" notice through sessions.synthetic(...) without resume: false. Session.synthetic calls execution.wake unless resume === false, so the session starts a new turn as soon as the interrupt settles. I reproduced this with the #60107 script: model request #3 carries <shell … state="cancelled">Cancelled</shell>. The image job's notifier probably behaves the same way. For the interrupt to actually stop the session, the cancelled notice should be recorded without waking it, and a test should cover interrupt → no new turn.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only rebases the PR onto newer dev. The two commits are unchanged, so the open point about the cancelled shell notice waking the session still stands. I re-ran the #60107 reproduction on the new head with the simulated model. The interrupt kills the shell, but a new model turn still starts right away carrying <shell … state="cancelled">Cancelled</shell>.

The unrelated herdr animations commit (the same change as #60111) is still included. The public API is unchanged since the last review.

This branch has not been deployed

No deployments
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