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`. - 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
- 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 - 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 - 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 { #markActiveAccounting(goal: Goal, resetWallClock = false): void {
if (this.#wallClock.activeGoalId !== goal.id) { if (resetWallClock || this.#wallClock.activeGoalId !== goal.id) {
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id }; this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id };
} }
if (this.#turnSnapshot) { 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 { onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void {
this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } }; this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } };
const state = this.#host.getState(); const state = this.#host.getState();
@@ -235,7 +241,7 @@ export class GoalRuntime {
return; return;
} }
await this.#withAccounting(async () => { await this.#withAccounting(async () => {
await this.#flushUsageLocked("suppressed"); await this.#flushUsageLocked("suppressed", undefined, options?.reason === "internal");
this.#turnSnapshot = undefined; this.#turnSnapshot = undefined;
if (options?.reason !== "interrupted") return; if (options?.reason !== "interrupted") return;
const cloned = this.#getStateClone(); 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(); const state = this.#getStateClone();
if (!state) return undefined; 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") { if (state.goal.status === "active") {
state.enabled = false; state.enabled = false;
state.goal.status = "paused"; state.goal.status = "paused";
@@ -301,6 +312,7 @@ export class GoalRuntime {
async #flushUsageLocked( async #flushUsageLocked(
steering: GoalBudgetSteering, steering: GoalBudgetSteering,
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(), currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
persistWallClock = false,
): Promise<void> { ): Promise<void> {
const state = this.#getStateClone(); const state = this.#getStateClone();
if (!state?.enabled || !isAccountingStatus(state.goal)) return; if (!state?.enabled || !isAccountingStatus(state.goal)) return;
@@ -333,10 +345,10 @@ export class GoalRuntime {
if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) { if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) {
this.#wallClock.lastAccountedAt += wallSeconds * 1000; this.#wallClock.lastAccountedAt += wallSeconds * 1000;
} }
// Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full // 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. // objective snapshots. Keep normal tool flushes in memory/UI only, but make wall-clock
const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited; // 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 }); await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
if (state.goal.status !== "budget-limited") { if (state.goal.status !== "budget-limited") {
@@ -797,7 +797,7 @@ export class InteractiveMode implements InteractiveModeContext {
await this.initHooksAndCustomTools(); await this.initHooksAndCustomTools();
// Restore mode from session (e.g. plan mode on resume) // 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(); await this.#reconcileModeFromSession();
// Brand-new sessions optionally start in plan mode when the user has made it // 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. */ /** Reconcile mode state from session entries on resume/switch. */
async #reconcileModeFromSession(): Promise<void> { async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise<void> {
await this.#clearTransientModeState(); await this.#clearTransientModeState();
const sessionContext = this.sessionManager.buildSessionContext(); const sessionContext = this.sessionManager.buildSessionContext();
const goalEnabled = this.session.settings.get("goal.enabled"); const goalEnabled = this.session.settings.get("goal.enabled");
if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) { if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) {
this.session.goalRuntime.clearAccounting();
this.sessionManager.appendModeChange("none"); this.sessionManager.appendModeChange("none");
return; return;
} }
@@ -1802,7 +1803,9 @@ export class InteractiveMode implements InteractiveModeContext {
mode: "active", mode: "active",
goal, goal,
}); });
const restored = await this.session.goalRuntime.onThreadResumed(); const restored = await this.session.goalRuntime.onThreadResumed({
preserveActiveGoal: options?.preserveActiveGoal,
});
this.goalModeEnabled = restored?.enabled === true; this.goalModeEnabled = restored?.enabled === true;
this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused"; this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
// sdk.ts excludes "goal" from the initial active tool set unconditionally. // sdk.ts excludes "goal" from the initial active tool set unconditionally.
@@ -1815,6 +1818,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.#updateGoalModeStatus(); this.#updateGoalModeStatus();
return; return;
} }
this.session.goalRuntime.clearAccounting();
if (!this.session.settings.get("plan.enabled")) { if (!this.session.settings.get("plan.enabled")) {
// Clear stale plan/plan_paused mode so re-enabling the setting // Clear stale plan/plan_paused mode so re-enabling the setting
// later doesn't unexpectedly restore an old plan session. // later doesn't unexpectedly restore an old plan session.
@@ -7375,7 +7375,7 @@ export class AgentSession {
throw new Error("Compaction already in progress"); throw new Error("Compaction already in progress");
} }
this.#disconnectFromAgent(); this.#disconnectFromAgent();
await this.abort(); await this.abort({ goalReason: "internal" });
const compactionAbortController = new AbortController(); const compactionAbortController = new AbortController();
this.#compactionAbortController = compactionAbortController; this.#compactionAbortController = compactionAbortController;
@@ -10960,7 +10960,7 @@ export class AgentSession {
} }
this.#disconnectFromAgent(); this.#disconnectFromAgent();
await this.abort(); await this.abort({ goalReason: "internal" });
// Flush pending writes before switching so restore snapshots reflect committed state. // Flush pending writes before switching so restore snapshots reflect committed state.
await this.sessionManager.flush(); await this.sessionManager.flush();
@@ -80,6 +80,9 @@ function createHarness(initial: { state?: GoalModeState; usage?: GoalTokenUsage;
return { return {
runtime: new GoalRuntime(host), runtime: new GoalRuntime(host),
getState: () => cloneState(state), getState: () => cloneState(state),
setState: (next: GoalModeState | undefined) => {
state = cloneState(next);
},
setUsage: (next: Partial<GoalTokenUsage>) => { setUsage: (next: Partial<GoalTokenUsage>) => {
usage = createUsage(next); usage = createUsage(next);
}, },
@@ -153,6 +156,65 @@ describe("goal runtime", () => {
expect(harness.persists).toHaveLength(0); 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 () => { it("steers only once until a budget mutation resets the cycle", async () => {
const harness = createHarness({ const harness = createHarness({
state: { state: {
@@ -219,6 +281,20 @@ describe("goal runtime", () => {
expect(harness.persists.at(-1)?.mode).toBe("goal_paused"); 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", () => { it("escapes XML in goal helpers and rendered prompts", () => {
const objective = "Fix <root>&keep>safe"; const objective = "Fix <root>&keep>safe";
const goal = createGoal({ objective }); const goal = createGoal({ objective });