fix(session): prevent goal mode pause during compaction/switch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user