Files
oh-my-opencode/src/hooks/todo-continuation-enforcer/continuation-injection.ts
T
柯杨 aa90cff9cc 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
2026-04-16 11:59:11 +08:00

223 lines
7.3 KiB
TypeScript

import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
getSessionAgent,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import {
createInternalAgentTextPart,
normalizeSDKResponse,
resolveInheritedPromptTools,
} from "../../shared"
import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
type ToolPermission,
} from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import {
getAgentConfigKey,
normalizeAgentForPromptKey,
} from "../../shared/agent-display-names"
import {
CONTINUATION_PROMPT,
DEFAULT_SKIP_AGENTS,
HOOK_NAME,
} from "./constants"
import { isCompactionGuardActive } from "./compaction-guard"
import { getMessageDir } from "./message-directory"
import { isTokenLimitError } from "./token-limit-detection"
import { getIncompleteCount } from "./todo"
import type { ResolvedMessageInfo, Todo } from "./types"
import type { SessionStateStore } from "./session-state"
function hasWritePermission(tools: Record<string, ToolPermission> | undefined): boolean {
const editPermission = tools?.edit
const writePermission = tools?.write
return (
!tools ||
(editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny")
)
}
export async function injectContinuation(args: {
ctx: PluginInput
sessionID: string
backgroundManager?: BackgroundManager
skipAgents?: string[]
resolvedInfo?: ResolvedMessageInfo
sessionStateStore: SessionStateStore
isContinuationStopped?: (sessionID: string) => boolean
}): Promise<void> {
const {
ctx,
sessionID,
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
resolvedInfo,
sessionStateStore,
isContinuationStopped,
} = args
const state = sessionStateStore.getExistingState(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
if (state?.wasCancelled) {
log(`[${HOOK_NAME}] Skipped injection: session was cancelled`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
return
}
const freshIncompleteCount = getIncompleteCount(todos)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
return
}
let agentName = resolvedInfo?.agent ?? getSessionAgent(sessionID)
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
let previousMessage = null
if (isSqliteBackend()) {
previousMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
}
agentName = agentName ?? previousMessage?.agent
model =
model ??
(previousMessage?.model?.providerID && previousMessage?.model?.modelID
? {
providerID: previousMessage.model.providerID,
modelID: previousMessage.model.modelID,
...(previousMessage.model.variant
? { variant: previousMessage.model.variant }
: {}),
}
: undefined)
tools = tools ?? previousMessage?.tools
}
const promptAgent = normalizeAgentForPromptKey(agentName)
const launchAgent = resolveRegisteredAgentName(agentName)
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
if (!promptAgent) {
const compactionState = sessionStateStore.getExistingState(sessionID)
if (compactionState && isCompactionGuardActive(compactionState, Date.now())) {
log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID })
return
}
}
if (!hasWritePermission(tools)) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${todoList}`
const injectionState = sessionStateStore.getExistingState(sessionID)
if (injectionState?.wasCancelled) {
log(`[${HOOK_NAME}] Skipped injection: session was cancelled before prompt`, { sessionID })
return
}
if (injectionState) {
injectionState.inFlight = true
}
try {
log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID,
agent: launchAgent ?? promptAgent,
model,
incompleteCount: freshIncompleteCount,
})
const inheritedTools = resolveInheritedPromptTools(sessionID, tools)
const launchModel = model
? { providerID: model.providerID, modelID: model.modelID }
: undefined
const launchVariant = model?.variant
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: launchAgent ?? promptAgent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
}
} catch (error) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
const errorObj = error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) }
if (isTokenLimitError(errorObj)) {
injectionState.tokenLimitDetected = true
log(`[${HOOK_NAME}] Token limit error detected during injection, stopping continuation`, { sessionID })
}
}
}
}