Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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. The subscription therefore keeps readiness false while metadata or existence events
observed during the bootstrap window remain in its deferred projection queue, including any full
metadata fetch needed to initialize a newly discovered document. 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. The metadata subscription
suite also injects both metadata and existence events after the bootstrap snapshot is captured,
asserts readiness stays false while their deferred projection is outstanding, and verifies the newly
discovered child is cached before readiness becomes true.

## Limits

This change does not alter which relationship fields define lifecycle containment or restore/delete
semantics. A failed or empty live full-metadata fetch remains pending until a later event or the
bootstrap snapshot supplies authoritative metadata; this adds no retry policy. The change only closes
the pre-hydration archive window tracked by
[Lody issue #574](https://github.com/LodyAI/Lody/issues/574).
3 changes: 3 additions & 0 deletions packages/components/src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ Parent `AGENTS.md` files also apply.
and the mobile workspace stack do not start early. The workspace identity's
syncing state follows that same scoped readiness, not the coarser connection
state; an online transport does not imply that workspace data is ready.
- Doc-meta readiness includes live metadata and existence events observed during
the bootstrap scan. Do not publish a ready scope until their deferred projection
batches and required full-metadata fetches have settled.

## Billing data

Expand Down
107 changes: 76 additions & 31 deletions packages/components/src/atoms/doc-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,36 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
const existenceEpochByDocId = new Map<string, number>();
const existenceStateByDocId = new Map<string, DocExistenceState>();
const fullMetaFetchEpochByDocId = new Map<string, number>();
// The bootstrap scan overlaps the live watch. Keep readiness false until any
// events observed during that window, including their full-metadata fetches,
// have reached the cache projection.
const pendingPatches = new Map<string, Record<string, unknown>>();
type ExistenceEvent = { docId: string; state: DocExistenceState };
const pendingExistenceUpdates: ExistenceEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let bootstrapCacheMerged = false;
let pendingFullMetaFetches = 0;

const markCacheReadyIfSettled = () => {
if (
cancelled ||
!bootstrapCacheMerged ||
flushTimer !== null ||
pendingExistenceUpdates.length > 0 ||
pendingPatches.size > 0 ||
pendingFullMetaFetches > 0 ||
pendingMetaDocIds.size > 0
) {
return;
}
set(docMetaCacheReadyAtom, true);
set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: true,
});
};

const clearCachedDocMeta = (docId: string) => {
if (isSessionDocRoomId(docId)) {
Expand Down Expand Up @@ -603,26 +633,46 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {

const fetchEpoch = (fullMetaFetchEpochByDocId.get(docId) ?? 0) + 1;
fullMetaFetchEpochByDocId.set(docId, fetchEpoch);

void fetchDocMeta(runtime.repo, docId).then((meta) => {
if (cancelled) return;
if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return;
if (existenceStateByDocId.get(docId) === 'deleted') return;
if (expectedExistence) {
if (existenceStateByDocId.get(docId) !== expectedExistence.state) return;
if ((existenceEpochByDocId.get(docId) ?? 0) !== expectedExistence.epoch) return;
}
if (!meta) {
if (expectedExistence?.state === 'missing') {
clearCachedDocMeta(docId);
} else {
pendingMetaDocIds.add(docId);
pendingFullMetaFetches += 1;

void fetchDocMeta(runtime.repo, docId)
.then((meta) => {
if (cancelled) return;
if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return;
if (existenceStateByDocId.get(docId) === 'deleted') return;
if (expectedExistence) {
if (existenceStateByDocId.get(docId) !== expectedExistence.state) return;
if ((existenceEpochByDocId.get(docId) ?? 0) !== expectedExistence.epoch) return;
}
return;
}
pendingMetaDocIds.delete(docId);
setCachedDocMeta(docId, meta);
});
if (!meta) {
if (expectedExistence?.state === 'missing') {
pendingMetaDocIds.delete(docId);
clearCachedDocMeta(docId);
} else if (hasCachedDocMeta(docId)) {
pendingMetaDocIds.delete(docId);
} else {
pendingMetaDocIds.add(docId);
}
return;
}
pendingMetaDocIds.delete(docId);
setCachedDocMeta(docId, meta);
})
.catch((error: unknown) => {
if (cancelled) return;
if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return;
if (existenceStateByDocId.get(docId) === 'deleted') return;
if (hasCachedDocMeta(docId)) {
pendingMetaDocIds.delete(docId);
return;
}
pendingMetaDocIds.add(docId);
console.warn('[doc-meta] Failed to fetch metadata:', docId, error);
})
.finally(() => {
pendingFullMetaFetches -= 1;
markCacheReadyIfSettled();
});
};

const handleDocumentExistence = (docId: string, state: DocExistenceState) => {
Expand All @@ -635,6 +685,7 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
existenceStateByDocId.set(docId, state);

if (state === 'deleted') {
pendingMetaDocIds.delete(docId);
clearCachedDocMeta(docId);
return;
}
Expand Down Expand Up @@ -695,11 +746,6 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
// Bound each projection turn so reconnect catch-up cannot monopolize the
// mobile main thread. Rejected: microtask-only batching, which still blocks
// paint until a large CRDT metadata burst is fully projected into Jotai.
const pendingPatches = new Map<string, Record<string, unknown>>();
type ExistenceEvent = { docId: string; state: DocExistenceState };
const pendingExistenceUpdates: ExistenceEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

const takePendingPatchBatch = (maxEntries: number): Array<[string, Record<string, unknown>]> => {
const entries: Array<[string, Record<string, unknown>]> = [];
if (maxEntries <= 0) return entries;
Expand Down Expand Up @@ -758,6 +804,7 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
if (pendingExistenceUpdates.length > 0 || pendingPatches.size > 0) {
scheduleFlush();
}
markCacheReadyIfSettled();
};

function scheduleFlush() {
Expand Down Expand Up @@ -821,13 +868,11 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
set(agentConfigMetaCacheAtom, (prev) =>
mergeBootstrapMetaCache(cache.agents, prev, existenceStateByDocId)
);
set(docMetaCacheReadyAtom, true);
set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: true,
});
for (const docId of pendingMetaDocIds) {
if (hasCachedDocMeta(docId)) pendingMetaDocIds.delete(docId);
}
bootstrapCacheMerged = true;
markCacheReadyIfSettled();
});

return () => {
Expand Down
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
Loading