← 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
M lib.jsM lib.test.jsM reaper.jsM server.jsA test/claim-once.jsM tk
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 →