diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 6037351de..197c3a7d8 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -5,12 +5,18 @@ import { } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared" +import { isSessionActive } from "../shared/session-idle-settle" import { HOOK_NAME } from "./hook-name" import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" import type { BackgroundTaskStatusProvider, SessionState } from "./types" -export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed" +export type BoulderContinuationResult = + | "injected" + | "skipped_active_session" + | "skipped_background_tasks" + | "skipped_agent_unavailable" + | "failed" const ACTIVE_BACKGROUND_TASK_STATUSES = new Set(["pending", "running"]) @@ -68,11 +74,16 @@ export async function injectBoulderContinuation(input: { sessionID, agent: continuationAgent ?? agent ?? "unknown", }) - return "skipped_agent_unavailable" - } + return "skipped_agent_unavailable" + } - try { - log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) + try { + if (await isSessionActive(ctx.client, sessionID)) { + log(`[${HOOK_NAME}] Skipped injection: session is active`, { sessionID }) + return "skipped_active_session" + } + + log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID) const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 118ed4e2d..5f98122c8 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -19,7 +19,7 @@ import { isSessionInBoulderLineage } from "./boulder-session-lineage" import { createInternalAgentContinuationTextPart } from "../../shared" import { getAgentConfigKey } from "../../shared/agent-display-names" import { log } from "../../shared/logger" -import { settleAfterSessionIdle } from "../shared/session-idle-settle" +import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle" import { injectBoulderContinuation } from "./boulder-continuation-injector" import { HOOK_NAME } from "./hook-name" import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" @@ -283,6 +283,11 @@ export async function handleAtlasSessionIdle(input: { 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 + } + await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { @@ -379,7 +384,10 @@ export async function handleAtlasSessionIdle(input: { return } - await settleAfterSessionIdle(options?.idleSettleMs) + if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) { + log(`[${HOOK_NAME}] Skipped: session became active during idle settle`, { sessionID }) + return + } await injectContinuation({ ctx, diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index ddbef7fe6..cd27c117e 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" +import { isSessionActive } from "../shared/session-idle-settle" import type { RalphLoopOptions, RalphLoopState } from "./types" import { HOOK_NAME } from "./constants" import { handleDetectedCompletion } from "./completion-handler" @@ -327,6 +328,10 @@ export function createRalphLoopEventHandler( }) return } + if (await isSessionActive(ctx.client, sessionID)) { + log(`[${HOOK_NAME}] Skipped: session became active during settle window`, { sessionID }) + return + } if (stateAfterSettle.verification_pending) { log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return @@ -484,6 +489,10 @@ export function createRalphLoopEventHandler( }) return } + if (await isSessionActive(ctx.client, sessionID)) { + log(`[${HOOK_NAME}] Skipped: session became active during settle window`, { sessionID }) + return + } if (stateAfterSettle.verification_pending) { log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return diff --git a/src/hooks/team-session-events/team-idle-wake-hint.test.ts b/src/hooks/team-session-events/team-idle-wake-hint.test.ts index 1d5d0370e..67b81473d 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.test.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.test.ts @@ -184,6 +184,41 @@ describe("createTeamIdleWakeHint", () => { expect(promptInput.body.parts[0]?.text).not.toContain("second message body") }) + test("#given stale idle event but member session is busy #when wake hint checks status #then it does not start an overlapping reply", async () => { + // given + const baseDir = await createTemporaryBaseDir() + const config = createConfig(baseDir) + const teamRunId = randomUUID() + await seedRuntimeState(createRuntimeState(teamRunId), config) + await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100) + + const promptAsyncSpy = mock(async (_input: WakeHintPromptInput) => ({})) + const handler = createTeamIdleWakeHint({ + directory: "/tmp/project", + client: { + session: { + promptAsync: promptAsyncSpy, + status: async () => ({ + data: { + "member-session": { type: "busy" }, + }, + }), + }, + }, + }, config, { idleSettleMs: 0 }) + + // when + await handler({ + event: { + type: "session.idle", + properties: { sessionID: "member-session" }, + }, + }) + + // then + expect(promptAsyncSpy).toHaveBeenCalledTimes(0) + }) + test("pins the recipient's resolved subagent_type and model on the wake-hint promptAsync", async () => { // given const baseDir = await createTemporaryBaseDir() diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index 8d2c8a5d9..e07898911 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -9,7 +9,7 @@ import { } from "../../features/team-mode/member-session-routing" import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" -import { settleAfterSessionIdle } from "../shared/session-idle-settle" +import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle" type PromptAsyncInput = { path: { id: string } @@ -27,6 +27,7 @@ type TeamIdleWakeHintContext = { client: { session: { promptAsync?: (input: PromptAsyncInput) => Promise + status?: () => Promise } } } @@ -99,7 +100,16 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea } applyMemberSessionRouting(sessionID, memberEntry) - await settleAfterSessionIdle(options?.idleSettleMs) + if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) { + log("team idle wake hint skipped because session is active", { + event: "team-mode-idle-wake-hint-active-session", + teamRunId: runtimeState.teamRunId, + memberName: memberEntry.name, + sessionID, + unreadCount: unreadMessages.length, + }) + return + } await ctx.client.session.promptAsync({ path: { id: sessionID }, diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 47bfaead6..da82b9622 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -21,6 +21,7 @@ import { getAgentConfigKey, normalizeAgentForPromptKey, } from "../../shared/agent-display-names" +import { isSessionActive } from "../shared/session-idle-settle" import { CONTINUATION_PROMPT, @@ -165,6 +166,11 @@ ${todoList}` return } + if (await isSessionActive(ctx.client, sessionID)) { + log(`[${HOOK_NAME}] Skipped injection: session is active before prompt`, { sessionID }) + return + } + if (injectionState) { injectionState.inFlight = true } diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 6f43a7840..729192965 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -12,7 +12,7 @@ import { isUnstableTask, THINKING_SUMMARY_MAX_CHARS, } from "./task-message-analyzer" -import { settleAfterSessionIdle } from "../shared/session-idle-settle" +import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle" const HOOK_NAME = "unstable-agent-babysitter" const DEFAULT_TIMEOUT_MS = 120000 @@ -49,6 +49,7 @@ type BabysitterContext = { } query?: { directory?: string } }) => Promise + status?: () => Promise } } } @@ -215,7 +216,10 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option ? { providerID: model.providerID, modelID: model.modelID } : undefined const launchVariant = model?.variant - await settleAfterSessionIdle(options.idleSettleMs) + if (!(await shouldPromptAfterSessionIdle(ctx.client, mainSessionID, options.idleSettleMs))) { + log(`[${HOOK_NAME}] Reminder skipped because main session is active`, { taskId: task.id, sessionID: mainSessionID }) + continue + } await ctx.client.session.promptAsync({ path: { id: mainSessionID },