457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import {
|
|
completeBoulder,
|
|
formatDurationHuman,
|
|
getPlanProgress,
|
|
getWorkForSession,
|
|
getTaskSessionState,
|
|
readBoulderState,
|
|
readCurrentTopLevelTask,
|
|
resolveBoulderPlanPath,
|
|
} from "../../features/boulder-state"
|
|
import {
|
|
getSessionAgent,
|
|
isAgentRegistered,
|
|
resolveRegisteredAgentName,
|
|
} from "../../features/claude-code-session-state"
|
|
import { getLastAgentFromSession } from "./session-last-agent"
|
|
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
|
import { createInternalAgentContinuationTextPart } from "../../shared"
|
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
|
import { log } from "../../shared/logger"
|
|
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
|
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
|
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
|
import { HOOK_NAME } from "./hook-name"
|
|
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
|
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
|
import type { AtlasHookOptions, SessionState } from "./types"
|
|
|
|
const CONTINUATION_COOLDOWN_MS = 5000
|
|
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
|
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
|
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
|
|
|
function getTaskLabelSortValue(taskLabel: string): number {
|
|
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
|
|
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
|
|
}
|
|
|
|
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
|
const backgroundManager = options?.backgroundManager
|
|
return backgroundManager
|
|
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
|
: false
|
|
}
|
|
|
|
async function injectContinuation(input: {
|
|
ctx: PluginInput
|
|
sessionID: string
|
|
sessionState: SessionState
|
|
options?: AtlasHookOptions
|
|
planName: string
|
|
progress: { total: number; completed: number }
|
|
agent?: string
|
|
worktreePath?: string
|
|
idleSettleMs?: number
|
|
}): Promise<void> {
|
|
const remaining = input.progress.total - input.progress.completed
|
|
if (input.sessionState.isInjectingContinuation) {
|
|
scheduleRetry({
|
|
ctx: input.ctx,
|
|
sessionID: input.sessionID,
|
|
sessionState: input.sessionState,
|
|
options: input.options,
|
|
})
|
|
return
|
|
}
|
|
|
|
input.sessionState.isInjectingContinuation = true
|
|
|
|
try {
|
|
const currentBoulder = readBoulderState(input.ctx.directory)
|
|
const currentPlanPath = currentBoulder
|
|
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
|
|
: null
|
|
const currentTask = currentBoulder
|
|
&& currentPlanPath
|
|
? readCurrentTopLevelTask(currentPlanPath)
|
|
: null
|
|
const preferredTaskSession = currentTask
|
|
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
|
: null
|
|
|
|
if (!currentBoulder) {
|
|
return
|
|
}
|
|
|
|
const canContinueSession = await canContinueTrackedBoulderSession({
|
|
client: input.ctx.client,
|
|
sessionID: input.sessionID,
|
|
sessionOrigin: currentBoulder.session_origins?.[input.sessionID],
|
|
boulderSessionIDs: currentBoulder.session_ids,
|
|
requiredAgent: currentBoulder.agent,
|
|
})
|
|
if (!canContinueSession) {
|
|
log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, {
|
|
sessionID: input.sessionID,
|
|
requiredAgent: currentBoulder.agent ?? "atlas",
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await injectBoulderContinuation({
|
|
ctx: input.ctx,
|
|
sessionID: input.sessionID,
|
|
planName: input.planName,
|
|
remaining,
|
|
total: input.progress.total,
|
|
agent: input.agent,
|
|
worktreePath: input.worktreePath,
|
|
preferredTaskSessionId: preferredTaskSession?.session_id,
|
|
preferredTaskTitle: preferredTaskSession?.task_title,
|
|
backgroundManager: input.options?.backgroundManager,
|
|
sessionState: input.sessionState,
|
|
idleSettleMs: input.idleSettleMs,
|
|
})
|
|
|
|
if (result === "injected") {
|
|
if (input.sessionState.pendingRetryTimer) {
|
|
clearTimeout(input.sessionState.pendingRetryTimer)
|
|
input.sessionState.pendingRetryTimer = undefined
|
|
}
|
|
input.sessionState.lastContinuationInjectedAt = Date.now()
|
|
return
|
|
}
|
|
|
|
if (result === "skipped_background_tasks") {
|
|
scheduleRetry({
|
|
ctx: input.ctx,
|
|
sessionID: input.sessionID,
|
|
sessionState: input.sessionState,
|
|
options: input.options,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (result === "failed") {
|
|
scheduleRetry({
|
|
ctx: input.ctx,
|
|
sessionID: input.sessionID,
|
|
sessionState: input.sessionState,
|
|
options: input.options,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
|
|
input.sessionState.promptFailureCount += 1
|
|
input.sessionState.lastFailureAt = Date.now()
|
|
scheduleRetry({
|
|
ctx: input.ctx,
|
|
sessionID: input.sessionID,
|
|
sessionState: input.sessionState,
|
|
options: input.options,
|
|
})
|
|
} finally {
|
|
input.sessionState.isInjectingContinuation = false
|
|
}
|
|
}
|
|
|
|
function scheduleRetry(input: {
|
|
ctx: PluginInput
|
|
sessionID: string
|
|
sessionState: SessionState
|
|
options?: AtlasHookOptions
|
|
}): void {
|
|
const { ctx, sessionID, sessionState, options } = input
|
|
if (sessionState.pendingRetryTimer) {
|
|
return
|
|
}
|
|
|
|
sessionState.pendingRetryTimer = setTimeout(async () => {
|
|
sessionState.pendingRetryTimer = undefined
|
|
|
|
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return
|
|
if (sessionState.waitingForFinalWaveApproval) return
|
|
|
|
const now = Date.now()
|
|
if (
|
|
sessionState.lastContinuationInjectedAt
|
|
&& now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS
|
|
) {
|
|
return
|
|
}
|
|
|
|
const currentBoulder = readBoulderState(ctx.directory)
|
|
if (!currentBoulder) return
|
|
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
|
|
|
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
|
|
if (currentProgress.isComplete) return
|
|
if (options?.isContinuationStopped?.(sessionID)) return
|
|
const canContinueSession = await canContinueTrackedBoulderSession({
|
|
client: ctx.client,
|
|
sessionID,
|
|
sessionOrigin: currentBoulder.session_origins?.[sessionID],
|
|
boulderSessionIDs: currentBoulder.session_ids,
|
|
requiredAgent: currentBoulder.agent,
|
|
})
|
|
if (!canContinueSession) return
|
|
if (hasRunningBackgroundTasks(sessionID, options)) {
|
|
scheduleRetry({ ctx, sessionID, sessionState, options })
|
|
return
|
|
}
|
|
|
|
await injectContinuation({
|
|
ctx,
|
|
sessionID,
|
|
sessionState,
|
|
options,
|
|
planName: currentBoulder.plan_name,
|
|
progress: currentProgress,
|
|
agent: currentBoulder.agent,
|
|
worktreePath: currentBoulder.worktree_path,
|
|
})
|
|
}, RETRY_DELAY_MS)
|
|
}
|
|
|
|
export async function handleAtlasSessionIdle(input: {
|
|
ctx: PluginInput
|
|
options?: AtlasHookOptions
|
|
getState: (sessionID: string) => SessionState
|
|
sessionID: string
|
|
}): Promise<void> {
|
|
const { ctx, options, getState, sessionID } = input
|
|
const sessionState = getState(sessionID)
|
|
|
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
|
|
|
const activeBoulderSession = await resolveActiveBoulderSession({
|
|
client: ctx.client,
|
|
directory: ctx.directory,
|
|
sessionID,
|
|
})
|
|
if (!activeBoulderSession) {
|
|
log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID })
|
|
return
|
|
}
|
|
|
|
const { boulderState, progress, appendedSession } = activeBoulderSession
|
|
if (progress.isComplete) {
|
|
const work = getWorkForSession(ctx.directory, sessionID)
|
|
if (work) {
|
|
completeBoulder(ctx.directory, work.work_id)
|
|
} else {
|
|
completeBoulder(ctx.directory, boulderState.active_work_id)
|
|
}
|
|
|
|
if (!work || work.status === "abandoned") {
|
|
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
|
return
|
|
}
|
|
|
|
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
|
|
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
|
return
|
|
}
|
|
|
|
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
|
|
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
|
|
|
|
const taskBreakdown = Object.values(work.task_sessions ?? {})
|
|
.sort((left, right) => {
|
|
const leftSortValue = getTaskLabelSortValue(left.task_label)
|
|
const rightSortValue = getTaskLabelSortValue(right.task_label)
|
|
if (leftSortValue !== rightSortValue) {
|
|
return leftSortValue - rightSortValue
|
|
}
|
|
|
|
return left.task_label.localeCompare(right.task_label)
|
|
})
|
|
.map((task) => {
|
|
if (typeof task.elapsed_ms === "number") {
|
|
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
|
|
}
|
|
|
|
return `- ${task.task_label} ${task.task_title}: (no timing)`
|
|
})
|
|
.join("\n")
|
|
|
|
const prompt = BOULDER_COMPLETE_PROMPT
|
|
.replace(/{PLAN_NAME}/g, work.plan_name)
|
|
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
|
|
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
|
|
|
|
const atlasAgent = resolveRegisteredAgentName(
|
|
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
|
)
|
|
if (atlasAgent && isAgentRegistered(atlasAgent)) {
|
|
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
|
|
log(`[${HOOK_NAME}] Boulder completion nudge skipped because session is active`, { sessionID })
|
|
return
|
|
}
|
|
|
|
const promptResult = await promptAsyncAfterSessionIdle({
|
|
client: ctx.client,
|
|
sessionID,
|
|
source: HOOK_NAME,
|
|
settleMs: options?.idleSettleMs,
|
|
input: {
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: atlasAgent,
|
|
parts: [createInternalAgentContinuationTextPart(prompt)],
|
|
},
|
|
query: { directory: ctx.directory },
|
|
},
|
|
})
|
|
if (promptResult.status !== "dispatched") {
|
|
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
|
|
sessionID,
|
|
status: promptResult.status,
|
|
})
|
|
return
|
|
}
|
|
sessionState.boulderCompletionNudgedAt = {
|
|
...(sessionState.boulderCompletionNudgedAt ?? {}),
|
|
[work.work_id]: Date.now(),
|
|
}
|
|
}
|
|
|
|
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
|
return
|
|
}
|
|
|
|
if (appendedSession) {
|
|
log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, {
|
|
sessionID,
|
|
plan: boulderState.plan_name,
|
|
})
|
|
}
|
|
|
|
const canContinueSession = await canContinueTrackedBoulderSession({
|
|
client: ctx.client,
|
|
sessionID,
|
|
sessionOrigin: boulderState.session_origins?.[sessionID],
|
|
boulderSessionIDs: boulderState.session_ids,
|
|
requiredAgent: boulderState.agent,
|
|
})
|
|
if (!canContinueSession) {
|
|
log(`[${HOOK_NAME}] Skipped: tracked descendant agent does not match boulder agent`, {
|
|
sessionID,
|
|
requiredAgent: boulderState.agent ?? "atlas",
|
|
})
|
|
return
|
|
}
|
|
|
|
const now = Date.now()
|
|
|
|
if (sessionState.waitingForFinalWaveApproval) {
|
|
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (sessionState.lastEventWasAbortError) {
|
|
sessionState.lastEventWasAbortError = false
|
|
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (sessionState.skipNextIdleAfterRuntimeErrorRetry) {
|
|
sessionState.skipNextIdleAfterRuntimeErrorRetry = false
|
|
log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
|
|
const timeSinceLastFailure =
|
|
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
|
|
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
|
|
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
|
|
sessionID,
|
|
promptFailureCount: sessionState.promptFailureCount,
|
|
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
|
|
})
|
|
return
|
|
}
|
|
|
|
sessionState.promptFailureCount = 0
|
|
sessionState.lastFailureAt = undefined
|
|
}
|
|
|
|
if (hasRunningBackgroundTasks(sessionID, options)) {
|
|
scheduleRetry({ ctx, sessionID, sessionState, options })
|
|
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (options?.isContinuationStopped?.(sessionID)) {
|
|
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
|
scheduleRetry({ ctx, sessionID, sessionState, options })
|
|
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
|
sessionID,
|
|
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt),
|
|
pendingRetry: !!sessionState.pendingRetryTimer,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
|
|
log(`[${HOOK_NAME}] Skipped: session became active during idle settle`, { sessionID })
|
|
return
|
|
}
|
|
|
|
await injectContinuation({
|
|
ctx,
|
|
sessionID,
|
|
sessionState,
|
|
options,
|
|
planName: boulderState.plan_name,
|
|
progress,
|
|
agent: boulderState.agent,
|
|
worktreePath: boulderState.worktree_path,
|
|
idleSettleMs: options?.idleSettleMs ?? 0,
|
|
})
|
|
}
|
|
|
|
async function canContinueTrackedBoulderSession(input: {
|
|
client: PluginInput["client"]
|
|
sessionID: string
|
|
sessionOrigin?: "direct" | "appended"
|
|
boulderSessionIDs: string[]
|
|
requiredAgent?: string
|
|
}): Promise<boolean> {
|
|
const ancestorSessionIDs = input.boulderSessionIDs.filter((trackedSessionID) => trackedSessionID !== input.sessionID)
|
|
if (ancestorSessionIDs.length === 0) {
|
|
return true
|
|
}
|
|
|
|
const isTrackedDescendant = await isSessionInBoulderLineage({
|
|
client: input.client,
|
|
sessionID: input.sessionID,
|
|
boulderSessionIDs: ancestorSessionIDs,
|
|
})
|
|
if (input.sessionOrigin === "direct") {
|
|
return true
|
|
}
|
|
|
|
if (!isTrackedDescendant) {
|
|
return false
|
|
}
|
|
|
|
const sessionAgent = await getLastAgentFromSession(input.sessionID, input.client)
|
|
?? getSessionAgent(input.sessionID)
|
|
if (!sessionAgent) {
|
|
return false
|
|
}
|
|
|
|
const requiredAgentKey = getAgentConfigKey(input.requiredAgent ?? "atlas")
|
|
const sessionAgentKey = getAgentConfigKey(sessionAgent)
|
|
return sessionAgentKey === requiredAgentKey
|
|
|| (requiredAgentKey === getAgentConfigKey("atlas") && sessionAgentKey === getAgentConfigKey("sisyphus"))
|
|
}
|