santifer/career-ops · error · Error
Reservation ownership token is required for release
Error message
Reservation ownership token is required for release
What it means
Sentinels are owned by the process that reserved them, proven by a UUID token attached (as a hidden Symbol property) to the array returned by reserveReportNumbers. releaseReportNumbers refuses to unlink sentinels unless you pass that token (options.reservationToken or the returned array itself), or set options.force === true for explicit administrative cleanup. This prevents one worker from deleting another worker's live reservation.
Solutions
- Always release with the exact array returned by reserveReportNumbers: const ids = await reserveReportNumbers(n); ... await releaseReportNumbers(ids);
- If the original array is gone, pass the saved token: await releaseReportNumbers([42], { reservationToken: token }).
- For intentional administrative cleanup of someone else's sentinels, use options.force === true (or the CLI --release), after verifying the reservation is truly stale.
- Persist the token (it is a plain UUID string) via options if you must cross process boundaries: read it as ids[RESERVATION_TOKEN] and store it in your job record.
- Run node reserve-report-num.mjs --gc to clear stale sentinels instead of hand-releasing without a token.
Example fix
// before const ids = await reserveReportNumbers(4); await releaseReportNumbers([ids[0], ids[1], ids[2], ids[3]]); // Error: token required // after const ids = await reserveReportNumbers(4); await releaseReportNumbers(ids); // token rides on the array via RESERVATION_TOKEN symbol
Defensive patterns
Strategy: try-catch
Validate before calling
const RESERVATION_TOKEN = Symbol.for('career-ops-report-reservation-token'); // conceptual
function canRelease(ids, options) {
return Boolean(options?.reservationToken) || Boolean(ids && Object.getOwnPropertySymbols(ids).length) || options?.force === true;
} Type guard
const hasReleaseAuth = (ids, options = {}) =>
options.force === true || (typeof options.reservationToken === 'string' && options.reservationToken.length > 0) ||
(Array.isArray(ids) && Object.getOwnPropertySymbols(ids).length > 0); Try / catch
try {
await releaseReportNumbers(ids);
} catch (err) {
if (err.message.includes('ownership token is required')) {
// no token available — only force if you verified the reservation is stale
if (isVerifiedStale(sentinelPath)) await releaseReportNumbers(nums, { force: true });
else throw err;
} else throw err;
} Prevention
- Always keep the array returned by reserveReportNumbers and pass it back to release — the token rides on it as a Symbol.
- Never rebuild the ID array by hand; symbols do not survive JSON round-trips.
- If crossing process boundaries, persist the token UUID explicitly in your job record and pass options.reservationToken.
- Reserve options.force for deliberate administrative cleanup, ideally via the CLI.
- Use --gc for stale sentinels instead of hand-releasing without a token.
When it happens
Trigger: Calling releaseReportNumbers([42]) with a hand-built plain array instead of the array returned by reserveReportNumbers, and without options.reservationToken — the Symbol-owned token is missing. Releasing across process boundaries (a supervisor process that did not make the reservation) without force:true. Passing reservationToken as an empty string or undefined after destructuring a config object.
Common situations: Splitting the reserve call and release call across modules/services so the original array (with its Symbol token) is lost. Persisting IDs to disk and reconstructing [42] later — symbols do not survive JSON serialization. Releasing a colleague's/cron job's sentinels without the admin force path.
Related errors
- Cannot verify tracker lock ownership at
- concurrent reservation test flaked
- Could not claim report slot(s) after retries
- ⚠️ Could not release report reservation
- ⚠️ Could not release report reservation
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/e02986e30f99db04.
Report an issue: GitHub.
Appendix: source
Thrown at reserve-report-num.mjs:238
throw new Error(`Could not claim ${count} report slot(s) after ${MAX_RETRIES} retries`);
}
/**
* Release reservation sentinels after report creation or on failure.
* Only the array returned by reserveReportNumbers owns its sentinels. The CLI
* uses force mode as an explicit administrative cleanup path.
*/
export async function releaseReportNumbers(numbers, options = {}) {
const reportsDir = reportsDirFor(options);
const values = Array.isArray(numbers) ? numbers : [numbers];
for (const num of values) {
if (!Number.isSafeInteger(num) || num < 1) {
throw new TypeError(`Report number must be a positive integer, got ${num}`);
}
}
const force = options.force === true;
const token = options.reservationToken || numbers?.[RESERVATION_TOKEN];
if (!force && !token) throw new Error('Reservation ownership token is required for release');
if (!existsSync(reportsDir)) return 0;
const trackerPath = trackerPathFor(options);
const lock = await acquireTrackerLock(trackerLockDirFor(trackerPath), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: trackerPath,
...options.lockOptions,
});
try {
return values.reduce(
(removed, num) => removed + Number(releaseSlot(reportsDir, num, { token, force })),
0,
);
} finally {
lock.release();
}View on GitHub (pinned to aac998c7ed)