aa90cff9cc
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
223 lines
7.3 KiB
TypeScript
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 })
|
|
}
|
|
}
|
|
}
|
|
}
|