nexu-io/open-design · warning · WorkspaceBillingInterestError
stale_generation
stale_generation
Error message
stale_generation
What it means
Thrown by setClientInterests when the client sends a generation number lower than the one the coordinator already has tracked for that clientId. This is an optimistic concurrency guard: each client must monotonically increase its generation with every interest-set mutation. A stale generation indicates the client's local state is behind — likely because a previous mutation succeeded server-side but the client didn't record the new generation (e.g., a race between two concurrent calls from the same client). The error includes the server's current accepted generation as the second argument so the client can re-sync.
Source
Thrown at apps/daemon/src/collab/workspace-billing-runtime.ts:265
this.interestSweepTimer = this.scheduler.setInterval(() => {
this.sweepExpiredInterests();
}, Math.max(1, options.interestSweepIntervalMs ?? DEFAULT_INTEREST_SWEEP_INTERVAL_MS));
this.interestSweepTimer.unref?.();
}
setClientInterests(
input: WorkspaceBillingRuntimeInterestSet,
): WorkspaceBillingRuntimeInterestLease {
this.assertUsable();
const clientId = input.clientId.trim();
const generationText = input.clientGeneration.trim();
if (!clientId || !/^(?:0|[1-9]\d*)$/.test(generationText)) {
throw new WorkspaceBillingInterestError('invalid_generation');
}
const generation = BigInt(generationText);
const current = this.clients.get(clientId);
if (current && generation < current.generation) {
throw new WorkspaceBillingInterestError(
'stale_generation',
current.generation.toString(),
);
}
const keys = new Map<string, WorkspaceBillingRuntimeKey>();
for (const interest of input.interests) {
const key = normalizeKey(interest);
keys.set(runtimeKey(key), key);
}
if (keys.size > this.maxInterestsPerClient) {
throw new WorkspaceBillingInterestError('interest_capacity_exceeded');
}
if (current && generation === current.generation) {
if (!sameStringSet(current.keys, new Set(keys.keys()))) {
throw new WorkspaceBillingInterestError(
'generation_payload_mismatch',
current.generation.toString(),
);View on GitHub (pinned to 5be4028344)
Solutions
- Read the acceptedGeneration from the error and set the client-side counter to max(local, acceptedGeneration) before retrying.
- Serialize interest-set mutations per clientId so concurrent calls cannot interleave generations.
- On reconnect, always fetch the current accepted generation from the server before submitting new interests.
- Use a single in-flight request guard: do not send a new interest set until the previous one's lease is confirmed.
Example fix
// before — fire-and-forget with no generation sync
client.sendInterests(generation++, keys);
// after — reconcile on stale_generation
try {
const lease = await runtime.setClientInterests({ clientId, clientGeneration: String(gen), interests });
gen = BigInt(lease.acceptedGeneration);
} catch (error) {
if (error instanceof WorkspaceBillingInterestError && error.code === 'stale_generation') {
gen = BigInt(error.acceptedGeneration!) + 1n;
await runtime.setClientInterests({ clientId, clientGeneration: String(gen), interests });
} else throw error;
} Defensive patterns
Strategy: retry
Type guard
export function isStaleGenerationError(
error: unknown,
): error is WorkspaceBillingInterestError {
return (
error instanceof WorkspaceBillingInterestError &&
error.code === 'stale_generation'
);
} Try / catch
try {
const lease = await coordinator.setClientInterests({
clientId,
clientGeneration: String(generation),
interests,
});
} catch (error) {
if (error instanceof WorkspaceBillingInterestError &&
error.code === 'stale_generation' &&
error.acceptedGeneration) {
// Reconcile: adopt the server's generation, bump, and retry
generation = BigInt(error.acceptedGeneration) + 1n;
const lease = await coordinator.setClientInterests({
clientId,
clientGeneration: String(generation),
interests,
});
} else throw error;
} Prevention
- Always update the client-side generation from the lease's acceptedGeneration after each successful call.
- Serialize interest-set mutations per clientId to prevent concurrent generation races.
- On reconnect, fetch the server's current generation before submitting new interests.
- Never reuse a generation number after it has been acknowledged by the server.
When it happens
Trigger: Client A sends generation 3, which succeeds. Client A's local counter is still at 2 (didn't process the ack) and sends generation 2 again. Or two concurrent requests from the same clientId race: the second commit (generation 3) lands first, then the first commit (generation 2) arrives and is rejected as stale.
Common situations: Two browser tabs sharing the same clientId send interest updates concurrently. A reconnect after network loss replays a stale request from an old connection. The client increments its counter locally but the HTTP response is lost, so on retry it sends the same generation.
Related errors
- generation_payload_mismatch
- automation proposal not found
- invalid_generation
- interest_capacity_exceeded
- Cloudflare reported an unknown asset hash: ${hash}
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/55cb3fab1791aa25.
Report an issue: GitHub.