fix(session): carry attribution across a fork

Codex found the session-id anchor too blunt. It assumes a new id means an
unrelated transcript, which holds for `/new` and for resuming something
else — but `fork()` mints a fresh id while cloning the transcript and
keeping the same recovery state running. Attribution and routing both
expired there, so immediately after `/fork` an unproven fallback
bootstrapped as the current model with `isFallback: false`: the run was
re-credited to a model that never produced any of it, and mislabelled as
the configured primary. Exactly the bug the anchor exists to prevent,
reopened for the one switch that is a continuation.

`AgentSession.fork()` now re-tags both onto the new id after the fork
succeeds, moving only state that belonged to the pre-fork id so an id left
behind by an earlier switch stays expired.
This commit is contained in:
enieuwy
2026-08-08 15:57:31 +08:00
parent ae5965241e
commit a5be3ea0df
3 changed files with 65 additions and 0 deletions
@@ -6396,6 +6396,7 @@ export class AgentSession {
async fork(): Promise<boolean> {
this.#assertVibeSessionTransitionAllowed("fork the session");
const previousSessionFile = this.sessionFile;
const previousSessionId = this.sessionManager.getSessionId();
// Emit session_before_switch event with reason "fork" (can be cancelled)
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -6434,6 +6435,9 @@ export class AgentSession {
}
this.#bash.markSessionTransition(bashTransition);
this.#bash.finishSessionTransition(bashTransition, true);
// The fork clones the transcript and keeps this recovery state running
// under a fresh id, so the work already produced is still this session's.
this.#recovery.reanchorServedAttribution(previousSessionId);
// Copy artifacts directory if it exists
const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
@@ -284,6 +284,29 @@ export class TurnRecovery {
return value;
}
/**
* Carries attribution onto a new session id that continues this conversation.
*
* The session-id anchor assumes a new id means an unrelated transcript, which
* holds for `/new` and for resuming something else. A fork breaks that
* assumption on purpose: it clones the transcript and keeps running the same
* recovery state under a fresh id. Dropping attribution there would bootstrap
* an unproven fallback as the primary and re-credit it with the work the
* previous model did — the very bug the anchor exists to prevent.
*
* Only state belonging to `previousSessionId` moves, so an id left behind by
* an earlier switch stays expired.
*/
reanchorServedAttribution(previousSessionId: string): void {
const sessionId = this.#host.sessionManager.getSessionId();
if (this.#lastServed?.sessionId === previousSessionId) {
this.#lastServed = { ...this.#lastServed, sessionId };
}
if (this.#fallbackRoutedFor === previousSessionId) {
this.#fallbackRoutedFor = sessionId;
}
}
/** Resets per-prompt recovery counters and terminal-stop acceptance. */
resetForNewPrompt(): void {
this.#emptyStopRetryCount = 0;
@@ -4296,6 +4296,44 @@ describe("AgentSession retry fallback", () => {
});
});
it("carries attribution across a fork, which continues the conversation under a new id", async () => {
using tempDir = TempDir.createSync("@omp-fallback-fork-");
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.fallbackChains": { default: [`${fallbackModel.provider}/${fallbackModel.id}`] },
});
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
await session.prompt("Fail over to the fallback");
await session.waitForIdle();
const served = {
selector: `${fallbackModel.provider}/${fallbackModel.id}`,
isFallback: true,
};
expect(session.servingModel).toEqual(served);
const sessionIdBeforeFork = sessionManager.getSessionId();
expect(await session.fork()).toBe(true);
expect(sessionManager.getSessionId()).not.toBe(sessionIdBeforeFork);
// A fork clones the transcript and keeps running the same session, so the
// work the fallback produced is still this session's — unlike a switch to
// an unrelated transcript, which expires it.
expect(session.servingModel).toEqual(served);
});
it("keeps attribution on a served fallback while the next candidate is unproven", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const firstFallback = getBundledModel("openai", "gpt-4o-mini");