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,39 @@
# Coalesce overlapping local history refreshes

Status: implemented
Translation: pending
PR: [#563](https://github.com/LodyAI/Lody/pull/563)

## Abstract

Opening the same imported conversation in overlapping renderer lifecycles could start the same
local history refresh twice, while the CLI rejected the second request as an error. Local project
history operations now share the result of an equivalent in-flight request and serialize distinct
requests for the same project. The coordinator is process-local, so it does not claim coordination
between separate CLI processes.

## Decision and scope

The CLI service owns coordination because it is the shared boundary for catalog sync, selected
session import, and conflict resolution. A request identity includes the provider, workspace,
machine, local project, operation, root path, and operation target. Import targets are treated as a
set, so equivalent selections in a different order reuse the same result.

Distinct operations and targets retain the existing single-writer behavior by waiting for the
project's current tail instead of surfacing a synthetic “already running” error. The original
operation result or failure still reaches every caller that requested it. A settled operation is
removed before callers continue, and a failed operation does not poison the queue, so a later
refresh can run normally.

This supplements the imported-history guarantees in
[One history writer before windowed readers](../architecture/2026-09-07-single-history-writer.md).
It does not change replay comparison, catalog persistence, conflict resolution policy, or the
cross-process concurrency limit recorded there.

## Evidence and limits

Deterministic service tests use deferred promises to show that equivalent imports execute once and
return the same result, different targets execute in order, and fresh requests run after both
failure and successful completion. CLI typechecking and the focused service suite validate the
process-local contract. The tests do not reproduce renderer remount timing or exercise filesystem
and provider I/O.
104 changes: 67 additions & 37 deletions apps/cli/src/lib/local-project-history-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,54 @@ import {
import { formatErrorMessage } from '@/utils/format-error';
import type { Logger } from '@/utils/logger';

const syncLeases = new Set<string>();
type HistorySyncCoordinator = {
tail: Promise<void>;
requests: Map<string, Promise<unknown>>;
};

// History refreshes are initiated from more than one renderer surface. Keep one
// coordinator per local project so equivalent requests can share their result,
// while distinct operations still preserve the service's single-writer order.
const historySyncCoordinators = new Map<string, HistorySyncCoordinator>();

function withHistorySyncCoordination<T>(
coordinatorKey: string,
requestKey: string,
operation: () => Promise<T>
): Promise<T> {
let coordinator = historySyncCoordinators.get(coordinatorKey);
if (!coordinator) {
coordinator = { tail: Promise.resolve(), requests: new Map() };
historySyncCoordinators.set(coordinatorKey, coordinator);
}

const existing = coordinator.requests.get(requestKey);
if (existing) {
// A request key includes every operation input, so this promise has the
// same result type as the caller that originally registered it.
return existing as Promise<T>;
}

const current = coordinator.tail.then(operation);
const settled = current.then(
() => undefined,
() => undefined
);
coordinator.requests.set(requestKey, current);
coordinator.tail = settled;

const cleanup = () => {
if (coordinator.requests.get(requestKey) === current) {
coordinator.requests.delete(requestKey);
}
if (coordinator.requests.size === 0 && coordinator.tail === settled) {
historySyncCoordinators.delete(coordinatorKey);
}
};
void current.then(cleanup, cleanup);

return current;
}

// In-process serializer for machineRoomId-scoped catalog writes. History rows
// are stored in machine Flock localProject entries, but each provider still does
Expand Down Expand Up @@ -625,20 +672,13 @@ export class LocalProjectHistorySyncService {
localProjectId: LocalProjectId;
rootPath: string;
}): Promise<LocalProjectHistoryCatalogResult> {
const leaseKey =
const coordinatorKey =
`${this.providerKey}:${this.context.workspaceId}:` +
`${this.context.machineId}:${args.localProjectId}`;
if (syncLeases.has(leaseKey)) {
throw new Error(
`${getProviderLabel(this.provider)} history sync is already running for this local project`
);
}
syncLeases.add(leaseKey);
try {
return await this.syncLocalProjectInner(args);
} finally {
syncLeases.delete(leaseKey);
}
const requestKey = stableJson(['sync', args.rootPath]);
return withHistorySyncCoordination(coordinatorKey, requestKey, () =>
this.syncLocalProjectInner(args)
);
}

private async syncLocalProjectInner(args: {
Expand All @@ -658,20 +698,17 @@ export class LocalProjectHistorySyncService {
rootPath: string;
acpSessionIds: string[];
}): Promise<LocalProjectHistoryImportResult> {
const leaseKey =
const coordinatorKey =
`${this.providerKey}:${this.context.workspaceId}:` +
`${this.context.machineId}:${args.localProjectId}`;
if (syncLeases.has(leaseKey)) {
throw new Error(
`${getProviderLabel(this.provider)} history sync is already running for this local project`
);
}
syncLeases.add(leaseKey);
try {
return await this.importLocalProjectSessionsInner(args);
} finally {
syncLeases.delete(leaseKey);
}
const requestKey = stableJson([
'import',
args.rootPath,
[...new Set(args.acpSessionIds)].sort(),
]);
return withHistorySyncCoordination(coordinatorKey, requestKey, () =>
this.importLocalProjectSessionsInner(args)
);
}

async resolveHistoryConflict(args: {
Expand All @@ -680,20 +717,13 @@ export class LocalProjectHistorySyncService {
sessionId: SessionId;
acpSessionId: string;
}): Promise<LocalProjectHistoryConflictResolveResult> {
const leaseKey =
const coordinatorKey =
`${this.providerKey}:${this.context.workspaceId}:` +
`${this.context.machineId}:${args.localProjectId}`;
if (syncLeases.has(leaseKey)) {
throw new Error(
`${getProviderLabel(this.provider)} history sync is already running for this local project`
);
}
syncLeases.add(leaseKey);
try {
return await this.resolveHistoryConflictInner(args);
} finally {
syncLeases.delete(leaseKey);
}
const requestKey = stableJson(['resolve', args.rootPath, args.sessionId, args.acpSessionId]);
return withHistorySyncCoordination(coordinatorKey, requestKey, () =>
this.resolveHistoryConflictInner(args)
);
}

private async importLocalProjectSessionsInner(args: {
Expand Down
145 changes: 145 additions & 0 deletions apps/cli/tests/local-project-history-sync-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
ACPSessionId,
ExternalAcpHistorySyncMeta,
LocalProjectHistoryCatalogItem,
LocalProjectHistoryCatalogResult,
LocalProjectHistoryImportResult,
LocalProjectId,
MachineId,
SessionHistoryInput,
Expand All @@ -25,6 +27,54 @@ const machineId = 'machine-1' as MachineId;
const localProjectId = 'project-1' as LocalProjectId;
const provider = { cliType: 'builtin', agentType: 'codex' } as const;

function deferred<T>() {
let settle: { resolve: (value: T) => void; reject: (reason: unknown) => void } | undefined;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
settle = { resolve: promiseResolve, reject: promiseReject };
});
if (!settle) {
throw new Error('Promise executor did not initialize synchronously');
}
return { promise, ...settle };
}

function catalogResult(marker: number): LocalProjectHistoryCatalogResult {
return { listed: marker, lastListedAt: marker, sessions: [] };
}

function importResult(marker: number): LocalProjectHistoryImportResult {
return {
summary: {
listed: marker,
imported: marker,
refreshed: 0,
skipped: 0,
conflicted: 0,
failed: 0,
failures: [],
},
catalog: catalogResult(marker),
};
}

function historySyncService() {
return new LocalProjectHistorySyncService(
{} as never,
{
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as never,
{
workspaceId: 'workspace-1' as never,
machineId,
userId: 'user-1',
},
provider
);
}

function externalHistory(overrides: Partial<ExternalAcpHistorySyncMeta> = {}) {
return {
provider: { cliType: 'builtin', agentType: 'codex' },
Expand Down Expand Up @@ -91,6 +141,101 @@ function materializedReplay(
};
}

describe('local project history request coordination', () => {
const importArgs = (acpSessionIds: string[]) => ({
localProjectId,
rootPath: '/tmp/project-1',
acpSessionIds,
});

it('coalesces overlapping imports for the same session set', async () => {
const service = historySyncService();
const gate = deferred<LocalProjectHistoryImportResult>();
const starts: string[][] = [];
(
service as unknown as {
importLocalProjectSessionsInner: typeof service.importLocalProjectSessions;
}
).importLocalProjectSessionsInner = async (args) => {
starts.push(args.acpSessionIds);
return gate.promise;
};

const first = service.importLocalProjectSessions(importArgs(['acp-2', 'acp-1']));
const second = service.importLocalProjectSessions(importArgs(['acp-1', 'acp-2', 'acp-1']));
await Promise.resolve();

expect(starts).toEqual([['acp-2', 'acp-1']]);
const expected = importResult(2);
gate.resolve(expected);
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBe(expected);
expect(secondResult).toBe(expected);
});

it('serializes different requests for the same local project', async () => {
const service = historySyncService();
const firstGate = deferred<LocalProjectHistoryImportResult>();
const secondGate = deferred<LocalProjectHistoryImportResult>();
const events: string[] = [];
(
service as unknown as {
importLocalProjectSessionsInner: typeof service.importLocalProjectSessions;
}
).importLocalProjectSessionsInner = async (args) => {
const sessionId = args.acpSessionIds[0] ?? 'missing';
events.push(`start:${sessionId}`);
const result = sessionId === 'acp-1' ? await firstGate.promise : await secondGate.promise;
events.push(`finish:${sessionId}`);
return result;
};

const first = service.importLocalProjectSessions(importArgs(['acp-1']));
const second = service.importLocalProjectSessions(importArgs(['acp-2']));
await Promise.resolve();
expect(events).toEqual(['start:acp-1']);

firstGate.resolve(importResult(1));
await first;
await Promise.resolve();
expect(events).toEqual(['start:acp-1', 'finish:acp-1', 'start:acp-2']);

secondGate.resolve(importResult(2));
await second;
expect(events).toEqual(['start:acp-1', 'finish:acp-1', 'start:acp-2', 'finish:acp-2']);
});

it('allows a fresh request after completion or failure', async () => {
const service = historySyncService();
const outcomes: Array<Error | LocalProjectHistoryCatalogResult> = [
new Error('catalog unavailable'),
catalogResult(2),
catalogResult(3),
];
(
service as unknown as {
syncLocalProjectInner: typeof service.syncLocalProject;
}
).syncLocalProjectInner = async () => {
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
if (!outcome) throw new Error('unexpected extra refresh');
return outcome;
};

await expect(
service.syncLocalProject({ localProjectId, rootPath: '/tmp/project-1' })
).rejects.toThrow('catalog unavailable');
await expect(
service.syncLocalProject({ localProjectId, rootPath: '/tmp/project-1' })
).resolves.toEqual(catalogResult(2));
await expect(
service.syncLocalProject({ localProjectId, rootPath: '/tmp/project-1' })
).resolves.toEqual(catalogResult(3));
expect(outcomes).toEqual([]);
});
});

describe('decideHistoryRefresh', () => {
it('skips when replay digest is unchanged', () => {
expect(
Expand Down