From c16a41fd9552ffbc4ac8d7ea8e9f7a346d9b0e18 Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Thu, 18 Jun 2026 10:30:40 -0500 Subject: [PATCH] fix(session): prevent goal mode pause during compaction/switch --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/goals/runtime.ts | 26 +++++-- .../src/modes/interactive-mode.ts | 10 ++- .../coding-agent/src/session/agent-session.ts | 4 +- .../test/goals/goal-runtime.test.ts | 76 +++++++++++++++++++ 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 59918deb7..22454c2b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,7 @@ - Centralized ZIP handling behind a single `src/utils/zip.ts` (`fflate`): the new document converters, the `write` tool's in-place archive editing, and the `read` tool's ranged archive reader now share one ZIP implementation instead of mixing `jszip` and `fflate`. ### Fixed +- Fixed active `/goal` mode being paused by internal compaction and session-switch lifecycle aborts, and made those switches persist wall-clock goal usage without charging time spent in another session to a preserved goal. - Fixed settings overlay crash when scrolling past the last row in list views - Improved tool result formatting by correctly wrapping `` blocks in dim-ink toggles diff --git a/packages/coding-agent/src/goals/runtime.ts b/packages/coding-agent/src/goals/runtime.ts index 27b3189fb..ae65b9abf 100644 --- a/packages/coding-agent/src/goals/runtime.ts +++ b/packages/coding-agent/src/goals/runtime.ts @@ -178,8 +178,8 @@ export class GoalRuntime { } } - #markActiveAccounting(goal: Goal): void { - if (this.#wallClock.activeGoalId !== goal.id) { + #markActiveAccounting(goal: Goal, resetWallClock = false): void { + if (resetWallClock || this.#wallClock.activeGoalId !== goal.id) { this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id }; } if (this.#turnSnapshot) { @@ -195,6 +195,12 @@ export class GoalRuntime { } } + clearAccounting(): void { + this.#turnSnapshot = undefined; + this.#clearActiveAccounting(); + this.#budgetReportedFor = undefined; + } + onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void { this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } }; const state = this.#host.getState(); @@ -235,7 +241,7 @@ export class GoalRuntime { return; } await this.#withAccounting(async () => { - await this.#flushUsageLocked("suppressed"); + await this.#flushUsageLocked("suppressed", undefined, options?.reason === "internal"); this.#turnSnapshot = undefined; if (options?.reason !== "interrupted") return; const cloned = this.#getStateClone(); @@ -249,9 +255,14 @@ export class GoalRuntime { }); } - async onThreadResumed(): Promise { + async onThreadResumed(options?: { preserveActiveGoal?: boolean }): Promise { const state = this.#getStateClone(); if (!state) return undefined; + if (options?.preserveActiveGoal && state.enabled && state.goal.status === "active") { + this.#markActiveAccounting(state.goal, true); + await this.#commitState(state, { emit: true }); + return state; + } if (state.goal.status === "active") { state.enabled = false; state.goal.status = "paused"; @@ -301,6 +312,7 @@ export class GoalRuntime { async #flushUsageLocked( steering: GoalBudgetSteering, currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(), + persistWallClock = false, ): Promise { const state = this.#getStateClone(); if (!state?.enabled || !isAccountingStatus(state.goal)) return; @@ -333,10 +345,10 @@ export class GoalRuntime { if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) { this.#wallClock.lastAccountedAt += wallSeconds * 1000; } - // Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full - // objective snapshots. Keep the in-memory/UI state fresh, but persist only token/budget changes. - const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited; + // objective snapshots. Keep normal tool flushes in memory/UI only, but make wall-clock + // usage durable before internal session switches because the active runtime is leaving. + const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited || (persistWallClock && wallSeconds > 0); await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined }); if (state.goal.status !== "budget-limited") { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index bc938d04e..d4109fff3 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -797,7 +797,7 @@ export class InteractiveMode implements InteractiveModeContext { await this.initHooksAndCustomTools(); // Restore mode from session (e.g. plan mode on resume) - this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession()); + this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession({ preserveActiveGoal: true })); await this.#reconcileModeFromSession(); // Brand-new sessions optionally start in plan mode when the user has made it @@ -1783,11 +1783,12 @@ export class InteractiveMode implements InteractiveModeContext { } /** Reconcile mode state from session entries on resume/switch. */ - async #reconcileModeFromSession(): Promise { + async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise { await this.#clearTransientModeState(); const sessionContext = this.sessionManager.buildSessionContext(); const goalEnabled = this.session.settings.get("goal.enabled"); if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) { + this.session.goalRuntime.clearAccounting(); this.sessionManager.appendModeChange("none"); return; } @@ -1802,7 +1803,9 @@ export class InteractiveMode implements InteractiveModeContext { mode: "active", goal, }); - const restored = await this.session.goalRuntime.onThreadResumed(); + const restored = await this.session.goalRuntime.onThreadResumed({ + preserveActiveGoal: options?.preserveActiveGoal, + }); this.goalModeEnabled = restored?.enabled === true; this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused"; // sdk.ts excludes "goal" from the initial active tool set unconditionally. @@ -1815,6 +1818,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#updateGoalModeStatus(); return; } + this.session.goalRuntime.clearAccounting(); if (!this.session.settings.get("plan.enabled")) { // Clear stale plan/plan_paused mode so re-enabling the setting // later doesn't unexpectedly restore an old plan session. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 00c2a57ab..991b23b5c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -7375,7 +7375,7 @@ export class AgentSession { throw new Error("Compaction already in progress"); } this.#disconnectFromAgent(); - await this.abort(); + await this.abort({ goalReason: "internal" }); const compactionAbortController = new AbortController(); this.#compactionAbortController = compactionAbortController; @@ -10960,7 +10960,7 @@ export class AgentSession { } this.#disconnectFromAgent(); - await this.abort(); + await this.abort({ goalReason: "internal" }); // Flush pending writes before switching so restore snapshots reflect committed state. await this.sessionManager.flush(); diff --git a/packages/coding-agent/test/goals/goal-runtime.test.ts b/packages/coding-agent/test/goals/goal-runtime.test.ts index 275194067..d4dd510a3 100644 --- a/packages/coding-agent/test/goals/goal-runtime.test.ts +++ b/packages/coding-agent/test/goals/goal-runtime.test.ts @@ -80,6 +80,9 @@ function createHarness(initial: { state?: GoalModeState; usage?: GoalTokenUsage; return { runtime: new GoalRuntime(host), getState: () => cloneState(state), + setState: (next: GoalModeState | undefined) => { + state = cloneState(next); + }, setUsage: (next: Partial) => { usage = createUsage(next); }, @@ -153,6 +156,65 @@ describe("goal runtime", () => { expect(harness.persists).toHaveLength(0); }); + it("persists wall-clock-only usage before internal compaction or session-switch aborts", async () => { + const harness = createHarness({ + state: { enabled: true, mode: "active", goal: createGoal() }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.advance(2_500); + await harness.runtime.onTaskAborted({ reason: "internal" }); + + expect(harness.getState()?.enabled).toBe(true); + expect(harness.getState()?.goal.status).toBe("active"); + expect(harness.getState()?.goal.timeUsedSeconds).toBe(2); + expect(harness.persists).toHaveLength(1); + expect(harness.persists[0]).toMatchObject({ + mode: "goal", + state: { goal: { timeUsedSeconds: 2 } }, + }); + }); + + it("resets wall-clock baseline when preserving an active goal after a no-goal switch", async () => { + const goal = createGoal(); + const harness = createHarness({ + state: { enabled: true, mode: "active", goal }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.setState(undefined); + harness.advance(10_000); + harness.setState({ enabled: true, mode: "active", goal }); + + const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + harness.advance(1_000); + await harness.runtime.flushUsage("suppressed"); + + expect(resumed?.goal.status).toBe("active"); + expect(harness.getState()?.goal.timeUsedSeconds).toBe(1); + expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(11_000); + }); + + it("clears stale accounting when reconciling to a no-goal session", async () => { + const goal = createGoal(); + const harness = createHarness({ + state: { enabled: true, mode: "active", goal }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.setState(undefined); + harness.runtime.clearAccounting(); + harness.advance(10_000); + harness.setState({ enabled: true, mode: "active", goal }); + + await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + harness.advance(1_000); + await harness.runtime.flushUsage("suppressed"); + + expect(harness.getState()?.goal.timeUsedSeconds).toBe(1); + expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(11_000); + }); + it("steers only once until a budget mutation resets the cycle", async () => { const harness = createHarness({ state: { @@ -219,6 +281,20 @@ describe("goal runtime", () => { expect(harness.persists.at(-1)?.mode).toBe("goal_paused"); }); + it("preserves an active goal during internal session-switch reconciliation", async () => { + const harness = createHarness({ + state: { enabled: true, mode: "active", goal: createGoal() }, + }); + + const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + + expect(resumed?.enabled).toBe(true); + expect(resumed?.goal.status).toBe("active"); + expect(harness.getState()?.enabled).toBe(true); + expect(harness.getState()?.goal.status).toBe("active"); + expect(harness.persists).toHaveLength(0); + }); + it("escapes XML in goal helpers and rendered prompts", () => { const objective = "Fix &keep>safe"; const goal = createGoal({ objective });