What happened, and what did you expect?
What happened: While the orchestrator had 2+ background task(..., background: true) lanes running (via @explorer/@librarian) and had called wait_for_user to pause for them, it got re-invoked and called wait_for_user again roughly every 4-9 seconds, repeatedly, for 30-40+ seconds per background job — 13 separate LLM turns in one incident — even though nothing new had happened and no external user message arrived in most of those cycles. Each of these is a full, separately-billed LLM completion.
Expected: Once wait_for_user is called, the orchestrator should stay quiesced until either (a) a background job actually needs to report a new completion the model hasn't seen yet, or (b) a real external user message arrives — not be re-invoked repeatedly for the same already-delivered completion.
Root cause (from plugin logs + session DB)
Reconstructed from ~/.local/share/opencode/log/oh-my-opencode-slim.*.log for the affected session. One representative burst:
19:39:01.501 job A (background task) completes → marked terminalUnreconciled:true
19:39:01.502 "terminal jobs injected for reconciliation" [job A] → orchestrator turn → calls wait_for_user
19:39:09.568 "terminal jobs injected for reconciliation" [job A] → orchestrator turn → calls wait_for_user (same job, again)
19:39:14.587 "terminal jobs injected for reconciliation" [job A] → ... again
19:39:18.577 "terminal jobs injected for reconciliation" [job A] → ... again
19:39:25.490 "terminal jobs injected for reconciliation" [job A] → ... again
19:39:31.377 "terminal jobs injected for reconciliation" [job A] → ... again
19:39:35.xxx "terminal jobs injected for reconciliation" [job A] → ... again
19:39:38.589 session finally goes idle → job A actually reconciled, reminder cleared
This is a feedback loop across three files in src/hooks/task-session-manager/:
board-injection.ts (injectCheckpointBoard / rememberInjectedTerminalJobs, called from the experimental.chat.messages.transform hook on every LLM request) re-attaches the "Background Job Board" reminder to every request as long as any job is terminalUnreconciled. It doesn't track whether the orchestrator has already been shown this specific still-unreconciled completion in a prior step.
idle-reconciliation.ts only clears terminalUnreconciled (via reconcileInjectedTerminalJobs) inside a timer that's scheduled on a genuine session.idle event.
event-router.ts's busy handler unconditionally calls idleReconciler.clearIdleTimers(sessionId) on every busy status pulse — and the logs show the session stayed continuously busy → busy → busy … with no idle event logged in between, for the entire 30-40s stretch.
Net effect: the completed job's reminder keeps getting reinjected on every step. The orchestrator model (reasonably, given what it's shown each time) reacts by calling wait_for_user again. That reaction is itself another busy pulse, which cancels the only timer that would have cleared the reminder. The loop only breaks once two busy pulses happen to land further apart than IDLE_RECONCILE_DELAY_MS (2000ms in index.ts), letting a queued idle-reconcile timer survive long enough to actually fire.
This isn't the model malfunctioning — it's being shown the same "you have an unreconciled completed background task" state repeatedly, with no way to have that state cleared before its next turn.
Suggested fix direction
Track per-job "already surfaced to the model at least once" state (e.g. in terminalJobsInjectedByParent, which already exists) and skip re-injecting/re-triggering a continuation for a job that's been injected but not yet reconciled — i.e., inject once, then wait for genuine idle or new user input, rather than re-injecting on every subsequent request. Alternatively, reconcile a job immediately at injection time instead of deferring reconciliation to an idle timer that competes with the busy pulses caused by the injection itself.
Steps to reproduce
- From an orchestrator session, dispatch 2 background tasks:
task(..., background: true) for two different subagents (e.g. one explorer, one librarian) doing multi-step work (multiple internal tool calls each, so they take at least 20-30s and don't finish at the same instant).
- Call
wait_for_user to pause for both.
- Watch
~/.local/share/opencode/log/oh-my-opencode-slim.*.log for the session: once the first background task completes, observe repeated "terminal jobs injected for reconciliation" log lines for the same task ID every few seconds, each accompanied by another orchestrator turn/wait_for_user call, until the session finally reaches a clean idle gap.
oh-my-opencode.json
Default config, no custom idleReconcileDelayMs or task-session-manager overrides.
OpenCode version
1.18.10
oh-my-opencode-slim version
2.2.8
Operating system
Linux (Fedora-based)
Logs, screenshots, or extra context
Relevant source:
src/hooks/task-session-manager/board-injection.ts (injectCheckpointBoard, rememberInjectedTerminalJobs)
src/hooks/task-session-manager/idle-reconciliation.ts (scheduleIdleReconciliation)
src/hooks/task-session-manager/event-router.ts (busy handler clearing idle timers unconditionally on every busy pulse)
src/tools/wait-for-user.ts (the tool itself; its beginUserWait gate correctly blocks new idle-reconcile scheduling, but doesn't stop an in-flight injection/continuation cycle already underway)
Log excerpt (session/task identifiers redacted):
[task-session-manager] background job status updated {"taskID":"<redacted>","state":"completed","terminalUnreconciled":true}
[task-session-manager] processed injected background completion {"taskID":"<redacted>","state":"completed"}
[task-session-manager] terminal jobs injected for reconciliation {"parentSessionID":"<redacted>","taskIDs":["<redacted>"]}
[task-session-manager] busy/status busy observed {"sessionID":"<redacted>","managesSession":true}
... (repeats ~6x over 30s for the same taskID, no idle event logged in between) ...
[task-session-manager] idle/status idle observed {"sessionID":"<redacted>","terminalJobsPending":0}
What happened, and what did you expect?
What happened: While the orchestrator had 2+ background
task(..., background: true)lanes running (via@explorer/@librarian) and had calledwait_for_userto pause for them, it got re-invoked and calledwait_for_useragain roughly every 4-9 seconds, repeatedly, for 30-40+ seconds per background job — 13 separate LLM turns in one incident — even though nothing new had happened and no external user message arrived in most of those cycles. Each of these is a full, separately-billed LLM completion.Expected: Once
wait_for_useris called, the orchestrator should stay quiesced until either (a) a background job actually needs to report a new completion the model hasn't seen yet, or (b) a real external user message arrives — not be re-invoked repeatedly for the same already-delivered completion.Root cause (from plugin logs + session DB)
Reconstructed from
~/.local/share/opencode/log/oh-my-opencode-slim.*.logfor the affected session. One representative burst:This is a feedback loop across three files in
src/hooks/task-session-manager/:board-injection.ts(injectCheckpointBoard/rememberInjectedTerminalJobs, called from theexperimental.chat.messages.transformhook on every LLM request) re-attaches the "Background Job Board" reminder to every request as long as any job isterminalUnreconciled. It doesn't track whether the orchestrator has already been shown this specific still-unreconciled completion in a prior step.idle-reconciliation.tsonly clearsterminalUnreconciled(viareconcileInjectedTerminalJobs) inside a timer that's scheduled on a genuinesession.idleevent.event-router.ts's busy handler unconditionally callsidleReconciler.clearIdleTimers(sessionId)on everybusystatus pulse — and the logs show the session stayed continuouslybusy → busy → busy …with noidleevent logged in between, for the entire 30-40s stretch.Net effect: the completed job's reminder keeps getting reinjected on every step. The orchestrator model (reasonably, given what it's shown each time) reacts by calling
wait_for_useragain. That reaction is itself another busy pulse, which cancels the only timer that would have cleared the reminder. The loop only breaks once two busy pulses happen to land further apart thanIDLE_RECONCILE_DELAY_MS(2000ms inindex.ts), letting a queued idle-reconcile timer survive long enough to actually fire.This isn't the model malfunctioning — it's being shown the same "you have an unreconciled completed background task" state repeatedly, with no way to have that state cleared before its next turn.
Suggested fix direction
Track per-job "already surfaced to the model at least once" state (e.g. in
terminalJobsInjectedByParent, which already exists) and skip re-injecting/re-triggering a continuation for a job that's been injected but not yet reconciled — i.e., inject once, then wait for genuine idle or new user input, rather than re-injecting on every subsequent request. Alternatively, reconcile a job immediately at injection time instead of deferring reconciliation to an idle timer that competes with the busy pulses caused by the injection itself.Steps to reproduce
task(..., background: true)for two different subagents (e.g. oneexplorer, onelibrarian) doing multi-step work (multiple internal tool calls each, so they take at least 20-30s and don't finish at the same instant).wait_for_userto pause for both.~/.local/share/opencode/log/oh-my-opencode-slim.*.logfor the session: once the first background task completes, observe repeated"terminal jobs injected for reconciliation"log lines for the same task ID every few seconds, each accompanied by another orchestrator turn/wait_for_usercall, until the session finally reaches a clean idle gap.oh-my-opencode.json
Default config, no custom
idleReconcileDelayMsor task-session-manager overrides.OpenCode version
1.18.10
oh-my-opencode-slim version
2.2.8
Operating system
Linux (Fedora-based)
Logs, screenshots, or extra context
Relevant source:
src/hooks/task-session-manager/board-injection.ts(injectCheckpointBoard,rememberInjectedTerminalJobs)src/hooks/task-session-manager/idle-reconciliation.ts(scheduleIdleReconciliation)src/hooks/task-session-manager/event-router.ts(busy handler clearing idle timers unconditionally on every busy pulse)src/tools/wait-for-user.ts(the tool itself; itsbeginUserWaitgate correctly blocks new idle-reconcile scheduling, but doesn't stop an in-flight injection/continuation cycle already underway)Log excerpt (session/task identifiers redacted):