nexu-io/open-design · error · LiveArtifactStaleRefreshError
live artifact refresh is older than the latest committed ref
Error message
live artifact refresh is older than the latest committed refresh
What it means
Thrown as LiveArtifactStaleRefreshError by markLiveArtifactRefreshCommitted() when the supplied refreshId's ordinal is <= the last successfully committed ordinal. Once a newer refresh has been committed, older allocated-but-uncommitted ids are superseded and must not be committed — doing so would roll the artifact backwards. The error exposes the stale refreshId and the lastCommittedRefreshId so callers can re-sync.
Source
Thrown at apps/daemon/src/live-artifacts/store.ts:839
export async function markLiveArtifactRefreshCommitted(
options: MarkLiveArtifactRefreshCommittedOptions,
): Promise<LiveArtifactRefreshState> {
const artifactId = validateLiveArtifactStorageId(options.artifactId);
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
const refreshOrdinal = parseRefreshOrdinal(options.refreshId);
const state = await readLiveArtifactRefreshState(paths, options.projectId, artifactId);
if (refreshOrdinal >= state.nextRefreshOrdinal) {
throw validationError('refreshId', 'live artifact refresh id has not been allocated');
}
if ((state.lastCommittedRefreshOrdinal ?? 0) >= refreshOrdinal) {
const staleOptions: { projectId: string; artifactId: string; refreshId: string; lastCommittedRefreshId?: string } = {
projectId: options.projectId,
artifactId,
refreshId: options.refreshId,
};
if (state.lastCommittedRefreshId !== undefined) staleOptions.lastCommittedRefreshId = state.lastCommittedRefreshId;
throw new LiveArtifactStaleRefreshError('live artifact refresh is older than the latest committed refresh', staleOptions);
}
const nextState: LiveArtifactRefreshState = {
...state,
nextRefreshOrdinal: Math.max(state.nextRefreshOrdinal, refreshOrdinal + 1),
lastCommittedRefreshId: options.refreshId,
lastCommittedRefreshOrdinal: refreshOrdinal,
};
await writeLiveArtifactRefreshState(paths, nextState);
return nextState;
}
export async function markLiveArtifactRefreshRunning(
options: MarkLiveArtifactRefreshRunningOptions,
): Promise<LiveArtifactStoreRecord> {
const artifactId = validateLiveArtifactStorageId(options.artifactId);
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
const current = await readPersistedLiveArtifact(paths);View on GitHub (pinned to 5be4028344)
Solutions
- Acquire a fresh refresh lock to get a new refreshId, then commit that — never reuse an ordinal older than the latest committed one.
- Make commit calls idempotent at the caller by tracking the latest committed refreshId and short-circuiting retries that match it.
- Inspect error.lastCommittedRefreshId to reconcile local state before retrying.
- Serialize commits per artifact so a newer refresh cannot overtake an older in-flight one.
Example fix
// before — reusing a stale id after a newer commit
await markLiveArtifactRefreshCommitted({ ..., refreshId: 'refresh-000002' }); // throws if 000003 committed
// after — acquire a fresh lock first
const lock = await acquireLiveArtifactRefreshLock({ ... });
await markLiveArtifactRefreshCommitted({ ..., refreshId: lock.metadata.refreshId }); Defensive patterns
Strategy: try-catch
Type guard
import { LiveArtifactStaleRefreshError } from './store';
function isStaleRefresh(error: unknown): boolean {
return error instanceof LiveArtifactStaleRefreshError;
} Try / catch
import { markLiveArtifactRefreshCommitted, acquireLiveArtifactRefreshLock, LiveArtifactStaleRefreshError } from './store';
try {
await markLiveArtifactRefreshCommitted({ projectsRoot, projectId, artifactId, refreshId });
} catch (error) {
if (error instanceof LiveArtifactStaleRefreshError) {
// Do NOT retry with the same refreshId — acquire a fresh lock and recommit.
const lock = await acquireLiveArtifactRefreshLock({ projectsRoot, projectId, artifactId });
await markLiveArtifactRefreshCommitted({ projectsRoot, projectId, artifactId, refreshId: lock.metadata.refreshId });
return;
}
throw error;
} Prevention
- Never reuse a refreshId after a newer refresh has committed.
- Track the latest committed refreshId client-side to short-circuit no-op retries.
- Serialize commits per artifact to prevent overtaking.
When it happens
Trigger: Caller acquires refresh-000002, then separately acquires and commits refresh-000003, then tries to commit the now-stale refresh-000002. Also when a retry fires after the original call already succeeded but the response was lost.
Common situations: At-least-once retry logic that re-sends a commit after a network blip; two workers both attempting to commit different refreshes of the same artifact; UI re-submitting a stale form after a newer refresh completed.
Related errors
- live artifact refresh already active
- connector refresh output must be a JSON object
- refresh source ${source.type} is not supported yet
- No refresh source is available yet.
- Refresh is disabled for this artifact source.
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/450c124ef8f55a10.
Report an issue: GitHub.