Skip to content

feat(recovery): add session fallback restore and manual compact - #1268

Open
eddieparc wants to merge 3 commits into
code-yeongyu:mainfrom
eddieparc:feat/session-recovery-commands
Open

feat(recovery): add session fallback restore and manual compact#1268
eddieparc wants to merge 3 commits into
code-yeongyu:mainfrom
eddieparc:feat/session-recovery-commands

Conversation

@eddieparc

@eddieparc eddieparc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Add two explicit, session-only recovery surfaces:

  • /fallback restore returns the current session to the model and thinking level active before retry fallback;
  • /compact can explicitly override Claude SDK OAuth's automatic-compaction delegation.

This is intentionally separate from the persistence/root-cause fixes in #1267.

Behavior

/fallback restore

  • restores the pre-fallback model for this session;
  • restores the original thinking level when one was active;
  • clears active fallback state;
  • does not change global model or thinking defaults;
  • reports a warning when the session has no active fallback.

manual /compact

Claude SDK OAuth continues to own all automatic compaction reasons. Only reason: "manual" bypasses the lane delegation, providing an escape hatch when SDK-native compaction did not fire.

The existing post-compaction continuity path remains unchanged: the binding is tainted, the next turn forks at the last assistant boundary, and delta traffic resumes after re-attachment.

Failing-first proof

  • disabling the restore command leaves the session on faux-2; the host-wiring test expects restoration to faux-1;
  • before the manual override, the alignment test fails with expected 'external-owner' not to be 'external-owner'.

Verification

  • focused recovery/compaction scope: 75 files, 505 tests passed;
  • bun run check: passed;
  • TUI smoke through a real tmux PTY: 5/5;
  • Anthropic mock-loop real CLI channel: 22/22, localhost-only, real auth unchanged;
  • earlier installed-engine QA compacted 64,779 tokens to 368 and continued with a 95.4% cache hit.

QA evidence is stored locally under local-ignore/qa-evidence/20260902-recovery-commands/.


Summary by cubic

Adds /fallback restore so a session can return to the model used before retry fallback, restoring the prior thinking level when one was active. Also makes a manual /compact override claude-sdk-oauth's automatic-compaction delegation; previously every /compact on that lane was rejected with external-owner. Both commands are session-only and leave global defaults unchanged.

New Features

  • /fallback restore clears active fallback state and warns when no fallback is active.
  • manual is the only compaction reason that bypasses the lane delegation; all automatic reasons are still delegated to the SDK.

Written for commit 23150e5. Summary will update on new commits.

Review in cubic

eddieparc and others added 3 commits September 2, 2026 12:20
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts">

<violation number="1" location="packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts:238">
P3: The manual-compaction test only asserts that the result is not rejected as `external-owner`, so it also passes when the handler returns `undefined` and no compaction actually ran. Since this test is the failing-first proof for the `/compact` override, assert the positive outcome (e.g. `result?.rejectionCause` is undefined / the handler returns a compaction or ran `applyCompaction`) instead of only the absence of the lane rejection.</violation>
</file>

<file name="packages/coding-agent/src/core/agent-session.ts">

<violation number="1" location="packages/coding-agent/src/core/agent-session.ts:6887">
P2: When the original model disappears from the registry, `/fallback restore` reports that no fallback is active while leaving the fallback state active. Return a distinct unavailable result or surface the unresolved original model instead of mapping this case to inactive.</violation>

<violation number="2" location="packages/coding-agent/src/core/agent-session.ts:6890">
P2: When the model-select step fails, `setSessionModel` has already cleared `_retryFallback`; the runtime rolls back to the fallback model, but `/fallback restore` can no longer retry it. Use the fallback-revert switch path and clear the controller only after the switch succeeds.</violation>

<violation number="3" location="packages/coding-agent/src/core/agent-session.ts:6890">
P3: Restore bypasses the retry controller's revert semantics. It clears state only via the side effect of `setSessionModel` -> `clearForManualModelChange` (which does not emit), so no `retry_fallback_reverted` event fires and the switch is recorded as an ordinary manual model change rather than a fallback revert. Consider restoring through a controller-level revert/`switchModel(...,"fallback-revert")` so history and the `retry_fallback_reverted` event stay consistent.</violation>

<violation number="4" location="packages/coding-agent/src/core/agent-session.ts:6890">
P2: `restoreFallbackPrimary` delegates to `setSessionModel`, which throws (auth missing via `checkAuth`, model unusable via `assertModelUsable`, or `emitModelSelect` failure) instead of returning false. The `/fallback restore` handler awaits it without try/catch, so such a failure surfaces as an uncaught command error rather than the graceful boolean/warning path, and on the auth-failure path the fallback state is left uncleared. Catch errors here and return false so the command reports "could not restore" instead of throwing.</violation>

<violation number="5" location="packages/coding-agent/src/core/agent-session.ts:6891">
P2: A restore interrupted after `setSessionModel` persists its model entry but before the next line persists the original thinking level reloads with the wrong thinking level. Apply the original level through the existing `fallback-revert` transition so model and thinking restoration are one persisted operation.</violation>
</file>

<file name="packages/coding-agent/src/core/extensions/builtin/compaction/index.ts">

<violation number="1" location="packages/coding-agent/src/core/extensions/builtin/compaction/index.ts:564">
P2: When `/compact` fails on the Claude SDK lane, the session-compaction failure handler still treats the lane as externally owned and does not debit the circuit breaker. Count failures for the newly senpi-owned `manual` route as well, so repeated failures remain protected after switching to a senpi-owned provider.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const active = this._retryFallback.activeState;
if (!active) return false;
const selector = parseFallbackSelector(active.originalSelector, this._modelRegistry);
const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the original model disappears from the registry, /fallback restore reports that no fallback is active while leaving the fallback state active. Return a distinct unavailable result or surface the unresolved original model instead of mapping this case to inactive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 6887:

<comment>When the original model disappears from the registry, `/fallback restore` reports that no fallback is active while leaving the fallback state active. Return a distinct unavailable result or surface the unresolved original model instead of mapping this case to inactive.</comment>

<file context>
@@ -6880,6 +6880,17 @@ export class AgentSession {
+						const active = this._retryFallback.activeState;
+						if (!active) return false;
+						const selector = parseFallbackSelector(active.originalSelector, this._modelRegistry);
+						const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
+						if (!model) return false;
+						const originalThinkingLevel = active.originalThinkingLevel;
</file context>

if (!model) return false;
const originalThinkingLevel = active.originalThinkingLevel;
await this.setSessionModel(model);
if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A restore interrupted after setSessionModel persists its model entry but before the next line persists the original thinking level reloads with the wrong thinking level. Apply the original level through the existing fallback-revert transition so model and thinking restoration are one persisted operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 6891:

<comment>A restore interrupted after `setSessionModel` persists its model entry but before the next line persists the original thinking level reloads with the wrong thinking level. Apply the original level through the existing `fallback-revert` transition so model and thinking restoration are one persisted operation.</comment>

<file context>
@@ -6880,6 +6880,17 @@ export class AgentSession {
+						if (!model) return false;
+						const originalThinkingLevel = active.originalThinkingLevel;
+						await this.setSessionModel(model);
+						if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);
+						return true;
+					},
</file context>

const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
if (!model) return false;
const originalThinkingLevel = active.originalThinkingLevel;
await this.setSessionModel(model);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the model-select step fails, setSessionModel has already cleared _retryFallback; the runtime rolls back to the fallback model, but /fallback restore can no longer retry it. Use the fallback-revert switch path and clear the controller only after the switch succeeds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 6890:

<comment>When the model-select step fails, `setSessionModel` has already cleared `_retryFallback`; the runtime rolls back to the fallback model, but `/fallback restore` can no longer retry it. Use the fallback-revert switch path and clear the controller only after the switch succeeds.</comment>

<file context>
@@ -6880,6 +6880,17 @@ export class AgentSession {
+						const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
+						if (!model) return false;
+						const originalThinkingLevel = active.originalThinkingLevel;
+						await this.setSessionModel(model);
+						if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);
+						return true;
</file context>

// compaction overrides the delegation: senpi cannot observe that the SDK
// failed to compact, so `/compact` is the only way back under the limit
// once the delegated owner has not delivered.
if (!isLaneOverrideReason(event.reason) && lanePolicy.disablesSenpiCompaction(ctx)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When /compact fails on the Claude SDK lane, the session-compaction failure handler still treats the lane as externally owned and does not debit the circuit breaker. Count failures for the newly senpi-owned manual route as well, so repeated failures remain protected after switching to a senpi-owned provider.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/extensions/builtin/compaction/index.ts, line 564:

<comment>When `/compact` fails on the Claude SDK lane, the session-compaction failure handler still treats the lane as externally owned and does not debit the circuit breaker. Count failures for the newly senpi-owned `manual` route as well, so repeated failures remain protected after switching to a senpi-owned provider.</comment>

<file context>
@@ -556,7 +557,11 @@ export default function compactionExtension(
+			// compaction overrides the delegation: senpi cannot observe that the SDK
+			// failed to compact, so `/compact` is the only way back under the limit
+			// once the delegated owner has not delivered.
+			if (!isLaneOverrideReason(event.reason) && lanePolicy.disablesSenpiCompaction(ctx)) {
 				return {
 					cancel: true,
</file context>

const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
if (!model) return false;
const originalThinkingLevel = active.originalThinkingLevel;
await this.setSessionModel(model);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: restoreFallbackPrimary delegates to setSessionModel, which throws (auth missing via checkAuth, model unusable via assertModelUsable, or emitModelSelect failure) instead of returning false. The /fallback restore handler awaits it without try/catch, so such a failure surfaces as an uncaught command error rather than the graceful boolean/warning path, and on the auth-failure path the fallback state is left uncleared. Catch errors here and return false so the command reports "could not restore" instead of throwing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 6890:

<comment>`restoreFallbackPrimary` delegates to `setSessionModel`, which throws (auth missing via `checkAuth`, model unusable via `assertModelUsable`, or `emitModelSelect` failure) instead of returning false. The `/fallback restore` handler awaits it without try/catch, so such a failure surfaces as an uncaught command error rather than the graceful boolean/warning path, and on the auth-failure path the fallback state is left uncleared. Catch errors here and return false so the command reports "could not restore" instead of throwing.</comment>

<file context>
@@ -6880,6 +6880,17 @@ export class AgentSession {
+						const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
+						if (!model) return false;
+						const originalThinkingLevel = active.originalThinkingLevel;
+						await this.setSessionModel(model);
+						if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);
+						return true;
</file context>

// The delegation covers senpi's AUTOMATIC compaction only. `/compact` is the
// user's escape hatch for the case the lane cannot detect: the SDK did not
// compact and the session has no other way back under the limit.
expect(result?.rejectionCause).not.toBe("external-owner");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The manual-compaction test only asserts that the result is not rejected as external-owner, so it also passes when the handler returns undefined and no compaction actually ran. Since this test is the failing-first proof for the /compact override, assert the positive outcome (e.g. result?.rejectionCause is undefined / the handler returns a compaction or ran applyCompaction) instead of only the absence of the lane rejection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts, line 238:

<comment>The manual-compaction test only asserts that the result is not rejected as `external-owner`, so it also passes when the handler returns `undefined` and no compaction actually ran. Since this test is the failing-first proof for the `/compact` override, assert the positive outcome (e.g. `result?.rejectionCause` is undefined / the handler returns a compaction or ran `applyCompaction`) instead of only the absence of the lane rejection.</comment>

<file context>
@@ -227,6 +227,17 @@ describe("claude-sdk-oauth lane: senpi compaction stands down", () => {
+		// The delegation covers senpi's AUTOMATIC compaction only. `/compact` is the
+		// user's escape hatch for the case the lane cannot detect: the SDK did not
+		// compact and the session has no other way back under the limit.
+		expect(result?.rejectionCause).not.toBe("external-owner");
+	});
+
</file context>
Suggested change
expect(result?.rejectionCause).not.toBe("external-owner");
expect(result?.rejectionCause).toBeUndefined();
expect(harness.applyCompaction).toHaveBeenCalled();

const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
if (!model) return false;
const originalThinkingLevel = active.originalThinkingLevel;
await this.setSessionModel(model);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Restore bypasses the retry controller's revert semantics. It clears state only via the side effect of setSessionModel -> clearForManualModelChange (which does not emit), so no retry_fallback_reverted event fires and the switch is recorded as an ordinary manual model change rather than a fallback revert. Consider restoring through a controller-level revert/switchModel(...,"fallback-revert") so history and the retry_fallback_reverted event stay consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 6890:

<comment>Restore bypasses the retry controller's revert semantics. It clears state only via the side effect of `setSessionModel` -> `clearForManualModelChange` (which does not emit), so no `retry_fallback_reverted` event fires and the switch is recorded as an ordinary manual model change rather than a fallback revert. Consider restoring through a controller-level revert/`switchModel(...,"fallback-revert")` so history and the `retry_fallback_reverted` event stay consistent.</comment>

<file context>
@@ -6880,6 +6880,17 @@ export class AgentSession {
+						const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
+						if (!model) return false;
+						const originalThinkingLevel = active.originalThinkingLevel;
+						await this.setSessionModel(model);
+						if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);
+						return true;
</file context>

@code-yeongyu

Copy link
Copy Markdown
Owner

Thanks @eddieparc — the compact-override diagnosis is still right, and it is not superseded.

What already landed, so we do not double-count it:

Remaining work on this PR:

  1. Product decision (restore): /fallback restore is a new session command. Auto-revert (maybeRestorePrimary) and /model already exist. The maintainer decision on an explicit restore is being surfaced separately; until then treat it as undecided. Please decide whether we want an explicit restore that bypasses never / pin / cooldown. If no, drop the restore half (agent-session.ts binding, types.ts / runner.ts seam, model-fallback command, host-wiring test, core/extensions changes.md entries, Added changelog bullet) and keep this PR as the compact override only.
  2. Keep the compact override, rebase onto main (6dd662c4b+), and resolve the mechanical doc conflicts (CHANGELOG.md Unreleased, compaction/changes.md stacked above the fix(compaction): recover manual compaction with deterministic fallback #1273 entry, core/changes.md). Do not edit fix(compaction): recover manual compaction with deterministic fallback #1273's tracker text.
  3. Fix the compact test: assert a positive outcome (not cancelled / compaction produced), not rejectionCause !== "external-owner".
  4. Debit the breaker for a failed senpi-owned manual compact on the SDK lane (session_compact currently skips recordFailure while disablesSenpiCompaction(ctx)).
  5. If restore is approved: do not call setSessionModel (throws; clears via clearForManualModelChange; no retry_fallback_reverted; thinking not atomic). Revert through the controller switchModel(..., "fallback-revert") path; distinct error when the original model is gone; catch failures instead of mapping them to "no active fallback."
  6. Re-verify SDK-lane /compact → taint → fork against fix(claude-sdk-oauth): never resume an SDK session id that init never confirmed #1318 confirmed-id resume.

Suggested tests after rebase (must pass in one run): bun test packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts; if restore stays, bun test packages/coding-agent/test/suite/model-fallback-command.test.ts packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts; then bun run check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants