Yeachan-Heo/oh-my-codex · error · UltragoalError
Timed out waiting for ultragoal mutation lock at ${repoRelat
Error message
Timed out waiting for ultragoal mutation lock at ${repoRelative(cwd, lockPath)}. What it means
Durable ultragoal mutations serialize through a file lock (open with O_EXCL in a retry loop with backoff capped at 250ms). If the lock file at lockPath cannot be acquired within the allotted attempts — because another process holds it or a stale lock file was left behind after a crash — this UltragoalError is thrown so the caller can retry rather than block forever.
Source
Thrown at src/ultragoal/artifacts.ts:956
options: { allowUnboundEnvironment?: boolean } = {},
): Promise<T> {
const beforeLock = await assertUltragoalWritableLifecycleAuthority(cwd, options);
await mkdir(ultragoalDir(cwd), { recursive: true });
const lockPath = join(ultragoalDir(cwd), ULTRAGOAL_MUTATION_LOCK);
let handle: Awaited<ReturnType<typeof open>> | undefined;
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
handle = await open(lockPath, 'wx');
await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: iso() }));
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') throw error;
await sleep(Math.min(25 + attempt * 5, 250));
}
}
if (!handle) {
throw new UltragoalError(`Timed out waiting for ultragoal mutation lock at ${repoRelative(cwd, lockPath)}.`);
}
try {
// The post-lock comparison addresses pointer changes while waiting for this
// lock only. A SessionStart publication can still land after it and before
// the operation's filesystem writes.
const afterLock = await assertUltragoalWritableLifecycleAuthority(cwd, options);
if (!writableAuthorityEquals(beforeLock, afterLock)) {
throw new UltragoalError(
`Refusing durable ultragoal mutation after writable lifecycle authority drift while waiting for the mutation lock: before lock ${describeWritableAuthority(beforeLock)}; after lock ${describeWritableAuthority(afterLock)}.`,
);
}
return await operation();
} finally {
await handle.close().catch(() => undefined);
await rm(lockPath, { force: true }).catch(() => undefined);
}
}
View on GitHub (pinned to 3ad79a8a6f)
Solutions
- Retry the operation with backoff — the lock is advisory and short-lived, so transient contention resolves itself
- Check for a stale lock file at the repo-relative path in the error message and delete it if no other process holds it (lsof/fuser to confirm)
- Serialize ultragoal mutations across your processes (queue them) instead of racing many writers
- If contention is chronic, reduce the number of concurrent mutators or lengthen your own outer timeout around the call
Example fix
// before
await appendStory(state, goal); // under contention -> timed out waiting for lock
// after
for (let i = 0; i < 5; i++) {
try { await appendStory(state, goal); break; }
catch (e) { if (!isLockTimeout(e) || i === 4) throw e; await sleep(500 * 2 ** i); }
} Defensive patterns
Strategy: retry
Validate before calling
import { existsSync } from 'node:fs';
function lockLooksStale(lockPath: string): boolean {
// only treat as stale if no other ultragoal process is running
return existsSync(lockPath);
} Type guard
function isLockTimeoutError(e: unknown): boolean {
return e instanceof UltragoalError && e.message.includes('Timed out waiting for ultragoal mutation lock');
} Try / catch
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await mutateUltragoalState(...);
} catch (e) {
if (!isLockTimeoutError(e) || attempt === 4) throw e;
await sleep(250 * 2 ** attempt);
}
} Prevention
- Queue ultragoal mutations instead of racing many concurrent writers
- Remove stale lock files only after confirming no live process holds them
- Use exponential backoff retries around mutation APIs
When it happens
Trigger: Calling any mutation wrapped by withUltragoalMutationLock while another long-running process (concurrent bench, another agent) holds the lock; or after a hard crash left the lock file on disk without an owner, exhausting retries with EEXIST every time.
Common situations: Parallel agents/benches mutating the same ultragoal state; a SIGKILLed process leaving a stale lock file; NFS/network filesystems where exclusive create semantics are flaky; very slow filesystems exceeding the total retry window.
Understand the failure class
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- timed out waiting for tmux extended-keys lease lock: ${lockP
- [native-assets] publication-lock-timeout: ${path}; elapsed=$
- exec_followup_queue_lock_timeout
- Timed out acquiring task claim lock for ${teamName}/${taskId
- autoresearch_active_run_exists
AI-assisted analysis of Yeachan-Heo/oh-my-codex@3ad79a8a6f (2026-08-27).
Data as JSON: /api/errors/a5e8ba80b77d27c5.
Report an issue: GitHub.