Skip to content

feat(opencode): add dispatch controls to the task tool - #34947

Open
iceteaSA wants to merge 3 commits into
anomalyco:devfrom
iceteaSA:task-dispatch
Open

feat(opencode): add dispatch controls to the task tool#34947
iceteaSA wants to merge 3 commits into
anomalyco:devfrom
iceteaSA:task-dispatch

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 2, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #17595. Also covers #6651, #26925, #29984 (model param for subagents) and #24757 (variant lost on task dispatch). Supersedes #29447 and #32122.

Type of change

  • New feature

What does this PR do?

Adds per-dispatch controls to the Task tool. Seven changes, one surface:

  • model param (provider/model-id) — run one dispatch on a different model than the agent's default. Gated behind a new model_override permission that defaults to deny, so an agent can't silently move work to an expensive model; you allow patterns per provider in config ("model_override": { "anthropic/*": "allow" }). The permission check runs even on the bypassAgentCheck path — a task spawned by another task can't skip the ask.
  • Resume keeps model and variant. Resuming a task_id previously reverted the child to the agent default mid-conversation. The child session now records its last-used model/variant and resumes on it; an explicit model param still wins.
  • Slug task_ids. task_id: "explore-auth" creates a named child whose session id derives from sha256(slug + root session id), so the same slug from the same session tree resumes the same child. The slug also becomes the child session's display slug. Slugs are parent-scoped: using another session's slug errors instead of hijacking it.
  • variant param — per-dispatch reasoning preset ("thinking", "high", …). Unknown variants are ignored, same as the existing variant resolution. deepseek-v4 models additionally get a none variant that disables thinking ({ thinking: { type: "disabled" } }, same shape as the existing minimax-m3 none and identical to what agent-frontmatter options.thinking produces) — without it there was no per-dispatch way to run deepseek with thinking off, forcing a dedicated agent definition for what is one flag.
  • metadata param — opaque object stored on the child session row for plugins/queries. Shallow-merged on resume. Not shown to the subagent.
  • resume: true gate. Reusing a task_id that names an idle (finished) session used to silently continue it — an agent reusing a slug by accident inherits a stale context. Resuming an idle session now requires resume: true; a live background task still accepts follow-up prompts without it. This is the one behavior change in the PR.
  • timeout + fallback_model — bound a dispatch attempt in ms; optionally retry once on a different model when the attempt fails or times out. Fallback never fires on parent abort (interrupt) or defects, only typed failures. The retry cancels the child's prompt runner via a new SessionRunState.cancelRun rather than cancel — full cancel tears down the enclosing background job itself (job id == child session id), which killed the fallback before it could run.

Why the runner-only cancel exists: Effect.timeout interrupts the caller fiber, but the actual run is forked into the session runner scope and stays Running — a second prompt would wait on it forever instead of starting the fallback. cancelRun interrupts just that runner. Both failure modes were found by probes during review, not speculation; the regression tests for them are in the diff.

How did you verify your code works?

  • 45 task-tool tests (24 existing + 21 new), each new behavior written red-first; bun test test/tool/ 310 pass / 0 fail, test/session/ 366 pass / 0 fail (prompt/run-state touched), test/provider/transform.test.ts 291 pass / 0 fail (deepseek none variant), typecheck clean on opencode, core, tui.
  • Mutation checks: replacing Exit.hasInterrupts with false fails the interrupt-no-fallback test; the model-override permission test asserts exactly one model_override ask on the bypass path.
  • Live on a running build: slug create/resume, resume-consent rejection and acceptance, model override (allowed + denied patterns), variant persistence across resume verified against the session DB (per-message variant + reasoning-token counts), metadata merge, 3s timeout killing a 60s task.
  • Known limit, stated honestly: the fallback path is covered by stubbed tests and an Effect-level probe of the runner semantics, not by an end-to-end test through the real SessionPrompt stack — the tool-test harness has no way to drive a real provider timeout deterministically.

Screenshots / recordings

Not a UI change.

Checklist

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

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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

Based on the search results, here are related/superseded PRs that are NOT duplicates (as noted in the PR description):

Superseded PRs:

Related/Prior Work:

The current PR (#34947) explicitly supersedes #29447 and #32122, and covers the issues mentioned. No actual duplicate open PRs found that would represent the same work.

@ArthurFranckPat

Copy link
Copy Markdown

Hi — one more concrete use case and a question about the review pipeline, from someone who keeps hitting this exact wall.

Use case

I'm running opencode (0.0.0-next-17028) as an orchestrator on a real project: I delegate to a backend agent and a frontend agent (mechanical work — should run on a fast/cheap model) and a review agent (needs a stronger model) in the same session. Today the only way to approximate that is duplicating agent definitions per model (.opencode/agents/backend-dev.md, backend-dev-strong.md...) or manually switching the session model between calls, which applies to everything. This is precisely what this PR and #11377 would fix.

The question

I dug through the related issues to understand the status before commenting:

From the outside this reads as: the request is real (this thread alone has dozens of use cases), two implementations exist, and neither has gotten a single maintainer comment in 6+ months. I don't want to assume anything — is this a triage/review backlog issue, is the feature out of scope for now, or is the direction (tier vs explicit model param vs dispatch) still being debated? What would help get this reviewed: an approved design, a smaller diff, someone to champion the PR?

Happy to help however is useful — I'd genuinely rather contribute than duplicate another request in a fourth issue.

@iceteaSA

iceteaSA commented Aug 8, 2026

Copy link
Copy Markdown
Author

@ArthurFranckPat Hi! As I understand it, it is Facebook's rules: The more likes, the more likely it is to go in? You could try a relentless social media campaign on discord, but your millage may vary.

Per-dispatch model override (permission-gated), resume that keeps model and
variant, slug task_ids, per-dispatch variant, opaque metadata, an explicit
resume consent gate, and timeout with fallback_model.
@iceteaSA

iceteaSA commented Aug 9, 2026

Copy link
Copy Markdown
Author

Added a75423f94d, fixing a bug in the timeout feature this PR introduces: a task that failed terminally left the child's agent loop running.

The child kept calling the model and running tools after the parent had already received its error frame, and no tool could stop it — task_abort refuses on a settled job, so the child was unreachable. Measured on a live build before the fix:

child dispatched with timeout: 25000, heartbeat every ~7.4s
parent received the error frame at ~25s
child continued to 20/20 ticks — 145s of work, 120s past the parent's frame
task_abort issued mid-way -> "has already finished"

Effect.onInterrupt fires on interruption only. Probed against this repo's Effect version:

typed failure (Effect.fail)  -> does not fire
Effect.timeout               -> does not fire
Effect.die                   -> does not fire
Effect.interrupt             -> fires

The child's loop is forked into the per-instance scope in effect/runner.ts and the caller only awaits a Deferred, so Effect.timeout interrupts the await, not the run fiber. The run passed to background.start is wrapped in Effect.onInterrupt(() => ops.cancel(childID)), which covers parent abort but never fires on a terminal failure — so nothing cancelled the runner.

The fix moves the existing ops.cancelRun(childID) call so it runs on any failure exit rather than only on the fallback path. cancelRun is the runner-only interrupt that skips cancelBackgroundJobs, so it cannot self-cancel the enclosing job.

A comment I added in the original timeout work was wrong and is replaced:

-// No ops.cancel here: Effect.timeout already interrupted the ops.prompt fiber,
+// Timeout interrupts the await, not the child runner; cancelRun stops that
+// runner without canceling the enclosing background job.

Effect.timeout did interrupt a fiber — just not the one that mattered.

Verification: red-first, and the mutation check removes only the new call. Both the new regression test and the pre-existing fallback test go red, so the single call now covers both paths. The prior timeout without fallback fails the task test passed with the bug present, since it only asserted the tool returned a failure; the new assertion is that the child's runner was actually cancelled.

task suite   44 pass / 0 fail
full suite   3289 pass / 0 fail
typecheck    clean
mutation     42 pass / 2 fail with the call removed

One behaviour worth stating precisely, since the placement suggests otherwise: the new call does not execute on parent abort. The outer fiber is being interrupted at that point, so the yield* is skipped — onInterrupt already handles that path. Cross-family review confirmed this empirically rather than by inspection.

@iceteaSA

iceteaSA commented Aug 9, 2026

Copy link
Copy Markdown
Author

Added 8333554e04. The previous commit fixed the orphaned-child bug for the primary attempt and missed the fallback attempt, which returned its failure directly with nothing cancelling the runner afterward.

Found by probing the fix rather than reading it. Live on a binary that already carried a75423f94d:

dispatch: timeout 20000 + fallback_model, child heartbeating every ~7.4s, 30 ticks of work
primary times out at 20s -> fallback fires -> fallback times out at ~40s
parent received <task_error>TimeoutError</task_error>
child continued: 30/30 ticks, 247s of work — ~207s past the parent's error frame

The primary-only path on the same binary stops at 3/20 ticks, so the earlier fix works where it applies; this was the hole beside it.

The fallback attempt is now captured with Effect.exit the same way the primary is, and both failure arms go through one shared cancelRun local. Verified across exit shapes before writing it — typed failure, timeout, and defect all cancel; success does not.

Two regression tests: the fallback-failure case, and a guard that a successful dispatch does not cancel the runner. That second one is the real risk of broadening a cancel, so it is pinned rather than assumed. Both mutation-checked independently — reverting only the fallback arm reddens only the fallback test, and mutating the success path to cancel unconditionally reddens only the success guard.

task suite   46 pass / 0 fail
full suite   3291 pass / 0 fail
typecheck    clean

Worth noting for anyone reviewing the pair: the first fix was reviewed and approved with real probes, and the gap still shipped, because the review brief scoped it to "terminal failure" and neither of us enumerated the function's exits. The discriminator was a two-minute heartbeat probe in both cases.

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.

[FEATURE]: Runtime model override for task tool subagents

2 participants