Merge PR #8664: fix(coding-agent): preserve artifacts in CLI forks (@anatoli-tsinovoy)

This commit is contained in:
can1357
2026-08-16 02:03:15 +02:00
6 changed files with 130 additions and 29 deletions
@@ -328,7 +328,7 @@ import {
SessionMaintenance,
type SessionMaintenanceHost,
} from "./session-maintenance";
import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
import { cleanupEmptyMoveSession, copySessionArtifacts, type SessionManager } from "./session-manager";
import { SessionMemory, type SessionMemoryHost } from "./session-memory";
import { buildSessionMetadata } from "./session-metadata";
import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary";
@@ -6700,24 +6700,7 @@ export class AgentSession {
// 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);
const newArtifactDir = forkResult.newSessionFile.slice(0, -6);
try {
const oldDirStat = await fs.promises.stat(oldArtifactDir);
if (oldDirStat.isDirectory()) {
await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true });
}
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to copy artifacts during fork", {
oldArtifactDir,
newArtifactDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
await copySessionArtifacts(forkResult.oldSessionFile, forkResult.newSessionFile);
// Update agent session ID
this.#freshProviderSessionId = undefined;
@@ -97,7 +97,31 @@ function fileSafeTimestamp(iso: string): string {
}
function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
if (!sessionFile?.endsWith(".jsonl")) return null;
return sessionFile.slice(0, -JSONL_SUFFIX_LENGTH);
}
/** Copy a session's artifact directory to another session, matching interactive `/fork`. */
export async function copySessionArtifacts(sourceSessionFile: string, destinationSessionFile: string): Promise<void> {
const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile);
const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile);
if (!sourceArtifactsDir || !destinationArtifactsDir) return;
if (path.resolve(sourceArtifactsDir) === path.resolve(destinationArtifactsDir)) return;
try {
const sourceStat = await fs.promises.stat(sourceArtifactsDir);
if (sourceStat.isDirectory()) {
await fs.promises.cp(sourceArtifactsDir, destinationArtifactsDir, { recursive: true });
}
} catch (error) {
if (!isEnoent(error)) {
logger.warn("Failed to copy artifacts during fork", {
sourceArtifactsDir,
destinationArtifactsDir,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
@@ -1440,10 +1464,13 @@ export class SessionManager {
const oldSessionFile = this.#sessionFile;
const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile);
const newArtifactsDir = artifactsDirectoryFor(newSessionFile);
const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
const artifactPathChanged =
oldArtifactsDir !== null &&
newArtifactsDir !== null &&
path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
sessionFileExisted = this.#storage.existsSync(oldSessionFile);
let sessionMoved = false;
@@ -1467,7 +1494,7 @@ export class SessionManager {
}
}
} catch (err) {
if (artifactsMoved) {
if (artifactsMoved && oldArtifactsDir && newArtifactsDir) {
try {
await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
} catch (rollbackErr) {
@@ -2533,16 +2560,16 @@ export class SessionManager {
* session file while creating a fresh session file in this sessionDir.
*
* `options.sessionFile` pins the new session's file path (default: an
* auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Callers that register
* the fork as a named agent (e.g. `/tan`) pass `<agentId>.jsonl` so the
* persisted-subagent scan keys the agent by the same id the live ref uses.
* auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Artifacts are copied
* recursively by default; nested agents that deliberately share their parent's
* artifact root may disable this with `copyArtifacts: false`.
*/
static async forkFrom(
sourcePath: string,
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
options?: { suppressBreadcrumb?: boolean; sessionFile?: string },
options?: { copyArtifacts?: boolean; suppressBreadcrumb?: boolean; sessionFile?: string },
): Promise<SessionManager> {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const manager = new SessionManager(cwd, dir, true, storage);
@@ -2575,6 +2602,9 @@ export class SessionManager {
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
manager.#forceFileCreation = true;
await manager.#rewriteAtomically();
if (options?.copyArtifacts !== false) {
await copySessionArtifacts(sourcePath, manager.#sessionFile!);
}
return manager;
}