Allow registered atlas boulder sessions to continue on idle
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -1,18 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { isAbortError } from "./is-abort-error"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
export function createAtlasEventHandler(input: {
|
||||
ctx: PluginInput
|
||||
options?: AtlasHookOptions
|
||||
@@ -39,157 +31,7 @@ export function createAtlasEventHandler(input: {
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
// Read boulder state FIRST to check if this session is part of an active boulder
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
|
||||
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
// Allow continuation only if: session is in boulder's session_ids OR is a background task
|
||||
if (!isBackgroundTaskSession && !isBoulderSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (state.lastEventWasAbortError) {
|
||||
state.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.promptFailureCount >= 2) {
|
||||
const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
|
||||
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
|
||||
sessionID,
|
||||
promptFailureCount: state.promptFailureCount,
|
||||
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state.promptFailureCount = 0
|
||||
state.lastFailureAt = undefined
|
||||
}
|
||||
|
||||
const backgroundManager = options?.backgroundManager
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!boulderState) {
|
||||
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionAgent = getSessionAgent(sessionID)
|
||||
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
|
||||
const effectiveAgent = sessionAgent ?? lastAgent
|
||||
const lastAgentKey = getAgentConfigKey(effectiveAgent ?? "")
|
||||
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
|
||||
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
|
||||
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
|
||||
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
|
||||
const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
|
||||
const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
|
||||
if (!agentMatches) {
|
||||
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
lastAgent: effectiveAgent ?? "unknown",
|
||||
requiredAgent,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
if (progress.isComplete) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
if (!state.pendingRetryTimer) {
|
||||
state.pendingRetryTimer = setTimeout(async () => {
|
||||
state.pendingRetryTimer = undefined
|
||||
|
||||
if (state.promptFailureCount >= 2) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
|
||||
const hasBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
|
||||
: false
|
||||
if (hasBgTasks) return
|
||||
|
||||
state.lastContinuationInjectedAt = Date.now()
|
||||
const currentRemaining = currentProgress.total - currentProgress.completed
|
||||
try {
|
||||
await injectBoulderContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
planName: currentBoulder.plan_name,
|
||||
remaining: currentRemaining,
|
||||
total: currentProgress.total,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: currentBoulder.worktree_path,
|
||||
backgroundManager,
|
||||
sessionState: state,
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Delayed retry failed`, { sessionID, error: err })
|
||||
state.promptFailureCount++
|
||||
}
|
||||
}, RETRY_DELAY_MS)
|
||||
}
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||
sessionID,
|
||||
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
|
||||
pendingRetry: !!state.pendingRetryTimer,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state.lastContinuationInjectedAt = now
|
||||
const remaining = progress.total - progress.completed
|
||||
try {
|
||||
await injectBoulderContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
planName: boulderState.plan_name,
|
||||
remaining,
|
||||
total: progress.total,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: boulderState.worktree_path,
|
||||
backgroundManager,
|
||||
sessionState: state,
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID, error: err })
|
||||
state.promptFailureCount++
|
||||
}
|
||||
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
212
src/hooks/atlas/idle-event.ts
Normal file
212
src/hooks/atlas/idle-event.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
return backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
: false
|
||||
}
|
||||
|
||||
function shouldSkipForAgentMismatch(input: {
|
||||
isBoulderSession: boolean
|
||||
sessionAgent: string | undefined
|
||||
lastAgent: string | null
|
||||
requiredAgent: string
|
||||
}): boolean {
|
||||
if (input.isBoulderSession) {
|
||||
return false
|
||||
}
|
||||
|
||||
const effectiveAgent = input.sessionAgent ?? input.lastAgent ?? ""
|
||||
const lastAgentKey = getAgentConfigKey(effectiveAgent)
|
||||
const requiredAgentKey = getAgentConfigKey(input.requiredAgent)
|
||||
const lastAgentMatchesRequired = lastAgentKey === requiredAgentKey
|
||||
const allowSisyphusForAtlasBoulder = requiredAgentKey === "atlas" && lastAgentKey === "sisyphus"
|
||||
|
||||
return !lastAgentMatchesRequired && !allowSisyphusForAtlasBoulder
|
||||
}
|
||||
|
||||
async function injectContinuation(input: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
sessionState: SessionState
|
||||
options?: AtlasHookOptions
|
||||
planName: string
|
||||
progress: { total: number; completed: number }
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
}): Promise<void> {
|
||||
const remaining = input.progress.total - input.progress.completed
|
||||
input.sessionState.lastContinuationInjectedAt = Date.now()
|
||||
|
||||
try {
|
||||
await injectBoulderContinuation({
|
||||
ctx: input.ctx,
|
||||
sessionID: input.sessionID,
|
||||
planName: input.planName,
|
||||
remaining,
|
||||
total: input.progress.total,
|
||||
agent: input.agent,
|
||||
worktreePath: input.worktreePath,
|
||||
backgroundManager: input.options?.backgroundManager,
|
||||
sessionState: input.sessionState,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
|
||||
input.sessionState.promptFailureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
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 >= 2) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, 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
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
if (!isBackgroundTaskSession && !isBoulderSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (sessionState.lastEventWasAbortError) {
|
||||
sessionState.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.promptFailureCount >= 2) {
|
||||
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)) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!boulderState) {
|
||||
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionAgent = getSessionAgent(sessionID)
|
||||
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
|
||||
if (
|
||||
shouldSkipForAgentMismatch({
|
||||
isBoulderSession,
|
||||
sessionAgent,
|
||||
lastAgent,
|
||||
requiredAgent: boulderState.agent ?? "atlas",
|
||||
})
|
||||
) {
|
||||
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
lastAgent: sessionAgent ?? lastAgent ?? "unknown",
|
||||
requiredAgent: boulderState.agent ?? "atlas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
if (progress.isComplete) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
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
|
||||
}
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: boulderState.plan_name,
|
||||
progress,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: boulderState.worktree_path,
|
||||
})
|
||||
}
|
||||
@@ -846,6 +846,38 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should inject when registered boulder session has incomplete tasks even if last agent differs", async () => {
|
||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
|
||||
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "atlas",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should not inject when boulder plan is complete", async () => {
|
||||
// given - boulder state with complete plan
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
@@ -1083,10 +1115,9 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
|
||||
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
test("should inject when registered atlas boulder session last agent does not match", async () => {
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
@@ -1103,17 +1134,15 @@ describe("atlas hook", () => {
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
|
||||
// given - boulder state expects sisyphus and last agent is sisyphus
|
||||
|
||||
Reference in New Issue
Block a user