agentHost: hide ephemeral sessions instead of deleting them (#344637)

* agentHost: don't persist the diff base branch of ephemeral sessions

Ephemeral sessions (e.g. editor inline chat) are tombstoned at creation so
they never surface in history, and the central session state refuses writes
to tombstoned sessions. The first-send folder-isolation step persisted the
default diff base branch unconditionally, failing the request with
'Cannot write the session state of ...: tombstoned'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: hide ephemeral sessions instead of deleting them

Ephemeral sessions (for example, editor inline chat) were tombstoned at creation to keep them out of
discovery. A tombstone also marks a session as deleted, so many write paths refused writes to the
live session. For example, a send failed with "Cannot write the session state of ...: tombstoned".

- Adds a separate "hidden" marker for ephemeral sessions. A hidden session is not registered and is
  not tombstoned, so a tombstone again means only "deleted".
- Adds `isSessionSuppressed` (deleted or hidden). Uses it only in the checks that prevent
  discovery, restore, surfacing, or reuse of a session identity.
- Keeps the hidden marker until teardown completes. Teardown deletes the session data, including
  the provider session, and then removes the marker.
- Adds a sweep at provider registration. The sweep disposes hidden sessions that a previous host
  left behind, for example after a crash.
- Does not persist the diff base branch of ephemeral sessions on the first send.

(Commit message generated by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: keep hidden session state and recheck the hidden sweep

- Keeps the central state of a session when the session is hidden. Hiding no longer removes state
  that a provider persisted during creation (for example, provisional backing data), so later
  writes to the live session do not fail.
- Rechecks that a session is still hidden and not live after a concurrent creation completes,
  before the startup sweep disposes it.

(Commit message generated by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Connor PeetandCopilot authored and GitHub committed 2026-10-27 21:55:56 +00:00
1 parent f76739a149
commit f9ba073c04
12 files changed
+580 -101

No files matched your search

@@ -48,10 +48,11 @@ export interface IAgentHostDatabaseSessionOptions {
}
export interface IAgentHostDatabaseRegisterOptions {
/** Rejects deleted or hidden identities; false explicitly clears both markers. */
readonly checkTombstone: boolean;
/**
* Exact provider backing URI for a discovery admission. When present,
* backing tombstones and cross-provider identity exclusions are checked in
* backing suppression and cross-provider identity exclusions are checked in
* the same transaction that claims `session`.
*/
readonly discoveryBackingSession?: string;
@@ -425,7 +426,7 @@ export interface IAgentHostDatabase extends IDisposable, IAgentHostSessionStateD
/**
* Records an identity in the legacy session registry for compatibility.
* When requested, the tombstone check and registration are atomic.
* When requested, the suppression check and registration are atomic.
*/
registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean>;
unregisterSession(session: string): Promise<void>;
@@ -434,6 +435,12 @@ export interface IAgentHostDatabase extends IDisposable, IAgentHostSessionStateD
* re-register it, recording the removal of its worktrees in the same transaction.
*/
tombstoneAndUnregisterSession(session: string, worktreeRemovals?: readonly IWorktreeRemovalRequest[]): Promise<void>;
/** Unregisters `session` and marks it hidden with its `provider`, clearing any deletion tombstone; its central state is kept and stays writable. */
hideSession(session: string, provider: string): Promise<void>;
/** Whether `session` carries a hidden marker. */
isSessionHidden(session: string): Promise<boolean>;
/** Every hidden session with its provider. */
listHiddenSessions(): Promise<readonly { readonly session: string; readonly provider: string }[]>;
updateSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise<void>;
/** Advances the durable last-observed modification time. */
updateSessionModifiedTime(session: string, modifiedTime: number): Promise<boolean>;
@@ -484,9 +491,11 @@ export interface IAgentHostDatabase extends IDisposable, IAgentHostSessionStateD
clearSessionsV2Exclusion(provider: AgentProvider, session: string): Promise<void>;
/** Whether `session` was explicitly deleted and must not be resurrected by backfill. */
isSessionTombstoned(session: string): Promise<boolean>;
/** Whether `session` is deleted or hidden, so it must not be discovered, restored, surfaced or reused. */
isSessionSuppressed(session: string): Promise<boolean>;
/** Durably records that `session` was explicitly deleted. */
markSessionTombstoned(session: string): Promise<void>;
/** Clears a session's deletion tombstone (used on explicit create/restore). */
/** Clears a session's tombstone and hidden marker (used after teardown or on explicit creation). */
clearSessionTombstone(session: string): Promise<void>;
/**
* Records a normal current-runtime identity in v2 and atomically mirrors its
@@ -1131,6 +1140,7 @@ function agentMergeEnabledKey(session: string): string {
}
const provisionalSessionKeyPrefix = 'sessionProvisional:';
const hiddenSessionKeyPrefix = 'sessionHidden:';
/** Set once a chat first moved out of the session it was created in (Block 4); never cleared. */
const CHATS_MOVED_METADATA_KEY = 'chatsV2Moved';
/** Prefixes a moved chat's attachment copy record (Block 4b), `chatAttachmentCopy:<chat>`. */
@@ -1159,6 +1169,11 @@ function provisionalSessionKey(session: string): string {
return `${provisionalSessionKeyPrefix}${session}`;
}
/** Metadata key recording the provider of a hidden session. */
function hiddenSessionKey(session: string): string {
return `${hiddenSessionKeyPrefix}${session}`;
}
/** Metadata key prefixes of the records that map runtime identities to their storage spelling. */
export type StorageIdentityMetadataPrefix = 'sessionStorageIdentity:' | 'chatStorageIdentity:';
@@ -1291,7 +1306,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
database,
`INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source)
SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ?
WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true')
WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE (key = ? AND value = 'true') OR key = ?)
ON CONFLICT(session_uri) DO UPDATE SET
provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions.provider END,
modified_time = MAX(sessions.modified_time, excluded.modified_time),
@@ -1306,10 +1321,10 @@ export class AgentHostDatabase implements IAgentHostDatabase {
WHEN sessions.registration_source = 'explicit' THEN 'explicit'
ELSE excluded.registration_source
END`,
[session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)],
[session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session), hiddenSessionKey(session)],
);
if (!registerOptions.checkTombstone) {
await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]);
await run(database, 'DELETE FROM metadata WHERE key IN (?, ?)', [tombstoneKey(session), hiddenSessionKey(session)]);
}
await exec(database, 'COMMIT');
return changes > 0;
@@ -1347,10 +1362,45 @@ export class AgentHostDatabase implements IAgentHostDatabase {
});
}
async hideSession(session: string, provider: string): Promise<void> {
return this._transactionSequencer.queue(async () => {
const database = await this._ensureDatabase();
await exec(database, 'BEGIN IMMEDIATE');
try {
await this._removeSessionRows(database, session);
await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]);
await run(database, `INSERT INTO metadata (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [hiddenSessionKey(session), provider]);
await exec(database, 'COMMIT');
} catch (error) {
await this._rollback(database, error, `Failed to hide session ${session}`);
}
});
}
async isSessionHidden(session: string): Promise<boolean> {
return !!await get(await this._ensureDatabase(), 'SELECT 1 AS present FROM metadata WHERE key = ?', [hiddenSessionKey(session)]);
}
async listHiddenSessions(): Promise<readonly { readonly session: string; readonly provider: string }[]> {
const rows = await all(
await this._ensureDatabase(),
`SELECT key, value FROM metadata WHERE key LIKE ? || '%'`,
[hiddenSessionKeyPrefix],
);
return rows.map(row => ({ session: (row.key as string).slice(hiddenSessionKeyPrefix.length), provider: row.value as string }));
}
/** Tombstones a session and unregisters it, with its catalog, the chats it still owns and its central state; inside the caller's transaction. */
private async _tombstoneSessionRows(database: Database, session: string): Promise<void> {
await run(database, `INSERT INTO metadata (key, value) VALUES (?, 'true')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [tombstoneKey(session)]);
await this._removeSessionRows(database, session);
await deleteSessionStateRows(this._sessionStateConnection(database), session);
}
/** Removes a session's registration, catalog and owned chats inside the caller's transaction; its central state is kept. */
private async _removeSessionRows(database: Database, session: string): Promise<void> {
await run(database, 'DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]);
await run(database, 'DELETE FROM metadata WHERE key = ?', [provisionalSessionKey(session)]);
await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]);
@@ -1361,7 +1411,6 @@ export class AgentHostDatabase implements IAgentHostDatabase {
await this._tombstoneOwnedChats(database, session);
await run(database, 'DELETE FROM session_chat_catalogs WHERE session_uri = ?', [session]);
await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [session]);
await deleteSessionStateRows(this._sessionStateConnection(database), session);
await run(database, 'DELETE FROM worktree_co_owners WHERE session_uri = ?', [session]);
}
@@ -1643,6 +1692,10 @@ export class AgentHostDatabase implements IAgentHostDatabase {
return row?.value === 'true';
}
async isSessionSuppressed(session: string): Promise<boolean> {
return !!await get(await this._ensureDatabase(), `SELECT 1 AS present FROM metadata WHERE (key = ? AND value = 'true') OR key = ?`, [tombstoneKey(session), hiddenSessionKey(session)]);
}
markSessionTombstoned(session: string): Promise<void> {
return this._run(
`INSERT INTO metadata (key, value) VALUES (?, 'true')
@@ -1652,7 +1705,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
}
clearSessionTombstone(session: string): Promise<void> {
return this._run('DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]);
return this._run('DELETE FROM metadata WHERE key IN (?, ?)', [tombstoneKey(session), hiddenSessionKey(session)]);
}
async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
@@ -1662,7 +1715,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
await exec(database, 'BEGIN IMMEDIATE');
try {
if (registerOptions.discoveryBackingSession !== undefined) {
const backingTombstone = await get(database, 'SELECT 1 AS present FROM metadata WHERE key = ? AND value = ?', [tombstoneKey(registerOptions.discoveryBackingSession), 'true']);
const backingSuppressed = await get(database, `SELECT 1 AS present FROM metadata WHERE (key = ? AND value = 'true') OR key = ?`, [tombstoneKey(registerOptions.discoveryBackingSession), hiddenSessionKey(registerOptions.discoveryBackingSession)]);
const claimedIdentities = await all(database, `SELECT session_uri, provider FROM sessions_v2 WHERE session_uri IN (?, ?)
UNION ALL
SELECT session_uri, provider FROM sessions WHERE session_uri IN (?, ?)`, [
@@ -1681,7 +1734,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
const backingExcluded = registerOptions.discoveryBackingSession !== session
&& identityExclusions.some(exclusion => exclusion.session === registerOptions.discoveryBackingSession);
const excludedByAnotherProvider = identityExclusions.some(exclusion => exclusion.provider !== provider);
if (backingTombstone || backingClaimed || claimedByAnotherProvider || backingExcluded || excludedByAnotherProvider) {
if (backingSuppressed || backingClaimed || claimedByAnotherProvider || backingExcluded || excludedByAnotherProvider) {
await exec(database, 'COMMIT');
return false;
}
@@ -1693,11 +1746,12 @@ export class AgentHostDatabase implements IAgentHostDatabase {
SELECT session_uri, provider, start_time, modified_time, external, registration_source
FROM sessions
WHERE session_uri = ?
AND (? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true'))
AND (? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE (key = ? AND value = 'true') OR key = ?))
AND NOT EXISTS (SELECT 1 FROM sessions_v2 WHERE session_uri = ?)`, [
session,
registerOptions.checkTombstone ? 1 : 0,
tombstoneKey(session),
hiddenSessionKey(session),
session,
]);
const changes = await this._registerSessionV2(database, session, provider, startTime, modifiedTime, source, registerOptions);
@@ -1728,7 +1782,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
}
}
if (!registerOptions.checkTombstone) {
await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]);
await run(database, 'DELETE FROM metadata WHERE key IN (?, ?)', [tombstoneKey(session), hiddenSessionKey(session)]);
}
if (registerOptions.provisional) {
await run(database, `INSERT INTO metadata (key, value) VALUES (?, 'true')
@@ -1870,7 +1924,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
try {
const changes = await this._registerSessionV2(database, session, provider, startTime, modifiedTime, source, registerOptions);
if (!registerOptions.checkTombstone) {
await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]);
await run(database, 'DELETE FROM metadata WHERE key IN (?, ?)', [tombstoneKey(session), hiddenSessionKey(session)]);
}
if (changes > 0) {
await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(provider, session)]);
@@ -3199,7 +3253,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
const targetExists = await get(database, `SELECT 1 AS present FROM sessions_v2 WHERE session_uri = ?
UNION ALL SELECT 1 FROM sessions WHERE session_uri = ?
UNION ALL SELECT 1 FROM session_chat_catalogs WHERE session_uri = ?
UNION ALL SELECT 1 FROM metadata WHERE key = ?`, [promotion.target, promotion.target, promotion.target, tombstoneKey(promotion.target)]);
UNION ALL SELECT 1 FROM metadata WHERE key IN (?, ?)`, [promotion.target, promotion.target, promotion.target, tombstoneKey(promotion.target), hiddenSessionKey(promotion.target)]);
const sourceHeader = await get(database, 'SELECT * FROM session_chat_catalogs WHERE session_uri = ?', [promotion.source]);
if (sourceHeader?.authority_version !== 2) {
await exec(database, 'COMMIT');
@@ -4128,7 +4182,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
database,
`INSERT INTO sessions_v2 (session_uri, provider, start_time, modified_time, external, registration_source)
SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ?
WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true')
WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE (key = ? AND value = 'true') OR key = ?)
ON CONFLICT(session_uri) DO UPDATE SET
provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions_v2.provider END,
modified_time = MAX(sessions_v2.modified_time, excluded.modified_time),
@@ -4145,7 +4199,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
WHERE ? = 0 OR sessions_v2.provider = excluded.provider`,
[
session, provider, startTime, modifiedTime, source, source,
registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session),
registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session), hiddenSessionKey(session),
registerOptions.discoveryBackingSession === undefined ? 0 : 1,
],
);
@@ -4328,6 +4382,7 @@ export class AgentHostDatabase implements IAgentHostDatabase {
get: (sql, parameters) => get(database, sql, parameters),
all: (sql, parameters) => all(database, sql, parameters),
isTombstoned: async session => !!await get(database, `SELECT 1 AS present FROM metadata WHERE key = ? AND value = 'true'`, [tombstoneKey(session)]),
isHidden: async session => !!await get(database, 'SELECT 1 AS present FROM metadata WHERE key = ?', [hiddenSessionKey(session)]),
isRegistered: async session => !!await get(database, `SELECT 1 AS present FROM sessions WHERE session_uri = ?
UNION SELECT 1 AS present FROM sessions_v2 WHERE session_uri = ? LIMIT 1`, [session, session]),
markPayloadDirty: async session => {
@@ -265,7 +265,7 @@ export class AgentHostSavedChatService {
/** Resolves saved repository ownership for file previews, without decoding a native transcript. */
async readWorkingDirectories(session: URI, owner: URI): Promise<readonly string[] | undefined> {
if (await this._database.isSessionTombstoned(session.toString())) {
if (await this._database.isSessionSuppressed(session.toString())) {
throw new Error(`Saved session not found: ${session.toString()}`);
}
const registered = await this._database.getSessionV2Registration(session.toString());
@@ -310,7 +310,7 @@ export class AgentHostSavedChatService {
this._database.readCatalogSnapshot([key]),
this._database.getSessionV2(key),
]);
if (!snapshot || isAgentHostChatBacking(session, snapshot.isChatBacking) || snapshot.provisional || await this._database.isSessionTombstoned(key)) {
if (!snapshot || isAgentHostChatBacking(session, snapshot.isChatBacking) || snapshot.provisional || await this._database.isSessionSuppressed(key)) {
throw new Error(`Saved session not found: ${key}`);
}
if (snapshot.identity.session !== key || catalog && (catalog.provider !== snapshot.identity.provider || snapshot.header?.sessionGeneration !== undefined && snapshot.header.sessionGeneration !== catalog.sessionGeneration)) {
@@ -241,7 +241,7 @@ export interface IAgentHostSessionStateDatabase {
* session identities equal to it.
*/
countStorageReferences(storage: string, excludingSession: string): Promise<number>;
/** Sessions with session state that are tombstoned or absent from both registries. */
/** Non-hidden sessions with session state that are tombstoned or absent from both registries. */
listSessionStateGarbage(limit: number): Promise<readonly string[]>;
/** Deletes the session state of `session` if it still is garbage. */
deleteSessionStateGarbage(session: string): Promise<boolean>;
@@ -256,6 +256,8 @@ export interface ISessionStateSqlConnection {
all(sql: string, parameters: readonly unknown[]): Promise<SqlRow[]>;
/** Whether the session is tombstoned. */
isTombstoned(session: string): Promise<boolean>;
/** Whether the session carries a hidden marker. */
isHidden(session: string): Promise<boolean>;
/** Whether the session is registered in either registry. */
isRegistered(session: string): Promise<boolean>;
/** Marks the session's catalog payload dirty when it has one. */
@@ -803,15 +805,16 @@ export class AgentHostSessionStateTables implements IAgentHostSessionStateDataba
throw new Error('Session state garbage limit must be a positive safe integer');
}
return this._host.read(async connection => (await connection.all(`SELECT session_uri FROM session_state AS state
WHERE EXISTS (SELECT 1 FROM metadata WHERE key = 'sessionTombstone:' || state.session_uri AND value = 'true')
OR (NOT EXISTS (SELECT 1 FROM sessions WHERE session_uri = state.session_uri)
AND NOT EXISTS (SELECT 1 FROM sessions_v2 WHERE session_uri = state.session_uri))
WHERE NOT EXISTS (SELECT 1 FROM metadata WHERE key = 'sessionHidden:' || state.session_uri)
AND (EXISTS (SELECT 1 FROM metadata WHERE key = 'sessionTombstone:' || state.session_uri AND value = 'true')
OR (NOT EXISTS (SELECT 1 FROM sessions WHERE session_uri = state.session_uri)
AND NOT EXISTS (SELECT 1 FROM sessions_v2 WHERE session_uri = state.session_uri)))
ORDER BY session_uri LIMIT ?`, [limit])).map(row => row.session_uri as string));
}
deleteSessionStateGarbage(session: string): Promise<boolean> {
return this._host.write(`Failed to delete the session state of ${session}`, async connection => {
if (!await connection.isTombstoned(session) && await connection.isRegistered(session)) {
if (await connection.isHidden(session) || (!await connection.isTombstoned(session) && await connection.isRegistered(session))) {
return false;
}
await deleteSessionStateRows(connection, session);
@@ -187,7 +187,7 @@ export class AgentHostSessionsV2MigrationService<T> {
): Promise<IAgentHostSessionsV2MigrationOutcome<T>> {
const session = candidate.session.toString();
try {
if (await this._database.isSessionTombstoned(session)) {
if (await this._database.isSessionSuppressed(session)) {
return { status: 'excluded' };
}
const priorProviderExclusion = candidate.current && candidate.current.provider !== provider
+59 -42
View File
@@ -1025,6 +1025,7 @@ export class AgentService extends Disposable implements IAgentService {
*/
private readonly _provisionalSessionKeys = new Set<string>();
private _provisionalSessionKeysLoaded: Promise<void> | undefined;
private _hiddenSessionsAtStartup: ReturnType<AgentSessionRegistry['listHidden']> | undefined;
private _movedChatOriginsLoaded: Promise<void> | undefined;
/** Copies moved chats' pre-move attachments into their own storage (Block 4b). */
private readonly _attachmentMigration: AgentHostAttachmentMigration;
@@ -1790,7 +1791,7 @@ export class AgentService extends Disposable implements IAgentService {
const resolved = await this._resolveWorkingDirectoryForResume(URI.parse(params.session), sessionId, pickedFolderUri);
const config = this._configurationService.getSessionConfigValues(params.session);
if (config?.[SessionConfigKey.Isolation] === 'folder') {
if (config?.[SessionConfigKey.Isolation] === 'folder' && !this._stateManager.isEphemeralSession(params.session)) {
const session = URI.parse(params.session);
const scope = resolveBranchChangesetScopeForSource(this._stateManager, params.chat);
const source = resolveBranchChangesetScopeForOwner(this._stateManager, scope.ownerUri)?.sourceUri ?? params.chat;
@@ -1983,6 +1984,7 @@ export class AgentService extends Disposable implements IAgentService {
}
private _onDidRegisterProvider(provider: IAgent): void {
void this._disposeHiddenSessions(provider).catch(err => this._logService.warn(`[AgentService] Failed to sweep hidden sessions for ${provider.id}`, err));
this._registerSkillCompletionProvider();
const initialMigration = this._trackInitialProviderMigration(provider, this._ensureSessionsV2Imported(provider));
// Persisted enablement must resume without a client opening the session.
@@ -1992,6 +1994,35 @@ export class AgentService extends Disposable implements IAgentService {
.catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err));
}
/** Retries teardown of hidden sessions left behind by a previous host. */
private async _disposeHiddenSessions(provider: IAgent): Promise<void> {
if (this._store.isDisposed) {
return;
}
const hiddenSessions = await (this._hiddenSessionsAtStartup ??= this._sessionRegistry.listHidden());
for (const { session, provider: providerId } of hiddenSessions) {
if (this._store.isDisposed || this._providerService.getProvider(provider.id) !== provider) {
return;
}
if (providerId !== provider.id || this._stateManager.getSessionState(session.toString())) {
continue;
}
try {
this._providerService.associateSession(session, provider.id);
const creation = this._sessionCreationSequencer.peek(AgentSession.id(session));
await this._sessionResidency.runDisposal(session, async () => {
await creation?.catch(() => undefined);
// Rechecked once a concurrent creation settles: it may have revived the URI.
if (!this._stateManager.getSessionState(session.toString()) && await this._sessionRegistry.isHidden(session)) {
await this._doDisposeSession(session, 'delete');
}
});
} catch (err) {
this._logService.warn(`[AgentService] Failed to dispose hidden session ${session.toString()}`, err);
}
}
}
private _trackInitialProviderMigration(provider: IAgent, migration: Promise<void>): Promise<void> {
this._initialProviderMigrations.set(provider.id, migration);
void migration
@@ -4136,7 +4167,7 @@ export class AgentService extends Disposable implements IAgentService {
* sessions are never resurrected: registration goes through
* {@link AgentSessionRegistry.register}, which atomically declines to
* (re-)register a session that is (or concurrently becomes)
* tombstoned, rather than trusting a separate up-front tombstone check that
* tombstoned or hidden, rather than trusting a separate up-front suppression check that
* could race a concurrent {@link disposeSession}.
*
* `undefined` from the provider means it cannot enumerate yet (its SDK may
@@ -4279,7 +4310,7 @@ export class AgentService extends Disposable implements IAgentService {
this._recordDiscoveryRegistrationProgress(observation, 'filtered');
return false;
}
if (!isEqual(session, providerSession) && await this._sessionRegistry.isTombstoned(providerSession)) {
if (!isEqual(session, providerSession) && await this._sessionRegistry.isSuppressed(providerSession)) {
this._recordDiscoveryRegistrationProgress(observation, 'filtered');
return false;
}
@@ -4332,7 +4363,7 @@ export class AgentService extends Disposable implements IAgentService {
}
} else {
this._recordDiscoveryRegistrationProgress(observation, 'filtered');
this._logService.trace(`[AgentService] discovery: ${session.toString()} was not registered (tombstoned)`);
this._logService.trace(`[AgentService] discovery: ${session.toString()} was not registered (suppressed)`);
}
return registered;
} catch (err) {
@@ -4809,7 +4840,7 @@ export class AgentService extends Disposable implements IAgentService {
}
}
/** Returns registered candidates. Tombstones remain candidates so registration can reject them atomically. */
/** Returns registered candidates. Suppressed sessions remain candidates so registration can reject them atomically. */
private async _filterKnownSessions(provider: AgentProvider, sessions: readonly URI[]): Promise<ReadonlySet<string>> {
const registered = await this._sessionRegistry.listSessionKeys();
const resolutions = usesStandardSessionUris(provider)
@@ -5068,7 +5099,7 @@ export class AgentService extends Disposable implements IAgentService {
const storageAccessesAtStart = this._storageAccessCounts();
// The registry is the source of truth for top-level sessions. Internal
// chat backings and subagent sessions never enter it; ephemeral sessions
// are tombstoned at creation. A transiently missing provider snapshot no
// are hidden at creation. A transiently missing provider snapshot no
// longer evicts a session.
let allRegistered = await this._listRegisteredSessions();
if (allRegistered.length === 0) {
@@ -5813,7 +5844,7 @@ export class AgentService extends Disposable implements IAgentService {
}
this._announcedSurfacedKeys.add(key);
try {
if (await this._sessionRegistry.isTombstoned(meta.session)) {
if (await this._sessionRegistry.isSuppressed(meta.session)) {
this._announcedSurfacedKeys.delete(key);
return;
}
@@ -5935,7 +5966,7 @@ export class AgentService extends Disposable implements IAgentService {
const keys = await this._sessionRegistry.listRuntimeCompatibleSessionKeys();
if ([...keys].some(key => AgentSession.id(key) === AgentSession.id(requested))
|| await this._fileService.exists(this._sessionDataService.getSessionDataDir(requested))
|| await this._sessionRegistry.isTombstoned(requested)) {
|| await this._sessionRegistry.isSuppressed(requested)) {
throw new Error(`Session storage identity is already in use: ${requested.toString()}`);
}
}
@@ -6006,8 +6037,8 @@ export class AgentService extends Disposable implements IAgentService {
if (isEphemeral) {
try {
await this._retryRegistryMutation(
() => this._sessionRegistry.tombstone(session),
`tombstoning ephemeral session ${session.toString()}`,
() => this._sessionRegistry.hide(session, provider.id),
`hiding ephemeral session ${session.toString()}`,
);
if (!isIdleProvisional) {
this._invalidateSessionList();
@@ -7675,7 +7706,7 @@ export class AgentService extends Disposable implements IAgentService {
const load = (async () => {
// A passive metadata update in progress finishes before this load reads the state it writes.
await this._passiveMetadataSequencer.queue(sessionKey, async () => { });
if (await this._sessionRegistry.isTombstoned(session)) {
if (await this._sessionRegistry.isSuppressed(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionKey}`);
}
if (await this._isWorkspaceConversionQuarantined(session)) {
@@ -9464,7 +9495,7 @@ export class AgentService extends Disposable implements IAgentService {
do {
session = AgentSession.uri(legacy ? provider.id : 'ahp-session', generateUuid());
} while (registeredIds.has(AgentSession.id(session))
|| await this._sessionRegistry.isTombstoned(session)
|| await this._sessionRegistry.isSuppressed(session)
|| await this._fileService.exists(this._sessionDataService.getSessionDataDir(session)));
return session;
}
@@ -9483,7 +9514,7 @@ export class AgentService extends Disposable implements IAgentService {
}
await this._orchestratorDatabase.prepareSessionStorage?.(session.toString(), provider.id);
const providerConfig = config ? this._toProviderConfig(config) : undefined;
const generation = await this._sessionRegistry.isTombstoned(session) ? generateUuid() : undefined;
const generation = await this._sessionRegistry.isSuppressed(session) ? generateUuid() : undefined;
const defaultChatUri = URI.parse(mintDefaultChatUri(this._creatorUsesLegacyChatUris(config) ? 'shaped' : 'opaque', session.toString(), generation));
this._orchestratorDatabase.recordChatStorageIdentity(session.toString(), defaultChatUri.toString(), { kind: 'default', generation });
if (config?.credentials) {
@@ -10456,7 +10487,7 @@ export class AgentService extends Disposable implements IAgentService {
let peerChatDeletionBegun = false;
let sessionStateDeletion: { dispose(): void } | undefined;
try {
const isEphemeral = this._stateManager.isEphemeralSession(sessionKey);
const isHidden = this._stateManager.isEphemeralSession(sessionKey) || await this._sessionRegistry.isHidden(session);
const isIdleProvisional = this._stateManager.isIdleProvisionalSession(sessionKey);
const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? [];
for (const chat of sessionChats) {
@@ -10539,17 +10570,11 @@ export class AgentService extends Disposable implements IAgentService {
const stateDeletion = this._sessionStateStore.beginDeletion(session);
sessionStateDeletion = stateDeletion;
await stateDeletion.whenDrained;
if (!isEphemeral) {
traceStage('tombstone');
await this._retryRegistryMutation(
() => this._sessionRegistry.tombstone(session, worktreeRemovals),
`unregistration for ${session.toString()}`,
);
} else {
for (const removal of worktreeRemovals) {
await this._orchestratorDatabase.upsertPendingWorktreeRemoval(removal);
}
}
traceStage('tombstone');
await this._retryRegistryMutation(
() => this._sessionRegistry.tombstone(session, worktreeRemovals),
`unregistration for ${session.toString()}`,
);
// Also when the session records no removal: it may have kept another session's worktree in use.
this._worktreeCleanup.schedule();
// The sessions it shared worktrees with may no longer share them: their older-build copy follows (Block 5).
@@ -10600,11 +10625,11 @@ export class AgentService extends Disposable implements IAgentService {
// The durable marker is dropped with the registration itself; keep the
// mirror listing reads in step with it.
this._provisionalSessionKeys.delete(sessionKey);
if (isEphemeral) {
traceStage('clearEphemeralTombstone');
if (isHidden) {
traceStage('clearHiddenTombstone');
await this._retryRegistryMutation(
() => this._sessionRegistry.clearTombstone(session),
`clearing ephemeral session tombstone for ${session.toString()}`,
`clearing hidden session tombstone for ${session.toString()}`,
);
}
} finally {
@@ -13312,13 +13337,8 @@ export class AgentService extends Disposable implements IAgentService {
if (this._stateManager.getSessionState(sessionStr)) {
return;
}
// A session explicitly deleted (tombstoned) must not be revived by a
// stale restore request — e.g. a client re-subscribing to a URI it
// still remembers after the session was deleted. Failing fast here
// (before any provider-side restoration work) also avoids the
// registration below silently declining later and leaving state
// partially hydrated.
if (await this._sessionRegistry.isTombstoned(session)) {
// Refuse deleted or hidden sessions before provider restoration can partially hydrate them.
if (await this._sessionRegistry.isSuppressed(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
const unresolvedRegistration = await this._orchestratorDatabase.getSessionV2Registration(sessionStr);
@@ -13354,8 +13374,8 @@ export class AgentService extends Disposable implements IAgentService {
readable = false;
this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err);
}
// This wait can be lengthy, so re-check that a delete has not landed meanwhile.
if (await this._sessionRegistry.isTombstoned(session)) {
// This wait can be lengthy, so re-check that the session was not deleted or hidden meanwhile.
if (await this._sessionRegistry.isSuppressed(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
return readable;
@@ -13514,7 +13534,7 @@ export class AgentService extends Disposable implements IAgentService {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
}
const ownerKey = owner.toString();
// Live throwaway sessions are deliberately tombstoned to exclude them from discovery.
// Live throwaway sessions are hidden and deliberately unregistered.
if (this._stateManager.isEphemeralSession(ownerKey)) {
return;
}
@@ -13990,10 +14010,7 @@ export class AgentService extends Disposable implements IAgentService {
`registration for restored session ${session.toString()}`,
);
if (!registered) {
// Tombstoned between the early check in `_doRestoreSession` and
// here (e.g. a concurrent `disposeSession` landed while this
// restore was reading turns/metadata). Fail the same way an
// up-front tombstone would, before any state-manager mutation.
// Refuse a session deleted or hidden during restoration before mutating state.
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
this._invalidateSessionList();
@@ -72,13 +72,11 @@ export type RegisteredSessionMigration = (entry: IStoredRegisteredSession, entri
* `markBackfilled` remains callable for tests and any explicit migration
* tooling, but nothing in the per-provider sweep invokes it automatically.
*
* Sessions passed to {@link tombstone} are durably tombstoned so a forced
* or repeated native discovery pass — which re-reads a provider's catalog from
* scratch — cannot register them. This covers both a session the user
* explicitly deleted and one that must never be listed at all (e.g. a
* throwaway chat surface, tombstoned at creation). Only {@link register} with
* `checkTombstone: false` clears a tombstone; restore, discovery, and adoption
* leave it intact.
* Deleted sessions are tombstoned; live throwaway sessions carry a separate
* hidden marker. Both prevent discovery and restore from registering them.
* An explicit {@link register} with `checkTombstone: false` or
* {@link clearTombstone} clears both markers; restore, discovery, and adoption
* leave them intact.
*/
export class AgentSessionRegistry extends Disposable {
declare readonly _serviceBrand: undefined;
@@ -90,7 +88,7 @@ export class AgentSessionRegistry extends Disposable {
super();
}
/** Records a session using source-aware provenance and tombstone behavior. */
/** Records a session using source-aware provenance and suppression behavior. */
register(session: URI, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
return this._database.registerRuntimeSession(session.toString(), sessionOptions, registerOptions);
}
@@ -130,17 +128,31 @@ export class AgentSessionRegistry extends Disposable {
}
/**
* Removes any registry entry for `session` (a true delete) and durably
* tombstones it so discovery cannot register it. Used both to delete a
* session the user explicitly removed and to keep a session that must never
* be listed (e.g. a throwaway chat surface) out of the registry entirely.
* No-op on the registry entry if absent; the tombstone is still written.
* `worktreeRemovals` are recorded in the same transaction as the tombstone.
* Deletes `session`, including hidden-session teardown, and atomically records its tombstone and worktree removals.
* Any hidden marker remains until {@link clearTombstone} clears it.
*/
async tombstone(session: URI, worktreeRemovals?: readonly IWorktreeRemovalRequest[]): Promise<void> {
await this._database.tombstoneAndUnregisterSession(session.toString(), worktreeRemovals);
}
/**
* Unregisters `session` and marks it hidden, not deleted: its state stays writable, but it is never discovered, listed or restored.
* Records `provider` so a later host can clean up after a crash ({@link listHidden}).
*/
hide(session: URI, provider: string): Promise<void> {
return this._database.hideSession(session.toString(), provider);
}
/** Whether `session` is hidden (see {@link hide}). */
isHidden(session: URI): Promise<boolean> {
return this._database.isSessionHidden(session.toString());
}
/** The hidden sessions recorded, with their providers. */
async listHidden(): Promise<readonly { readonly session: URI; readonly provider: string }[]> {
return (await this._database.listHiddenSessions()).map(({ session, provider }) => ({ session: URI.parse(session), provider }));
}
/** Advances the durable last-observed provider modification time. */
updateModifiedTime(session: URI, modifiedTime: number): Promise<boolean> {
return this._database.updateSessionModifiedTime(session.toString(), modifiedTime);
@@ -386,7 +398,12 @@ export class AgentSessionRegistry extends Disposable {
return this._database.isSessionTombstoned(session.toString());
}
/** Clears an explicit-deletion tombstone for `session` (used on explicit create/restore). */
/** Whether `session` is deleted or hidden, so it must not be discovered, restored, surfaced or reused. */
async isSuppressed(session: URI): Promise<boolean> {
return this._database.isSessionSuppressed(session.toString());
}
/** Clears the tombstone and hidden marker for `session` (used after teardown or on explicit creation). */
async clearTombstone(session: URI): Promise<void> {
await this._database.clearSessionTombstone(session.toString());
}
@@ -94,7 +94,7 @@ export class CompatibleAgentHostDatabase extends Disposable implements IAgentHos
if (!isEqual(resource, AgentSession.uri('ahp-session', AgentSession.id(resource))) || await this.raw.getSessionV2Registration(session) || await this.raw.getSession(session)) {
return;
}
if (await this.raw.isSessionTombstoned(session) || await this.raw.isSessionTombstoned(storage)) {
if (await this.raw.isSessionSuppressed(session) || await this.raw.isSessionSuppressed(storage)) {
return;
}
if (await this.raw.getSessionV2Registration(storage) || await this.raw.getSession(storage)) {
@@ -126,6 +126,18 @@ export class CompatibleAgentHostDatabase extends Disposable implements IAgentHos
return this.identity.record(result, 'runtime');
}
async hideSession(...[session, provider]: Parameters<IAgentHostDatabase['hideSession']>): ReturnType<IAgentHostDatabase['hideSession']> {
return this._allocations.queue(() => this.raw.hideSession(this.identity.toStorage(session), provider));
}
async isSessionHidden(...[session]: Parameters<IAgentHostDatabase['isSessionHidden']>): ReturnType<IAgentHostDatabase['isSessionHidden']> {
return this.raw.isSessionHidden(this.identity.toStorage(session));
}
async listHiddenSessions(...[]: Parameters<IAgentHostDatabase['listHiddenSessions']>): ReturnType<IAgentHostDatabase['listHiddenSessions']> {
return (await this.raw.listHiddenSessions()).map(({ session, provider }) => ({ session: this.identity.toRuntime(session), provider }));
}
async updateSessionExternal(...[updates]: Parameters<IAgentHostDatabase['updateSessionExternal']>): ReturnType<IAgentHostDatabase['updateSessionExternal']> {
const result = await this.raw.updateSessionExternal(this.identity.record(updates, 'storage'));
return this.identity.record(result, 'runtime');
@@ -250,6 +262,10 @@ export class CompatibleAgentHostDatabase extends Disposable implements IAgentHos
return this.identity.record(result, 'runtime');
}
async isSessionSuppressed(...[session]: Parameters<IAgentHostDatabase['isSessionSuppressed']>): ReturnType<IAgentHostDatabase['isSessionSuppressed']> {
return this.raw.isSessionSuppressed(this.identity.toStorage(session));
}
async markSessionTombstoned(...[session]: Parameters<IAgentHostDatabase['markSessionTombstoned']>): ReturnType<IAgentHostDatabase['markSessionTombstoned']> {
const result = await this._allocations.queue(() => this.raw.markSessionTombstoned(this.identity.toStorage(session)));
return this.identity.record(result, 'runtime');
@@ -247,12 +247,15 @@ suite('AgentHostDatabase session state', () => {
for (const [cleanup, remove] of [
['tombstone', (database: AgentHostDatabase, session: string) => database.tombstoneAndUnregisterSession(session)],
['hide', (database: AgentHostDatabase, session: string) => database.hideSession(session, 'copilotcli')],
['exclusion', async (database: AgentHostDatabase, session: string) => { await database.excludeSessionV2({ provider: 'copilotcli', session, reason: 'backing', fingerprint: 'f' }, { identity: await database.getSessionV2Registration(session), catalog: undefined }); }],
['runtime unregistration', (database: AgentHostDatabase, session: string) => database.unregisterRuntimeSession(session)],
['sessions_v2 unregistration', (database: AgentHostDatabase, session: string) => database.unregisterSessionV2(session)],
['provider-absent exclusion', async (database: AgentHostDatabase, session: string) => { await database.excludeSessionV2({ provider: 'copilotcli', session, reason: 'providerAbsent', fingerprint: 'f' }, { identity: await database.getSessionV2Registration(session), catalog: undefined }); }],
] as const) {
test(`${cleanup} ${cleanup === 'provider-absent exclusion' ? 'keeps' : 'removes'} every session state row and marker`, async () => {
// A hidden session is live, and a provider-absent one may return: both keep their state.
const keeps = cleanup === 'hide' || cleanup === 'provider-absent exclusion';
test(`${cleanup} ${keeps ? 'keeps' : 'removes'} every session state row and marker`, async () => {
const database = await open();
const session = 'copilotcli:/removed';
const other = 'copilotcli:/kept';
@@ -264,7 +267,7 @@ suite('AgentHostDatabase session state', () => {
await database.close();
const counts = await withRaw(path, async raw => ({ removed: await sessionStateRowCounts(raw, session), other: await sessionStateRowCounts(raw, other) }));
const full = { ...FULL_STATE_ROWS };
assert.deepStrictEqual(counts, { removed: cleanup === 'provider-absent exclusion' ? full : Object.fromEntries(Object.keys(full).map(key => [key, 0])), other: full });
assert.deepStrictEqual(counts, { removed: keeps ? full : Object.fromEntries(Object.keys(full).map(key => [key, 0])), other: full });
});
}
@@ -317,6 +320,42 @@ suite('AgentHostDatabase session state', () => {
}, { import: 'tombstoned', write: { status: 'tombstoned' }, rowMirror: 'tombstoned' });
});
test('hidden sessions import and accept writes without becoming session state garbage', async () => {
const database = await open();
const hidden = 'copilotcli:/hidden';
const deleted = 'copilotcli:/deleted';
await database.tombstoneAndUnregisterSession(hidden);
await database.hideSession(hidden, 'copilotcli');
await database.tombstoneAndUnregisterSession(deleted);
const write = async (session: string) => ({
import: await database.importSessionState(session, importData(session, 'ahp-chat://default/h', 'ahp-chat://peer/h')),
write: await database.writeSessionState(session, 1, { values: { isArchived: 'true' }, folders: [], chats: [], markPayloadDirty: false }),
rowMirror: await database.writeChatRowMirror(session, 'ahp-chat://peer/h', 'draft', '{}'),
});
assert.deepStrictEqual({
hidden: await database.isSessionHidden(hidden),
tombstoned: await database.isSessionTombstoned(hidden),
suppressed: await database.isSessionSuppressed(hidden),
results: [await write(hidden), await write(deleted)],
garbage: await database.listSessionStateGarbage(10),
deleteHidden: await database.deleteSessionStateGarbage(hidden),
value: (await database.readSessionState(hidden))?.values.find(entry => entry.key === 'isArchived')?.value,
}, {
hidden: true, tombstoned: false, suppressed: true,
results: [
{ import: 'imported', write: { status: 'applied', revision: 2 }, rowMirror: 'applied' },
{ import: 'tombstoned', write: { status: 'tombstoned' }, rowMirror: 'tombstoned' },
],
garbage: [], deleteHidden: false, value: 'true',
});
await database.tombstoneAndUnregisterSession(hidden);
assert.deepStrictEqual({
hidden: await database.isSessionHidden(hidden),
tombstoned: await database.isSessionTombstoned(hidden),
results: await write(hidden),
}, { hidden: true, tombstoned: true, results: { import: 'tombstoned', write: { status: 'tombstoned' }, rowMirror: 'tombstoned' } });
});
test('the compatibility database stores session state in storage spelling and reads it in runtime spelling', async () => {
const compatible = store.add(new CompatibleAgentHostDatabase(path));
await compatible.initialize();
@@ -527,6 +527,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
private readonly _sessionsV2Backfilled = new Set<string>();
private readonly _sessionsV2Exclusions = new Map<string, IAgentHostDatabaseSessionsV2Exclusion>();
private readonly _tombstones = new Set<string>();
private readonly _hiddenSessions = new Map<string, string>();
private readonly _agentMergeEnabled = new Set<string>();
private readonly _provisionalSessions = new Set<string>();
private readonly _sessionChats = new Map<string, IAgentHostDatabaseSessionChatCatalog>();
@@ -578,7 +579,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
this._beforeWrite();
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -592,6 +593,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
this._sessions.set(session, { ...next, modifiedTime: Math.max(existing?.modifiedTime ?? modifiedTime, modifiedTime) });
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
return true;
}
@@ -734,10 +736,28 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
this._sessionsV2Exclusions.delete(`${provider}:${session}`);
}
override async hideSession(session: string, provider: string): Promise<void> {
await this.unregisterRuntimeSession(session);
this._tombstones.delete(session);
this._hiddenSessions.set(session, provider);
}
override async isSessionHidden(session: string): Promise<boolean> {
return this._hiddenSessions.has(session);
}
override async listHiddenSessions(): Promise<readonly { readonly session: string; readonly provider: string }[]> {
return [...this._hiddenSessions].map(([session, provider]) => ({ session, provider }));
}
async isSessionTombstoned(session: string): Promise<boolean> {
return this._tombstones.has(session);
}
override async isSessionSuppressed(session: string): Promise<boolean> {
return this._tombstones.has(session) || this._hiddenSessions.has(session);
}
async markSessionTombstoned(session: string): Promise<void> {
this._beforeWrite();
this._tombstones.add(session);
@@ -746,12 +766,14 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
async clearSessionTombstone(session: string): Promise<void> {
this._beforeWrite();
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
this._beforeWrite();
if (registerOptions.discoveryBackingSession !== undefined
&& (this._tombstones.has(registerOptions.discoveryBackingSession)
|| this._hiddenSessions.has(registerOptions.discoveryBackingSession)
|| registerOptions.discoveryBackingSession !== session && (this._sessionV2Registrations.has(registerOptions.discoveryBackingSession) || this._sessions.has(registerOptions.discoveryBackingSession))
|| [...this._sessionsV2Exclusions.values()].some(exclusion =>
(exclusion.session === session && exclusion.provider !== sessionOptions.provider
@@ -759,7 +781,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
&& (exclusion.reason === 'providerAbsent' || exclusion.reason === 'staleExternal')))) {
return false;
}
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -777,6 +799,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
this._sessionsV2Exclusions.delete(`${provider}:${session}`);
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
if (registerOptions.provisional) {
this._provisionalSessions.add(session);
@@ -843,7 +866,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
this._beforeWrite();
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -855,6 +878,7 @@ class TransientRegistryWriteDatabase extends SessionStateUnsupportedDatabase imp
this._sessionsV2Exclusions.delete(`${provider}:${session}`);
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
return true;
}
@@ -1106,6 +1130,7 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
private readonly _sessionsV2Backfilled = new Set<string>();
private readonly _sessionsV2Exclusions = new Map<string, IAgentHostDatabaseSessionsV2Exclusion>();
private readonly _tombstones = new Set<string>();
private readonly _hiddenSessions = new Map<string, string>();
private readonly _agentMergeEnabled = new Set<string>();
private readonly _provisionalSessions = new Set<string>();
private readonly _sessionChats = new Map<string, IAgentHostDatabaseSessionChatCatalog>();
@@ -1117,7 +1142,7 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
lastModifiedTimesBatchSize = 0;
async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -1127,6 +1152,7 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
: { session, provider, startTime, modifiedTime, external: source === 'discovery', source });
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
return true;
}
@@ -1243,21 +1269,41 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
this._sessionsV2Exclusions.delete(`${provider}:${session}`);
}
override async hideSession(session: string, provider: string): Promise<void> {
await this.unregisterRuntimeSession(session);
this._tombstones.delete(session);
this._hiddenSessions.set(session, provider);
}
override async isSessionHidden(session: string): Promise<boolean> {
return this._hiddenSessions.has(session);
}
override async listHiddenSessions(): Promise<readonly { readonly session: string; readonly provider: string }[]> {
return [...this._hiddenSessions].map(([session, provider]) => ({ session, provider }));
}
async isSessionTombstoned(session: string): Promise<boolean> {
return this._tombstones.has(session);
}
override async isSessionSuppressed(session: string): Promise<boolean> {
return this._tombstones.has(session) || this._hiddenSessions.has(session);
}
async markSessionTombstoned(session: string): Promise<void> {
this._tombstones.add(session);
}
async clearSessionTombstone(session: string): Promise<void> {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
if (registerOptions.discoveryBackingSession !== undefined
&& (this._tombstones.has(registerOptions.discoveryBackingSession)
|| this._hiddenSessions.has(registerOptions.discoveryBackingSession)
|| registerOptions.discoveryBackingSession !== session && (this._sessionV2Registrations.has(registerOptions.discoveryBackingSession) || this._sessions.has(registerOptions.discoveryBackingSession))
|| [...this._sessionsV2Exclusions.values()].some(exclusion =>
(exclusion.session === session && exclusion.provider !== sessionOptions.provider
@@ -1321,7 +1367,7 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
}
async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -1333,6 +1379,7 @@ class TestAgentHostOrchestratorDatabase extends SessionStateUnsupportedDatabase
this._sessionsV2Exclusions.delete(`${provider}:${session}`);
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
return true;
}
@@ -8980,6 +9027,41 @@ suite('AgentService (node dispatcher)', () => {
}
}
test('an ephemeral folder session sends without persisting a diff base', async () => {
const db = new TestSessionDatabase();
const directory = URI.file('/workspace/repo');
const git = createNoopGitService();
git.getRepositoryRoot = async () => directory;
git.getDefaultBranch2 = async () => ({ kind: GitRefType.Head, name: 'main', ref: 'refs/heads/main' });
git.getCurrentBranch = async () => 'feature';
const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, git));
const agent = disposables.add(new MockAgent('copilot'));
registerTestAgentProvider(svc, agent);
const session = await svc.createSession({
legacyChatUris: true,
provider: agent.id,
workingDirectories: [directory],
config: { [SessionConfigKey.Isolation]: 'folder' },
_meta: withEphemeralSessionMeta(undefined, true),
});
const chat = buildDefaultChatUri(session);
svc.dispatchAction(chat, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: new Date().toISOString(),
message: { text: 'send this message', origin: { kind: MessageKind.User } },
}, 'client', 1);
await waitForCondition(agent.onDidSendMessage, () => agent.sendMessageCalls.length === 1, 'the message should reach the provider');
assert.deepStrictEqual({
sendMessageCalls: agent.sendMessageCalls.length,
baseBranch: await db.getMetadata(META_DIFF_BASE_BRANCH),
}, {
sendMessageCalls: 1,
baseBranch: undefined,
});
});
test('folder send uses the folder and branch selected after draft creation', async () => {
const db = new TestSessionDatabase();
const original = URI.file('/workspace/original');
@@ -9497,12 +9579,12 @@ suite('AgentService (node dispatcher)', () => {
test('new standard allocations skip tombstones and occupied raw-ID directories', async () => {
const tombstoned: string[] = [];
const database = new class extends TestAgentHostOrchestratorDatabase {
override async isSessionTombstoned(session: string): Promise<boolean> {
override async isSessionSuppressed(session: string): Promise<boolean> {
if (session.startsWith('ahp-session:') && tombstoned.length === 0) {
tombstoned.push(session);
await this.markSessionTombstoned(session);
}
return super.isSessionTombstoned(session);
return super.isSessionSuppressed(session);
}
}();
const occupied: URI[] = [];
@@ -20411,14 +20493,14 @@ suite('AgentService (node dispatcher)', () => {
await svc.listSessions();
const session = AgentSession.uri('copilot', 'announcement-retry');
const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry;
const originalIsTombstoned = registry.isTombstoned.bind(registry);
const originalIsSuppressed = registry.isSuppressed.bind(registry);
let fail = true;
registry.isTombstoned = async candidate => {
registry.isSuppressed = async candidate => {
if (fail && candidate.toString() === session.toString()) {
fail = false;
throw new Error('transient tombstone read failure');
}
return originalIsTombstoned(candidate);
return originalIsSuppressed(candidate);
};
agent.fireDiscoveredChats([discoveredChat(session, false)]);
@@ -30169,7 +30251,137 @@ suite('AgentService (node dispatcher)', () => {
]);
});
test('ephemeral session teardown clears its discovery tombstone', async () => {
test('ephemeral sessions accept central state writes and teardown deletes their hidden state', async () => {
const perSession = createPerSessionDataService();
const database = disposables.add(new AgentHostDatabase(':memory:'));
const svc = disposables.add(createTestAgentService(
new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(),
undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database,
));
// Like Copilot, a provisional provider returns backing data that is persisted before the session is hidden.
class ProvisionalBackedAgent extends MockAgent {
override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({
createChat: async (chat, context, options) => ({
...await expectCreatedChat(base.createChat(chat, context, options)),
providerData: 'sdk-session',
provisional: true,
}),
}));
}
registerTestAgentProvider(svc, disposables.add(new ProvisionalBackedAgent('copilot')));
const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry;
const session = await svc.createSession({
legacyChatUris: true,
provider: 'copilot',
_meta: withEphemeralSessionMeta(undefined, true),
});
await getTestSessionStateStore(svc).update(session, 'configValues', () => '{}', SessionStateMirrorFlush.Immediate);
const before = {
configValues: (await database.readSessionState(session.toString()))?.values.find(value => value.key === 'configValues')?.value,
hidden: await registry.isHidden(session),
tombstoned: await registry.isTombstoned(session),
suppressed: await registry.isSuppressed(session),
};
await svc.disposeSession(session);
assert.deepStrictEqual({
before,
after: {
state: await database.readSessionState(session.toString()),
hidden: await registry.isHidden(session),
tombstoned: await registry.isTombstoned(session),
},
}, {
before: { configValues: '{}', hidden: true, tombstoned: false, suppressed: true },
after: { state: undefined, hidden: false, tombstoned: false },
});
});
test('ephemeral sessions accept default-chat title snapshots', async () => {
const perSession = createPerSessionDataService();
const database = disposables.add(new AgentHostDatabase(':memory:'));
const svc = disposables.add(createTestAgentService(
new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(),
undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database,
));
registerTestAgentProvider(svc, disposables.add(new MockAgent('copilot')));
const session = await svc.createSession({
legacyChatUris: true,
provider: 'copilot',
_meta: withEphemeralSessionMeta(undefined, true),
});
const defaultChat = URI.parse(buildDefaultChatUri(session));
const persistence = getTestChatPersistenceService(svc);
const snapshotError = await persistence.persistDefaultChatTitleSnapshot(session, defaultChat, 'Original title').then(() => undefined, error => String(error));
assert.deepStrictEqual({
snapshotError,
hidden: await database.isSessionHidden(session.toString()),
tombstoned: await database.isSessionTombstoned(session.toString()),
}, {
snapshotError: undefined,
hidden: true,
tombstoned: false,
});
});
test('provider registration sweeps hidden sessions left by a previous host with their cold backings', async () => {
const swept = new DeferredPromise<void>();
class HiddenSessionDatabase extends AgentHostDatabase {
override async clearSessionTombstone(session: string): Promise<void> {
await super.clearSessionTombstone(session);
await swept.complete();
}
}
const disposed: { chat: string; backing: string | undefined }[] = [];
class BackedAgent extends MockAgent {
private _backing: string | undefined;
override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({
createChat: async (chat, context, options) => ({
...await base.createChat(chat, context, options),
providerData: 'hidden-sdk-session',
}),
}));
override async materializeChat(_chat: URI, _context: URI | IAgentChatContext, providerData: string | undefined): Promise<void> {
this._backing = providerData;
}
override async disposeChat(_session: URI, chat: URI): Promise<void> {
disposed.push({ chat: chat.toString(), backing: this._backing });
}
}
const perSession = createPerSessionDataService();
const database = disposables.add(new HiddenSessionDatabase(':memory:'));
const createService = () => disposables.add(createTestAgentService(
new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(),
undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database,
));
const creating = createService();
registerTestAgentProvider(creating, disposables.add(new BackedAgent('copilot')));
const session = await creating.createSession({
legacyChatUris: true,
provider: 'copilot',
_meta: withEphemeralSessionMeta(undefined, true),
});
await creating.whenCatalogReconciliationIdle();
const restarted = createService();
registerTestAgentProvider(restarted, disposables.add(new BackedAgent('copilot')));
await swept.p;
assert.deepStrictEqual({
disposed,
hidden: await database.isSessionHidden(session.toString()),
tombstoned: await database.isSessionTombstoned(session.toString()),
state: await database.readSessionState(session.toString()),
}, {
disposed: [{ chat: buildDefaultChatUri(session), backing: 'hidden-sdk-session' }],
hidden: false,
tombstoned: false,
state: undefined,
});
});
test('ephemeral session teardown clears its hidden marker', async () => {
const perSession = createPerSessionDataService();
const agent = disposables.add(new MockAgent('copilot'));
const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
@@ -30181,15 +30393,15 @@ suite('AgentService (node dispatcher)', () => {
provider: 'copilot',
_meta: withEphemeralSessionMeta(undefined, true),
});
const tombstonedBeforeDispose = await registry.isTombstoned(session);
const hiddenBeforeDispose = await registry.isHidden(session);
await svc.disposeSession(session);
assert.deepStrictEqual({
tombstonedBeforeDispose,
tombstonedAfterDispose: await registry.isTombstoned(session),
hiddenBeforeDispose,
hiddenAfterDispose: await registry.isHidden(session),
}, {
tombstonedBeforeDispose: true,
tombstonedAfterDispose: false,
hiddenBeforeDispose: true,
hiddenAfterDispose: false,
});
});
@@ -74,6 +74,7 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
private readonly _sessionsV2Backfilled = new Set<string>();
private readonly _sessionsV2Exclusions = new Map<string, IAgentHostDatabaseSessionsV2Exclusion>();
private readonly _tombstones = new Set<string>();
private readonly _hiddenSessions = new Map<string, string>();
private _writeFailures = 0;
private _readFailures = 0;
listCalls = 0;
@@ -89,7 +90,7 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
this._throwWriteFailure();
if (registerOptions.checkTombstone && this._tombstones.has(session)) {
if (registerOptions.checkTombstone && (this._tombstones.has(session) || this._hiddenSessions.has(session))) {
return false;
}
const { provider, startTime, modifiedTime = startTime, source } = sessionOptions;
@@ -103,6 +104,7 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
this.sessions.set(session, { ...next, modifiedTime: Math.max(existing?.modifiedTime ?? modifiedTime, modifiedTime) });
if (!registerOptions.checkTombstone) {
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
return true;
}
@@ -118,6 +120,23 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
this.sessions.delete(session);
}
override async hideSession(session: string, provider: string): Promise<void> {
this._throwWriteFailure();
this.sessions.delete(session);
this._tombstones.delete(session);
this._hiddenSessions.set(session, provider);
}
override async isSessionHidden(session: string): Promise<boolean> {
this._throwReadFailure();
return this._hiddenSessions.has(session);
}
override async listHiddenSessions(): Promise<readonly { readonly session: string; readonly provider: string }[]> {
this._throwReadFailure();
return [...this._hiddenSessions].map(([session, provider]) => ({ session, provider }));
}
async updateSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise<void> {
this.externalUpdates.push(...updates);
for (const update of updates) {
@@ -235,6 +254,11 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
return this._tombstones.has(session);
}
override async isSessionSuppressed(session: string): Promise<boolean> {
this._throwReadFailure();
return this._tombstones.has(session) || this._hiddenSessions.has(session);
}
async markSessionTombstoned(session: string): Promise<void> {
this._throwWriteFailure();
this._tombstones.add(session);
@@ -243,11 +267,13 @@ class TestAgentHostDatabase extends SessionStateUnsupportedDatabase implements I
async clearSessionTombstone(session: string): Promise<void> {
this._throwWriteFailure();
this._tombstones.delete(session);
this._hiddenSessions.delete(session);
}
async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise<boolean> {
if (registerOptions.discoveryBackingSession !== undefined
&& (this._tombstones.has(registerOptions.discoveryBackingSession)
|| this._hiddenSessions.has(registerOptions.discoveryBackingSession)
|| registerOptions.discoveryBackingSession !== session && this.sessions.has(registerOptions.discoveryBackingSession)
|| [...this._sessionsV2Exclusions.values()].some(exclusion =>
(exclusion.session === session && exclusion.provider !== sessionOptions.provider
@@ -1037,6 +1063,48 @@ suite('AgentSessionRegistry', () => {
assert.strictEqual(await registry.isTombstoned(a), true, 'the tombstone must remain in place');
});
test('hidden sessions stay suppressed through discovery and are only tombstoned at teardown', async () => {
const registry = createRegistry();
await registerExplicit(registry, a, 'copilot', 100);
await registry.hide(a, 'copilot');
const second = createRegistry();
const snapshot = async () => ({
hidden: await second.isHidden(a),
tombstoned: await second.isTombstoned(a),
suppressed: await second.isSuppressed(a),
sessions: (await second.listHidden()).map(({ session, provider }) => ({ session: session.toString(), provider })),
listed: await list(second),
});
const discovered = await registerDiscovered(second, a, 'copilot', 200);
const discoveredViaBacking = await second.registerDiscovered(AgentSession.uri('ahp-session', 'a'), a, { provider: 'copilot', startTime: 200, source: 'discovery' });
const legacyDiscovered = await database.registerSession(a.toString(), { provider: 'copilot', startTime: 200, source: 'discovery' }, { checkTombstone: true });
const v2Discovered = await database.registerSessionV2(a.toString(), { provider: 'copilot', startTime: 200, source: 'discovery' }, { checkTombstone: true });
const hidden = await snapshot();
await second.tombstone(a);
const retombstoned = await snapshot();
await second.clearTombstone(a);
const cleared = await snapshot();
const expectedHidden = { hidden: true, tombstoned: false, suppressed: true, sessions: [{ session: a.toString(), provider: 'copilot' }], listed: [] };
assert.deepStrictEqual({ discovered, discoveredViaBacking, legacyDiscovered, v2Discovered, hidden, retombstoned, cleared }, {
discovered: false, discoveredViaBacking: false, legacyDiscovered: false, v2Discovered: false,
hidden: expectedHidden, retombstoned: { ...expectedHidden, tombstoned: true },
cleared: { hidden: false, tombstoned: false, suppressed: false, sessions: [], listed: [] },
});
});
test('explicit registration clears the hidden marker as well as the tombstone', async () => {
const registry = createRegistry();
await registry.hide(a, 'copilot');
await registerExplicit(registry, a, 'copilot', 100);
assert.deepStrictEqual({
hidden: await registry.isHidden(a),
tombstoned: await registry.isTombstoned(a),
suppressed: await registry.isSuppressed(a),
sessions: await registry.listHidden(),
listed: (await list(registry)).map(entry => entry.session.toString()),
}, { hidden: false, tombstoned: false, suppressed: false, sessions: [], listed: [a.toString()] });
});
test('discovery registers a session that is not tombstoned', async () => {
const registry = createRegistry();
const registered = await registerDiscovered(registry, a, 'copilot', 100);
@@ -174,6 +174,10 @@ export abstract class SessionStateUnsupportedDatabase implements IAgentHostSessi
this._sessionStateDatabase = undefined;
}
hideSession(...args: Parameters<IAgentHostDatabase['hideSession']>): ReturnType<IAgentHostDatabase['hideSession']> { return this._sessionState.hideSession(...args); }
isSessionSuppressed(...args: Parameters<IAgentHostDatabase['isSessionSuppressed']>): ReturnType<IAgentHostDatabase['isSessionSuppressed']> { return this._sessionState.isSessionSuppressed(...args); }
isSessionHidden(...args: Parameters<IAgentHostDatabase['isSessionHidden']>): ReturnType<IAgentHostDatabase['isSessionHidden']> { return this._sessionState.isSessionHidden(...args); }
listHiddenSessions(...args: Parameters<IAgentHostDatabase['listHiddenSessions']>): ReturnType<IAgentHostDatabase['listHiddenSessions']> { return this._sessionState.listHiddenSessions(...args); }
readSessionState(...args: Parameters<IAgentHostSessionStateDatabase['readSessionState']>): ReturnType<IAgentHostSessionStateDatabase['readSessionState']> { return this._sessionState.readSessionState(...args); }
getWorktreeDiskUsageRevision(...args: Parameters<IAgentHostDatabase['getWorktreeDiskUsageRevision']>): ReturnType<IAgentHostDatabase['getWorktreeDiskUsageRevision']> { return this._sessionState.getWorktreeDiskUsageRevision(...args); }
readWorktreeDiskUsage(...args: Parameters<IAgentHostDatabase['readWorktreeDiskUsage']>): ReturnType<IAgentHostDatabase['readWorktreeDiskUsage']> { return this._sessionState.readWorktreeDiskUsage(...args); }
@@ -224,6 +224,33 @@ suite('Session storage identity compatibility', () => {
});
}
test('hidden sessions persist in storage spelling and list in runtime spelling after reopening', async () => {
const db = await database();
const session = 'ahp-session:/hidden';
const storage = 'copilotcli:/hidden';
await db.prepareSessionStorage(session, 'copilotcli');
await db.hideSession(session, 'copilotcli');
await db.close();
const next = await database();
const hidden = {
runtime: await next.listHiddenSessions(),
storage: await next.raw.listHiddenSessions(),
isHidden: await next.isSessionHidden(session),
tombstoned: await next.isSessionTombstoned(session),
suppressed: await next.isSessionSuppressed(session),
stored: await next.raw.isSessionHidden(storage),
notRuntime: await next.raw.isSessionHidden(session),
};
await next.clearSessionTombstone(session);
assert.deepStrictEqual({ hidden, cleared: await next.listHiddenSessions(), tombstoned: await next.isSessionTombstoned(session) }, {
hidden: {
runtime: [{ session, provider: 'copilotcli' }], storage: [{ session: storage, provider: 'copilotcli' }],
isHidden: true, tombstoned: false, suppressed: true, stored: true, notRuntime: false,
},
cleared: [], tombstoned: false,
});
});
test('discovery does not allocate a compatibility identity over a deleted legacy backing', async () => {
const db = await database();
await db.raw.markSessionTombstoned('copilotcli:/deleted');
@@ -237,6 +264,27 @@ suite('Session storage identity compatibility', () => {
}, { registered: false, tombstoned: true, markers: [] });
});
for (const hidden of ['ahp-session:/hidden', 'copilotcli:/hidden']) {
test(`discovery does not allocate a compatibility identity over hidden ${hidden}`, async () => {
const db = await database();
const session = 'ahp-session:/hidden';
await db.raw.hideSession(hidden, 'copilotcli');
await db.prepareSessionStorage(session, 'copilotcli');
const registered = await db.registerRuntimeSession(session, {
provider: 'copilotcli', startTime: 2, source: 'discovery',
}, { checkTombstone: true, discoveryBackingSession: 'copilotcli:/hidden' });
assert.deepStrictEqual({
registered,
hidden: await db.raw.isSessionHidden(hidden),
tombstoned: await db.raw.isSessionTombstoned(hidden),
suppressed: await db.raw.isSessionSuppressed(hidden),
identity: db.identity.toStorage(session),
markers: await db.raw.readStorageIdentityMetadata('sessionStorageIdentity:'),
registrations: await db.listSessionV2Registrations(),
}, { registered: false, hidden: true, tombstoned: false, suppressed: true, identity: session, markers: [], registrations: [] });
});
}
test('preparing deleted native data cannot bypass explicit creation collision checks', async () => {
const db = await database();
const session = URI.parse('ahp-session:/deleted');