From 61eb0ee04a7878999baf761e23d1ad57c750c898 Mon Sep 17 00:00:00 2001 From: ismeth Date: Tue, 24 Feb 2026 12:14:06 +0100 Subject: [PATCH] fix(background-agent): add post-compaction continuation + fix stale/idle race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/background-agent/manager.ts | 13 +++++ .../post-compaction-continuation.ts | 55 +++++++++++++++++++ .../session-idle-event-handler.ts | 10 ++++ 3 files changed, 78 insertions(+) create mode 100644 src/features/background-agent/post-compaction-continuation.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index f6406549..545cb329 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -44,6 +44,8 @@ import { tryFallbackRetry } from "./fallback-retry-handler" import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver" 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 { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" @@ -766,6 +768,11 @@ export class BackgroundManager { findBySession: (id) => this.findBySession(id), idleDeferralTimers: this.idleDeferralTimers, recentlyCompactedSessions: this.recentlyCompactedSessions, + onPostCompactionIdle: (t, sid) => { + if (t.agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) { + sendPostCompactionContinuation(this.client, t, sid) + } + }, validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), 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 } + // 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 const hasValidOutput = await this.validateSessionHasOutput(sessionID) if (!hasValidOutput) { diff --git a/src/features/background-agent/post-compaction-continuation.ts b/src/features/background-agent/post-compaction-continuation.ts new file mode 100644 index 00000000..934fd23a --- /dev/null +++ b/src/features/background-agent/post-compaction-continuation.ts @@ -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() + } +} diff --git a/src/features/background-agent/session-idle-event-handler.ts b/src/features/background-agent/session-idle-event-handler.ts index a5b1c087..70c0e653 100644 --- a/src/features/background-agent/session-idle-event-handler.ts +++ b/src/features/background-agent/session-idle-event-handler.ts @@ -12,6 +12,7 @@ export function handleSessionIdleBackgroundEvent(args: { findBySession: (sessionID: string) => BackgroundTask | undefined idleDeferralTimers: Map> recentlyCompactedSessions?: Set + onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void validateSessionHasOutput: (sessionID: string) => Promise checkSessionTodos: (sessionID: string) => Promise tryCompleteTask: (task: BackgroundTask, source: string) => Promise @@ -22,6 +23,7 @@ export function handleSessionIdleBackgroundEvent(args: { findBySession, idleDeferralTimers, recentlyCompactedSessions, + onPostCompactionIdle, validateSessionHasOutput, checkSessionTodos, tryCompleteTask, @@ -37,6 +39,7 @@ export function handleSessionIdleBackgroundEvent(args: { if (recentlyCompactedSessions?.has(sessionID)) { recentlyCompactedSessions.delete(sessionID) log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID }) + onPostCompactionIdle?.(task, sessionID) return } @@ -63,6 +66,13 @@ export function handleSessionIdleBackgroundEvent(args: { 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) .then(async (hasValidOutput) => { if (task.status !== "running") {