Skip to content

fix(core): settle a declined tool call as a deliberate stop - #54344

Closed
argszero wants to merge 1 commit into
anomalyco:devfrom
argszero:decline-stop-reason
Closed

argszero wants to merge 1 commit into
anomalyco:devfrom
argszero:decline-stop-reason

Conversation

@argszero

@argszero argszero commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #54180

Type of change

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

What does this PR do?

Declining a tool call settles the turn through a bare Effect.interrupt with no reason recorded, so the settle path reads it as a "shutdown" interrupt. The claim therefore survives the drain, and the declined turn resumes after a server restart.

The fix gives that interrupt a name so the settle path can tell a deliberate user stop from a shutdown:

  • Coordinator gains stopReason(key, reason) (packages/core/src/session/run-coordinator.ts), which writes execution.interruptionReason.
  • The drain input contract gains reportDeclined (packages/core/src/session/runner/index.ts).
  • packages/core/src/session/runner/step.ts calls input.reportDeclined() before its bare Effect.interrupt, and runner/llm.ts threads the callback from the drain input into the step.
  • packages/core/src/session/execution.ts wires it to coordinator.stopReason(sessionID, "user").

The settle path already does the right thing once the reason is not "shutdown" — jobs.cancel runs and the claim is released — so the declined turn no longer resurrects.

How did you verify your code works?

  • Two tests. A composed one in packages/core/test/session-execution.test.ts (settles a declined tool call as a deliberate stop that never resurrects) asserts the Execution.Interrupted reason is ["user"], that the claim is released, and that restart.resumeSuspendedSessions finds nothing to resume. A runner-level assertion in packages/core/test/session-runner.test.ts covers the step naming the stop before it interrupts.
  • The affected session tests run 285 pass / 0 fail; tsgo -b tsconfig.json tsconfig.tests.json exits 0; oxlint reports 0 problems.
  • The full packages/core suite shows two more failures than a stashed baseline, and both are the known load flakes (PluginSupervisor reload, subagent observer lifetime): re-running them standalone fails on both sides, in different combinations.

Checklist

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

Added by the opencode bot (AI-generated).

Related: #36347

Declining a tool call interrupts the runner fiber with no reason, which the
settlement path read as "shutdown": the declined turn kept its claim,
skipped its job cancellation, and resumed after a server restart. The drain
now names its own stop through the coordinator before interrupting, so a
decline settles as a deliberate user stop and releases the turn.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot 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.

The fix works. I ran the #54180 reproduction from source with the simulated model: on the base, rejecting the permission and restarting the server sends a second model request with "The server restarted while you were working". With this PR there is no further request after the restart. session-execution and session-runner tests pass (259/0) and packages/core typecheck is clean.

Public API: unchanged. Coordinator.stopReason and the reportDeclined drain input are internal to core. The only visible difference is that session.execution.interrupted now reports reason: "user" instead of "shutdown" for a declined tool call, which is an existing value, and the claim is released and session jobs are cancelled the same way as for a user interrupt.

No blocking concerns. One design note for maintainers: the stop reason is passed as a callback through four layers (execution → runner → llm → step) right before a bare Effect.interrupt. Having the step return a typed declined result that execution maps to { type: "interrupted", reason: "user" } would keep the decision visible in the return type rather than in a side channel. That's a preference, not a defect. The two added tests are small and focused.

Related: #36347

@argszero

Copy link
Copy Markdown
Contributor Author

Closing this to stay within GitHub's 3-open-pull-request limit for contributors without write access, which currently blocks every new pull request here. The branch remains on my fork, so I can reopen it on request.

@argszero argszero closed this Oct 20, 2026
vleonbonnet added a commit to vleonbonnet/opencode that referenced this pull request Oct 21, 2026
A declined permission or a dismissed question ends the turn with a bare self-interrupt, which the settle reads as a shutdown. The turn then keeps its execution claim, is reported as `session.execution.interrupted` with reason `shutdown`, projects no idle outcome, and resumes at the next server start with "The server restarted while you were working…", so the model goes on with the work the user declined. Clients that wait out a shutdown (opencode.el) also keep the session busy after the reject.

The step now names the stop before interrupting: the drain input gains `reportDeclined`, which the execution layer wires to the coordinator's new `stopReason(key, "user")`. The settle releases the claim and cancels the session's jobs as for any user interrupt. Teardown is unchanged: a location that shuts down is closed before its pending asks are failed, and the step completes those for continuation before it reaches the decline.

This follows upstream PR anomalyco#54344 (closed unmerged) for upstream issue anomalyco#54180.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v2: declining a tool call is recorded as shutdown, so the declined turn resumes after a server restart

1 participant