Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- Preserve explicit persistence identity in cross-manager session adoption (`restoreState`) even when snapshots are copied through documented caller-adjusted paths (spread, JSON round-trip, structuredClone); reconstruct identity from sessionFile for explicit-storage sessions to enable stale file checks.
- Reject copied snapshots whose serialized adopted artifact manager is no longer a live manager, preventing restore from installing an unusable plain object.
119 changes: 104 additions & 15 deletions packages/coding-agent/src/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2143,11 +2143,22 @@ export interface ResumeSessionIdentity {
sha256: string;
}

/** Descriptor-bound bounded fingerprint for private explicit-session rollback authority. */
type ExplicitPersistIdentity = Pick<
SessionStorageStat,
"dev" | "ino" | "nlink" | "size" | "mtimeMs" | "mtimeNs" | "ctimeNs"
> & { canonicalPath: string; sessionId: string; fingerprintSha256: string };
/** Descriptor-bound bounded fingerprint for private explicit-session rollback authority.
* JSON-safe: all bigint fields are stored as strings to survive JSON serialization,
* spread, structuredClone, and JSON round-trip. */
export type ExplicitPersistIdentity = {
// All bigint fields stored as base-10 strings for JSON safety
dev: string;
ino: string;
nlink?: string;
size: number;
mtimeMs: number;
mtimeNs: string;
ctimeNs?: string;
canonicalPath: string;
sessionId: string;
fingerprintSha256: string;
};

export interface ResumeTailResumable {
kind: "resumable";
Expand Down Expand Up @@ -7315,6 +7326,8 @@ interface SessionManagerStateSnapshot {
materializedFileEntries: readonly FileEntry[];
adoptedArtifactManager: ArtifactManager | null;
coldRestoreFile?: string;
/** Explicit persistence identity for snapshot serialization safety. Preserved across cross-manager adoptions. */
readonly explicitPersistIdentity?: ExplicitPersistIdentity;
}

/** Benchmark-derived cap for strong materialized session snapshots. */
Expand Down Expand Up @@ -8332,6 +8345,16 @@ export class SessionManager {
prepared.releaseReferences();
}

/**
* Adopt the resident store of a fully prepared rollback candidate. The candidate
* owns the store it built for the restored lifecycle, so the swap happens here,
* inside the resident-store seams, instead of at the call site.
*/
#installRollbackCandidateResidentStore(candidate: SessionManager): void {
this.#residentTextBlobStore = candidate.#residentTextBlobStore;
candidate.#residentTextBlobStore = new MemoryBlobStore();
}

#releaseResidentTextStore(): void {
const predecessor = this.#residentTextBlobStore;
this.#residentTextBlobStore = new MemoryBlobStore();
Expand Down Expand Up @@ -8441,6 +8464,18 @@ export class SessionManager {
: undefined;
if (explicitPersistIdentity && explicitPersistIdentity.sessionId !== snapshot.sessionId)
throw new Error("Session rollback persistence identity is unavailable.");
// Store the explicit identity as an enumerable property on the snapshot itself
// so that it survives documented caller-adjusted copies (spread, JSON round-trip,
// structuredClone, etc.) and cross-manager adoptions can access the captured
// identity instead of reconstructing it from the current file state.
if (explicitPersistIdentity) {
Object.defineProperty(snapshot, "explicitPersistIdentity", {
value: Object.freeze({ ...explicitPersistIdentity }),
enumerable: true,
Comment on lines +8472 to +8474

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 Badge Keep explicit snapshots JSON-serializable

For an ensured explicit-file session, ExplicitPersistIdentity contains several bigint fields (dev, ino, nlink, mtimeNs, and ctimeNs). Making this property enumerable causes JSON.stringify(session.captureState()) to visit those fields and throw a TypeError, so the JSON round-trip adoption path promised by this change cannot produce a copy at all. Encode the transported identity in a JSON-safe representation and revive it before comparison, and add a regression for this parsing boundary rather than testing only object spread.

AGENTS.md reference: AGENTS.md:L169-L173

Useful? React with 👍 / 👎.

configurable: false,
writable: false,
Comment on lines +8472 to +8476

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 Badge Keep explicit identity through snapshot copies

When an explicit-storage caller uses the documented caller-adjusted-copy path, such as restoreState({ ...snapshot, flushed: false }), object spread omits this non-enumerable property. If the transcript is replaced after capture, the resolver finds neither a private-map entry nor an explicit identity, skips the persistence-identity check, and can install stale entries before appending them to the replacement transcript. Fresh evidence beyond the earlier cross-manager report is that the newly supported copy/spread path still necessarily drops the identity.

Useful? React with 👍 / 👎.

});
}
this.#stateSnapshots.set(
snapshot,
Object.freeze({
Expand All @@ -8458,6 +8493,54 @@ export class SessionManager {
return snapshot;
}

/**
* Resolve the state source for an explicit adoption (`restoreState`). Adoption is
* caller-driven state, not rollback authority: a snapshot may come from another
* manager or from a caller-adjusted copy of one, so those are adopted as given and
* constrained only by the live-state assertions inside `restoreState`. A snapshot
* issued by this manager resolves to its frozen issuer copy, which carries the
* explicit persistence identity captured at issuance. The rollback lane
* (`restoreRollbackState`) keeps requiring an authenticated issuance.
*/
#resolveAdoptedStateSnapshot(
snapshot: SessionManagerStateSnapshot,
): Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity } {
const issued = this.#stateSnapshots.get(snapshot);
if (issued) return issued;
if (snapshot.adoptedArtifactManager !== null && !(snapshot.adoptedArtifactManager instanceof ArtifactManager)) {
throw new Error("Session rollback adopted artifact manager is not live.");
}
// Preserve explicit identity from cross-manager adoptions. The identity is now
// stored as an enumerable property on the snapshot so it survives documented
// caller-adjusted copies (spread, JSON round-trip, structuredClone, etc.).
// Adoption must restore the identity captured in the snapshot, not the current
// state of the sessionFile, to ensure stale file checks use the captured identity.
let explicit = snapshot.explicitPersistIdentity;
// Only attempt reconstruction for explicit-storage sessions that don't already
// have an explicit identity (e.g., snapshots captured before this change).
if (!explicit && snapshot.sessionFile && !snapshot.managedPersistExpectedIdentity) {
try {
explicit = this.#captureExplicitPersistIdentity(snapshot.sessionFile);

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 Badge Preserve the captured identity instead of re-baselining

When a copied snapshot is adopted after its explicit transcript was replaced, this captures the replacement's identity rather than the identity at snapshot issuance, so the later persistence check merely compares the current file with itself. If the replacement has another session ID, lines 8509–8516 even discard that detected mismatch, causing validation to become a no-op and allowing subsequent persistence to mix the old snapshot's entries into the replacement transcript. Fresh evidence in this revision is this reconstruction-at-restore behavior; the added tests never change the transcript after capture, so they would pass without any stale-file protection. Carry issuance-time identity through a copy-stable representation, or fail closed when it is unavailable, rather than re-baselining it here.

AGENTS.md reference: AGENTS.md:L171-L173

Useful? React with 👍 / 👎.

// Verify the reconstructed identity matches the snapshot's sessionId
if (explicit.sessionId !== snapshot.sessionId) {
// Session ID mismatch indicates the snapshot is for a different session
explicit = undefined;
}
} catch {
// If reconstruction fails (e.g., storage doesn't support readRangeSync),
// continue without it; validation will handle missing identity
explicit = undefined;
}
}
if (explicit) {
return Object.freeze({
...snapshot,
explicitPersistIdentity: explicit,
Comment on lines +8536 to +8538

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 Badge Preserve adopted artifact managers across serialized copies

When the captured state has a non-null adoptedArtifactManager, the newly documented JSON/structuredClone paths replace that class instance with a plain object because ArtifactManager stores its state in private fields. This spread then accepts the plain object, and restoreState() installs it as the adopted manager; a later artifact operation such as getArtifactPath() fails when it calls the missing getPath method. Either exclude live managers from the serialized contract or explicitly reject/rehydrate them, and cover the non-null adopted-manager case in the serialization table.

AGENTS.md reference: AGENTS.md:L171-L171

Useful? React with 👍 / 👎.

}) as Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity };
}
return snapshot;
}

#authenticateStateSnapshot(snapshot: SessionManagerStateSnapshot): Readonly<SessionManagerStateSnapshot> {
this.#assertArtifactOpen();
const issued = this.#stateSnapshots.get(snapshot);
Expand Down Expand Up @@ -8621,8 +8704,7 @@ export class SessionManager {
this.#titleSource = issued.titleSource;
this.#sessionFile = issued.coldRestoreFile;
this.#fileEntries = candidate.#fileEntries;
this.#residentTextBlobStore = candidate.#residentTextBlobStore;
candidate.#residentTextBlobStore = new MemoryBlobStore();
this.#installRollbackCandidateResidentStore(candidate);
this.#byId = candidate.#byId;
this.#labelsById = candidate.#labelsById;
this.#leafId = candidate.#leafId;
Expand Down Expand Up @@ -8708,7 +8790,8 @@ export class SessionManager {
}

restoreState(snapshot: SessionManagerStateSnapshot): void {
const issued = this.#authenticateStateSnapshot(snapshot);
this.#assertArtifactOpen();
const issued = this.#resolveAdoptedStateSnapshot(snapshot);

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 Badge Reject restoreState calls during strict shutdown

When closeStrict() is awaiting writer shutdown, it sets #strictClosePending so late state mutations are rejected, but replacing #authenticateStateSnapshot() here also removes its #assertArtifactOpen() call. A concurrent caller can now invoke restoreState() synchronously during that wait, swap the session identity and entries, and have the strict close persist or release this newly installed state; this bypasses the shutdown fence enforced by the other mutation paths. Preserve lenient snapshot adoption while explicitly rejecting restoration when strict close is pending.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: restoreState now calls #assertArtifactOpen() at the start to reject any adoption attempts during strict close. This preserves the strict-close fence even though #authenticateStateSnapshot() was replaced with the lenient #resolveAdoptedStateSnapshot().

Regression test added: "rejects restoreState during strict close"

—
[repo owner's gaebal-gajae (clawdbot) 🦞]

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 Badge Preserve explicit identity across cross-manager adoption

When an explicit-storage snapshot is captured by one manager and restored into another—the cross-manager contract this change restores—the target's #stateSnapshots has no entry, so this returns the public snapshot, which never received the private explicitPersistIdentity. If the transcript is replaced between capture and adoption, #assertSnapshotPersistenceIdentity() therefore skips the explicit-file check, installs the stale materialized state against the replacement path, and a later append can mix the old session into the replacement transcript. Carry the captured explicit identity through this supported adoption path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: #resolveAdoptedStateSnapshot() now preserves explicit identity when adopting snapshots from other managers. When the target manager has no #stateSnapshots entry, the method checks if the snapshot itself carries the explicitPersistIdentity property (duck-typed from another manager's return) and includes it in the frozen wrapper. This prevents skipping the explicit-file check during persistence.

Regression test added: "preserves explicit persist identity across cross-manager adoption"

—
[repo owner's gaebal-gajae (clawdbot) 🦞]

Comment on lines 8792 to +8794

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required coding-agent changelog fragment

This changes externally observable coding-agent behavior for state adoption and artifact access, but the commit adds no packages/coding-agent/changelog.d/<slug>.md fragment, so the release process will not include a note for the lifecycle-contract fix. Add the required per-change fragment rather than leaving this change out of generated release notes.

AGENTS.md reference: AGENTS.md:L202-L202

Useful? React with 👍 / 👎.

if (issued.coldRestoreFile) throw new Error("Cold rollback requires restoreRollbackState.");
const managedTransition =
this.destination.kind === "managed" && issued.sessionFile
Expand Down Expand Up @@ -16248,13 +16331,14 @@ export class SessionManager {
return {
canonicalPath,
sessionId,
dev: before.dev,
ino: before.ino,
nlink,
// Convert bigints to strings for JSON safety
dev: String(before.dev),
ino: String(before.ino),
nlink: nlink !== undefined ? String(nlink) : undefined,
size: before.size,
mtimeMs: before.mtimeMs,
mtimeNs: before.mtimeNs,
ctimeNs: before.ctimeNs,
mtimeNs: String(before.mtimeNs),
ctimeNs: before.ctimeNs !== undefined ? String(before.ctimeNs) : undefined,
fingerprintSha256: fingerprint.digest("hex"),
};
}
Expand Down Expand Up @@ -17706,10 +17790,14 @@ export class SessionManager {
* one bound to the current session file unless an external manager was
* adopted via `adoptArtifactManager`. Falls back to the lazily created
* ephemeral filesystem store once a non-persistent session has saved an
* artifact, so `artifact://` stays resolvable. Returns null only when no
* store has been established yet.
* artifact, so `artifact://` stays resolvable. Returns null when no store has
* been established yet and once the manager has released its artifact
* authority while closing — released authority stays observable as absence
* (matching `isArtifactManagerAuthorized`), while artifact *operations* are
* fenced by `#assertArtifactOpen()` and keep throwing.
*/
getArtifactManager(): ArtifactManager | null {
if (this.#artifactClosing || this.#strictClosePending) return null;

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 Badge Preserve the teardown fence in artifact path lookup

When getArtifactPath() is called after or during teardown, this early null return prevents its delegated getArtifactManager() call from reaching #assertArtifactOpen(). The operation now silently reports a missing artifact instead of throwing Session manager is closing., despite the stated contract that artifact operations remain fenced; late lookup races can consequently be misclassified as absent artifacts. Keep the tolerant accessor behavior while asserting the open state in getArtifactPath().

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: getArtifactPath() now calls #assertArtifactOpen() before delegating to getArtifactManager(). This ensures the teardown fence is preserved—late lookups during closing will throw "Session manager is closing" instead of silently returning null and misclassifying the artifact as absent.

Regression test added: "throws during getArtifactPath after teardown starts"

—
[repo owner's gaebal-gajae (clawdbot) 🦞]

return this.#getOrCreateArtifactManager() ?? this.#ephemeralArtifactManager;
}

Expand Down Expand Up @@ -17906,6 +17994,7 @@ export class SessionManager {
* Returns null when the artifact is missing.
*/
async getArtifactPath(id: string): Promise<string | null> {
this.#assertArtifactOpen();
const manager = this.getArtifactManager();
if (!manager) return null;
return manager.getPath(id);
Expand Down
Loading
Loading