fix(background-agent): add post-compaction continuation + fix stale/idle race

Extract sendPostCompactionContinuation to dedicated file — council members now resume after compaction instead of silently failing. Refresh lastUpdate before async validation in both idle handler and polling path to prevent stale timeout from racing with completion detection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
ismeth 2026-02-24 12:14:06 +01:00 committed by YeonGyu-Kim
parent e503697d92
commit 61eb0ee04a
3 changed files with 78 additions and 0 deletions

View File

@ -44,6 +44,8 @@ import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver" import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler" import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { sendPostCompactionContinuation } from "./post-compaction-continuation"
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
import { MESSAGE_STORAGE } from "../hook-message-injector" import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path" import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller" import { pruneStaleTasksAndNotifications } from "./task-poller"
@ -766,6 +768,11 @@ export class BackgroundManager {
findBySession: (id) => this.findBySession(id), findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers, idleDeferralTimers: this.idleDeferralTimers,
recentlyCompactedSessions: this.recentlyCompactedSessions, recentlyCompactedSessions: this.recentlyCompactedSessions,
onPostCompactionIdle: (t, sid) => {
if (t.agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
sendPostCompactionContinuation(this.client, t, sid)
}
},
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id), checkSessionTodos: (id) => this.checkSessionTodos(id),
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
@ -1494,6 +1501,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
continue continue
} }
// Refresh lastUpdate so the next poll's stale check doesn't kill
// the task while we're awaiting async validation
if (task.progress) {
task.progress.lastUpdate = new Date()
}
// Edge guard: Validate session has actual output before completing // Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID) const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) { if (!hasValidOutput) {

View File

@ -0,0 +1,55 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask } from "./types"
import {
log,
getAgentToolRestrictions,
createInternalAgentTextPart,
} from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
type OpencodeClient = PluginInput["client"]
const CONTINUATION_PROMPT =
"Your session was compacted (context summarized). Continue your analysis from where you left off. Report your findings when done."
export function sendPostCompactionContinuation(
client: OpencodeClient,
task: BackgroundTask,
sessionID: string,
): void {
if (task.status !== "running") return
const resumeModel = task.model
? { providerID: task.model.providerID, modelID: task.model.modelID }
: undefined
const resumeVariant = task.model?.variant
client.session.promptAsync({
path: { id: sessionID },
body: {
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
},
}).catch((error) => {
log("[background-agent] Post-compaction continuation error:", {
taskId: task.id,
error: String(error),
})
})
if (task.progress) {
task.progress.lastUpdate = new Date()
}
}

View File

@ -12,6 +12,7 @@ export function handleSessionIdleBackgroundEvent(args: {
findBySession: (sessionID: string) => BackgroundTask | undefined findBySession: (sessionID: string) => BackgroundTask | undefined
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
recentlyCompactedSessions?: Set<string> recentlyCompactedSessions?: Set<string>
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
validateSessionHasOutput: (sessionID: string) => Promise<boolean> validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean> checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
@ -22,6 +23,7 @@ export function handleSessionIdleBackgroundEvent(args: {
findBySession, findBySession,
idleDeferralTimers, idleDeferralTimers,
recentlyCompactedSessions, recentlyCompactedSessions,
onPostCompactionIdle,
validateSessionHasOutput, validateSessionHasOutput,
checkSessionTodos, checkSessionTodos,
tryCompleteTask, tryCompleteTask,
@ -37,6 +39,7 @@ export function handleSessionIdleBackgroundEvent(args: {
if (recentlyCompactedSessions?.has(sessionID)) { if (recentlyCompactedSessions?.has(sessionID)) {
recentlyCompactedSessions.delete(sessionID) recentlyCompactedSessions.delete(sessionID)
log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID }) log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID })
onPostCompactionIdle?.(task, sessionID)
return return
} }
@ -63,6 +66,13 @@ export function handleSessionIdleBackgroundEvent(args: {
return return
} }
// Refresh lastUpdate to prevent stale timeout from racing with this async validation.
// Without this, checkAndInterruptStaleTasks can kill the task synchronously
// while validateSessionHasOutput is still awaiting an API response.
if (task.progress) {
task.progress.lastUpdate = new Date()
}
validateSessionHasOutput(sessionID) validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => { .then(async (hasValidOutput) => {
if (task.status !== "running") { if (task.status !== "running") {