{"record":{"id":"2e3174b28c44e622","repo":"santifer/career-ops","slug":"removed-stale-reservation-sentinel-name","errorCode":null,"errorMessage":"Removed stale reservation sentinel: ${name}","messagePattern":"Removed stale reservation sentinel: (.+?)","errorType":"console","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"verify-pipeline.mjs","lineNumber":246,"sourceCode":"if (boldScores === 0) ok('No bold in scores');\n\n// --- Check 8: Stale report-number sentinels (GC) ---\n// reserve-report-num.mjs drops NNN-RESERVED.md files in reports/ when a\n// number is claimed.  If the process crashed before writing the real report\n// and deleting the sentinel it will linger.  Sentinels older than 4 h are\n// stale; remove them here so they don't skew the next slot allocation.\nconst SENTINEL_MAX_AGE_MS = 4 * 60 * 60 * 1000;\nlet staleSentinels = 0;\nif (existsSync(REPORTS_DIR)) {\n  const now = Date.now();\n  for (const name of readdirSync(REPORTS_DIR)) {\n    if (!name.endsWith('-RESERVED.md')) continue;\n    const full = join(REPORTS_DIR, name);\n    try {\n      const { mtimeMs } = statSync(full);\n      if (now - mtimeMs > SENTINEL_MAX_AGE_MS) {\n        unlinkSync(full);\n        warn(`Removed stale reservation sentinel: ${name}`);\n        staleSentinels++;\n      }\n    } catch {\n      // Already gone between readdir and stat — fine.\n    }\n  }\n}\nif (staleSentinels === 0) ok('No stale reservation sentinels');\n\n// --- Check 9: Duplicate reports for the same company+role (#1425) ---\n// Two concurrent evaluators can each write a report for the same role.\n// merge-tracker dedups the TRACKER, but nothing watched reports/ itself.\n// Warning-level, not error: duplicates can be legitimate (re-evaluation\n// after a JD change).\nconst REPORT_FILE_RE = /^(\\d+)-(.+)-\\d{4}-\\d{2}-\\d{2}\\.md$/;\n// Shares normalizeTextKey with Check 2 so the two checks fold text the same\n// way (#2393). That is where the guarantee ends: this check keys off the\n// FILENAME slug, already ASCII by the time a report is written, while Check 2","sourceCodeStart":228,"sourceCodeEnd":264,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/verify-pipeline.mjs#L228-L264","documentation":"verify-pipeline.mjs check 8 garbage-collects reservation sentinels: reserve-report-num.mjs drops NNN-RESERVED.md files into reports/ as atomic claims on report slots, and if the claiming process dies before writing the real report and deleting the sentinel, the claim lingers and would skew future allocation. Sentinels older than 4 hours (SENTINEL_MAX_AGE_MS) are unlinked here with this warning; statSync races with concurrent deletion are swallowed (empty catch).","triggerScenarios":"A batch worker killed mid-run (CI timeout, OOM, Ctrl-C) after reserving but before writing; a releaseReportNumbers() failure (the #226 path); machine crash/sleep during a fan-out.","commonSituations":"CI ceilings killing long evaluators; laptop sleep interrupting overnight batches; any interrupted reservation whose --release was never issued.","solutions":["Nothing to repair -- the GC already removed the file; this warning records that a prior run died without releasing its slot.","If it recurs, identify which worker keeps dying and why (see test-all's slow-kill warning, OOM logs) -- the sentinel is a symptom, not the disease.","Always release in a finally path: node reserve-report-num.mjs --release NNN-MMM when a reservation goes unused."],"exampleFix":"// before: worker can die between reserve and write, leaking the sentinel\nconst nums = await reserveReportNumbers(count, opts);\nawait writeReports(nums);            // crash here -> stale NNN-RESERVED.md\n// after: release whatever was not consumed\nconst nums = await reserveReportNumbers(count, opts);\ntry { await writeReports(nums); }\nfinally { await releaseReportNumbers(unusedOf(nums), opts).catch(() => {}); }","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"// Make every reservation crash-safe: release unconsumed slots in finally\nconst nums = await reserveReportNumbers(count, { reportsDir });\nlet consumed = [];\ntry {\n  consumed = await writeReports(nums);\n} finally {\n  try { await releaseReportNumbers(nums.filter(n => !consumed.includes(n)), { reportsDir }); }\n  catch { /* verify-pipeline GCs anything left after 4h */ }\n}","preventionTips":["Reserve right before spawning workers (the sentinel lifetime is only 4h) and always release in a finally block.","Treat recurring 'Removed stale reservation sentinel' lines as a symptom of workers dying mid-run — find the killer (CI timeout, OOM), don't just let the GC mop up.","Run node verify-pipeline.mjs as part of post-batch cleanup so crashed-run residue is collected deterministically."],"tags":["verify-pipeline","sentinel-gc","reservations","crash-recovery","report-numbering"],"backgroundTag":"stale-lock-cleanup","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}