Switch background task loop detection from percentage-based sliding window
(80% of 20-call window) to consecutive same-tool counting. Triggers when
same tool signature is called 20+ times in a row; a different tool resets
the counter.
- Replace countedToolPartIDs string[] with Set<string> for O(1) has/add vs O(n) includes/spread (C2)
- Cache resolveCircuitBreakerSettings at manager level to avoid repeated object creation (C3)
- Optimize recordToolCall to avoid full array copy with slice (L1)
Previously, pollRunningTasks() and checkAndInterruptStaleTasks() treated
any non-"idle" session status as "still running", which caused tasks with
terminal statuses like "interrupted" to be skipped indefinitely — both
for completion detection AND stale timeout. This made the parent session
hang forever waiting for an ALL COMPLETE notification that never came.
Extract isActiveSessionStatus() and isTerminalSessionStatus() that
classify session statuses explicitly. Only known active statuses
("busy", "retry", "running") protect tasks from completion/stale checks.
Known terminal statuses ("interrupted") trigger immediate completion.
Unknown statuses fall through to the standard idle/gone path with output
validation as a conservative default.
Introduced by: a0c93816 (2026-02-14), dc370f7f (2026-03-08)
Adds a configurable maxToolCalls limit (default: 200) that automatically
cancels background tasks when they exceed the threshold. This prevents
runaway subagent loops from burning unlimited tokens, as reported in #2571
where a Gemini subagent ran 809 consecutive tool calls over 3.5 hours
costing ~$350.
The circuit breaker triggers in the existing tool call tracking path
(message.part.updated/delta events) and cancels the task with a clear
error message explaining what happened. The limit is configurable via
background_task.maxToolCalls in oh-my-opencode.jsonc.
Fixes#2571
- MCP teardown race: add shutdownGeneration counter to prevent
in-flight connections from resurrecting after disconnectAll
- MCP multi-key disconnect race: replace disconnectedSessions Set
with generation-based Map to track per-session disconnect events
- MCP clients: check shutdownGeneration in stdio/http client
creators before inserting into state.clients
- BackgroundManager: call clearTaskHistoryWhenParentTasksGone after
timer-based task removal in scheduleTaskRemoval and notifyParentSession
- BackgroundManager: clean completedTaskSummaries when parent has
no remaining tasks
- Plugin dispose: remove duplicate tmuxSessionManager.cleanup call
since BackgroundManager.shutdown already handles it via onShutdown
Test expected timers only after allComplete, but H2 fix intentionally
decoupled per-task cleanup from sibling completion state. Updated
assertion to expect timer after individual task notification.
H3: cancelTask(skipNotification=true) now schedules task removal.
Previously the early return path skipped cleanup, leaking task objects
in this.tasks Map permanently. Extracted scheduleTaskRemoval() helper
called from both skipNotification and normal paths.
H2: Per-task completion cleanup timer decoupled from allComplete check.
Previously cleanup timer only ran when ALL sibling tasks completed. Now
each finished task gets its own removal timer regardless of siblings.
H1+C2: TaskHistory.clearAll() added and wired into shutdown(). Added
clearSession() calls on session error/deletion and prune cycles.
taskHistory was the only data structure missed by shutdown().
Tests: 10 pass (3 cancel + 3 completion + 4 history)
- Check response.error and !response.data after session.get() to fail closed
- Prevents unlimited spawning when SDK returns non-throwing error responses
- Added regression tests for SDK error and missing data scenarios
Implements question-denied session permission rules when creating child
sessions via background task delegation. This prevents subagents from
asking questions by passing explicit permission configuration during
session creation.
🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode
When a completed session is no longer returned by session.status(),
allStatuses[sessionID] is undefined. Previously this fell through to
a 'still running' log, leaving the task stuck as running forever.
Match the sync-session-poller pattern: only continue (skip completion
check) when sessionStatus EXISTS and is not idle. When undefined,
fall through to validateSessionHasOutput + checkSessionTodos +
tryCompleteTask, same as idle.
Align runtime defaults, tests, docs, and generated artifacts with the newer GPT-5.4 baseline. Keep think-mode and prompt-routing expectations consistent after the model version bump.
When a background task completes and the parent session is waiting for
user input, promptAsync() fails with an aborted error. Previously the
notification was silently dropped — lost forever.
Fix: queue the notification text in-memory on the BackgroundManager
when promptAsync fails with an aborted/idle error. On the user's next
message to that session, the queued notifications are injected into the
chat context before the agent sees the message.
- BackgroundManager: add pendingNotifications map + queuePendingNotification()
and injectPendingNotificationsIntoChatMessage() methods
- background-notification hook: add chat.message handler that calls injection
- chat-message.ts: wire backgroundNotificationHook.chat.message into the
message processing chain
- Add tests covering queue-on-abort and next-message delivery