fix(session): prevent goal mode pause during compaction/switch

This commit is contained in:
usr_bin_roygbiv
2026-06-18 10:30:40 -05:00
parent 2023a6f674
commit c16a41fd95
5 changed files with 105 additions and 12 deletions
+1
View File
@@ -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 `<out>` blocks in dim-ink toggles
+19 -7
View File
@@ -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<GoalModeState | undefined> {
async onThreadResumed(options?: { preserveActiveGoal?: boolean }): Promise<GoalModeState | undefined> {
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<void> {
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") {
@@ -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<void> {
async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise<void> {
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.
@@ -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();
@@ -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<GoalTokenUsage>) => {
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 <root>&keep>safe";
const goal = createGoal({ objective });