From b9c947237afc676267be5be6f9b74c8fbcfcaec3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 03:22:23 +0100 Subject: [PATCH] refactor(agents): extract attempt session boundaries --- .../run/attempt-session-boundary.test.ts | 132 ++++++++++++++++++ .../run/attempt-session-boundary.ts | 119 ++++++++++++++++ .../run/attempt-session-settle.test.ts | 52 +++++++ .../run/attempt-session-settle.ts | 43 ++++++ .../embedded-agent-runner/run/attempt.ts | 120 ++-------------- 5 files changed, 360 insertions(+), 106 deletions(-) create mode 100644 src/agents/embedded-agent-runner/run/attempt-session-boundary.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-session-boundary.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-session-settle.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-session-settle.ts diff --git a/src/agents/embedded-agent-runner/run/attempt-session-boundary.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-boundary.test.ts new file mode 100644 index 000000000000..8796e65e31f5 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-session-boundary.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildTimestampPrefix } from "../../../gateway/server-methods/agent-timestamp.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; +import type { AgentSession } from "../../sessions/index.js"; +import { prepareEmbeddedAttemptSessionBoundary } from "./attempt-session-boundary.js"; + +function createActiveSession(messages: AgentMessage[] = []) { + const reset = vi.fn(); + const convertToLlm = vi.fn((input: AgentMessage[]) => input as never); + const activeSession = { + agent: { + reset, + state: { messages }, + convertToLlm, + }, + } as unknown as Pick; + return { activeSession, convertToLlm, reset }; +} + +function createSessionManager( + overrides: Record = {}, +): ReturnType { + return { + getLeafEntry: () => undefined, + ...overrides, + } as unknown as ReturnType; +} + +describe("prepareEmbeddedAttemptSessionBoundary", () => { + it("resets restored state and preserves exact prompt bytes for raw model probes", async () => { + const { activeSession, reset } = createActiveSession(); + const setActiveSessionSystemPrompt = vi.fn(); + + const boundary = prepareEmbeddedAttemptSessionBoundary({ + activeSession, + attempt: { prompt: "exact probe" }, + isRawModelRun: true, + preparedUserTurnMessage: undefined, + sessionManager: createSessionManager(), + setActiveSessionSystemPrompt, + }); + const converted = await activeSession.agent.convertToLlm([ + { role: "user", content: [{ type: "text", text: "exact probe" }], timestamp: 1 }, + ]); + + expect(reset).toHaveBeenCalledOnce(); + expect(setActiveSessionSystemPrompt).toHaveBeenCalledWith(""); + expect(boundary).toMatchObject({ + boundaryTimezone: undefined, + includeBoundaryTimestamp: false, + orphanRepair: undefined, + }); + expect((converted[0] as { content?: unknown }).content).toBe("exact probe"); + }); + + it("applies the prepared current-turn timestamp at the LLM boundary", async () => { + const { activeSession } = createActiveSession(); + const preparedTimestamp = 1_717_570_800_000; + const boundary = prepareEmbeddedAttemptSessionBoundary({ + activeSession, + attempt: { + config: { agents: { defaults: { userTimezone: "UTC" } } }, + prompt: "Current ask", + trigger: "user", + }, + isRawModelRun: false, + preparedUserTurnMessage: undefined, + sessionManager: createSessionManager(), + setActiveSessionSystemPrompt: vi.fn(), + }); + boundary.setCurrentUserTimestampOverride({ + timestamp: preparedTimestamp, + text: "Current ask", + }); + + const converted = await activeSession.agent.convertToLlm([ + { + role: "user", + content: [{ type: "text", text: "Current ask" }], + timestamp: preparedTimestamp + 60_000, + }, + ]); + + expect((converted[0] as { content?: unknown }).content).toBe( + `${buildTimestampPrefix(new Date(preparedTimestamp), { timezone: "UTC" })}Current ask`, + ); + }); + + it("repairs an orphaned user leaf before rebuilding active session messages", () => { + const repairedMessages: AgentMessage[] = [ + { role: "user", content: [{ type: "text", text: "repaired" }], timestamp: 2 }, + ]; + const { activeSession } = createActiveSession([ + { role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }, + ]); + const branch = vi.fn(); + const clearNextUserMessagePersistenceSuppression = vi.fn(); + const onUserMessagePersistenceInvalidated = vi.fn(); + const sessionManager = createSessionManager({ + getLeafEntry: () => ({ + id: "user-leaf", + parentId: "parent-entry", + type: "message", + timestamp: "2026-07-13T00:00:00.000Z", + message: { role: "user", content: "old" }, + }), + branch, + clearNextUserMessagePersistenceSuppression, + buildSessionContext: () => ({ messages: repairedMessages }), + }); + + const boundary = prepareEmbeddedAttemptSessionBoundary({ + activeSession, + attempt: { + onUserMessagePersistenceInvalidated, + prompt: "new", + trigger: "user", + }, + isRawModelRun: false, + preparedUserTurnMessage: undefined, + sessionManager, + setActiveSessionSystemPrompt: vi.fn(), + }); + + expect(boundary.orphanRepair?.removeLeaf).toBe(true); + expect(branch).toHaveBeenCalledWith("parent-entry"); + expect(clearNextUserMessagePersistenceSuppression).toHaveBeenCalledOnce(); + expect(onUserMessagePersistenceInvalidated).toHaveBeenCalledOnce(); + expect(activeSession.agent.state.messages).toBe(repairedMessages); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts b/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts new file mode 100644 index 000000000000..58397136b3fc --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts @@ -0,0 +1,119 @@ +/** Prepares the restored transcript at the LLM boundary for one attempt. */ +import { resolveUserTimezone } from "../../date-time.js"; +import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; +import type { AgentSession } from "../../sessions/index.js"; +import { + replayTrailingEntriesForOrphanRepair, + resolveOrphanRepairPlan, +} from "./attempt-orphan-repair.js"; +import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js"; +import { detachPrePersistedCurrentUserTurn } from "./pre-persisted-user-turn.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +type SessionBoundaryAttempt = Pick< + EmbeddedRunAttemptParams, + | "config" + | "onUserMessagePersistenceInvalidated" + | "prompt" + | "suppressNextUserMessagePersistence" + | "trigger" + | "userTurnTranscriptRecorder" +>; + +type LlmBoundaryOptions = NonNullable[1]>; + +export type CurrentUserTimestampOverride = NonNullable< + LlmBoundaryOptions["currentUserTimestampOverride"] +>; + +export function prepareEmbeddedAttemptSessionBoundary(input: { + activeSession: Pick; + attempt: SessionBoundaryAttempt; + isRawModelRun: boolean; + preparedUserTurnMessage: AgentMessage | undefined; + sessionManager: ReturnType; + setActiveSessionSystemPrompt: (systemPrompt: string) => void; +}): { + boundaryTimezone: string | undefined; + includeBoundaryTimestamp: boolean; + orphanRepair: ReturnType; + setCurrentUserTimestampOverride: (override: CurrentUserTimestampOverride | undefined) => void; +} { + const { activeSession, attempt, isRawModelRun, sessionManager } = input; + if (isRawModelRun) { + // Raw probes measure only the requested provider prompt. Restored history, + // queued work, and the normal system prompt would contaminate it. + activeSession.agent.reset(); + input.setActiveSessionSystemPrompt(""); + } + + const orphanRepair = isRawModelRun + ? undefined + : resolveOrphanRepairPlan({ + sessionManager, + prompt: attempt.prompt, + trigger: attempt.trigger, + }); + if (orphanRepair?.removeLeaf) { + if (orphanRepair.messageEntry.parentId) { + sessionManager.branch(orphanRepair.messageEntry.parentId); + } else { + sessionManager.resetLeaf(); + } + replayTrailingEntriesForOrphanRepair(sessionManager, orphanRepair.trailingEntries); + // The old canonical user turn is gone. Its persistence suppression must not + // discard the merged replacement prompt. + sessionManager.clearNextUserMessagePersistenceSuppression?.(); + attempt.onUserMessagePersistenceInvalidated?.(); + activeSession.agent.state.messages = sessionManager.buildSessionContext().messages; + } + + detachPrePersistedCurrentUserTurn({ + activeSession, + preparedUserTurnMessage: input.preparedUserTurnMessage, + suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence, + userTurnAlreadyPersisted: attempt.userTurnTranscriptRecorder?.hasPersisted() === true, + }); + + // This is the single timestamping source for user messages sent to the LLM. + // Raw probes retain exact prompt bytes. + const boundaryTimezone = isRawModelRun + ? undefined + : resolveUserTimezone(attempt.config?.agents?.defaults?.userTimezone); + const includeBoundaryTimestamp = + !isRawModelRun && attempt.config?.agents?.defaults?.envelopeTimestamp !== "off"; + let currentUserTimestampOverride: CurrentUserTimestampOverride | undefined; + const buildBoundaryOptions = (): LlmBoundaryOptions | undefined => { + if (isRawModelRun) { + return undefined; + } + return { + ...(boundaryTimezone ? { timezone: boundaryTimezone } : {}), + ...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }), + ...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}), + }; + }; + + if (typeof activeSession.agent.convertToLlm === "function") { + const baseConvertToLlm = activeSession.agent.convertToLlm.bind(activeSession.agent); + activeSession.agent.convertToLlm = async (messages) => + await baseConvertToLlm( + // Wire-only relocation keeps the request append-only through the active + // user turn without changing position-sensitive precheck normalization. + relocateCurrentRuntimeContextCarrierToTail( + normalizeMessagesForLlmBoundary(messages, buildBoundaryOptions()), + ), + ); + } + + return { + boundaryTimezone, + includeBoundaryTimestamp, + orphanRepair, + setCurrentUserTimestampOverride: (override) => { + currentUserTimestampOverride = override; + }, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-session-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-settle.test.ts new file mode 100644 index 000000000000..361e6f28ff6c --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-session-settle.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentSession } from "../../sessions/index.js"; +import { createEmbeddedAttemptSessionSettleTracker } from "./attempt-session-settle.js"; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("createEmbeddedAttemptSessionSettleTracker", () => { + it("waits for both prompt and abort settlement during cleanup", async () => { + const prompt = deferred(); + const abort = deferred(); + const abortSession = vi.fn(() => abort.promise); + const tracker = createEmbeddedAttemptSessionSettleTracker({ + abort: abortSession, + } as unknown as Pick); + + void tracker.trackPromptSettlePromise(prompt.promise); + const abortReason = new Error("stop"); + void tracker.abortActiveSession(abortReason); + const settled = tracker.buildAbortSettlePromise(); + expect(settled).not.toBeNull(); + expect(abortSession).toHaveBeenCalledWith(abortReason); + + let finished = false; + void settled?.then(() => { + finished = true; + }); + prompt.resolve(); + await Promise.resolve(); + expect(finished).toBe(false); + + abort.resolve(); + await settled; + expect(finished).toBe(true); + expect(tracker.buildAbortSettlePromise()).toBeNull(); + }); + + it("settles rejected prompt work without leaking it into later cleanup", async () => { + const tracker = createEmbeddedAttemptSessionSettleTracker({ + abort: async () => undefined, + } as unknown as Pick); + void tracker.trackPromptSettlePromise(Promise.reject(new Error("prompt failed"))); + + await expect(tracker.buildAbortSettlePromise()).resolves.toBeUndefined(); + expect(tracker.buildAbortSettlePromise()).toBeNull(); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-settle.ts b/src/agents/embedded-agent-runner/run/attempt-session-settle.ts new file mode 100644 index 000000000000..d47534a078d1 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-session-settle.ts @@ -0,0 +1,43 @@ +/** Tracks native prompt and abort settlement through attempt cleanup. */ +import type { AgentSession } from "../../sessions/index.js"; + +export function createEmbeddedAttemptSessionSettleTracker( + activeSession: Pick, +): { + abortActiveSession: (reason?: unknown) => Promise; + buildAbortSettlePromise: () => Promise | null; + trackPromptSettlePromise: (promise: Promise) => Promise; +} { + const inFlightPromptSettlePromises = new Set>(); + const inFlightAbortSettlePromises = new Set>(); + const trackSettlePromise = ( + promises: Set>, + promise: Promise, + ): Promise => { + promises.add(promise); + void promise.then( + () => { + promises.delete(promise); + }, + () => { + promises.delete(promise); + }, + ); + return promise; + }; + + const trackPromptSettlePromise = (promise: Promise): Promise => + trackSettlePromise(inFlightPromptSettlePromises, promise); + const abortActiveSession = (reason?: unknown): Promise => + trackSettlePromise(inFlightAbortSettlePromises, Promise.resolve(activeSession.abort(reason))); + const buildAbortSettlePromise = (): Promise | null => { + const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises]; + return promises.length === 0 ? null : Promise.allSettled(promises).then(() => undefined); + }; + + return { + abortActiveSession, + buildAbortSettlePromise, + trackPromptSettlePromise, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 01fbfb576bfe..6fac74bb736e 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -36,12 +36,10 @@ import { resolveAgentDir, resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { isHeartbeatLifecycleRunKind } from "../../bootstrap-mode.js"; import { createCacheTrace } from "../../cache-trace.js"; -import { resolveUserTimezone } from "../../date-time.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js"; import { isSignalTimeoutReason } from "../../failover-error.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; -import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; import { acquireSessionWriteLock } from "../../session-write-lock.js"; @@ -80,10 +78,6 @@ import { prepareEmbeddedAttemptBootstrap } from "./attempt-bootstrap-prepare.js" import { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js"; import { summarizeSessionContext } from "./attempt-context-summary.js"; import { prepareEmbeddedAttemptHistory } from "./attempt-history-prepare.js"; -import { - replayTrailingEntriesForOrphanRepair, - resolveOrphanRepairPlan, -} from "./attempt-orphan-repair.js"; import { prepareEmbeddedAttemptPromptAssembly } from "./attempt-prompt-assembly.js"; import { prepareEmbeddedAttemptPromptContext } from "./attempt-prompt-context.js"; import { @@ -92,8 +86,10 @@ import { } from "./attempt-prompt-preflight.js"; import { submitEmbeddedAttemptPrompt } from "./attempt-prompt-submit.js"; import { completeEmbeddedAttemptResult } from "./attempt-result.js"; +import { prepareEmbeddedAttemptSessionBoundary } from "./attempt-session-boundary.js"; import { cleanupEmbeddedAttemptSessionPhase } from "./attempt-session-cleanup.js"; import { prepareEmbeddedAttemptSessionManager } from "./attempt-session-manager-prepare.js"; +import { createEmbeddedAttemptSessionSettleTracker } from "./attempt-session-settle.js"; import { prepareEmbeddedAttemptAgentSession } from "./attempt-session.js"; import { prepareEmbeddedAttemptSetup } from "./attempt-setup.js"; import { createEmbeddedRunStageTracker } from "./attempt-stage-timing.js"; @@ -117,7 +113,6 @@ import { resolveAttemptTrajectorySessionFile, } from "./attempt-transcript-helpers.js"; import { buildLoopPromptCacheInfo } from "./attempt.context-engine-helpers.js"; -import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js"; import { buildAfterTurnRuntimeContext, resolvePromptSubmissionSkipReason, @@ -141,7 +136,6 @@ import { shouldFlagCompactionTimeout } from "./compaction-timeout.js"; import { installHistoryImagePruneContextTransform } from "./history-image-prune.js"; import { detectAndLoadPromptImages } from "./images.js"; import { isMidTurnPrecheckSignal, type MidTurnPrecheckRequest } from "./midturn-precheck.js"; -import { detachPrePersistedCurrentUserTurn } from "./pre-persisted-user-turn.js"; import { PREEMPTIVE_OVERFLOW_ERROR_TEXT } from "./preemptive-compaction.js"; import { clearToolActivityRun } from "./tool-activity-heartbeat.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; @@ -587,113 +581,25 @@ export async function runEmbeddedAttempt( sessionLockController, sessionManager, }); - if (isRawModelRun) { - // Raw model probes should measure exactly the requested prompt against - // the selected provider/model. Reset clears restored transcript state - // and queues; the empty system prompt prevents the runtime from rebuilding the - // normal OpenClaw agent/tool prompt when `session.prompt()` starts. - activeSession.agent.reset(); - setActiveSessionSystemPrompt(""); - } - const orphanRepair = isRawModelRun - ? undefined - : resolveOrphanRepairPlan({ - sessionManager, - prompt: params.prompt, - trigger: params.trigger, - }); - if (orphanRepair?.removeLeaf) { - if (orphanRepair.messageEntry.parentId) { - sessionManager.branch(orphanRepair.messageEntry.parentId); - } else { - sessionManager.resetLeaf(); - } - replayTrailingEntriesForOrphanRepair(sessionManager, orphanRepair.trailingEntries); - // Suppression assumes the canonical user turn still exists. Orphan repair - // removed it, so the replacement prompt must become the one durable copy. - sessionManager.clearNextUserMessagePersistenceSuppression?.(); - params.onUserMessagePersistenceInvalidated?.(); - activeSession.agent.state.messages = sessionManager.buildSessionContext().messages; - } - detachPrePersistedCurrentUserTurn({ + const sessionBoundary = prepareEmbeddedAttemptSessionBoundary({ activeSession, + attempt: params, + isRawModelRun, preparedUserTurnMessage, - suppressNextUserMessagePersistence: params.suppressNextUserMessagePersistence, - userTurnAlreadyPersisted: params.userTurnTranscriptRecorder?.hasPersisted() === true, + sessionManager, + setActiveSessionSystemPrompt, }); - // Single source for the per-message timestamp prefix (issue #3658): - // normal embedded runs stamp every user message from its own timestamp. - // Raw model probes must keep the requested prompt text exact. - const boundaryTimezone = isRawModelRun - ? undefined - : resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const includeBoundaryTimestamp = - !isRawModelRun && params.config?.agents?.defaults?.envelopeTimestamp !== "off"; - let currentUserTimestampOverride: - | { timestamp: number; text: string; alternateText?: string } - | undefined; - const buildBoundaryOptions = () => { - if (isRawModelRun) { - return undefined; - } - return { - ...(boundaryTimezone ? { timezone: boundaryTimezone } : {}), - ...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }), - ...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}), - }; - }; - if (typeof activeSession.agent.convertToLlm === "function") { - const baseConvertToLlm = activeSession.agent.convertToLlm.bind(activeSession.agent); - activeSession.agent.convertToLlm = async (messages) => - await baseConvertToLlm( - // Wire-only: move the current-turn runtime-context carrier to the - // absolute tail so the request is an append-only prefix-extension - // through the active user turn (see the function's cache rationale). - // Applied here, not inside normalizeMessagesForLlmBoundary, because - // normalizeMessagesForCurrentPromptBoundary slices off its appended - // prompt by position and must not see the carrier relocated past it. - relocateCurrentRuntimeContextCarrierToTail( - normalizeMessagesForLlmBoundary(messages, buildBoundaryOptions()), - ), - ); - } + const { boundaryTimezone, includeBoundaryTimestamp, orphanRepair } = sessionBoundary; let prePromptMessageCount = activeSession.messages.length; // Session-owned projections survive attempt teardown so already-sent tool results // cannot rewrite the provider prompt-cache tail between turns (#99495). const sessionPromptState = getEmbeddedSessionPromptState(params.sessionId); const toolResultPromptProjectionState = sessionPromptState.toolResults; let contextEngineAfterTurnCheckpoint: number | null = null; - const inFlightPromptSettlePromises = new Set>(); - const inFlightAbortSettlePromises = new Set>(); - const trackSettlePromise = ( - promises: Set>, - promise: Promise, - ): Promise => { - promises.add(promise); - void promise.then( - () => { - promises.delete(promise); - }, - () => { - promises.delete(promise); - }, - ); - return promise; - }; - const trackPromptSettlePromise = (promise: Promise): Promise => - trackSettlePromise(inFlightPromptSettlePromises, promise); - const trackAbortSettlePromise = (promise: Promise): Promise => - trackSettlePromise(inFlightAbortSettlePromises, promise); - const abortActiveSession = (reason?: unknown): Promise => - trackAbortSettlePromise(Promise.resolve(activeSession.abort(reason))); + const sessionSettleTracker = createEmbeddedAttemptSessionSettleTracker(activeSession); + const { abortActiveSession, trackPromptSettlePromise } = sessionSettleTracker; abortActiveSessionForExternalSignal = abortActiveSession; - buildAbortSettlePromise = (): Promise | null => { - const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises]; - if (promises.length === 0) { - return null; - } - return Promise.allSettled(promises).then(() => undefined); - }; + buildAbortSettlePromise = sessionSettleTracker.buildAbortSettlePromise; abortSessionForYield = () => { yieldAbortSettled = abortActiveSession(SESSIONS_YIELD_ABORT_REASON); }; @@ -1255,7 +1161,9 @@ export async function runEmbeddedAttempt( systemPromptForHook, } = promptContext; prePromptMessageCount = promptContext.prePromptMessageCount; - currentUserTimestampOverride = promptContext.currentUserTimestampOverride; + sessionBoundary.setCurrentUserTimestampOverride( + promptContext.currentUserTimestampOverride, + ); if (aggregatePressureEngaged) { // Compaction and aggregate truncation both target about half the window; // compact-then-truncate prevents re-hitting the same cap on the next turn.