Skip to content

feat(session): add experimental.stopping continuation hook - #54487

Closed
jwitmann wants to merge 12 commits into
anomalyco:devfrom
jwitmann:feat/experimental-session-stopping
Closed

jwitmann wants to merge 12 commits into
anomalyco:devfrom
jwitmann:feat/experimental-session-stopping

Conversation

@jwitmann

@jwitmann jwitmann commented Oct 11, 2026 •

Copy link
Copy Markdown

Pre-terminal continuation votes at natural turn end; capped synthetic-steer loop with drain-owned counter.

Issue for this PR

Closes #60461 (supersedes #16626, closed as not-planned V1-only)

Type of change

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

What does this PR do?

Adds an experimental.stopping session hook that fires at natural turn end, before Complete(). A voter can grant a continuation (shouldContinue + steer text, admitted as a synthetic steer) or the turn ends. Grants are capped at 2 per turn with the counter carried in the drain continuation, and everything fail-closed: default stop, throwing voter stops the turn, empty grant text abstains. Review asks from #16626: contract shape, trigger placement, counter home.

Update after review: the counter no longer touches session metadata — it rides in the drain continuation (fresh drains start at 0, moves carry it). The entry-site vote is gone too; only the in-loop site votes. The vote's step remains a dedicated executed-steps counter, since the drain resets step to 1 just before mid-work votes.

How did you verify your code works?

  • 12 new scenarios in packages/core/test/session-runner.test.ts (grant, cap exhaustion, stepsLimit, throw, empty-text, voter ordering, move-pending, fresh-turn restart, no metadata writes, empty wake, move carry); fail-before proven by stashing the implementation. Full file 236/236 green, bun run check green.
  • Dogfooded with opencode-dev-framework v0.5.1 as the consumer: gate-fail → continue → rework → pass, and persistent failure → 2 grants then silent stop, both against a locally built core.

Screenshots / recordings

N/A — no UI change.

Checklist

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

Added by the opencode bot (AI-generated).

Related: #12472

Public API: adds the experimental.stopping session plugin hook with SessionStopping / SessionStoppingDecision types (backwards compatible)

@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 hook works as described: I ran session-runner.test.ts (234 pass; the new scenarios cover a grant, the cap of 2, a throwing voter, empty text and the reset, all through the real drain with the simulated model) and typecheck in packages/core and packages/plugin, both clean.

Public API: adds the experimental.stopping session hook to the plugin API, with the new SessionStopping and SessionStoppingDecision types in both @opencode/plugin effect and promise. As a side effect, core now writes the opencode.stopping.continuations key into session metadata, which emits durable session.metadata.updated events and shows up to clients. Additive and backwards compatible; nothing is removed or changed.

Two concerns, both inline:

  • Where the counter lives. SessionMetadata is documented as host-supplied and opaque to core. Storing a runner counter there with read-modify-write updates can overwrite the host's metadata changes, can be wiped by setMetadata in the middle of a turn (reopening the cap), and leaves the key behind for clients, children and forks after the turn. Turn-scoped runner state seems a better home.
  • The extra vote where the drain starts. It runs right after the reset with step: 0, so any wake with nothing pending can hand out two more grants with no user input. The per-turn cap doesn't hold across such wakes.

Fixes #16626
Related: #12472

Comment thread packages/core/src/session/runner/llm.ts Resolved
Comment thread packages/core/src/session/runner/llm.ts Resolved

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

Both concerns from my last review are resolved. The stopping counter now lives in the drain: it is carried across reloads and moves, starts at 0 on each fresh turn, and is never written to session metadata. The vote at drain start is gone, so a drain with nothing pending completes without asking voters. I re-ran session-runner.test.ts (236 pass) and core typecheck (clean).

Public API: adds the experimental.stopping session hook and the SessionStopping / SessionStoppingDecision types to @opencode/plugin (effect and promise). Additive and backwards compatible. The internal SessionRunner.Continuation type gains a stoppingContinuations field, but nothing outside core depends on it.

One thing left: prettier --check flags packages/core/src/session/runner/llm.ts (for example the long DrainResult.Reloaded(...) line) and packages/core/test/session-runner.test.ts; the base passes the same check. Please run the formatter on both files.

Fixes #16626
Related: #12472

@jwitmann

Copy link
Copy Markdown
Author

prettier --check is satisfied by 831121a

jwitmann added a commit to jwitmann/opencode-dev-framework that referenced this pull request Oct 11, 2026
@jwitmann

jwitmann commented Oct 11, 2026 via email

Copy link
Copy Markdown
Author

@opencode-agent

Copy link
Copy Markdown
Contributor

Thanks, I checked 831121a: it only reformats code, and prettier --check now passes on llm.ts and session-runner.test.ts. The counter change in 6d6e841 is the one I covered in my last review, so I have nothing further to raise.

@jwitmann

Copy link
Copy Markdown
Author

Update since the last review (831121a):

  • Peer review found 2 real defects, both fixed in 8c38173:
    1. Stopping counter leaked across same-drain turns — fresh queued input inherits the spent counter. Reset now happens alongside executedSteps, excluding move/compaction carry and granted steers.
    2. The stopping vote ran inside the uninterruptible mask — untrusted voters could stall abort/shutdown. The vote now runs via restore(), mutation stays masked.
  • Coverage in fad98e3:
    • New resets the stopping counter when fresh input lands mid-drain scenario (queued input admitted mid-granted-steer; asserts seen == [0,0]). Proven red without the fix ([0,1]), green with it.
    • Corrected the crash comment ("one free steer" → "up to two fresh grants").
  • Verified: prettier --check clean on all 7 files, core typecheck clean, session-runner.test.ts 243/243 pass.

Rebased onto upstream/dev f1632d0 — CONTINUE_AFTER_OUTPUT_LIMIT and STOPPING_MAX_CONTINUATIONS coexist, both handlers wired.

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

Both earlier concerns stay resolved: the counter is kept in the drain with no metadata writes, and the vote at drain start is gone. The new commits also restart the counter when fresh input arrives mid-drain, and the vote no longer holds up abort or shutdown. I re-ran session-runner.test.ts (243 pass), core typecheck and prettier --check (all clean).

Public API: unchanged since my last review. It adds the experimental.stopping session hook and the SessionStopping / SessionStoppingDecision types to @opencode/plugin (effect and promise); additive and backwards compatible.

Blocking: the new reset makes the per-turn cap of 2 bypassable in drains that only promote steers (the ones started by interrupt with resume) whenever a queued prompt is parked. Details and a repro are inline.

Related: #12472

Comment thread packages/core/src/session/runner/llm.ts Resolved
@jwitmann

Copy link
Copy Markdown
Author

Update since ddab695 (now head faa5c01):

  • Peer review on the parked-queue fix found one should-fix: a throwing voter stopped the turn silently. maybeContinueStopping now logs before abstaining — logWarning("stopping voter failed", { sessionID, cause }), interrupt branch untouched. Behavior unchanged except observability.
  • Docs: effect.mdx / index.mdx now pin the three contract gaps the review surfaced — vote only fires with no fresh input waiting, step is budget-relative (compaction resets it), and an idle-boundary reload/move ends the turn without a vote (successor starts at 0, same as crash — missed continuation, never extra).
  • Tests: new stays interruptible during a stopping vote scenario (blocks in the vote, interrupts, asserts interrupted exit + 1 vote + 1 request + no synthetic). Test mock now threads promotable and preserves force on Reloaded, matching execution.ts.
  • Verified: session-runner.test.ts 245/245, core typecheck clean, prettier clean, code-reviewer no blockers.

This also closes the open llm.ts:162 sub-thread — whole-block skip stays, per the steps == [1,1] reasoning above.

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

My earlier points are addressed. The cap now holds in steer-only drains with a queued prompt parked, which matches my repro. The new commits look sound. A voter that throws is now logged as a warning, and the turn still stops. The vote's step is reported consistently, and the docs now describe a reload or move landing on the turn boundary. I re-ran session-runner.test.ts (249 pass), typecheck:tsc in core and plugin, and prettier --check, all clean. The typecheck script now calls bun check, which my installed Bun lacks, so I used typecheck:tsc; that is unrelated to this PR.

Public API: adds the experimental.stopping session hook and the SessionStopping / SessionStoppingDecision types to @opencode/plugin (effect and promise). Additive and backwards compatible. The docs moved to build/plugins/server.mdx, following an upstream rename.

No further concerns.

@jwitmann
jwitmann force-pushed the feat/experimental-session-stopping branch from 544f9e3 to dd3913a Compare October 29, 2026 03:46
jwitmann added a commit to jwitmann/opencode-dev-framework that referenced this pull request Oct 29, 2026
README described the gate as continuation-not-veto without pointing at the proposed fix. Now references anomalyco/opencode#54487 (open) as the missing pre-terminal vote hook, framed as direction-not-promise.
…ssion-stopping

# Conflicts:
#	packages/core/src/session/runner/llm.ts
@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

@jwitmann

jwitmann commented Nov 12, 2026 •

Copy link
Copy Markdown
Author

Still actively maintained — rebased onto current dev on Nov 6, mergeable, CI green. This implements #60461 (pre-Complete veto with budget), which triage confirmed as distinct from the idle-loop proposals that all cross-link it as the complementary mechanism. Has a real downstream consumer waiting (completion gate in opencode-dev-framework). Please reopen.

For context: four PRs have implemented this hook (#41811, #44712, #47300, #54487) and none has received a maintainer review — the only maintainer comment in the lineage is a V1→V2 redirect. This keeps getting closed by automation without ever being looked at.

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.

[FEATURE]: [V2] experimental.stopping hook (pre-Complete veto with budget)

1 participant