Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/runtime-host/src/__tests__/execution-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,10 +951,27 @@ test('production composition reaches Ready when the optional context Store canno
true,
);
await composition.recover();
// Recovery defers the physical purge out of the ready path (issue #4027):
// nothing discards the copy before Ready, so no cleanup diagnostic runs.
assert.equal(
diagnostics.some((message) =>
message.includes('conversation copy cleanup deferred during recovery'),
),
false,
);
// The maintenance lane takes the queued discard after Ready. This
// environment cannot complete the purge (the optional context store is
// closed), so the lane reports the retry and leaves the copy untouched.
composition.startMaintenance!();
const retryDeadline = Date.now() + 5_000;
while (
Date.now() < retryDeadline &&
!diagnostics.some((message) => message.includes('deferred session discard will retry'))
) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(
diagnostics.some((message) => message.includes('deferred session discard will retry')),
true,
);
} finally {
Expand All @@ -975,6 +992,59 @@ test('production composition reaches Ready when the optional context Store canno
});
});

test('deferred conversation-copy discards drain through post-ready maintenance', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const requestFingerprint = `sha256:${'b'.repeat(64)}` as const;
const preparingSessionId = 'preparing-branch-copy';
const sessionStore = createSessionStore(root);
await sessionStore.createStableSession({
sessionId: preparingSessionId,
requestFingerprint,
input: {
cwd: root,
llmConnectionId: FAKE_CONNECTION_ID,
llmConnectionSlug: 'fake',
model: 'fake-model',
permissionMode: 'ask',
name: 'Preparing branch copy',
labels: [],
parentSessionId: 'source-session',
branchOfTurnId: 'source-turn',
conversationCopy: {
kind: 'branch',
sourceSessionId: 'source-session',
sourceTurnId: 'source-turn',
requestFingerprint,
state: 'preparing',
},
},
});
await sessionStore.close?.();
const composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
try {
await composition.recover();
composition.startMaintenance!();
// The first lane tick runs ~100 ms after Ready; give it time to land.
await new Promise((resolve) => setTimeout(resolve, 500));
} finally {
await composition.close();
}
const reopened = createSessionStore(root);
try {
let discarded = false;
try {
await reopened.readHeaderSnapshot(preparingSessionId);
} catch {
// The deferred discard removed the Session after Ready.
discarded = true;
}
assert.equal(discarded, true);
} finally {
await reopened.close?.();
}
});
});

test('production composition closes long-term memory after a later startup failure', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
Expand Down
40 changes: 40 additions & 0 deletions packages/runtime-host/src/__tests__/storage-maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,46 @@ test('the retention lane sweeps a second apart while work remains and every quar
await maintenance.close();
});

test('the deferred session discard lane drains recovery-deferred copies, then idles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const queue = [true, true, false];
let sweeps = 0;
const maintenance = new HostStorageMaintenance({
artifacts: {
reclaimUpgradeResidue: async () => ({
nextAfter: null,
processedPaths: 0,
failedPaths: 0,
}),
},
sessionDiscards: {
sweep: async () => {
sweeps += 1;
return queue.shift() ?? false;
},
},
onError: assert.fail,
});
maintenance.start();
t.mock.timers.tick(99);
await settle();
assert.equal(sweeps, 0);
t.mock.timers.tick(1);
await settle();
assert.equal(sweeps, 1);
t.mock.timers.tick(100);
await settle();
assert.equal(sweeps, 2);
t.mock.timers.tick(100);
await settle();
assert.equal(sweeps, 3);
// Queue drained: the lane idles on a minute instead of polling every 100 ms.
t.mock.timers.tick(100);
await settle();
assert.equal(sweeps, 3);
await maintenance.close();
});

test('context-offload page reclamation lane runs with bounded batches', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let reclaimCalls = 0;
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2868,6 +2868,7 @@ export async function createExecutionRuntimeHostComposition(
artifacts: openedArtifactStore,
contextOffload: openedContextOffloadStore,
retention: archiveRetention,
sessionDiscards: { sweep: () => sessionRevisions.drainPendingDiscards() },
onError: (name, error) =>
console.error(`[runtime-host] ${name} will retry: ${generalizedErrorMessage(error)}`),
});
Expand Down
65 changes: 58 additions & 7 deletions packages/runtime-host/src/server/session-revision-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export class HostSessionRevisionCoordinator {
readonly #stores: ExecutionStoresWriter<'interactive'>;
readonly #artifacts: InteractiveArtifactStoreWriter;
readonly #sessionTodo: InteractiveSessionTodoWriter;
/**
* Conversation copies that recovery decided must be discarded, waiting for
* the post-ready maintenance lane. The durable headers stay the authority:
* an entry that never drains (crash before Ready) is re-derived by the next
* recovery pass, and every reader treats a preparing copy as inert in the
* meantime.
*/
readonly #pendingDiscards: string[] = [];

constructor(private readonly options: HostSessionRevisionCoordinatorOptions) {
this.#stores = authenticateExecutionStoresWriter(options.stores, 'interactive');
Expand All @@ -138,7 +146,7 @@ export class HostSessionRevisionCoordinator {
(header) => header.conversationCopy !== undefined,
);
for (const header of copies) {
if (header.conversationCopy!.state === 'preparing') await this.#discardDuringRecovery(header);
if (header.conversationCopy!.state === 'preparing') this.#deferDiscard(header);
}

const committed = copies.filter((header) => header.conversationCopy!.state === 'committed');
Expand Down Expand Up @@ -177,19 +185,62 @@ export class HostSessionRevisionCoordinator {
if (retained.has(header.id)) {
await this.options.manager.commitRevisionVersion(header.id);
} else {
await this.#discardDuringRecovery(header);
this.#deferDiscard(header);
}
}
}

async #discardDuringRecovery(header: SessionHeader): Promise<void> {
#deferDiscard(header: SessionHeader): void {
// The physical sidecar purge resolves artifact paths across the whole
// store, so it runs from the post-ready maintenance lane instead of
// blocking Host readiness (issue #4027).
if (!this.#pendingDiscards.includes(header.id)) this.#pendingDiscards.push(header.id);
}

/**
* Drains one deferred conversation-copy discard. Only the post-ready
* storage maintenance lane calls this: the purge walks the artifact store
* and must never compete with Host readiness. The header is re-read under
* the Session admission lease so a copy committed or discarded since
* recovery wins over the stale snapshot.
*
* @returns true while more deferred discards remain.
*/
async drainPendingDiscards(): Promise<boolean> {
const sessionId = this.#pendingDiscards[0];
if (sessionId === undefined) return false;
try {
await this.#discard(header);
await this.options.admission.run(sessionId, () => this.#drainOneDiscard(sessionId));
} catch (error) {
console.error(
`[runtime-host] conversation copy cleanup deferred during recovery (${header.id}): ${conversationCopyCommitFailureDiagnostic(error)}`,
);
// Rotate the failed entry behind the rest so one stuck Session cannot
// starve the queue; the maintenance lane's backoff paces the retry.
this.#pendingDiscards.push(this.#pendingDiscards.shift()!);
throw error;
}
this.#pendingDiscards.shift();
return this.#pendingDiscards.length > 0;
}

async #drainOneDiscard(sessionId: string): Promise<void> {
let header: SessionHeader;
try {
header = await this.#stores.sessionStore.readHeaderSnapshot(sessionId);
} catch {
return; // The copy is gone: another path completed the discard.
}
const copy = header.conversationCopy;
if (copy === undefined || copy.state === 'committed') return;
// A revision can admit its first turn between recovery and this drain;
// admitted revisions are committed, never discarded (same rule as recovery).
if (
copy.kind === 'revision' &&
header.revisionState === 'preparing' &&
(await this.#hasAdmittedRevisionTurn(sessionId))
) {
await this.options.manager.commitRevisionVersion(sessionId);
return;
}
await this.#discard(header);
}

async #copy(
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-host/src/server/storage-maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export class HostStorageMaintenance {
contextOffload?: Pick<InteractiveContextOffloadWriter, 'collectGarbage' | 'reclaimFreePages'>;
/** The opt-in archived-task retention sweep; true while candidates remain. */
retention?: { sweep(): Promise<boolean> };
/**
* Conversation-copy discards that recovery deferred out of the ready
* path; true while queued copies remain.
*/
sessionDiscards?: { sweep(): Promise<boolean> };
onError: (name: string, error: unknown) => void;
}) {
this.#onError = input.onError;
Expand All @@ -79,6 +84,15 @@ export class HostStorageMaintenance {
},
},
];
const sessionDiscards = input.sessionDiscards;
if (sessionDiscards)
this.#lanes.push({
name: 'deferred session discard',
failures: 0,
activeDelay: ACTIVE_DELAY_MS,
idleDelay: IDLE_DELAY_MS,
run: () => sessionDiscards.sweep(),
});
const context = input.contextOffload;
if (context) {
this.#lanes.push({
Expand Down