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

  1. Always release with the exact array returned by reserveReportNumbers: const ids = await reserveReportNumbers(n); ... await releaseReportNumbers(ids);
  2. If the original array is gone, pass the saved token: await releaseReportNumbers([42], { reservationToken: token }).
  3. For intentional administrative cleanup of someone else's sentinels, use options.force === true (or the CLI --release), after verifying the reservation is truly stale.
  4. 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.
  5. 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

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


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)