fix(cli-run): prevent premature exit when background tasks are active

When using `opencode run`, the process exits prematurely if the main
agent dispatches background subtasks. The CLI completion checker relies
on `session.children()` + `session.status()` APIs which cannot see
tasks in "pending" state (no session created yet) or tasks whose
sessions are momentarily idle between operations.

This fix bridges BackgroundManager state to the CLI completion checker
using the existing run-continuation-state marker system:

- Add "background-task" continuation marker source
- BackgroundManager writes/clears markers on task lifecycle events
  (launch, cancel, complete, crash)
- CLI completion checker blocks exit when marker is active
- Fix todo-continuation-enforcer to also check "pending" task status

Closes #3452
This commit is contained in:
柯杨
2026-04-16 11:59:11 +08:00
parent c55e08c1da
commit aa90cff9cc
6 changed files with 44 additions and 3 deletions
+5
View File
@@ -20,6 +20,11 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
return false
}
if (continuationState.hasActiveBackgroundTaskMarker) {
logWaiting(ctx, continuationState.activeHookMarkerReason ?? "background tasks are active")
return false
}
if (!await areAllChildrenIdle(ctx)) {
return false
}
+2
View File
@@ -16,6 +16,7 @@ export interface ContinuationState {
hasActiveRalphLoop: boolean
hasHookMarker: boolean
hasTodoHookMarker: boolean
hasActiveBackgroundTaskMarker: boolean
hasActiveHookMarker: boolean
activeHookMarkerReason: string | null
}
@@ -32,6 +33,7 @@ export async function getContinuationState(
hasActiveRalphLoop: hasActiveRalphLoopContinuation(directory, sessionID),
hasHookMarker: marker !== null,
hasTodoHookMarker: marker?.sources.todo !== undefined,
hasActiveBackgroundTaskMarker: marker?.sources["background-task"]?.state === "active",
hasActiveHookMarker: isContinuationMarkerActive(marker),
activeHookMarkerReason: getActiveContinuationMarkerReason(marker),
}
+34
View File
@@ -48,6 +48,7 @@ import {
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import {
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
@@ -370,6 +371,9 @@ export class BackgroundManager {
spawnReservation.commit()
this.markPreStartDescendantReservation(task)
// Signal CLI run mode that background tasks are active
this.updateBackgroundTaskMarker(input.parentSessionID)
// Trigger processing (fire-and-forget)
void this.processKey(key)
@@ -657,6 +661,21 @@ export class BackgroundManager {
return result
}
private updateBackgroundTaskMarker(parentSessionID: string): void {
const tasks = this.getTasksByParentSession(parentSessionID)
const activeTasks = tasks.filter(t => t.status === "running" || t.status === "pending")
if (activeTasks.length > 0) {
setContinuationMarkerSource(
this.directory, parentSessionID, "background-task", "active",
`${activeTasks.length} background task(s) active`,
)
} else {
setContinuationMarkerSource(
this.directory, parentSessionID, "background-task", "idle",
)
}
}
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
const directChildren = this.getTasksByParentSession(sessionID)
@@ -1585,6 +1604,11 @@ export class BackgroundManager {
removeTaskToastTracking(task.id)
// Update continuation marker for CLI run mode
if (task.parentSessionID) {
this.updateBackgroundTaskMarker(task.parentSessionID)
}
if (options?.skipNotification) {
this.cleanupPendingByParent(task)
this.scheduleTaskRemoval(task.id)
@@ -1700,6 +1724,11 @@ export class BackgroundManager {
SessionCategoryRegistry.remove(task.sessionID)
}
// Update continuation marker for CLI run mode
if (task.parentSessionID) {
this.updateBackgroundTaskMarker(task.parentSessionID)
}
try {
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
log(`[background-agent] Task completed via ${source}:`, task.id)
@@ -1993,6 +2022,11 @@ export class BackgroundManager {
SessionCategoryRegistry.remove(task.sessionID)
}
// Update continuation marker for CLI run mode
if (task.parentSessionID) {
this.updateBackgroundTaskMarker(task.parentSessionID)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for crashed task:", { taskId: task.id, error: err })
+1 -1
View File
@@ -1,4 +1,4 @@
export type ContinuationMarkerSource = "todo" | "stop"
export type ContinuationMarkerSource = "todo" | "stop" | "background-task"
export type ContinuationMarkerState = "idle" | "active" | "stopped"
@@ -79,7 +79,7 @@ export async function injectContinuation(args: {
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {
@@ -71,7 +71,7 @@ export async function handleSessionIdle(args: {
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {