[object Object]

← back to Ticket System

Front desk: atomic lease-based ticket claim (TK-12235)

5b093eee4ea59e51850ef6821d17a39d13a40bb4 · 2026-09-25 10:12:22 -0700 · steve

Add lib.js claim/renew/release/activeLease/reapExpiredLeases (lease/lease_end
events folded under the existing withLock so check+write is atomic), wire
POST /api/claim (?dry=1 preview), /api/renew, /api/release into server.js
behind the existing basic-auth gate, add `tk claim/renew/release` and make
`tk take` refuse a live-leased ticket unless --force, and teach reaper.js to
skip live-leased tickets and sweep expired leases back to open. So two
workers on different Macs can never hold the same ticket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJmtLm2HzLnqEsye4iifpy

Files touched

Diff

commit 5b093eee4ea59e51850ef6821d17a39d13a40bb4
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 10:12:22 2026 -0700

    Front desk: atomic lease-based ticket claim (TK-12235)
    
    Add lib.js claim/renew/release/activeLease/reapExpiredLeases (lease/lease_end
    events folded under the existing withLock so check+write is atomic), wire
    POST /api/claim (?dry=1 preview), /api/renew, /api/release into server.js
    behind the existing basic-auth gate, add `tk claim/renew/release` and make
    `tk take` refuse a live-leased ticket unless --force, and teach reaper.js to
    skip live-leased tickets and sweep expired leases back to open. So two
    workers on different Macs can never hold the same ticket.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LJmtLm2HzLnqEsye4iifpy
---
 lib.js             | 140 +++++++++++++++++++++++++++++++++++++++++++-
 lib.test.js        | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 reaper.js          |  79 ++++++++++++++++++-------
 server.js          |  41 ++++++++++++-
 test/claim-once.js |  13 +++++
 tk                 |  50 ++++++++++++++--
 6 files changed, 459 insertions(+), 30 deletions(-)

diff --git a/lib.js b/lib.js
index 7585503e..703fa545 100644
--- a/lib.js
+++ b/lib.js
@@ -3,6 +3,7 @@
 const fs = require('fs');
 const path = require('path');
 const os = require('os');
+const { execFileSync } = require('child_process');
 
 const DATA_DIR = process.env.TICKET_DATA_DIR || path.join(os.homedir(), '.claude', 'tickets');
 const EVENTS = path.join(DATA_DIR, 'events.jsonl');
@@ -273,7 +274,144 @@ const resolveList = (refs, map) => { const out = []; if (!Array.isArray(refs)) r
   for (const r of refs) { if (!REFRE.test(String(r).trim())) continue; const id = resolveId(r, map); if (id && IDRE.test(id)) out.push(id); }
   return [...new Set(out)]; };
 
+// ── Front desk: lease-based ticket claim (TK-12235) ─────────────────────────
+// One atomic way for workers on N machines to get work so two workers can
+// never hold the same ticket. `lease`/`lease_end` are ordinary events on the
+// same append-only log; claim/renew/release all run their check+write inside
+// withLock (the same O_EXCL lock nextId() uses to avoid duplicate ids), so
+// the read-then-write is atomic against every concurrent local caller.
+const LEASE_MS = 30 * 60 * 1000; // 30 minutes
+const shortTk = id => (String(id).match(/^(TK-\d+)/) || [id, id])[1];
+function leaseId() { return 'L-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
+
+// Durable PARKED registry, same source server.js uses. Fail-open (unreadable
+// registry -> nothing parked) so a claim is never silently starved by a
+// broken registry read.
+const PARKED_MJS = path.join(os.homedir(), '.claude', 'skills', 'parked', 'parked.mjs');
+function parkedIds() {
+  try {
+    const out = execFileSync(process.execPath, [PARKED_MJS, 'list-parked', '--json'], { encoding: 'utf8', timeout: 8000 });
+    return new Set(JSON.parse(out).filter(e => e && e.kind === 'ticket').map(e => e.id));
+  } catch { return new Set(); }
+}
+
+// Fold lease/lease_end events into the CURRENT lease per ticket id (or none).
+// This is the raw latest lease regardless of expiry — activeLease() below is
+// the expiry-aware read most callers want.
+function leases() {
+  const map = new Map();
+  for (const ev of readEvents()) {
+    if (ev.type === 'lease') map.set(ev.id, { id: ev.id, agent: ev.agent || '', machine: ev.machine || '', engine: ev.engine || '', lease_id: ev.lease_id, lease_expires: ev.lease_expires, ts: ev.ts });
+    else if (ev.type === 'lease_end') { const l = map.get(ev.id); if (l && l.lease_id === ev.lease_id) map.delete(ev.id); }
+  }
+  return map;
+}
+// The live lease for id, or null if none / expired.
+function activeLease(id, nowMs = Date.now()) {
+  const l = leases().get(id);
+  if (!l) return null;
+  const exp = Date.parse(l.lease_expires);
+  if (!Number.isFinite(exp) || exp <= nowMs) return null;
+  return l;
+}
+
+// Pick the top eligible OPEN task ticket and atomically assign + lease it.
+// Eligible = kind 'task', status 'open', not parked, no live unexpired lease,
+// and (if `projects` given) project is in that list. Ranking: oldest
+// created_at first (FIFO), numeric id as tiebreak — no separate "board rank"
+// is a pure/exported function outside server.js's HTTP-only scoring, so this
+// is the "priority then oldest" fallback the spec allows.
+// `dry: true` returns what WOULD be claimed without writing any event.
+function claim({ machine = '', engine = '', agent = 'codex', projects, dry = false } = {}) {
+  agent = String(agent || '').trim();
+  if (!agent) throw new Error('agent is required');
+  const run = () => {
+    const nowMs = Date.now();
+    const map = tickets();
+    const parked = parkedIds();
+    const projSet = Array.isArray(projects) && projects.length ? new Set(projects.filter(Boolean)) : null;
+    const leaseMap = leases();
+    const candidates = [...map.values()].filter(t => {
+      if ((t.kind || 'task') !== 'task') return false;
+      if (t.status !== 'open') return false;
+      if (parked.has(shortTk(t.id)) || parked.has(t.id)) return false;
+      if (projSet && !projSet.has(t.project)) return false;
+      const l = leaseMap.get(t.id);
+      if (l) { const exp = Date.parse(l.lease_expires); if (Number.isFinite(exp) && exp > nowMs) return false; }
+      return true;
+    });
+    if (!candidates.length) return null;
+    candidates.sort((a, b) => (+new Date(a.created_at) - +new Date(b.created_at)) || a.id.localeCompare(b.id, 'en', { numeric: true }));
+    const t = candidates[0];
+    if (dry) return { id: t.id, title: t.title, project: t.project, agent, machine, engine, dry: true };
+    const tsNow = now();
+    const lease_id = leaseId();
+    const lease_expires = new Date(nowMs + LEASE_MS).toISOString();
+    append({ ts: tsNow, type: 'assign', id: t.id, agent });
+    append({ ts: tsNow, type: 'status', id: t.id, status: 'doing', agent });
+    append({ ts: tsNow, type: 'lease', id: t.id, agent, machine, engine, lease_id, lease_expires });
+    return { id: t.id, title: t.title, project: t.project, agent, machine, engine, lease_id, lease_expires };
+  };
+  return dry ? run() : withLock(run); // read-only dry preview needs no lock
+}
+// Extend a live lease by LEASE_MS. Fails (throws) if lease_id doesn't match
+// the current live lease, or the lease already expired.
+function renew({ id, lease_id }) {
+  id = requireTicket(id);
+  return withLock(() => {
+    const l = leases().get(id);
+    if (!l || l.lease_id !== lease_id) throw new Error('no matching live lease for ' + id);
+    const exp = Date.parse(l.lease_expires);
+    if (!Number.isFinite(exp) || exp <= Date.now()) throw new Error('lease expired for ' + id);
+    const tsNow = now();
+    const lease_expires = new Date(Date.now() + LEASE_MS).toISOString();
+    append({ ts: tsNow, type: 'lease', id, agent: l.agent, machine: l.machine, engine: l.engine, lease_id, lease_expires });
+    return { id, lease_id, lease_expires };
+  });
+}
+// End a lease (matching lease_id required, expired or not — so a stale local
+// lease can always be cleaned up), optionally also setting ticket status.
+function release({ id, lease_id, status }) {
+  id = requireTicket(id);
+  return withLock(() => {
+    const l = leases().get(id);
+    if (!l || l.lease_id !== lease_id) throw new Error('no matching live lease for ' + id);
+    const tsNow = now();
+    append({ ts: tsNow, type: 'lease_end', id, lease_id, agent: l.agent, reason: 'release' });
+    if (status) {
+      status = String(status).toLowerCase();
+      if (!STATUSES.includes(status)) throw new Error('invalid status: ' + status);
+      append({ ts: tsNow, type: 'status', id, status, agent: l.agent });
+    }
+    return { id, released: true, status: status || null };
+  });
+}
+// Sweep every EXPIRED live lease: end it, and (only if the ticket is still
+// 'doing' — i.e. nothing else already moved it on) reopen the ticket so it's
+// claimable again. Never touches a ticket whose lease is still live.
+function reapExpiredLeases(agent = 'reaper') {
+  return withLock(() => {
+    const nowMs = Date.now();
+    const map = tickets();
+    const swept = [];
+    for (const l of leases().values()) {
+      const exp = Date.parse(l.lease_expires);
+      if (Number.isFinite(exp) && exp > nowMs) continue; // still live — leave it alone
+      const tsNow = now();
+      append({ ts: tsNow, type: 'lease_end', id: l.id, lease_id: l.lease_id, agent, reason: 'expired' });
+      const t = map.get(l.id);
+      if (t && t.status === 'doing') {
+        append({ ts: tsNow, type: 'status', id: l.id, status: 'open', agent });
+        append({ ts: tsNow, type: 'action', id: l.id, agent, text: `lease expired (was held by ${l.agent}${l.machine ? '@' + l.machine : ''}) → reopened for claim [TK-12235]` });
+      }
+      swept.push({ id: l.id, wasAgent: l.agent, wasMachine: l.machine });
+    }
+    return swept;
+  });
+}
+
 module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule, EVENTS,
   IDRE, REFRE, resolveList,
   nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents,
-  correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, ticketBlocker, ticketDesignate, sendDm, markMessageRead };
+  correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, ticketBlocker, ticketDesignate, sendDm, markMessageRead,
+  leases, activeLease, claim, renew, release, reapExpiredLeases, shortTk, LEASE_MS };
diff --git a/lib.test.js b/lib.test.js
index 5c15ffaf..15d41c37 100644
--- a/lib.test.js
+++ b/lib.test.js
@@ -15,7 +15,12 @@ const lib = require('./lib.js');
 const {
   append, tickets, resolveId, resolveList, IDRE, STATUSES, KINDS, BLOCKER_TYPES, ticketBlocker, ticketDesignate,
   nextMid, messages, isBroadcast, withLock, EVENTS,
+  claim, renew, release, activeLease, leases, reapExpiredLeases,
 } = lib;
+const { execFile } = require('child_process');
+const { promisify } = require('util');
+const execFileP = promisify(execFile);
+const CLAIM_ONCE = path.join(__dirname, 'test', 'claim-once.js');
 
 // Fail LOUD if a future require-order regression lets EVENTS bind to the real
 // store (require before setting TICKET_DATA_DIR) — otherwise the suite would
@@ -251,3 +256,164 @@ test('two sequential withLock calls succeed and the lock is cleaned up', () => {
   assert.equal(b, 'second');
   assert.equal(fs.existsSync(lockPath), false);
 });
+
+// ── 8. Front desk: lease-based claim (TK-12235) ───────────────────────────────
+const mkOpenTask = (title, project = 'ticket-system') => {
+  const ev = append({ ts: iso(), type: 'create', id: `TK-${10100 + Math.floor(Math.random() * 89000)}-${title}`, title, project, agent: 'seed' });
+  return ev.id;
+};
+
+test('claim() picks an eligible open ticket and atomically assigns + leases it', () => {
+  const id = mkOpenTask('lease-claim-basic');
+  const r = claim({ agent: 'w1', machine: 'mac2', engine: 'codex', projects: ['ticket-system'] });
+  assert.ok(r, 'expected a claim');
+  const t = tickets().get(r.id);
+  assert.equal(t.status, 'doing');
+  assert.equal(t.assignee, 'w1');
+  const l = activeLease(r.id);
+  assert.ok(l, 'expected a live lease');
+  assert.equal(l.agent, 'w1');
+  assert.equal(l.lease_id, r.lease_id);
+});
+
+test('claim() dry:1 previews the pick without writing any event', () => {
+  const id = mkOpenTask('lease-claim-dry');
+  const before = fs.readFileSync(EVENTS, 'utf8').length;
+  const r = claim({ agent: 'w-dry', dry: true, projects: undefined });
+  const after = fs.readFileSync(EVENTS, 'utf8').length;
+  assert.equal(before, after, 'dry claim must not append any event');
+  // and the ticket must still be genuinely claimable afterward
+  const real = claim({ agent: 'w-real', projects: [tickets().get(id).project] });
+  assert.ok(real);
+});
+
+// NEGATIVE: renew with the wrong lease_id must fail, never silently extend.
+test('renew() with a mismatched lease_id throws (never extends someone else\'s lease)', () => {
+  const id = mkOpenTask('lease-renew-mismatch');
+  const r = claim({ agent: 'w2', projects: [tickets().get(id).project] });
+  assert.throws(() => renew({ id: r.id, lease_id: 'L-not-the-real-one' }), /no matching live lease/);
+  // the real lease is untouched
+  const l = activeLease(r.id);
+  assert.equal(l.lease_id, r.lease_id);
+});
+
+test('renew() with the correct lease_id extends lease_expires', () => {
+  const id = mkOpenTask('lease-renew-ok');
+  const r = claim({ agent: 'w3', projects: [tickets().get(id).project] });
+  const before = activeLease(r.id).lease_expires;
+  const out = renew({ id: r.id, lease_id: r.lease_id });
+  assert.equal(out.lease_id, r.lease_id);
+  assert.ok(Date.parse(out.lease_expires) >= Date.parse(before));
+});
+
+test('release() ends a lease and can also set a status', () => {
+  const id = mkOpenTask('lease-release');
+  const r = claim({ agent: 'w4', projects: [tickets().get(id).project] });
+  const out = release({ id: r.id, lease_id: r.lease_id, status: 'open' });
+  assert.equal(out.released, true);
+  assert.equal(activeLease(r.id), null, 'lease must be gone after release');
+  assert.equal(tickets().get(r.id).status, 'open');
+  // released ticket is claimable again immediately
+  const r2 = claim({ agent: 'w5', projects: [tickets().get(id).project] });
+  assert.ok(r2 && r2.id === r.id);
+});
+
+// NEGATIVE: an unexpired lease blocks a second claim of the SAME ticket (scoped by project).
+test('claim() never re-claims a ticket with a live lease', () => {
+  const project = 'lease-live-block-' + Date.now();
+  const id = mkOpenTask('lease-live-block', project);
+  const r1 = claim({ agent: 'holder', projects: [project] });
+  assert.ok(r1);
+  const r2 = claim({ agent: 'other', projects: [project] });
+  assert.equal(r2, null, 'no other ticket in this project to fall back to — must return null, not steal the live one');
+});
+
+// NEGATIVE: expired lease IS reclaimable, and reapExpiredLeases() reopens it + ends the lease,
+// while a ticket with a still-live lease is left completely untouched.
+test('an expired lease is reclaimable by claim(), and reapExpiredLeases() reaps it', () => {
+  const project = 'lease-expiry-' + Date.now();
+  const expiredId = mkOpenTask('lease-expired', project);
+  // Simulate a claim whose lease already expired (no need to wait the real 30m TTL).
+  append({ ts: iso(), type: 'assign', id: expiredId, agent: 'ghost' });
+  append({ ts: iso(), type: 'status', id: expiredId, status: 'doing', agent: 'ghost' });
+  append({ ts: iso(), type: 'lease', id: expiredId, agent: 'ghost', machine: 'mac1', engine: 'codex', lease_id: 'L-expired-1', lease_expires: iso(-60_000) });
+  assert.equal(activeLease(expiredId), null, 'an expired lease must not read as active');
+
+  // reclaimable: a fresh claim() picks it straight back up (still 'doing' but lease is dead).
+  // claim() only filters status==='open', so flip it back to open the way the real reaper would
+  // BEFORE reaping, to prove the "reclaimable" half independently of reapExpiredLeases().
+  append({ ts: iso(), type: 'status', id: expiredId, status: 'open', agent: 'ghost' });
+  const reclaimed = claim({ agent: 'newowner', projects: [project] });
+  assert.ok(reclaimed && reclaimed.id === expiredId);
+  assert.notEqual(reclaimed.lease_id, 'L-expired-1');
+
+  // Now prove reapExpiredLeases() itself: one ticket with an expired lease (status doing),
+  // one ticket with a LIVE lease — only the expired one may be touched.
+  const deadProject = 'lease-reap-dead-' + Date.now();
+  const deadId = mkOpenTask('lease-reap-dead', deadProject);
+  append({ ts: iso(), type: 'assign', id: deadId, agent: 'ghost2' });
+  append({ ts: iso(), type: 'status', id: deadId, status: 'doing', agent: 'ghost2' });
+  append({ ts: iso(), type: 'lease', id: deadId, agent: 'ghost2', machine: 'mac1', engine: 'codex', lease_id: 'L-expired-2', lease_expires: iso(-60_000) });
+
+  const liveProject = 'lease-reap-live-' + Date.now();
+  const liveId = mkOpenTask('lease-reap-live', liveProject);
+  const liveClaim = claim({ agent: 'alive', projects: [liveProject] });
+
+  const swept = reapExpiredLeases('reaper-test');
+  const sweptIds = swept.map(s => s.id);
+  assert.ok(sweptIds.includes(deadId), 'expired lease must be swept');
+  assert.equal(activeLease(deadId), null);
+  assert.equal(tickets().get(deadId).status, 'open', 'expired+doing ticket reopens on reap');
+
+  assert.ok(!sweptIds.includes(liveId), 'a ticket with a LIVE lease must never be swept');
+  const stillLive = activeLease(liveId);
+  assert.ok(stillLive, 'live lease must be untouched by reap');
+  assert.equal(stillLive.lease_id, liveClaim.lease_id);
+  assert.equal(tickets().get(liveId).status, 'doing', 'live-leased ticket status must be untouched');
+});
+
+// NEGATIVE: `tk take` refuses a ticket another agent holds a live lease on.
+test('CLI: tk take refuses a live-leased ticket unless --force', async () => {
+  const project = 'lease-cli-take-' + Date.now();
+  const id = mkOpenTask('lease-cli-take', project);
+  const TK = path.join(__dirname, 'tk');
+  const env = { ...process.env, TICKET_DATA_DIR: TMP };
+  // agentA claims it via the lib directly (simulating a live worker elsewhere).
+  const held = claim({ agent: 'agentA', machine: 'mac1', projects: [project] });
+  assert.ok(held);
+
+  // agentB tries to `tk take` the same ticket — must be refused, exit 2.
+  await assert.rejects(
+    execFileP(process.execPath, [TK, 'take', held.id], { env: { ...env, TK_AGENT: 'agentB' } }),
+    (err) => { assert.equal(err.code, 2); assert.match(err.stderr, /live lease/i); return true; }
+  );
+  // status/assignee are unchanged — the refusal did not mutate the ticket.
+  assert.equal(tickets().get(held.id).assignee, 'agentA');
+
+  // --force overrides, records a comment, and reassigns.
+  const { stdout } = await execFileP(process.execPath, [TK, 'take', held.id, '--force', 'testing override'], { env: { ...env, TK_AGENT: 'agentB' } });
+  assert.match(stdout, /forced over lease/);
+  assert.equal(tickets().get(held.id).assignee, 'agentB');
+});
+
+// NEGATIVE: two truly concurrent processes claiming from the SAME single-ticket
+// project must never both win — the O_EXCL lock serializes the check+write.
+test('CLI/process: concurrent claim() calls across real OS processes never double-assign', async () => {
+  const project = 'lease-race-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
+  const id = mkOpenTask('lease-race', project);
+  const env = { ...process.env, TICKET_DATA_DIR: TMP };
+  const N = 8;
+  const runs = await Promise.all(Array.from({ length: N }, (_, i) =>
+    execFileP(process.execPath, [CLAIM_ONCE, `racer-${i}`, 'mac-race', project], { env })
+      .then(r => JSON.parse(r.stdout))
+  ));
+  const winners = runs.filter(r => r.result);
+  assert.equal(winners.length, 1, `expected exactly 1 winner of the single-ticket race, got ${winners.length}: ${JSON.stringify(runs)}`);
+  assert.equal(winners[0].result.id, id);
+  const loseIds = new Set(runs.filter(r => !r.result).map(r => r.error || 'null'));
+  assert.ok(runs.every(r => r.error === null), `no claim() call should error: ${JSON.stringify(runs.filter(r => r.error))}`);
+  // exactly one live lease exists for this ticket afterward
+  const finalLease = activeLease(id);
+  assert.ok(finalLease);
+  assert.equal(finalLease.lease_id, winners[0].result.lease_id);
+});
diff --git a/reaper.js b/reaper.js
index 96cc7cdb..fcd7f0e8 100644
--- a/reaper.js
+++ b/reaper.js
@@ -12,7 +12,7 @@
 //   node reaper.js --block         # set each zombie → its suggested status (reversible)
 //
 const { execSync } = require('child_process');
-const { tickets: loadTickets } = require('./lib.js'); // read the store directly
+const { tickets: loadTickets, leases, reapExpiredLeases } = require('./lib.js'); // read the store directly
 const THRESHOLD_H = Number(process.env.REAPER_IDLE_H || 6);
 const BLOCK = process.argv.includes('--block');
 
@@ -62,10 +62,24 @@ function suggest(t, lastText) {
   return 'open'; // session died mid-work, not clearly finished or gated
 }
 
+// front-desk leases (TK-12235): a ticket with a LIVE (unexpired) lease is never
+// a zombie candidate, even if idle by the action-log heuristic above — a lease
+// renewal (tk renew) doesn't append an `action` event, so idleH alone can't see
+// it. Expired leases are swept separately, below.
+const leaseMap = leases();
+const nowLease = Date.now();
+function hasLiveLease(id) {
+  const l = leaseMap.get(id);
+  if (!l) return false;
+  const exp = Date.parse(l.lease_expires);
+  return Number.isFinite(exp) && exp > nowLease;
+}
+
 const zombies = [];
 for (const t of tickets) {
   if (t.status !== 'doing') continue;
   if (parkedTickets.has(shortId(t.id)) || parkedTickets.has(t.id)) continue; // parked → held, never reaped
+  if (hasLiveLease(t.id)) continue; // front desk lease is live → never touch
   const acts = (t.actions || []).map(a => ({ ts: +new Date(a.ts), text: a.text })).sort((a, b) => b.ts - a.ts);
   const last = acts[0] || { ts: +new Date(t.updated_at || t.created_at || now), text: '' };
   const idleH = (now - last.ts) / 3600000;
@@ -79,27 +93,48 @@ for (const t of tickets) {
   }
 }
 
-if (!zombies.length) { console.log(`✅ no zombie DOING tickets (idle ≥ ${THRESHOLD_H}h with no live worker).`); process.exit(0); }
-console.log(`⚠ ${zombies.length} zombie DOING ticket(s) — idle ≥ ${THRESHOLD_H}h, no live worker:\n`);
-for (const z of zombies)
-  console.log(`  ${z.id.padEnd(11)} idle ${String(z.idleH).padStart(6)}h  @${(z.assignee||'—').padEnd(22)} → ${z.suggest.padEnd(7)}  «${z.last}»`);
+if (!zombies.length) console.log(`✅ no zombie DOING tickets (idle ≥ ${THRESHOLD_H}h with no live worker).`);
+else {
+  console.log(`⚠ ${zombies.length} zombie DOING ticket(s) — idle ≥ ${THRESHOLD_H}h, no live worker:\n`);
+  for (const z of zombies)
+    console.log(`  ${z.id.padEnd(11)} idle ${String(z.idleH).padStart(6)}h  @${(z.assignee||'—').padEnd(22)} → ${z.suggest.padEnd(7)}  «${z.last}»`);
+
+  if (BLOCK) {
+    console.log(`\n--block: setting each zombie → its suggested disposition (done/blocked/open; reversible)…`);
+    const TK = require('path').join(__dirname, 'tk');
+    const icon = { done: '✅', blocked: '⛔', open: '↩︎' };
+    for (const z of zombies) {
+      // Honor suggest() literally: done→done, blocked→blocked, open→open. `open`
+      // returns the ticket to the dispatch queue (Steve's call 2026-09-14). All
+      // three are valid STATUSES and every set is reversible (tk status <id> doing).
+      sh(`TK_AGENT=reaper node ${TK} comment ${z.fullId} "reaper: DOING but idle ${z.idleH}h with no live worker → auto-set ${z.suggest} (owning session ended without tk done/status). Reversible: tk status ${z.fullId} doing." 2>/dev/null`);
+      // TK_DONE_GUARD=0: reaper is a sanctioned auto-closer — it acts only on 6h+ idle
+      // tickets with no live worker (which by construction have no tk log in 24h) and
+      // leaves its own audit comment above, so the tk done evidence guard (TK-11903)
+      // is bypassed HERE, in the caller, rather than weakened in tk.
+      sh(`TK_DONE_GUARD=0 TK_AGENT=reaper node ${TK} status ${z.fullId} ${z.suggest} 2>/dev/null`);
+      console.log(`  ${icon[z.suggest] || '•'} ${z.id} → ${z.suggest}`);
+    }
+  } else {
+    console.log(`\n(report-only — re-run with --block to auto-block, or reconcile by hand.)`);
+  }
+}
 
-if (BLOCK) {
-  console.log(`\n--block: setting each zombie → its suggested disposition (done/blocked/open; reversible)…`);
-  const TK = require('path').join(__dirname, 'tk');
-  const icon = { done: '✅', blocked: '⛔', open: '↩︎' };
-  for (const z of zombies) {
-    // Honor suggest() literally: done→done, blocked→blocked, open→open. `open`
-    // returns the ticket to the dispatch queue (Steve's call 2026-09-14). All
-    // three are valid STATUSES and every set is reversible (tk status <id> doing).
-    sh(`TK_AGENT=reaper node ${TK} comment ${z.fullId} "reaper: DOING but idle ${z.idleH}h with no live worker → auto-set ${z.suggest} (owning session ended without tk done/status). Reversible: tk status ${z.fullId} doing." 2>/dev/null`);
-    // TK_DONE_GUARD=0: reaper is a sanctioned auto-closer — it acts only on 6h+ idle
-    // tickets with no live worker (which by construction have no tk log in 24h) and
-    // leaves its own audit comment above, so the tk done evidence guard (TK-11903)
-    // is bypassed HERE, in the caller, rather than weakened in tk.
-    sh(`TK_DONE_GUARD=0 TK_AGENT=reaper node ${TK} status ${z.fullId} ${z.suggest} 2>/dev/null`);
-    console.log(`  ${icon[z.suggest] || '•'} ${z.id} → ${z.suggest}`);
+// front-desk lease expiry sweep (TK-12235): a lease older than its lease_expires
+// (30m TTL) is dead work-in-progress — end it and, if the ticket is still
+// 'doing' (nothing else already moved it), reopen it so it's claimable again.
+// Deterministic TTL expiry, not a heuristic, so this runs unconditionally
+// (report-only without --block, exactly like the zombie block above).
+const expiring = [...leaseMap.values()].filter(l => { const e = Date.parse(l.lease_expires); return !Number.isFinite(e) || e <= nowLease; });
+if (!expiring.length) console.log(`✅ no expired front-desk leases.`);
+else {
+  console.log(`\n⚠ ${expiring.length} expired front-desk lease(s):\n`);
+  for (const l of expiring) console.log(`  ${shortId(l.id).padEnd(11)} was @${(l.agent || '—').padEnd(22)} machine=${l.machine || '?'} expired=${l.lease_expires}`);
+  if (BLOCK) {
+    console.log(`\n--block: reaping expired leases (lease_end + reopen if still doing; reversible)…`);
+    const swept = reapExpiredLeases('reaper');
+    for (const s of swept) console.log(`  ↩︎ ${shortId(s.id)} lease ended (was @${s.wasAgent}${s.wasMachine ? '@' + s.wasMachine : ''})`);
+  } else {
+    console.log(`(report-only — re-run with --block to reap.)`);
   }
-} else {
-  console.log(`\n(report-only — re-run with --block to auto-block, or reconcile by hand.)`);
 }
diff --git a/server.js b/server.js
index c139ec73..b6551a4b 100644
--- a/server.js
+++ b/server.js
@@ -5,7 +5,8 @@ const path = require('path');
 const os = require('os');
 const { exec, execFile, execFileSync, spawn } = require('child_process');
 const zlib = require('zlib');
-const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS } = require('./lib.js');
+const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS,
+  claim: claimTicket, renew: renewLease, release: releaseLease } = require('./lib.js');
 
 // ── ticket-run + DTD wiring (TK-10527) ──
 const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
@@ -664,6 +665,44 @@ http.createServer((req, res) => {
       json(res, (failed.length && !done.length) ? 500 : 200, { [verb + 'ed']: done, failed });
     });
   }
+  // Front desk (TK-12235) — atomic lease-based claim/renew/release, one shared way for
+  // workers on N machines to get work so two workers can never hold the same ticket.
+  // ?dry=1 on /api/claim previews what WOULD be claimed without writing any event —
+  // used to demo against prod without creating a real lease.
+  if (req.method === 'POST' && (req.url === '/api/claim' || req.url.startsWith('/api/claim?'))) {
+    const qs = new URLSearchParams(req.url.slice(req.url.indexOf('?') + 1));
+    const dry = qs.get('dry') === '1';
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const agentName = String(body.agent || '').trim();
+      if (!agentName) return json(res, 400, { error: 'agent is required' });
+      const projects = Array.isArray(body.projects) ? body.projects : undefined;
+      let result;
+      try { result = claimTicket({ machine: body.machine || '', engine: body.engine || '', agent: agentName, projects, dry }); }
+      catch (e) { return json(res, 400, { error: String(e.message || e) }); }
+      if (!result) return json(res, 200, { claimed: null });
+      if (!dry) invalidateViews();
+      json(res, 200, { claimed: result });
+    });
+  }
+  if (req.method === 'POST' && req.url === '/api/renew') {
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const id = resolveId(body.id, cachedTickets());
+      if (!id) return json(res, 400, { error: 'no such ticket ' + body.id });
+      try { const r = renewLease({ id, lease_id: body.lease_id }); json(res, 200, { renewed: r }); }
+      catch (e) { json(res, 409, { error: String(e.message || e) }); }
+    });
+  }
+  if (req.method === 'POST' && req.url === '/api/release') {
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const id = resolveId(body.id, cachedTickets());
+      if (!id) return json(res, 400, { error: 'no such ticket ' + body.id });
+      try { const r = releaseLease({ id, lease_id: body.lease_id, status: body.status }); invalidateViews(); json(res, 200, { released: r }); }
+      catch (e) { json(res, 409, { error: String(e.message || e) }); }
+    });
+  }
   // DTD — trigger a batched run-now sweep (POST) / read the latest verdicts + running state (GET).
   if (req.method === 'POST' && req.url === '/api/dtd') {
     if (execBlockedForRemote(req, res)) return;
diff --git a/test/claim-once.js b/test/claim-once.js
new file mode 100644
index 00000000..565c1080
--- /dev/null
+++ b/test/claim-once.js
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+// Test helper (TK-12235): perform exactly ONE claim() call and print the JSON
+// result to stdout. lease.test.js (in lib.test.js) spawns several of these as
+// real concurrent OS processes against a shared TICKET_DATA_DIR to prove
+// claim()'s check+write is atomic — never double-assigns the same ticket.
+const { claim } = require(require('path').join(__dirname, '..', 'lib.js'));
+const agent = process.argv[2] || 'race-agent';
+const machine = process.argv[3] || 'test-machine';
+const project = process.argv[4] || undefined;
+let result = null, error = null;
+try { result = claim({ agent, machine, engine: 'test', projects: project ? [project] : undefined }); }
+catch (e) { error = String(e.message || e); }
+process.stdout.write(JSON.stringify({ result, error }));
diff --git a/tk b/tk
index c814a550..17a6276d 100755
--- a/tk
+++ b/tk
@@ -10,7 +10,10 @@
 //   tk challenge TK-3 "text" [-a agent]       # record a CHALLENGE (what fought back)
 //   tk cody TK-3 "text" [-a agent]            # record Cody the Contrarian's dissent
 //   tk log TK-3 "action taken" [-a agent]     # record an action performed under this ticket
-//   tk take TK-3 -a agent                     # assign to an agent
+//   tk take TK-3 -a agent                     # assign to an agent (refused if another agent holds a live lease; --force "<reason>" overrides)
+//   tk claim [--engine X] [--machine Y] [--projects a,b]   # atomically pick + assign + 30m-lease the top eligible open ticket
+//   tk renew TK-3 --lease-id L-xxx            # extend a live lease 30m
+//   tk release TK-3 --lease-id L-xxx [--status open|blocked]   # end a lease, optional status change
 //   tk status TK-3 open|doing|blocked|done
 //   tk blocker TK-3 <type> --condition "..." --next "..." --owner agent --evidence-at ISO [--recheck-at ISO] [--steve-one-action]
 //   tk done TK-3 [-a agent]                   # shorthand for status done
@@ -25,7 +28,9 @@
 //   Also: an @agent mention inside a comment/note auto-DMs that agent, linked to the ticket.
 // Agent identity defaults to $TK_AGENT, else "claude@<tty-or-pid>".
 const { withLock, append, tickets, nextId, resolveId, STATUSES, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule,
-  nextMid, messages, inbox, thread, resolveMid, parseMentions, knownAgents, isBroadcast } = require(require('path').join(__dirname, 'lib.js'));
+  nextMid, messages, inbox, thread, resolveMid, parseMentions, knownAgents, isBroadcast,
+  activeLease, claim, renew, release } = require(require('path').join(__dirname, 'lib.js'));
+const os = require('os');
 
 const argv = process.argv.slice(2);
 const cmd = argv.shift();
@@ -82,15 +87,15 @@ function doneGuard(id, t, nowIso) {
   }
 }
 // Parse --force. Presence without a non-empty reason is itself an error (exit 2).
-function forceReason() {
+function forceReason(cmdLabel = 'done') {
   const i = argv.indexOf('--force'); if (i === -1) return null;
   const v = argv[i + 1]; argv.splice(i, (v !== undefined && !v.startsWith('-')) ? 2 : 1);
   const reason = String(v || '').trim();
-  if (!reason || reason.startsWith('-')) { console.error('tk done --force requires a non-empty reason: --force "<why this close is legitimate>"'); process.exit(2); }
+  if (!reason || reason.startsWith('-')) { console.error(`tk ${cmdLabel} --force requires a non-empty reason: --force "<why this override is legitimate>"`); process.exit(2); }
   return reason;
 }
 function closeDone(id) {
-  const force = forceReason();
+  const force = forceReason('done');
   const t = tickets().get(id); if (!t) die('no such ticket ' + id);
   if (force) append({ ts, type: 'comment', id, kind: 'comment', agent, text: 'FORCED DONE: ' + force });
   else doneGuard(id, t, ts);
@@ -179,9 +184,42 @@ if (cmd === 'new') {
   append({ ts, type: 'action', id, agent, text }); console.log(`action logged on ${id}`);
 } else if (cmd === 'take') {
   const id = norm(argv.shift()); if (!tickets().has(id)) die('no such ticket ' + id);
+  const force = forceReason('take');
+  // Front desk (TK-12235): refuse to hand out a ticket another agent already
+  // holds a live lease on — the same conflict `tk claim` avoids atomically.
+  // Same agent re-taking its own live lease is not a conflict.
+  const lease = activeLease(id);
+  if (lease && lease.agent !== agent) {
+    if (!force) {
+      console.error(`tk take refused (exit 2): ${id} is held by a live lease — agent=${lease.agent} machine=${lease.machine || '?'} lease_id=${lease.lease_id} expires=${lease.lease_expires}. Override: tk take ${id} --force "<reason>" (recorded as a comment). [TK-12235]`);
+      process.exit(2);
+    }
+    append({ ts, type: 'comment', id, kind: 'comment', agent, text: `FORCED TAKE over live lease held by ${lease.agent}: ${force}` });
+  }
   append({ ts, type: 'assign', id, agent }); append({ ts, type: 'status', id, status: 'doing', agent });
-  console.log(`${id} → ${agent} (doing)`);
+  console.log(`${id} → ${agent} (doing)${force && lease ? ' (forced over lease)' : ''}`);
   titleWindow(id);
+} else if (cmd === 'claim') {
+  const engine = opt('--engine') || '';
+  const machine = opt('--machine') || os.hostname();
+  const projectsRaw = opt('--projects');
+  const projects = projectsRaw ? projectsRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
+  let result;
+  try { result = claim({ machine, engine, agent, projects }); }
+  catch (e) { die('tk claim failed: ' + e.message); }
+  if (!result) { console.log('(no eligible ticket)'); process.exit(0); }
+  console.log(`${result.id} → ${agent} (doing) lease_id=${result.lease_id} expires=${result.lease_expires}${result.title ? ' — ' + result.title : ''}`);
+  titleWindow(result.id);
+} else if (cmd === 'renew') {
+  const id = norm(argv.shift()); const lease_id = opt('--lease-id') || opt('-l');
+  if (!lease_id) die('usage: tk renew TK-3 --lease-id L-xxx');
+  try { const r = renew({ id, lease_id }); console.log(`${id} lease renewed → expires=${r.lease_expires}`); }
+  catch (e) { die('tk renew failed: ' + e.message); }
+} else if (cmd === 'release') {
+  const id = norm(argv.shift()); const lease_id = opt('--lease-id') || opt('-l'); const status = opt('--status');
+  if (!lease_id) die('usage: tk release TK-3 --lease-id L-xxx [--status open|blocked]');
+  try { release({ id, lease_id, status }); console.log(`${id} lease released${status ? ' → ' + status : ''}`); }
+  catch (e) { die('tk release failed: ' + e.message); }
 } else if (cmd === 'status') {
   const id = norm(argv.shift()); const s = (argv.shift() || '').toLowerCase();
   if (!STATUSES.includes(s)) die('status must be one of: ' + STATUSES.join(' '));

← bb516063 auto-data-snapshot: 2026-09-24T11:17:31 (1 data files) — TK-  ·  back to Ticket System  ·  front desk review fixes (TK-12235): tk claim/renew/release r 312dca66 →