santifer/career-ops · error · SeedError
LOCK_TIMEOUT
LOCK_TIMEOUT
Error message
Timed out waiting for follow-ups lock at ${lockDir} What it means
followup-seed.mjs writes to data/follow-ups.md under an exclusive filesystem lock (mirrors merge-tracker.mjs). LOCK_TIMEOUT fires when the process cannot acquire that lock within timeoutMs (default 60000ms): neither mkdirSync won the race nor could a stale lock be recovered (owner PID still alive, or ownerless dir younger than staleMs floored at OWNERLESS_GRACE_MS). The lock guards the read-check-append critical section so two writers cannot corrupt follow-ups.md.
Source
Thrown at followup-seed.mjs:366
}
}
if (hasRecoverGuard) {
try {
if (lockCanRecover(lockDir, staleMs)) {
rmSync(lockDir, { recursive: true, force: true });
continue;
}
} finally {
rmSync(recoverGuardDir, { recursive: true, force: true });
}
}
await sleep(retryMs);
}
}
throw new SeedError('LOCK_TIMEOUT', `Timed out waiting for follow-ups lock at ${lockDir}`);
}
// --- Atomic write (mirrors writeFileAtomic in tracker.mjs / merge-tracker.mjs) --
function writeFileAtomic(filePath, content) {
const tmpPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
try {
writeFileSync(tmpPath, content);
renameSync(tmpPath, filePath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw err;
}
}
function appendPins(existingContent, pinLines) {
const joined = pinLines.join('\n');
if (existingContent == null) {View on GitHub (pinned to 9b17a8ac97)
Solutions
- Inspect the lock directory printed in the error (lockDir) — check owner.json for the pid and followups path; if that PID is an unrelated or dead process, remove the lock dir with rm -rf.
- Ensure no other followup-seed / merge-tracker / set-status process is actively writing to the same follow-ups.md.
- Increase CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS (default 60000) if contention is legitimate.
- If the owner PID is genuinely dead but the lock won't age out, lower CAREER_OPS_FOLLOWUPS_LOCK_STALE_MS (default 600000) so stale recovery triggers sooner — but never below OWNERLESS_GRACE_MS (1000ms).
Defensive patterns
Strategy: retry
Validate before calling
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
function isLockStale(lockDir, staleMs = 600000) {
try {
const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf-8'));
if (owner?.pid) {
try { process.kill(owner.pid, 0); return false; }
catch { return true; }
}
} catch {}
return true;
}
// Before calling seedFollowup, check for a stuck lock
const lockDir = resolveLockDir(null, followupsPath);
if (existsSync(lockDir) && isLockStale(lockDir)) {
console.warn(`Removing stale lock at ${lockDir}`);
rmSync(lockDir, { recursive: true, force: true });
} Try / catch
import { seedFollowup, SeedError } from './followup-seed.mjs';
try {
await seedFollowup(appNum, { force });
} catch (err) {
if (err instanceof SeedError && err.code === 'LOCK_TIMEOUT') {
console.error(`Lock contention on follow-ups. Wait or remove: ${lockDirFromMessage}`);
// Optionally retry once with a longer timeout:
// await seedFollowup(appNum, { force, lockTimeoutMs: 120000 });
process.exit(4);
}
throw err;
} Prevention
- Do not run followup-seed, merge-tracker, or set-status against the same tracker concurrently.
- In CI, clean /tmp lock dirs (career-ops-followups-*) before each run.
- If a process is killed, check for and remove its orphaned lock dir manually.
When it happens
Trigger: Another followup-seed or merge-tracker process holds the lock for the entire 60s window; a prior process was kill -9'd leaving owner.json whose PID was later reused by an unrelated OS process (so PID-liveness says alive); CAREER_OPS_FOLLOWUPS_LOCK points at a lock dir whose .recover guard is itself wedged and below the staleMs age floor.
Common situations: Running followup-seed concurrently with merge-tracker or a second seedBackfill; a crashed CI runner left an orphaned lock in /tmp whose owner PID now belongs to a different program; an aggressively short CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS override in CI combined with a legitimately busy lock.
Understand the failure class
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- pipeline lock timeout: ${lockDir} held > ${timeoutMs}ms
- portal-health lock timeout: ${lockDir} held > ${timeoutMs}ms
- Could not claim ${count} report slot(s) after ${MAX_RETRIES}
- Cannot verify tracker lock ownership at ${lockDir}
- ${msg}
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/1211e5a89a8e8459.
Report an issue: GitHub.