anomalyco / anomalyco/opencode

Background task completions bypass queued human messages

Open
#38,523 1 comment 0 reactions 1 assignee View on GitHub

@nexxeln is already working on this.

Since Jul 23, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

If you send a message to your agent while background tasks are finishing, the agent can fail to answer it. The message is accepted and stays in context, but it never gets its own response turn — the parent's next reply answers a background worker's completion instead. To the user, the agent appears to ignore them.

Impact

Any human turn typed while task(background=true) workers are completing can be starved of its own response. It's a race that triggers whenever completions arrive faster than the parent answers them (arrival rate > service rate). With a capable model the human's content may get folded into a completion's reply; with a weaker model, a long completion, or a request that needs its own tool calls, the human message gets no response at all.

Reproduction

The race is reliable when the parent stays busy while higher-id completions keep landing:

  1. From one parent session, dispatch several task(background=true) workers whose completions land ~1s apart (e.g. sleep 3,4,5,6,7,8).
  2. Use a parent model slow enough that a single turn takes longer than the gap between completions.
  3. The instant the first completion lands (parent now busy), inject a human message.
  4. Let the remaining completions arrive.

Reproduced 3/3 this way on 1.18.0 with the server started --pure (no external plugins), so the behavior is core, not a plugin.

Observed behavior

One reproducing run (opencode serve --pure, parent on a slow model, workers sleep 3..8):

55.312  COMPLETION(syn)  worker-1        <task … state="completed">
55.316  assistant        -> answers worker-1        (parent now busy)
55.419  USER (human)     "…what is 17 + 25? reply HUMANACK <n>"   (injected while busy)
56.299  COMPLETION(syn)  worker-2        (id > human, lands while parent still busy)
57.995  assistant        -> anchored to worker-2    (folds "HUMANACK 42" in from context)
58.820  COMPLETION(syn)  worker-3
00.482  assistant        -> anchored to worker-3
…

Reconstructing each assistant turn's anchor (the max-id user row created at/before the turn) shows no turn anchored to the human message — every turn answered a completion or the initial prompt. The human's answer appeared only because it was folded into worker-2's turn; the human message itself never got a dedicated response.

Environment
  • OpenCode 1.18.0
  • Reproduced with opencode serve --pure (rules out plugins).
  • OPENCODE_EXPERIMENTAL=true was set during earlier reproduction; the --pure repro did not depend on it.
Root cause

The parent session has one processing loop and one inbox. Human messages and synthetic worker-completion messages share the same stream, and a completion is stored as a synthetic user message (synthetic: true).

When several messages arrive during an active run, the loop does not drain them in order or combine them; each iteration re-selects the single newest user row as the turn to answer. The human message stays in the model's context — it is included in the message history sent to the model — but it never becomes the selected turn, so it gets no dedicated response. While higher-id completions keep arriving, the human row is never the newest, so it is never chosen.

Code path (all at commit 7985c2066a8f38c48a7d8fefbafcbab96ffa3117):

  • TaskTool.injectBackgroundResult forks a prompt() into the parent with a synthetic: true text part when a job finishes.
  • SessionPrompt.prompt() persists that row before entering the loop.
  • Runner.ensureRunning() makes concurrent callers join the in-progress run and discard their own work unless the session is idle, so completions don't get separate ordered turns.
  • Each loop iteration selects the anchor via MessageV2.latest(), which picks the user row with the max monotonic id, and the turn is built with user: lastUser plus the full history in SessionPrompt.run.
Why this needs a core fix (not a plugin)

The stable plugin API cannot cancel prompt admission, set row priority, suppress native task injection, or preempt the parent without cancelling its background jobs. This needs a change in session admission or turn selection.

Expected behavior
  • An un-answered human message is not superseded as the turn anchor by a newer synthetic completion.
  • Completions that arrive together are coalesced into bounded parent work.
  • Every result stays available, with no duplicate delivery.
Suggested direction

OpenCode already distinguishes synthetic messages — SessionPrompt checks !m.parts.every((p) => p.synthetic). A minimal fix is to make the anchor selection in latest() (or its caller) not let an all-synthetic user row supersede an older un-answered human row. Alternatives: coalesce pending completions into one turn, or drain the inbox in arrival order.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.