diff --git a/src/goal-plugin.js b/src/goal-plugin.js index b4a52ae..1726072 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -40,6 +40,7 @@ function legacyHomeStateFilePath(env = process.env) { return join(homeBase(env), ".opencode-goal-plugin", "state.json") } const MAX_HISTORY_ENTRIES = 20 +const MAX_STALLED_COMPACTIONS = 2 // Marks a plugin-synthesized parent wake so the receiving pass knows it is // re-examining an assistant turn that has already been scored. const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake") @@ -1104,6 +1105,10 @@ function resetGoalBudget(goal) { goal.formatFailures = 0 goal.lastAssistantMessageID = "" goal.continuationClaim = null + goal.compactionEpoch = 0 + goal.stalledCompactions = 0 + goal.lastCompactionEventID = "" + goal.compactionSourceAssistantMessageID = "" goal.skipNextTerminalCheck = false goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES) } @@ -1433,15 +1438,30 @@ function normalizePersistedGoal(rawGoal) { stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "", promptFailures: toNonNegativeInteger(rawGoal.promptFailures), formatFailures: toNonNegativeInteger(rawGoal.formatFailures), + compactionEpoch: toNonNegativeInteger(rawGoal.compactionEpoch), + stalledCompactions: toNonNegativeInteger(rawGoal.stalledCompactions), + lastCompactionEventID: + typeof rawGoal.lastCompactionEventID === "string" && + rawGoal.lastCompactionEventID.length <= MAX_GOAL_META_LENGTH + ? rawGoal.lastCompactionEventID + : "", + compactionSourceAssistantMessageID: + typeof rawGoal.compactionSourceAssistantMessageID === "string" && + rawGoal.compactionSourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH + ? rawGoal.compactionSourceAssistantMessageID + : "", executionContext: normalizeExecutionContext(rawGoal.executionContext), continuationClaim: isPlainObject(rawGoal.continuationClaim) && typeof rawGoal.continuationClaim.runId === "string" && rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH && + Number.isSafeInteger(rawGoal.continuationClaim.compactionEpoch) && + rawGoal.continuationClaim.compactionEpoch >= 0 && typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" && rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH ? { runId: rawGoal.continuationClaim.runId, + compactionEpoch: rawGoal.continuationClaim.compactionEpoch, sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID, } : null, @@ -2635,7 +2655,42 @@ function systemBlockContainsGoal(block, goalId) { } function findLatestAssistantMessage(messages) { - return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null + return [...(messages || [])] + .reverse() + .find( + (message) => + messageRole(message) === "assistant" && !isCompactionAssistantMessage(message), + ) || null +} + +function isCompactionAssistantMessage(message) { + if (messageRole(message) !== "assistant") return false + const info = isPlainObject(message?.info) ? message.info : message + return ( + info?.summary === true || + info?.agent === "compaction" || + info?.mode === "compaction" || + message?.agent === "compaction" || + message?.mode === "compaction" + ) +} + +function compactionEventIdentity(event) { + const candidates = [ + event?.id, + event?.properties?.compactionID, + event?.properties?.summaryID, + event?.properties?.messageID, + event?.properties?.id, + event?.data?.compactionID, + event?.data?.summaryID, + event?.data?.messageID, + event?.data?.id, + ] + const identity = candidates.find( + (candidate) => typeof candidate === "string" && candidate.length > 0, + ) + return identity && identity.length <= MAX_GOAL_META_LENGTH ? identity : "" } function messageParentID(message) { @@ -2771,6 +2826,7 @@ function continuationSnapshot(messages, ownedMessages = currentRuntime().ownedPl .reverse() .find((message) => (messageRole(message) === "assistant" || messageRole(message) === "user") && + !isCompactionAssistantMessage(message) && !isPluginGeneratedMessage(message, ownedMessages), ) return { @@ -2939,6 +2995,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = " stopReason: "", promptFailures: 0, formatFailures: 0, + compactionEpoch: 0, + stalledCompactions: 0, + lastCompactionEventID: "", + compactionSourceAssistantMessageID: "", executionContext: normalizeExecutionContext( meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID), ), @@ -4397,18 +4457,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) sessionID, goalID, runID, + compactionEpoch, baselineMessages, { refreshMessages = false } = {}, ) => { const goalBeforeRefresh = activeGoal(sessionID, goalID, runID) - if (!goalBeforeRefresh) return null + if (!goalBeforeRefresh || goalBeforeRefresh.compactionEpoch !== compactionEpoch) return null const hostMessages = refreshMessages ? await sessionApi.messages(sessionID, { limit: goalBeforeRefresh.options.maxRecentMessages, }) : baselineMessages const goal = activeGoal(sessionID, goalID, runID) - if (!goal) return null + if (!goal || goal.compactionEpoch !== compactionEpoch) return null const messages = Array.isArray(hostMessages) ? hostMessages.slice(-goal.options.maxRecentMessages) : [] @@ -4523,12 +4584,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const sourceAssistantMessageID = refreshed.latestAssistantID || "" if ( goal.continuationClaim?.runId === runID && + goal.continuationClaim?.compactionEpoch === compactionEpoch && goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID ) { return null } - goal.continuationClaim = { runId: runID, sourceAssistantMessageID } + goal.continuationClaim = { runId: runID, compactionEpoch, sourceAssistantMessageID } const claimPersisted = await persist(sessionID) if (!claimPersisted && persistenceOptions.persistState) { goal.continuationClaim = null @@ -4545,7 +4607,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }) return null } - return goal + // Let an already-published compaction event invalidate this claim before + // the caller enters promptAsync. The final epoch check is the atomic edge: + // a claim is valid only while its context epoch is still current. + await Promise.resolve() + return activeGoal(sessionID, goalID, runID)?.compactionEpoch === compactionEpoch + ? goal + : null } const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => { @@ -5328,15 +5396,47 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (event?.type === "session.compacted") { const sessionID = getSessionID(event) const goal = goalStates.get(sessionID) - if (!goal) return + if (!goal || goal.stopped) return + const identity = compactionEventIdentity(event) + if (identity && identity === goal.lastCompactionEventID) return + if (identity) goal.lastCompactionEventID = identity + + goal.compactionEpoch += 1 + goal.stalledCompactions += 1 + goal.compactionSourceAssistantMessageID = + goal.continuationClaim?.runId === goal.runId + ? goal.continuationClaim.sourceAssistantMessageID + : "" goal.messageIDs = new Set() goal.totalTokens = 0 - // Compaction rewrites the context: a continuation claim for a - // pre-compaction source turn must not suppress the post-compaction - // continuation (the recent tail can still end on the same assistant - // message, which would otherwise stall the goal loop until the user - // nudges it). + // Compaction rewrites the context. The epoch-scoped claim lets the same + // retained assistant source continue once in the new epoch without + // allowing duplicate idle delivery to continue it twice. goal.continuationClaim = null + + // An idle handler can already have persisted its source claim when the + // compaction lands. Abort its cooldown and release the per-session guard; + // the epoch checks around promptAsync prevent that stale handler from + // sending while allowing the post-compaction idle to start immediately. + currentRuntime().continuationControllers.get(sessionID)?.abort() + currentRuntime().continuationControllers.delete(sessionID) + activeContinues.delete(sessionID) + + if (goal.stalledCompactions >= MAX_STALLED_COMPACTIONS) { + await pauseActiveGoal(sessionID, { + stopReason: "stalled compaction", + status: `Goal paused after ${goal.stalledCompactions} compactions without a productive assistant or tool turn.`, + history: `Paused after ${goal.stalledCompactions} compactions without productive non-compaction work.`, + }) + if (typeof client?.session?.abort === "function") { + try { + await sessionApi.abort(sessionID) + } catch (error) { + await logPluginError(client, "Failed to abort a stalled compaction loop", error) + } + } + return + } await persist(sessionID) return } @@ -5344,6 +5444,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (event?.type === "message.updated") { const message = messageInfoFromEvent(event) if (!message) return + const messageEnvelope = + event?.properties?.message || event?.data?.message || message const currentMessageID = messageID(message) if (!currentMessageID) return @@ -5395,6 +5497,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if ( messageRole(message) === "assistant" && + !isCompactionAssistantMessage(messageEnvelope) && + currentMessageID !== goal.compactionSourceAssistantMessageID && currentOutputTokens > previousOutputTokens && runtime.suppressedCommandAssistants.get(currentMessageID) !== currentSessionID ) { @@ -5402,6 +5506,18 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) changed = true } + if ( + messageRole(message) === "assistant" && + !isCompactionAssistantMessage(messageEnvelope) && + currentMessageID !== goal.compactionSourceAssistantMessageID && + (currentOutputTokens > previousOutputTokens || messageHasToolCall(messageEnvelope)) && + goal.stalledCompactions > 0 + ) { + goal.stalledCompactions = 0 + goal.compactionSourceAssistantMessageID = "" + changed = true + } + if (changed) await persist(messageSessionID(message)) return } @@ -5497,10 +5613,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (!goal || goal.stopped || activeContinues.has(sessionID)) return const goalID = goal.goalId const runID = goal.runId + const compactionEpoch = goal.compactionEpoch const continueToken = randomUUID() const continueController = new AbortController() let claimedSourceAssistantMessageID = "" + let claimedCompactionEpoch = -1 activeContinues.set(sessionID, continueToken) currentRuntime().continuationControllers.set(sessionID, continueController) try { @@ -5513,7 +5631,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) ? hostMessages.slice(-goal.options.maxRecentMessages) : [] const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID) - if (!activeGoalAfterMessages) return + if ( + !activeGoalAfterMessages || + activeGoalAfterMessages.compactionEpoch !== compactionEpoch + ) return if (!activeGoalAfterMessages.executionContext) { activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages) } @@ -5528,7 +5649,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) latestAssistantID && latestAssistantID === activeGoalAfterMessages.lastAssistantMessageID const activationBoundary = currentRuntime().suppressedCommandAssistants.get(latestAssistantID) === sessionID || - activeGoalAfterMessages.skipNextTerminalCheck === true + activeGoalAfterMessages.skipNextTerminalCheck === true || + ( + activeGoalAfterMessages.compactionSourceAssistantMessageID && + activeGoalAfterMessages.compactionSourceAssistantMessageID === latestAssistantID + ) activeGoalAfterMessages.skipNextTerminalCheck = false if (!activationBoundary && latestText && (!assistantRepeated || assistantChanged)) { @@ -5555,6 +5680,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const sourceAssistantMessageID = latestAssistantID || "" if ( activeGoalAfterMessages.continuationClaim?.runId === runID && + activeGoalAfterMessages.continuationClaim?.compactionEpoch === compactionEpoch && activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID ) { @@ -5801,6 +5927,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) sessionID, goalID, runID, + compactionEpoch, messages, ) if (!claimedGoal) return @@ -6014,12 +6141,16 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) sessionID, goalID, runID, + compactionEpoch, messages, { refreshMessages: cooldownWaited }, ) if (!activeGoalBeforePrompt) return claimedSourceAssistantMessageID = activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || "" + claimedCompactionEpoch = + activeGoalBeforePrompt.continuationClaim?.compactionEpoch ?? -1 + if (claimedCompactionEpoch !== activeGoalBeforePrompt.compactionEpoch) return const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt) if (budgetWrapup) { @@ -6115,6 +6246,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID) const message = `Auto-continue failed: ${response.error.name || "unknown error"}` if ( + activeGoalAfterPrompt?.continuationClaim?.compactionEpoch === + claimedCompactionEpoch && activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID === claimedSourceAssistantMessageID ) { @@ -6133,6 +6266,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } else { const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID) if ( + activeGoalAfterPrompt?.continuationClaim?.compactionEpoch === + claimedCompactionEpoch && activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID === claimedSourceAssistantMessageID ) { @@ -6164,6 +6299,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (activeGoalAfterError) { if ( claimedSourceAssistantMessageID && + activeGoalAfterError.continuationClaim?.compactionEpoch === + claimedCompactionEpoch && activeGoalAfterError.continuationClaim?.sourceAssistantMessageID === claimedSourceAssistantMessageID ) { diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index dd0f0e1..3e6b636 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -4369,7 +4369,10 @@ test("auto-continue fires after compaction despite a pre-compaction continuation app: { log: async () => {} }, session: { messages: async () => ({ - data: [pluginContinuationMessage(), message("did a step")], + data: [ + pluginContinuationMessage(), + message("did a step", { input: 1, output: 1, reasoning: 0 }), + ], }), promptAsync: async (input) => { calls.push(input) @@ -4379,7 +4382,7 @@ test("auto-continue fires after compaction despite a pre-compaction continuation } const hooks = await GoalPlugin( { client }, - { persistState: false, minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + { persistState: false, minDelayMs: 1, noToolCallTurnsBeforePause: 1 }, ) await hooks["command.execute.before"]( { command: "goal", sessionID: "session-1", arguments: "ship it" }, @@ -4392,11 +4395,28 @@ test("auto-continue fires after compaction despite a pre-compaction continuation // compaction interrupted it; after compaction the recent tail still ends on // that same assistant message, so the stale claim must not suppress the // post-compaction continuation. - goal.continuationClaim = { runId: goal.runId, sourceAssistantMessageID: "msg-assistant" } + goal.continuationClaim = { + runId: goal.runId, + compactionEpoch: goal.compactionEpoch, + sourceAssistantMessageID: "msg-assistant", + } await hooks.event({ event: { type: "session.compacted", properties: { sessionID: "session-1" } }, }) + await hooks.event({ + event: { + type: "message.updated", + properties: { + message: message( + "did a step", + { input: 1, output: 1, reasoning: 0 }, + "msg-assistant", + ), + }, + }, + }) + assert.equal(goal.stalledCompactions, 1, "retained source is not new work progress") await hooks.event({ event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, @@ -4404,6 +4424,200 @@ test("auto-continue fires after compaction despite a pre-compaction continuation assert.equal(calls.length, 1) assert.equal(currentGoal("session-1").stopped, false) + assert.equal(currentGoal("session-1").compactionEpoch, 1) + assert.deepEqual(currentGoal("session-1").continuationClaim, { + runId: goal.runId, + compactionEpoch: 1, + sourceAssistantMessageID: "msg-assistant", + }) + + await hooks.event({ + event: { id: "idle-after-compact-duplicate", type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1, "one compaction epoch must send only one continuation") +}) + +test("duplicate session.compacted delivery does not open another continuation epoch", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-compact-duplicate", arguments: "ship it" }, + { parts: [] }, + ) + + const compacted = { + id: "compact-event-1", + type: "session.compacted", + properties: { sessionID: "session-compact-duplicate" }, + } + await hooks.event({ event: compacted }) + await hooks.event({ + event: { id: "idle-compact-1", type: "session.status", properties: { sessionID: "session-compact-duplicate", status: { type: "idle" } } }, + }) + await hooks.event({ event: structuredClone(compacted) }) + await hooks.event({ + event: { id: "idle-compact-2", type: "session.status", properties: { sessionID: "session-compact-duplicate", status: { type: "idle" } } }, + }) + + const goal = currentGoal("session-compact-duplicate") + assert.equal(calls.length, 1) + assert.equal(goal.compactionEpoch, 1) + assert.equal(goal.stalledCompactions, 1) + assert.equal(goal.stopped, false) +}) + +test("repeated compactions without work progress pause and abort the active goal", async () => { + const { aborts, calls, hooks } = await createHooks({ + options: { minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + }) + const sessionID = "session-compact-stalled" + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { id: "compact-stalled-1", type: "session.compacted", properties: { sessionID } }, + }) + await hooks.event({ + event: { id: "idle-stalled-1", type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + await hooks.event({ + event: { id: "compact-stalled-2", type: "session.compacted", properties: { sessionID } }, + }) + + const goal = currentGoal(sessionID) + assert.equal(calls.length, 1) + assert.equal(aborts.length, 1) + assert.equal(aborts[0].path.id, sessionID) + assert.equal(goal.compactionEpoch, 2) + assert.equal(goal.stalledCompactions, 2) + assert.equal(goal.stopped, true) + assert.equal(goal.stopReason, "stalled compaction") +}) + +test("a productive tool turn resets the stalled-compaction circuit breaker", async () => { + const { aborts, hooks } = await createHooks({ + options: { minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + }) + const sessionID = "session-compact-progress" + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { id: "compact-progress-1", type: "session.compacted", properties: { sessionID } }, + }) + const toolTurn = { + info: { + id: "msg-tool-progress", + role: "assistant", + sessionID, + tokens: { input: 1, output: 0, reasoning: 0 }, + }, + parts: [{ type: "tool", tool: "bash", state: { status: "completed" } }], + } + await hooks.event({ + event: { type: "message.updated", properties: { message: toolTurn } }, + }) + assert.equal(currentGoal(sessionID).stalledCompactions, 0) + + await hooks.event({ + event: { id: "compact-progress-2", type: "session.compacted", properties: { sessionID } }, + }) + const goal = currentGoal(sessionID) + assert.equal(aborts.length, 0) + assert.equal(goal.stalledCompactions, 1) + assert.equal(goal.stopped, false) +}) + +test("compaction-summary assistants are neither progress nor continuation sources", async () => { + const workAssistant = message( + "implemented the next step", + undefined, + "msg-real-work", + "session-compact-summary", + ) + const compactSummary = { + info: { + id: "msg-compact-summary", + role: "assistant", + sessionID: "session-compact-summary", + summary: true, + mode: "compaction", + tokens: { input: 100, output: 100, reasoning: 0 }, + }, + parts: [textPart("What did we do so far?")], + } + const { aborts, calls, hooks } = await createHooks({ + messages: async () => ({ data: [workAssistant, compactSummary] }), + options: { minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + }) + const sessionID = "session-compact-summary" + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { id: "compact-summary-1", type: "session.compacted", properties: { sessionID } }, + }) + await hooks.event({ + event: { type: "message.updated", properties: { message: compactSummary } }, + }) + assert.equal(currentGoal(sessionID).stalledCompactions, 1) + + await hooks.event({ + event: { id: "idle-compact-summary", type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1) + assert.equal(currentGoal(sessionID).continuationClaim.sourceAssistantMessageID, "msg-real-work") + + await hooks.event({ + event: { id: "compact-summary-2", type: "session.compacted", properties: { sessionID } }, + }) + assert.equal(aborts.length, 1) + assert.equal(currentGoal(sessionID).stopReason, "stalled compaction") +}) + +test("compaction after claim persistence invalidates the stale idle handler", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, noToolCallTurnsBeforePause: 0 }, + }) + const sessionID = "session-compact-claim-race" + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + + const goal = currentGoal(sessionID) + let claim = null + let compactionPromise = null + Object.defineProperty(goal, "continuationClaim", { + configurable: true, + get: () => claim, + set: (value) => { + claim = value + if (value && !compactionPromise) { + queueMicrotask(() => { + compactionPromise = hooks.event({ + event: { id: "compact-claim-race", type: "session.compacted", properties: { sessionID } }, + }) + }) + } + }, + }) + + await hooks.event({ + event: { id: "idle-claim-race", type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + await compactionPromise + + assert.equal(calls.length, 0) + assert.equal(goal.compactionEpoch, 1) + assert.equal(goal.continuationClaim, null) }) test("parses --max-duration-ms flag directly", () => { @@ -4776,6 +4990,7 @@ test("continuation source claims and initiating execution context persist before }) assert.deepEqual(goal.continuationClaim, { runId: goal.runId, + compactionEpoch: 0, sourceAssistantMessageID: "assistant-durable-source", })