From 317461af99059ec9c31d189fb845e45a41fd7b6f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 12 Oct 2026 21:56:52 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20name=20the=20ful?= =?UTF-8?q?l-clear=20turn=20precheck=20and=20context=20discard=20in=20trun?= =?UTF-8?q?cateHistory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/workspaceService.ts | 150 +++++++++++++++----------- 1 file changed, 88 insertions(+), 62 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9500a02d8fd..ad64881eee0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -18340,6 +18340,87 @@ export class WorkspaceService }); } + /** + * Full-clear precondition, run under the admission guard and refine lock: refuse while a + * turn is active, and discard any pending auto-retry so it cannot replay the cleared context. + */ + private async settleTurnWorkBeforeFullClear( + workspaceId: string, + session: AgentSession | undefined + ): Promise> { + // Recheck under the guard + lock: the admission block refuses ordinary + // turn starts during the awaits above, but in-turn compaction retries + // bypass admission gating when they cross a transient idle gap. + if (session?.hasActiveOrPendingTurnWork() || this.aiService.isStreaming(workspaceId)) { + return Err( + "Cannot truncate history while a turn is active. Press Esc to stop the stream first." + ); + } + // r41: a retry scheduled before this clear would replay the discarded + // context after the guard releases — cancel it and drop the partial + // durably before the transcript goes away. + if (session) { + const retryDiscard = await session.discardAutoRetryForContextMutation(); + if (!retryDiscard.success) { + return Err( + `Cannot clear history: pending retry state could not be discarded (${retryDiscard.error})` + ); + } + } + return Ok(undefined); + } + + /** + * After a full clear: require goal acknowledgment and durably discard the context derived + * from the cleared transcript (file state, post-compaction carryover, sandbox kernel state). + */ + private async discardContextStateAfterFullClear(workspaceId: string): Promise> { + // A full chat clear removes the context the goal loop was using; require + // one user re-engagement before later continuation slices resume it. + try { + await this.workspaceGoalService?.requireUserAcknowledgment(workspaceId); + } catch (error) { + return Err(getErrorMessage(error)); + } + this.sessions.get(workspaceId)?.clearFileState(); + // Same new-segment invariant as resetContext: pre-clear read/skill + // carryover must not be injected after the transcript is gone, and the + // discard must be durable before the clear reports success (a stale + // persisted file would re-inject pre-clear context after a restart). + try { + await this.getOrCreateSession(workspaceId).clearPostCompactionState(); + } catch (error) { + return Err( + `History was cleared, but the persisted post-compaction carryover could not be ` + + `durably discarded (${getErrorMessage(error)}). Pre-clear read/skill context may ` + + `be re-injected after a restart; retry once the session storage is writable.` + ); + } + // The persistent RLM sandbox holds context DERIVED from the cleared + // transcript (vars populated by code execution), and its latest durable + // snapshot would restore it after a restart — later turns could read + // data from the supposedly cleared context through the kernel. Same + // durable invalidation + partial-failure posture as resetContext. + try { + await sandboxHostService.discardScope( + workspaceId, + path.join(this.config.sessionsDir, workspaceId) + ); + } catch (error) { + log.error( + `Failed to durably invalidate sandbox state for ${workspaceId} after history clear; ` + + `the sandbox kernel stays unavailable until invalidation succeeds`, + error + ); + return Err( + `History was cleared, but the sandbox kernel state could not be durably invalidated ` + + `(${getErrorMessage(error)}). The sandbox stays unavailable and cleared variables ` + + `may reappear after a restart; retry once the session storage is writable.` + ); + } + return Ok(undefined); + } + async truncateHistory(workspaceId: string, percentage?: number): Promise> { // Perf harness replay mode (XUM_REPLAY_TAPES): real chat history stays untouched. Checked // before any session or history work (clear, truncate). @@ -18382,26 +18463,10 @@ export class WorkspaceService refineLock = refineLockResult.data; } await using _refineLock = refineLock; - // Recheck under the guard + lock: the admission block refuses ordinary - // turn starts during the awaits above, but in-turn compaction retries - // bypass admission gating when they cross a transient idle gap. - if ( - isFullClear && - (session?.hasActiveOrPendingTurnWork() || this.aiService.isStreaming(workspaceId)) - ) { - return Err( - "Cannot truncate history while a turn is active. Press Esc to stop the stream first." - ); - } - // r41: a retry scheduled before this clear would replay the discarded - // context after the guard releases — cancel it and drop the partial - // durably before the transcript goes away. - if (isFullClear && session) { - const retryDiscard = await session.discardAutoRetryForContextMutation(); - if (!retryDiscard.success) { - return Err( - `Cannot clear history: pending retry state could not be discarded (${retryDiscard.error})` - ); + if (isFullClear) { + const ready = await this.settleTurnWorkBeforeFullClear(workspaceId, session); + if (!ready.success) { + return ready; } } // Kernel workflow run references belong to the conversation this truncation mutates: a @@ -18503,48 +18568,9 @@ export class WorkspaceService // On full clear (the plan file is already deleted), also clear file change tracking if (isFullClear) { - // A full chat clear removes the context the goal loop was using; require - // one user re-engagement before later continuation slices resume it. - try { - await this.workspaceGoalService?.requireUserAcknowledgment(workspaceId); - } catch (error) { - return Err(getErrorMessage(error)); - } - this.sessions.get(workspaceId)?.clearFileState(); - // Same new-segment invariant as resetContext: pre-clear read/skill - // carryover must not be injected after the transcript is gone, and the - // discard must be durable before the clear reports success (a stale - // persisted file would re-inject pre-clear context after a restart). - try { - await this.getOrCreateSession(workspaceId).clearPostCompactionState(); - } catch (error) { - return Err( - `History was cleared, but the persisted post-compaction carryover could not be ` + - `durably discarded (${getErrorMessage(error)}). Pre-clear read/skill context may ` + - `be re-injected after a restart; retry once the session storage is writable.` - ); - } - // The persistent RLM sandbox holds context DERIVED from the cleared - // transcript (vars populated by code execution), and its latest durable - // snapshot would restore it after a restart — later turns could read - // data from the supposedly cleared context through the kernel. Same - // durable invalidation + partial-failure posture as resetContext. - try { - await sandboxHostService.discardScope( - workspaceId, - path.join(this.config.sessionsDir, workspaceId) - ); - } catch (error) { - log.error( - `Failed to durably invalidate sandbox state for ${workspaceId} after history clear; ` + - `the sandbox kernel stays unavailable until invalidation succeeds`, - error - ); - return Err( - `History was cleared, but the sandbox kernel state could not be durably invalidated ` + - `(${getErrorMessage(error)}). The sandbox stays unavailable and cleared variables ` + - `may reappear after a restart; retry once the session storage is writable.` - ); + const discarded = await this.discardContextStateAfterFullClear(workspaceId); + if (!discarded.success) { + return discarded; } } From 04981a325cbcba33ad6af72ebbb25185f1540572 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 12 Oct 2026 22:17:30 +0000 Subject: [PATCH 2/2] Point the moved recheck comments at their new homes --- src/node/services/workspaceService.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index ad64881eee0..933ac02520e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -18349,8 +18349,9 @@ export class WorkspaceService session: AgentSession | undefined ): Promise> { // Recheck under the guard + lock: the admission block refuses ordinary - // turn starts during the awaits above, but in-turn compaction retries - // bypass admission gating when they cross a transient idle gap. + // turn starts during truncateHistory's earlier awaits (scope preflight, + // refine drain and lock), but in-turn compaction retries bypass admission + // gating when they cross a transient idle gap. if (session?.hasActiveOrPendingTurnWork() || this.aiService.isStreaming(workspaceId)) { return Err( "Cannot truncate history while a turn is active. Press Esc to stop the stream first." @@ -18488,10 +18489,11 @@ export class WorkspaceService ); } // In-turn compaction retries bypass admission gating across a transient idle gap (see - // the full-clear recheck above), and this retirement is the last await before the - // rewrite for BOTH row-removing scopes: revalidate here or a retry admitted during it - // would have its history truncated underneath the stream. Refusing after retirement is - // the documented fail-safe direction (dropped wake, retrievable via resume). + // the full-clear recheck in settleTurnWorkBeforeFullClear), and this retirement is the + // last await before the rewrite for BOTH row-removing scopes: revalidate here or a retry + // admitted during it would have its history truncated underneath the stream. Refusing + // after retirement is the documented fail-safe direction (dropped wake, retrievable via + // resume). if (session?.hasActiveOrPendingTurnWork() || this.aiService.isStreaming(workspaceId)) { return Err( "Cannot truncate history while a turn is active. Press Esc to stop the stream first."