Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Archive only after metadata hydration

Status: implemented
Translation: pending

## Abstract

Session Detail can render a bootstrapped root Session before the workspace metadata scan has
discovered its child Tabs. Archive now waits for that initial scan before deriving the lifecycle
subtree, preventing an early action from archiving only the root. Already-hydrated actions retain
their existing repository-read and rendered-cache fallback behavior.

## Problem

Archive derives lifecycle descendants from `sessionMetaCacheAtom`. During cold start, bootstrap
metadata may make the requested root interactive while `docMetaCacheReadyAtom` is still false and
the cache does not yet contain a direct `parentSessionId` child. Starting writes from that partial
view leaves the child active after its root is archived.

## Decision

`archiveSession` waits for both `docMetaCacheReadyAtom` and a ready `docMetaCacheScopeAtom` owned by
the captured workspace runtime before reading the lifecycle cache or authoring archive side effects.
Readiness is the existing signal that the workspace-wide metadata scan has merged its snapshot with
live events, so this keeps descendant discovery on the same source of truth without issuing another
full metadata query for each archive action. A runtime switch rejects the pending action and releases
its subscriptions rather than combining the old repository with a new workspace cache.

The individual root metadata read still prefers the repository and falls back to rendered metadata.
This preserves closing a visible Session when its own repository read lags after the initial scan.

## Verification

The owning `use-session-actions` suite constructs a visible root with readiness false, starts an
archive, and asserts that no metadata write occurs. It then hydrates a synthetic direct child Tab,
marks the cache ready, and verifies that both root and child receive the archived idle state. A second
case switches runtimes during the wait and verifies rejection without writes. Existing coverage
continues to verify the rendered-meta fallback after initial hydration.

## Limits

This change does not alter which relationship fields define lifecycle containment, restore or delete
semantics, or metadata scan failure handling. It only closes the pre-hydration archive window tracked
by [Lody issue #574](https://github.com/LodyAI/Lody/issues/574).
5 changes: 3 additions & 2 deletions packages/components/src/components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ Ownership and explanations: [README.md](README.md).
reintroduce a create-then-hand-off flow (pending-turn refs, post-mount ref flushes): a
promoted tab must not exist before its first message is locally durable, and preserved
composer text crosses the promotion via the input draft cache, not a component ref.
`archiveSession` falls back to the rendered meta cache when the repo read lags
hydration, and a close failure surfaces a toast — never a silent no-op.
`archiveSession` waits for the initial doc-meta scan before deriving lifecycle
descendants, then falls back to the rendered meta cache when an individual repo read
lags; a close failure surfaces a toast — never a silent no-op.
- Desktop changelogs open in-app as sanitized Markdown with raw HTML off. Only
missing notes fall back to the website, via `getChangelogUrl` and
`openExternalUrl`, never a hardcoded link.
Expand Down
71 changes: 71 additions & 0 deletions packages/components/src/hooks/use-session-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import debug from 'debug';
import { v4 as uuidv4 } from 'uuid';
import { activeWorkspaceRuntimeAtom, type WorkspaceRuntime } from '@/atoms/runtime';
import {
docMetaCacheScopeAtom,
docMetaCacheReadyAtom,
setDocMetaByRoomIdAtom,
sessionMetaCacheAtom,
sessionMetaCountAtom,
Expand All @@ -64,6 +66,68 @@ import { useAuthenticatedConvex } from './use-authenticated-convex';

const log = debug('lody:session-actions');

function assertDocMetaCacheReadyForRuntime(
store: ReturnType<typeof useStore>,
runtime: WorkspaceRuntime
): void {
const activeRuntime = store.get(activeWorkspaceRuntimeAtom);
const cacheScope = store.get(docMetaCacheScopeAtom);
if (
activeRuntime !== runtime ||
cacheScope?.runtime !== runtime ||
!cacheScope.ready ||
!store.get(docMetaCacheReadyAtom)
) {
throw new Error('Workspace changed while waiting for session metadata');
}
}

function waitForDocMetaCacheReady(
store: ReturnType<typeof useStore>,
runtime: WorkspaceRuntime
): Promise<void> {
const isReady = () => {
const cacheScope = store.get(docMetaCacheScopeAtom);
return (
store.get(activeWorkspaceRuntimeAtom) === runtime &&
cacheScope?.runtime === runtime &&
cacheScope.ready &&
store.get(docMetaCacheReadyAtom)
);
};
if (isReady()) return Promise.resolve();

return new Promise((resolve, reject) => {
let settled = false;
let unsubscribeReady: () => void = () => undefined;
let unsubscribeScope: () => void = () => undefined;
let unsubscribeRuntime: () => void = () => undefined;
const settle = (error?: Error) => {
if (settled) return;
settled = true;
unsubscribeReady();
unsubscribeScope();
unsubscribeRuntime();
if (error) reject(error);
else resolve();
};
const check = () => {
if (store.get(activeWorkspaceRuntimeAtom) !== runtime) {
settle(new Error('Workspace changed while waiting for session metadata'));
return;
}
if (isReady()) settle();
};

unsubscribeReady = store.sub(docMetaCacheReadyAtom, check);
unsubscribeScope = store.sub(docMetaCacheScopeAtom, check);
unsubscribeRuntime = store.sub(activeWorkspaceRuntimeAtom, check);

// Close the check-to-subscribe race after installing all subscriptions.
check();
});
}

type RepoDocMetaPatch = Parameters<WorkspaceRuntime['repo']['upsertDocMeta']>[1];
type CreateSessionResult = {
sessionId: SessionId;
Expand Down Expand Up @@ -1208,10 +1272,17 @@ export function useSessionActions(): SessionActions {
throw new Error('Runtime not ready');
}

// Lifecycle descendants are discovered from the workspace-wide metadata
// cache. A rendered root can arrive through bootstrap data before that
// cache contains its child tabs, so do not author any archive writes until
// the initial scan establishes a complete containment view.
await waitForDocMetaCacheReady(store, runtime);
Comment thread
Dante-dan marked this conversation as resolved.

const sessionRoomId = getSessionRoomId(sessionId);
const repoMeta = (await runtime.repo.getDocMeta(sessionRoomId))?.meta as
| SessionMeta
| undefined;
assertDocMetaCacheReadyForRuntime(store, runtime);
// The repo read is preferred (freshest lifecycle fields), but it can lag
// a session the UI already renders. The archive write below is an
// idempotent patch, so the rendered meta cache is enough to proceed — a
Expand Down
116 changes: 112 additions & 4 deletions packages/components/tests/use-session-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ vi.mock('../src/hooks/use-authenticated-convex', () => ({
}));

import { runtimeAtom, type WorkspaceRuntime } from '../src/atoms/runtime';
import { docMetaCacheReadyAtom, sessionMetaCacheAtom } from '../src/atoms/doc-meta';
import {
docMetaCacheReadyAtom,
docMetaCacheScopeAtom,
sessionMetaCacheAtom,
} from '../src/atoms/doc-meta';
import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '../src/atoms/workspace-context';
import {
countSessionMentions,
Expand Down Expand Up @@ -234,11 +238,18 @@ describe('useSessionActions', () => {
workspaceSlug?: string | null;
docMetaCacheReady?: boolean;
sessionMetaCache?: Record<string, SessionMeta>;
jotaiStore?: ReturnType<typeof createStore>;
} = {}
): Promise<SessionActions> => {
const jotaiStore = createStore();
const jotaiStore = options.jotaiStore ?? createStore();
jotaiStore.set(runtimeAtom, runtime);
jotaiStore.set(docMetaCacheReadyAtom, options.docMetaCacheReady ?? false);
jotaiStore.set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: options.docMetaCacheReady ?? false,
});
jotaiStore.set(sessionMetaCacheAtom, options.sessionMetaCache ?? {});
jotaiStore.set(currentWorkspaceIdAtom, options.workspaceId ?? ('workspace-1' as WorkspaceId));
jotaiStore.set(currentWorkspaceSlugAtom, options.workspaceSlug ?? 'workspace-slug');
Expand Down Expand Up @@ -1051,7 +1062,7 @@ describe('useSessionActions', () => {
flush: vi.fn(async () => undefined),
} as unknown as WorkspaceRuntime['repo'],
});
const actions = await renderActions(runtime);
const actions = await renderActions(runtime, { docMetaCacheReady: true });

await actions.archiveSession(sessionId);

Expand Down Expand Up @@ -1080,6 +1091,7 @@ describe('useSessionActions', () => {
repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'],
});
const actions = await renderActions(runtime, {
docMetaCacheReady: true,
sessionMetaCache: { [getSessionRoomId(sessionId)]: renderedMeta },
});

Expand All @@ -1096,6 +1108,99 @@ describe('useSessionActions', () => {
);
});

it('waits for complete metadata hydration before archiving a root and its child tab', async () => {
const rootSession = {
id: 'archive-hydrating-root' as SessionId,
machineId: 'machine-root' as MachineId,
createdAt: '2026-09-10T00:00:00.000Z',
} as SessionMeta;
const tabSession = {
id: 'archive-hydrating-tab' as SessionId,
machineId: rootSession.machineId,
parentSessionId: rootSession.id,
createdAt: '2026-09-10T00:01:00.000Z',
} as SessionMeta;
const upsertDocMeta = vi.fn(async () => undefined);
const getDocMeta = vi.fn(async (roomId: string) => {
if (roomId === getSessionRoomId(rootSession.id)) return { meta: rootSession };
if (roomId === getMachineRoomId(rootSession.machineId)) return { meta: {} };
return undefined;
});
const runtime = createRuntime({
repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'],
});
const jotaiStore = createStore();
const actions = await renderActions(runtime, {
jotaiStore,
docMetaCacheReady: false,
sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession },
});

const archivePromise = actions.archiveSession(rootSession.id);
await Promise.resolve();
expect(upsertDocMeta).not.toHaveBeenCalled();

jotaiStore.set(sessionMetaCacheAtom, {
[getSessionRoomId(rootSession.id)]: rootSession,
[getSessionRoomId(tabSession.id)]: tabSession,
});
jotaiStore.set(docMetaCacheReadyAtom, true);
jotaiStore.set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: true,
});
await archivePromise;

for (const session of [rootSession, tabSession]) {
expect(upsertDocMeta).toHaveBeenCalledWith(
getSessionRoomId(session.id),
expect.objectContaining({ isArchived: true, status: { type: 'idle' } })
);
}
});

it('cancels a pre-hydration archive when the workspace runtime changes', async () => {
const sessionId = 'archive-old-workspace-root' as SessionId;
const sessionMeta = {
id: sessionId,
machineId: 'machine-old' as MachineId,
createdAt: '2026-09-10T00:00:00.000Z',
} as SessionMeta;
const upsertDocMeta = vi.fn(async () => undefined);
const runtime = createRuntime({
repo: {
getDocMeta: vi.fn(async () => ({ meta: sessionMeta })),
upsertDocMeta,
} as unknown as WorkspaceRuntime['repo'],
});
const jotaiStore = createStore();
const actions = await renderActions(runtime, {
jotaiStore,
docMetaCacheReady: false,
sessionMetaCache: { [getSessionRoomId(sessionId)]: sessionMeta },
});

const archivePromise = actions.archiveSession(sessionId);
const nextRuntime = createRuntime({ workspaceId: 'workspace-2' as WorkspaceId });
jotaiStore.set(runtimeAtom, nextRuntime);

await expect(archivePromise).rejects.toThrow(
'Workspace changed while waiting for session metadata'
);
expect(upsertDocMeta).not.toHaveBeenCalled();

jotaiStore.set(docMetaCacheReadyAtom, true);
jotaiStore.set(docMetaCacheScopeAtom, {
runtime: nextRuntime,
workspaceId: nextRuntime.workspaceId,
workspaceSlug: nextRuntime.workspaceSlug,
ready: true,
});
expect(upsertDocMeta).not.toHaveBeenCalled();
});

it('archives child tabs and independently opened session workspaces together', async () => {
const rootSession = {
id: 'archive-root' as SessionId,
Expand Down Expand Up @@ -1136,7 +1241,10 @@ describe('useSessionActions', () => {
const runtime = createRuntime({
repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'],
});
const actions = await renderActions(runtime, { sessionMetaCache });
const actions = await renderActions(runtime, {
docMetaCacheReady: true,
sessionMetaCache,
});

await actions.archiveSession(rootSession.id);

Expand Down