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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user