atris 3.74.25 → 3.74.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/gm.js CHANGED
@@ -1895,6 +1895,8 @@ function wakeableMember(args) {
1895
1895
  }
1896
1896
 
1897
1897
  async function gmCommand(...args) {
1898
+ const leaseDb = require('../lib/task-db');
1899
+ try { leaseDb.sweepExpiredLeases(); } catch (_) { /* Existing views report unreadable databases. */ }
1898
1900
  // the walk has its own help
1899
1901
  if (positional(args)[0] === 'walk') {
1900
1902
  const at = args.indexOf('walk');
@@ -46,7 +46,15 @@ function readHere(deps = {}) {
46
46
  const team = new Map(((lineup && lineup.ok && lineup.team) || []).map((row) => [String(row.member || '').toLowerCase(), row]));
47
47
  const members = rows.map((row) => {
48
48
  const seat = team.get(row.name.toLowerCase()) || {};
49
- return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, job: seat.job || null, model: seat.model || null };
49
+ const reasons = new Map();
50
+ for (const play of played.outcomes || []) {
51
+ if (play.member === row.name && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
52
+ reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
53
+ }
54
+ }
55
+ const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
56
+ .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
57
+ return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
50
58
  });
51
59
  const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
52
60
  const buildModel = buildJob && buildJob.now_runs ? buildJob.now_runs.model : null;
@@ -104,12 +112,18 @@ function renderMember(m, card) {
104
112
  ` landed ${m.landed} of ${m.finished} finished jobs in the last 30 days`,
105
113
  ];
106
114
  if (m.ovr == null) lines.push(' rated once it has 3 finished jobs');
115
+ if (m.miss_reasons && m.miss_reasons.length) {
116
+ lines.push(' top miss reasons:');
117
+ for (const item of m.miss_reasons) lines.push(` ${item.count}: ${item.reason}`);
118
+ }
107
119
  if (card && m.job === 'build') lines.push(` its engine, ${card.label}, is ${rated(card.ovr)} in the combine: atris rating ${card.label.split(' ')[0]}`);
108
120
  lines.push(` every play: atris gm card ${m.member}`);
109
121
  return lines.join('\n');
110
122
  }
111
123
 
112
124
  function ratingCommand(args = [], deps = {}) {
125
+ const taskDb = require('../lib/task-db');
126
+ taskDb.sweepExpiredLeases();
113
127
  const write = deps.write || ((text) => process.stdout.write(text));
114
128
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
115
129
  write(`${USAGE}\n`);
package/commands/task.js CHANGED
@@ -208,6 +208,7 @@ atris task - durable local task state (SQLite, gitignored)
208
208
  atris task auto-accept-certified --dry-run [--strict-verify] [--all] [--limit <n>]
209
209
  Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
210
210
  atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
211
+ atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
211
212
  atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
212
213
  atris task revise <id> --note "..." Send reviewed work back to Do
213
214
 
@@ -6252,7 +6253,10 @@ function cmdClaim(args) {
6252
6253
  const taskDb = getTaskDb();
6253
6254
  const db = taskDb.open();
6254
6255
  const taskId = requireTaskId(taskDb, db, id, 'atris task claim');
6255
- const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner) });
6256
+ const leaseMinutes = flag(args, '--lease-minutes');
6257
+ const leasePid = flag(args, '--lease-pid');
6258
+ if (leaseMinutes != null && (!Number.isFinite(Number(leaseMinutes)) || Number(leaseMinutes) <= 0 || !Number.isInteger(Number(leasePid)) || Number(leasePid) <= 0)) throw new Error('invalid claim lease');
6259
+ const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner), ...(leaseMinutes == null ? {} : { leaseMinutes: Number(leaseMinutes), leasePid: Number(leasePid) }) });
6256
6260
  if (result.claimed) {
6257
6261
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
6258
6262
  if (wantsJson(args)) {
@@ -10801,11 +10805,23 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
10801
10805
 
10802
10806
  function cmdSweep(args) {
10803
10807
  if (!hasFlag(args, '--auto-accept')) {
10804
- failTask(
10805
- 'atris task sweep',
10806
- 'missing_auto_accept',
10807
- 'atris task sweep currently requires --auto-accept for the explicit verified accept policy',
10808
- );
10808
+ const taskDb = getTaskDb();
10809
+ const staleRaw = flag(args, '--stale-days');
10810
+ if (staleRaw === true || (staleRaw != null && (!Number.isFinite(Number(staleRaw)) || Number(staleRaw) < 0))) throw new Error('stale days must be a nonnegative number');
10811
+ const claimer = flag(args, '--claimer');
10812
+ if (claimer === true) throw new Error('claimer requires a name');
10813
+ const dryRun = hasFlag(args, '--dry-run');
10814
+ const options = { staleDays: staleRaw == null ? undefined : Number(staleRaw), claimer };
10815
+ const tasks = dryRun ? taskDb.previewExpiredLeases(options) : taskDb.expireLeases(taskDb.open(), options);
10816
+ const groups = {};
10817
+ for (const task of tasks) (groups[task.claimed_by] ||= []).push(task);
10818
+ if (wantsJson(args)) printJson({ dry_run: dryRun, groups, tasks });
10819
+ else if (!tasks.length) console.log(dryRun ? 'no claims would be released' : 'no claims released');
10820
+ else for (const [name, entries] of Object.entries(groups)) {
10821
+ console.log(`${name}: ${entries.length} ${dryRun ? 'would be released' : 'released'}`);
10822
+ for (const entry of entries) console.log(` ${entry.title}: ${entry.reason}`);
10823
+ }
10824
+ return;
10809
10825
  }
10810
10826
  // Sweep has no dry-run implementation: every successful invocation can
10811
10827
  // write accepts, even when an unrecognized --dry-run flag is supplied.
@@ -13076,6 +13092,11 @@ function cmdServe(args) {
13076
13092
 
13077
13093
  async function runTaskCommand(args) {
13078
13094
  const raw = args || [];
13095
+ // A dry run must not mutate even already-expired leases.
13096
+ if (!raw.includes('--dry-run') && !(raw[0] === 'sweep' && !raw.includes('--auto-accept'))) {
13097
+ const taskDb = getTaskDb();
13098
+ taskDb.sweepExpiredLeases();
13099
+ }
13079
13100
  if (raw.includes('--help') || raw.includes('-h')) return help();
13080
13101
  const first = raw[0];
13081
13102
  if (!first) return cmdFirstMinute();
@@ -13273,7 +13294,7 @@ async function run(args) {
13273
13294
  return;
13274
13295
  }
13275
13296
  const result = await runTaskCommand(raw);
13276
- const skipsRender = sub === 'clear-done' && hasFlag(raw, '--dry-run');
13297
+ const skipsRender = hasFlag(raw, '--dry-run');
13277
13298
  if (MUTATING_TASK_COMMANDS.has(sub) && !skipsRender) autoRenderTodoFromDb();
13278
13299
  return result;
13279
13300
  }
@@ -1201,7 +1201,6 @@ const PROTECTED_BRANCHES = new Set(['main', 'master']);
1201
1201
  // (zero commits yet), so without a grace window the janitor reaps it while
1202
1202
  // the engine that requested it is still booting inside.
1203
1203
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
1204
- const COMPLETED_UNMERGED_REAP_MS = WORKTREE_REAP_GRACE_MS;
1205
1204
  const GENERATED_WORKTREE_CACHE_DIRS = [
1206
1205
  '.derivedData',
1207
1206
  '.next',
@@ -1218,15 +1217,6 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
1218
1217
  }
1219
1218
  }
1220
1219
 
1221
- function completedAgentOutputAgeMs(worktreePath, now = Date.now()) {
1222
- try {
1223
- const output = path.join(worktreePath, '.codex-last-message.txt');
1224
- return now - fs.statSync(output).mtimeMs;
1225
- } catch {
1226
- return null;
1227
- }
1228
- }
1229
-
1230
1220
  function canonicalPath(value) {
1231
1221
  try {
1232
1222
  return fs.realpathSync(value);
@@ -1237,17 +1227,20 @@ function canonicalPath(value) {
1237
1227
 
1238
1228
  // null means the scan failed: we cannot tell who is inside, so callers keep
1239
1229
  // every folder. An empty list would read as "nobody is here" and delete.
1240
- function listActiveProcessCwds() {
1241
- const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
1230
+ function listActiveProcessFiles() {
1231
+ const result = spawnSync('lsof', ['-n', '-F', 'n'], {
1242
1232
  encoding: 'utf8',
1243
1233
  maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1234
+ timeout: 20000,
1235
+ killSignal: 'SIGKILL',
1244
1236
  });
1245
- if (result.error || result.status !== 0) return null;
1246
- return String(result.stdout || '')
1237
+ if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
1238
+ const paths = String(result.stdout || '')
1247
1239
  .split(/\r?\n/)
1248
- .filter((line) => line.startsWith('n'))
1240
+ .filter((line) => line.startsWith('n/'))
1249
1241
  .map((line) => canonicalPath(line.slice(1)))
1250
1242
  .filter(Boolean);
1243
+ return paths.length ? paths : null;
1251
1244
  }
1252
1245
 
1253
1246
  // Absolute paths named on each program's command line, including flag values
@@ -1274,17 +1267,20 @@ function listActiveProcessArgPaths() {
1274
1267
  const result = spawnSync('ps', ['-axww', '-o', 'args='], {
1275
1268
  encoding: 'utf8',
1276
1269
  maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1270
+ timeout: 5000,
1271
+ killSignal: 'SIGKILL',
1277
1272
  });
1278
- if (result.error || result.status !== 0) return [];
1273
+ if (result.error || result.status !== 0) return null;
1279
1274
  return parseProcessArgPaths(result.stdout);
1280
1275
  }
1281
1276
 
1282
- // Everything a running program is anchored to: its current folder plus any
1283
- // script path on its command line.
1277
+ // Open files and working folders, plus script paths on command lines.
1284
1278
  function listActiveProcessPaths() {
1285
- const cwds = listActiveProcessCwds();
1286
- if (!Array.isArray(cwds)) return cwds;
1287
- return cwds.concat(listActiveProcessArgPaths());
1279
+ const files = listActiveProcessFiles();
1280
+ if (!Array.isArray(files)) return null;
1281
+ const args = listActiveProcessArgPaths();
1282
+ if (!Array.isArray(args)) return null;
1283
+ return files.concat(args);
1288
1284
  }
1289
1285
 
1290
1286
  function worktreeHasActiveProcess(worktreePath, activeCwds) {
@@ -1311,7 +1307,44 @@ function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reaso
1311
1307
  } catch {}
1312
1308
  }
1313
1309
 
1314
- function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
1310
+ // Only webpack's disposable pack files have a sufficiently narrow contract
1311
+ // for unattended cleanup. Unknown files or links turn the folder into review.
1312
+ function webpackCacheSnapshot(worktreePath) {
1313
+ try {
1314
+ const root = fs.realpathSync(worktreePath);
1315
+ let current = root;
1316
+ for (const part of ['.next', 'cache', 'webpack']) {
1317
+ current = path.join(current, part);
1318
+ const stat = fs.lstatSync(current);
1319
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
1320
+ }
1321
+ if (fs.realpathSync(current) !== current) return null;
1322
+ const entries = [];
1323
+ function walk(dir) {
1324
+ for (const name of fs.readdirSync(dir).sort()) {
1325
+ const file = path.join(dir, name);
1326
+ const stat = fs.lstatSync(file);
1327
+ if (stat.isSymbolicLink()) return false;
1328
+ if (stat.isDirectory()) {
1329
+ entries.push([file, 'd', stat.ino, stat.mtimeMs]);
1330
+ if (!walk(file)) return false;
1331
+ } else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
1332
+ entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
1333
+ } else return false;
1334
+ }
1335
+ return true;
1336
+ }
1337
+ if (!walk(current)) return null;
1338
+ const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
1339
+ const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
1340
+ if (!match) return null;
1341
+ return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
1342
+ } catch {
1343
+ return null;
1344
+ }
1345
+ }
1346
+
1347
+ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
1315
1348
  const candidates = [];
1316
1349
  const pruned = [];
1317
1350
  for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
@@ -1319,34 +1352,70 @@ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
1319
1352
  if (!fs.existsSync(target)) continue;
1320
1353
  const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1321
1354
  if (ignored.status !== 0) continue;
1322
- const item = { worktree: worktreePath, path: target, relative };
1355
+ const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
1323
1356
  candidates.push(item);
1324
- if (!apply) continue;
1325
- try {
1326
- fs.rmSync(target, { recursive: true, force: true });
1327
- pruned.push(item);
1328
- } catch (error) {
1329
- item.error = String((error && error.message) || error);
1357
+ }
1358
+ const relative = '.next/cache/webpack';
1359
+ const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1360
+ const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
1361
+ if (snapshot) {
1362
+ const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
1363
+ candidates.push(item);
1364
+ if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
1365
+ const active = listActiveProcessPaths();
1366
+ const fresh = webpackCacheSnapshot(worktreePath);
1367
+ const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1368
+ if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
1369
+ && stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
1370
+ && fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
1371
+ try {
1372
+ fs.rmSync(snapshot.path, { recursive: true });
1373
+ budget.count += 1;
1374
+ budget.kib += snapshot.kib;
1375
+ pruned.push(item);
1376
+ } catch (error) {
1377
+ item.error = String((error && error.message) || error);
1378
+ }
1379
+ }
1330
1380
  }
1331
1381
  }
1332
1382
  return { candidates, pruned };
1333
1383
  }
1334
1384
 
1385
+ function originBaseIsCurrent(root, base) {
1386
+ const prefix = base.startsWith('origin/') ? 'origin/'
1387
+ : base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
1388
+ if (!prefix) {
1389
+ if (base === 'HEAD') {
1390
+ const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
1391
+ return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
1392
+ }
1393
+ return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
1394
+ }
1395
+ const branch = base.slice(prefix.length);
1396
+ const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
1397
+ const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
1398
+ const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
1399
+ return local.status === 0 && remote.status === 0 && Boolean(match)
1400
+ && match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
1401
+ }
1402
+
1335
1403
  function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
1336
1404
  const worktrees = listWorktrees(root);
1337
1405
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1338
1406
  const current = path.resolve(root);
1339
1407
  const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
1340
1408
  const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
1341
- refreshRemoteRef(root, base);
1409
+ const refreshError = refreshRemoteRef(root, base);
1342
1410
  const candidates = [];
1343
1411
  const kept = [];
1344
1412
  const removed = [];
1345
1413
  const cacheCandidates = [];
1346
1414
  const cachePruned = [];
1415
+ const cacheBudget = { count: 0, kib: 0 };
1347
1416
  const pruneCachesForKept = (wtPath) => {
1348
1417
  if (worktreeWithinReapGrace(wtPath)) return;
1349
- const caches = pruneGeneratedWorktreeCaches(wtPath, { apply });
1418
+ const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
1350
1419
  cacheCandidates.push(...caches.candidates);
1351
1420
  cachePruned.push(...caches.pruned);
1352
1421
  };
@@ -1374,9 +1443,8 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1374
1443
  kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
1375
1444
  continue;
1376
1445
  }
1377
- const rawCounts = statusCounts(wt.path);
1378
- const counts = statusCounts(wt.path, { ignoreAny: isConductorArtifact });
1379
- if (!rawCounts || !counts) {
1446
+ const counts = statusCounts(wt.path);
1447
+ if (!counts) {
1380
1448
  kept.push({ ...item, reason: 'missing_or_unreadable' });
1381
1449
  continue;
1382
1450
  }
@@ -1385,58 +1453,78 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1385
1453
  kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
1386
1454
  continue;
1387
1455
  }
1456
+ // Git removes ignored files along with a worktree. They may include local
1457
+ // state, credentials, or user data, so a clean status is not enough proof.
1458
+ const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1459
+ if (ignored.status !== 0 || ignored.error) {
1460
+ kept.push({ ...item, reason: 'ignored_scan_failed' });
1461
+ continue;
1462
+ }
1463
+ if (ignored.stdout) {
1464
+ pruneCachesForKept(wtPath);
1465
+ kept.push({ ...item, reason: 'ignored_data' });
1466
+ continue;
1467
+ }
1388
1468
  if (!wt.head) {
1389
1469
  pruneCachesForKept(wtPath);
1390
1470
  kept.push({ ...item, reason: 'missing_head' });
1391
1471
  continue;
1392
1472
  }
1393
- const artifactOnlyDirty = Boolean(
1394
- (rawCounts.staged || rawCounts.unstaged || rawCounts.untracked)
1395
- && !(counts.staged || counts.unstaged || counts.untracked)
1396
- );
1473
+ if (refreshError) {
1474
+ kept.push({ ...item, reason: 'base_refresh_failed' });
1475
+ continue;
1476
+ }
1397
1477
  const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
1398
1478
  if (!merged) {
1399
- const completedAgeMs = completedAgentOutputAgeMs(wtPath);
1400
- if (completedAgeMs === null || completedAgeMs < COMPLETED_UNMERGED_REAP_MS) {
1401
- pruneCachesForKept(wtPath);
1402
- kept.push({
1403
- ...item,
1404
- reason: completedAgeMs === null ? 'unmerged' : 'completed_unmerged_retention',
1405
- ...(completedAgeMs === null ? {} : { retention_hours: 1 }),
1406
- });
1407
- continue;
1408
- }
1409
- const candidate = {
1410
- ...item,
1411
- reason: 'completed_unmerged_checkout_expired',
1412
- branch_preserved: true,
1413
- artifact_only_dirty: artifactOnlyDirty,
1414
- };
1415
- candidates.push(candidate);
1416
- if (!apply) continue;
1417
- const removeArgs = ['worktree', 'remove'];
1418
- if (artifactOnlyDirty) removeArgs.push('--force');
1419
- removeArgs.push(wt.path);
1420
- const removedResult = runGit(removeArgs, { cwd: root, check: false });
1421
- if (removedResult.status === 0) {
1422
- removed.push(candidate);
1423
- recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
1424
- } else {
1425
- kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
1426
- }
1479
+ pruneCachesForKept(wtPath);
1480
+ kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
1427
1481
  continue;
1428
1482
  }
1429
1483
  if (worktreeWithinReapGrace(wtPath)) {
1430
1484
  kept.push({ ...item, reason: 'fresh_worktree_grace' });
1431
1485
  continue;
1432
1486
  }
1433
- const candidate = { ...item, reason: 'merged_into_base', artifact_only_dirty: artifactOnlyDirty };
1487
+ if (!originBaseIsCurrent(root, base)) {
1488
+ kept.push({ ...item, reason: 'base_unverified' });
1489
+ continue;
1490
+ }
1491
+ let real;
1492
+ try { real = fs.realpathSync(wtPath); } catch { real = ''; }
1493
+ if (!real || real !== wtPath) {
1494
+ kept.push({ ...item, reason: 'path_not_canonical' });
1495
+ continue;
1496
+ }
1497
+ const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1498
+ const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
1499
+ const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
1500
+ if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
1501
+ kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
1502
+ continue;
1503
+ }
1504
+ const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
1434
1505
  candidates.push(candidate);
1506
+ cacheBudget.count += 1;
1507
+ cacheBudget.kib += kib;
1435
1508
  if (!apply) continue;
1436
- const removeArgs = ['worktree', 'remove'];
1437
- if (artifactOnlyDirty) removeArgs.push('--force');
1438
- removeArgs.push(wt.path);
1439
- const removedResult = runGit(removeArgs, { cwd: root, check: false });
1509
+ const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
1510
+ const freshCounts = statusCounts(wt.path);
1511
+ const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1512
+ const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1513
+ const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
1514
+ const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
1515
+ const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
1516
+ if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
1517
+ || !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
1518
+ || freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
1519
+ || gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
1520
+ || freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
1521
+ || runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
1522
+ || !originBaseIsCurrent(root, base)
1523
+ || canonicalPath(wtPath) !== wtPath) {
1524
+ kept.push({ ...item, reason: 'changed_or_active_during_verification' });
1525
+ continue;
1526
+ }
1527
+ const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
1440
1528
  if (removedResult.status === 0) {
1441
1529
  removed.push(candidate);
1442
1530
  recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
@@ -1465,10 +1553,13 @@ function cleanup(args) {
1465
1553
  for (const item of result.apply ? result.removed : result.candidates) {
1466
1554
  console.log(`${action}: ${item.path} branch=${item.branch} reason=${item.reason}`);
1467
1555
  }
1468
- const cacheAction = result.apply ? 'cache_pruned' : 'cache_candidate';
1469
- console.log(`${cacheAction}s: ${result.apply ? result.cachePruned.length : result.cacheCandidates.length}`);
1470
- for (const item of result.apply ? result.cachePruned : result.cacheCandidates) {
1471
- console.log(`${cacheAction}: ${item.path}`);
1556
+ const review = result.cacheCandidates.filter((item) => item.reason === 'manual_review_required');
1557
+ const safe = result.apply ? result.cachePruned : result.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
1558
+ console.log(`cache ${result.apply ? 'removed' : 'candidate'}s: ${safe.length}`);
1559
+ for (const item of safe) console.log(`cache: ${item.path} size_kib=${item.kib}`);
1560
+ console.log(`cache review needed: ${review.length}`);
1561
+ for (const item of review) {
1562
+ console.log(`review: ${item.path} reason=${item.reason}`);
1472
1563
  }
1473
1564
  console.log(`kept: ${result.kept.length}`);
1474
1565
  if (!result.apply && result.candidates.length) console.log('next: atris worktree cleanup --apply');
@@ -1524,7 +1615,7 @@ function help() {
1524
1615
  console.log(' atris worktree status');
1525
1616
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
1526
1617
  console.log(' atris worktree prune [--apply]');
1527
- console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and ignored build caches');
1618
+ console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
1528
1619
  }
1529
1620
 
1530
1621
  function worktreeCommand(args = []) {
package/lib/fleet.js CHANGED
@@ -3208,14 +3208,27 @@ async function runFleetFlight({
3208
3208
  // when the operator explicitly wants launcher-HEAD.
3209
3209
  const startBaseArgs = ['--base', landTarget];
3210
3210
  const prepared = [];
3211
+ const leaseDb = require('./task-db');
3212
+ const leaseConnection = leaseDb.open();
3213
+ const leased = new Map();
3214
+ const leaseTimer = setInterval(() => {
3215
+ for (const [id, holder] of leased) leaseDb.renewLease(leaseConnection, { id, holder, pid: process.pid });
3216
+ }, 5 * 60000);
3217
+ leaseTimer.unref();
3218
+ try {
3211
3219
  for (const entry of staffed) {
3212
- const claimed = cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
3220
+ const claimed = cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3213
3221
  if (!claimed || claimed.status !== 0) {
3214
3222
  const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
3215
3223
  flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
3216
3224
  log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at claim${detail ? `, ${detail}` : ''}`);
3217
3225
  continue;
3218
3226
  }
3227
+ const leaseId = entry.task.id || entry.task.display_id;
3228
+ const holder = `fleet-${entry.engine}`;
3229
+ const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder });
3230
+ if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
3231
+ if (leaseSet) leased.set(leaseId, holder);
3219
3232
  const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`, ...startBaseArgs]);
3220
3233
  const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
3221
3234
  if (!wt) {
@@ -3340,6 +3353,13 @@ async function runFleetFlight({
3340
3353
  log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
3341
3354
  log('');
3342
3355
  return flight;
3356
+ } finally {
3357
+ clearInterval(leaseTimer);
3358
+ for (const [id, holder] of leased) {
3359
+ const row = leaseDb.getTask(leaseConnection, id);
3360
+ if (row && row.status !== 'claimed') leaseDb.clearLease(leaseConnection, { id, holder });
3361
+ }
3362
+ }
3343
3363
  }
3344
3364
 
3345
3365
  // ---------------------------------------------------------------------------
@@ -3474,6 +3494,9 @@ async function runDispatchFlight({
3474
3494
  }
3475
3495
 
3476
3496
  const cli = ownCli || defaultOwnCli(root);
3497
+ const leaseDb = require('./task-db');
3498
+ const leaseConnection = leaseDb.open();
3499
+ try { leaseDb.expireLeases(leaseConnection); } catch (err) { if (process.env.ATRIS_DEBUG) process.stderr.write(`lease sweep failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`); }
3477
3500
  // Cut worktrees from, and check self-landing against, the workspace's real
3478
3501
  // protected branch, not a hard-coded origin/master (CLI-1298).
3479
3502
  const localBase = reviewOnly ? '' : localDispatchBase(root);
@@ -3517,6 +3540,12 @@ async function runDispatchFlight({
3517
3540
  // default so rebase-before-ship never replays a launcher feature branch.
3518
3541
  const startBaseArgs = landTarget ? ['--base', landTarget] : [];
3519
3542
  const prepared = [];
3543
+ const leasedIds = new Set();
3544
+ const leaseTimer = setInterval(() => {
3545
+ for (const id of leasedIds) leaseDb.renewLease(leaseConnection, { id, holder: taskActor, pid: process.pid });
3546
+ }, 5 * 60000);
3547
+ leaseTimer.unref();
3548
+ try {
3520
3549
  for (const taskId of ids) {
3521
3550
  const task = readTaskById(cli, taskId);
3522
3551
  if (!task) {
@@ -3524,7 +3553,7 @@ async function runDispatchFlight({
3524
3553
  log(` ✗ ${taskId} not found`);
3525
3554
  continue;
3526
3555
  }
3527
- const claimed = cli(['task', 'claim', taskId, '--as', taskActor]);
3556
+ const claimed = cli(['task', 'claim', taskId, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3528
3557
  if (!claimed || claimed.status !== 0) {
3529
3558
  const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
3530
3559
  flight.paused.push({ task: taskId, stage: 'claim', detail });
@@ -3532,6 +3561,10 @@ async function runDispatchFlight({
3532
3561
  continue;
3533
3562
  }
3534
3563
  try {
3564
+ const leaseId = task.id || taskId;
3565
+ const leased = leaseDb.setLease(leaseConnection, { id: leaseId, holder: taskActor });
3566
+ if (!leased && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
3567
+ if (leased) leasedIds.add(leaseId);
3535
3568
  let landingWorktreePath = '';
3536
3569
  let remoteBoundary = null;
3537
3570
  if (enforceRemoteBoundary) {
@@ -4212,4 +4245,11 @@ async function runDispatchFlight({
4212
4245
  log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
4213
4246
  log('');
4214
4247
  return flight;
4248
+ } finally {
4249
+ clearInterval(leaseTimer);
4250
+ for (const id of leasedIds) {
4251
+ const row = leaseDb.getTask(leaseConnection, id);
4252
+ if (row && row.status !== 'claimed') leaseDb.clearLease(leaseConnection, { id, holder: taskActor });
4253
+ }
4254
+ }
4215
4255
  }
package/lib/gm-season.js CHANGED
@@ -322,6 +322,12 @@ function outcomeFromEvent(row) {
322
322
  } else if (row.event_type === 'claim_released') {
323
323
  kind = 'dropped';
324
324
  member = payload.previous_claimed_by || payload.released_by || row.actor;
325
+ } else if (row.event_type === 'claim_expired') {
326
+ kind = 'dropped';
327
+ member = payload.claimed_by;
328
+ } else if ((row.event_type === 'proof_ready' || row.event_type === 'landing') && hasStructuredMerge(payload)) {
329
+ kind = 'landed';
330
+ member = holder;
325
331
  } else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
326
332
  kind = 'landed';
327
333
  member = holder;
@@ -340,6 +346,7 @@ function outcomeFromEvent(row) {
340
346
  const claimId = kind === 'took'
341
347
  ? (row.event_id || `${row.task_id}:${row.created_at}`)
342
348
  : (row.claim_event_id || `${row.task_id}:before`);
349
+ const leaseUntil = typeof payload.lease_until === 'number' ? payload.lease_until : typeof payload.lease_until === 'string' ? Date.parse(payload.lease_until) : NaN;
343
350
  return {
344
351
  event_id: row.event_id || null,
345
352
  claim_id: claimId,
@@ -347,14 +354,56 @@ function outcomeFromEvent(row) {
347
354
  member: String(member),
348
355
  title: row.title || '',
349
356
  kind,
350
- at: Number(row.created_at),
357
+ at: row.event_type === 'claim_expired' && Number.isFinite(leaseUntil) ? leaseUntil : Number(row.created_at),
358
+ reason: kind !== 'took' && kind !== 'landed' ? (typeof payload.reason === 'string' && payload.reason.trim() || typeof payload.note === 'string' && payload.note.trim() || null) : null,
351
359
  claimed_at: claimedAt,
352
360
  landed_by: kind === 'landed' ? landedBy(row, payload) : null,
353
361
  };
354
362
  }
355
363
 
356
- function outcomesFromEvents(rows) {
357
- return (rows || []).map(outcomeFromEvent).filter(Boolean).sort(byTimeAsc);
364
+ function hasStructuredMerge(payload) {
365
+ const objects = [payload, payload.landing, payload.pull_request, payload.pr].filter(x => x && typeof x === 'object' && !Array.isArray(x));
366
+ return objects.some(x => {
367
+ const commit = x.merge_commit || x.merge_commit_sha || x.merge_sha;
368
+ return (typeof commit === 'string' && /^[0-9a-f]{7,64}$/i.test(commit)) ||
369
+ x.merged === true || Boolean(x.merged_at) ||
370
+ ((typeof x.merged_pr === 'string' && x.merged_pr.trim() !== '') || (typeof x.merged_pr === 'number' && x.merged_pr > 0) || (x.merged_pr && typeof x.merged_pr === 'object' && (x.merged_pr.merged === true || x.merged_pr.state === 'merged' || x.merged_pr.merged_at))) ||
371
+ ((x.state === 'merged' || x.status === 'merged') && (x.number != null || x.url != null));
372
+ });
373
+ }
374
+
375
+ function outcomesFromEvents(rows, since = -Infinity) {
376
+ const ordered = [...(rows || [])].sort((a, b) => String(a.task_id).localeCompare(String(b.task_id)) ||
377
+ (a.version != null && b.version != null ? Number(a.version) - Number(b.version) : Number(a.created_at) - Number(b.created_at)));
378
+ const plays = [];
379
+ const finished = new Map();
380
+ const closed = new Set();
381
+ for (let i = 0; i < ordered.length; i++) {
382
+ const row = ordered[i];
383
+ const play = outcomeFromEvent(row);
384
+ const key = `${row.task_id}:${row.claim_event_id || `${row.task_id}:before`}`;
385
+ const ended = ['claim_released', 'claim_expired', 'completed', 'reopened', 'archived'].includes(row.event_type);
386
+ if (!play) { if (ended) closed.add(key); continue; }
387
+ if (play.kind === 'took') { plays.push(play); continue; }
388
+ if (row.event_type === 'claim_released') {
389
+ let next = null;
390
+ for (let j = i + 1; j < ordered.length && ordered[j].task_id === row.task_id; j++) {
391
+ if (ordered[j].event_type === 'claimed') { next = ordered[j]; break; }
392
+ }
393
+ const nextMember = next && (next.actor || (parseJson(next.payload) || {}).claimed_by);
394
+ if (next && nextMember &&
395
+ Number(next.created_at) >= Number(row.created_at) && Number(next.created_at) - Number(row.created_at) <= 120000 &&
396
+ String(nextMember) !== play.member) { closed.add(key); continue; }
397
+ }
398
+ const prior = finished.get(key);
399
+ if (!prior && !closed.has(key)) { finished.set(key, play); plays.push(play); }
400
+ else if (prior && !closed.has(key) && play.kind === 'landed' && prior.kind !== 'landed') {
401
+ plays[plays.indexOf(prior)] = play;
402
+ finished.set(key, play);
403
+ }
404
+ if (ended) closed.add(key);
405
+ }
406
+ return plays.filter(play => play.at >= since).sort(byTimeAsc);
358
407
  }
359
408
 
360
409
  function emptySeason(workspaceRoot, now) {
@@ -467,7 +516,7 @@ function missionLastAt(mission) {
467
516
  // right holder. `extraWhere` narrows it (one workspace, or none for the league).
468
517
  function playEventsSql(extraWhere = '') {
469
518
  return `
470
- SELECT e.event_id, e.task_id, e.workspace_root, e.event_type, e.actor, e.payload, e.created_at,
519
+ SELECT e.event_id, e.task_id, e.workspace_root, e.event_type, e.actor, e.payload, e.created_at, e.version,
471
520
  t.title, t.claimed_by, t.claimed_at,
472
521
  c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
473
522
  CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
@@ -485,8 +534,8 @@ function playEventsSql(extraWhere = '') {
485
534
  SELECT p.event_id FROM task_events p
486
535
  WHERE p.task_id = e.task_id AND p.event_type = 'claimed' AND p.version < e.version
487
536
  ORDER BY p.version DESC LIMIT 1)
488
- WHERE e.created_at >= ?
489
- AND e.event_type IN ('claimed', 'claim_released', 'completed', 'revision_requested', 'reopened', 'archived')
537
+ WHERE EXISTS (SELECT 1 FROM task_events recent WHERE recent.task_id = e.task_id AND recent.created_at >= ?)
538
+ AND e.event_type IN ('claimed', 'claim_released', 'claim_expired', 'proof_ready', 'landing', 'completed', 'revision_requested', 'reopened', 'archived')
490
539
  ${extraWhere}
491
540
  ORDER BY e.created_at ASC`;
492
541
  }
@@ -531,10 +580,10 @@ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPat
531
580
  db = new DatabaseSync(target, { readOnly: true });
532
581
  const since = now - days * DAY_MS;
533
582
  const playsSince = db.prepare(playEventsSql('AND e.workspace_root = ?'));
534
- season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot));
583
+ season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot), since);
535
584
  const seenAt = Number(season.last_seen_at);
536
585
  season.recap_outcomes = Number.isFinite(seenAt) && seenAt > 0 && seenAt < since
537
- ? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot))
586
+ ? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot), seenAt)
538
587
  : null;
539
588
 
540
589
  const taskRows = db.prepare(`
@@ -588,7 +637,7 @@ function loadTargetView(db, workspaceRoot, { now, since, playsSince, outcomes, c
588
637
  const gmTarget = require('./gm-target');
589
638
  const store = gmTarget.readTargets(workspaceRoot, dir ? { dir } : {});
590
639
  const from = gmTarget.historyStart(store, now);
591
- const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot)) : outcomes;
640
+ const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot), from) : outcomes;
592
641
  const hasPastWeeks = Object.keys(store.weeks).some(k => k < gmTarget.weekKey(now));
593
642
  return {
594
643
  has_target: Object.keys(store.weeks).length > 0,
@@ -758,7 +807,7 @@ function loadLeague({ now = Date.now(), days = SEASON_DAYS, dbPath, isClub = isC
758
807
  }
759
808
  const gmTarget = require('./gm-target');
760
809
  league.clubs = [...byRoot.values()].map((club) => {
761
- const outcomes = outcomesFromEvents(club.events);
810
+ const outcomes = outcomesFromEvents(club.events, since);
762
811
  const store = gmTarget.readTargets(club.root, targetsDir ? { dir: targetsDir } : {});
763
812
  return {
764
813
  root: club.root,
@@ -1111,11 +1160,9 @@ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, lea
1111
1160
  // same unique labels as the league, so /one/app and /two/app stay apart
1112
1161
  const names = clubNames([...new Set(kept.map(row => row.workspace_root))]);
1113
1162
  const clubOf = root => names.get(root) || path.basename(root);
1114
- out.plays = kept
1115
- .map(row => ({ row, play: outcomeFromEvent(row) }))
1116
- .filter(x => x.play && Number(x.play.at) <= now)
1117
- .map(x => ({ ...x.play, club: clubOf(x.row.workspace_root) }))
1118
- .sort(byTimeAsc);
1163
+ const rootByTask = new Map(kept.map(row => [row.task_id, row.workspace_root]));
1164
+ out.plays = outcomesFromEvents(kept, since).filter(play => play.at <= now)
1165
+ .map(play => ({ ...play, club: clubOf(rootByTask.get(play.task_id)) }));
1119
1166
  const claimed = db.prepare(`
1120
1167
  SELECT id, workspace_root, claimed_by, claimed_at, updated_at,
1121
1168
  (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
package/lib/task-db.js CHANGED
@@ -580,7 +580,7 @@ function workspaceHead(workspaceRoot) {
580
580
  // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
581
581
  // Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
582
582
  // writes; busy_timeout absorbs contention. Caller must check `.claimed`.
583
- function claimTask(db, { id, claimedBy }) {
583
+ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
584
584
  if (!id) throw new Error('id required');
585
585
  if (!claimedBy) throw new Error('claimedBy required');
586
586
  const now = Date.now();
@@ -589,11 +589,13 @@ function claimTask(db, { id, claimedBy }) {
589
589
  SET status = 'claimed',
590
590
  claimed_by = ?,
591
591
  claimed_at = ?,
592
- updated_at = ?
592
+ updated_at = ?,
593
+ metadata = CASE WHEN ? IS NULL THEN metadata ELSE json_set(COALESCE(metadata, '{}'), '$.lease', json(?)) END
593
594
  WHERE id = ?
594
595
  AND status = 'open'
595
596
  `);
596
- const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
597
+ const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
598
+ const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
597
599
  if (result.changes === 1) {
598
600
  let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
599
601
  // First claim wins: a re-claim after a revision keeps the original start.
@@ -621,6 +623,106 @@ function claimTask(db, { id, claimedBy }) {
621
623
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
622
624
  }
623
625
 
626
+ // Lease updates are guarded by the holder and do not create activity events.
627
+ function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now() }) {
628
+ const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
629
+ const row = getTask(db, id);
630
+ if (!row || row.metadata_unreadable || row.status !== 'claimed' || row.claimed_by !== holder) return false;
631
+ const metadata = { ...(row.metadata || {}), lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder, pid } };
632
+ return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ?")
633
+ .run(JSON.stringify(metadata), id, holder, raw.metadata)).changes === 1;
634
+ }
635
+
636
+ function renewLease(db, options) {
637
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(options.id);
638
+ const metadata = row && row.metadata ? safeJSON(row.metadata) : null;
639
+ const lease = metadata && metadata.lease;
640
+ if (!row || row.status !== 'claimed' || row.claimed_by !== options.holder || !lease || lease.holder !== options.holder || (options.pid != null && lease.pid !== options.pid)) return false;
641
+ const minutes = options.minutes ?? lease.minutes;
642
+ const now = options.now ?? Date.now();
643
+ const updated = { ...metadata, lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder: options.holder, pid: options.pid ?? lease.pid } };
644
+ return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ?")
645
+ .run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata)).changes === 1;
646
+ }
647
+
648
+ function clearLease(db, { id, holder }) {
649
+ const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
650
+ const row = getTask(db, id);
651
+ if (!row || !row.metadata || !row.metadata.lease || row.metadata.lease.holder !== holder) return false;
652
+ const metadata = { ...row.metadata };
653
+ delete metadata.lease;
654
+ return withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ?')
655
+ .run(JSON.stringify(metadata), id, raw.metadata)).changes === 1;
656
+ }
657
+
658
+ function pidAlive(pid) {
659
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
660
+ }
661
+
662
+ function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false, isAlive = pidAlive } = {}) {
663
+ const timestamp = now instanceof Date ? now.getTime() : typeof now === 'string' ? Date.parse(now) : Number(now);
664
+ if (!Number.isFinite(timestamp)) throw new Error('invalid now');
665
+ if (staleDays != null && (!Number.isFinite(Number(staleDays)) || Number(staleDays) < 0)) throw new Error('invalid stale days');
666
+ const rows = db.prepare(`SELECT t.*, (SELECT MAX(created_at) FROM task_events e WHERE e.task_id = t.id) AS last_seen_at FROM tasks t WHERE t.status = 'claimed'`).all();
667
+ const expired = [];
668
+ for (const row of rows) {
669
+ if (claimer && row.claimed_by !== claimer) continue;
670
+ const metadata = row.metadata ? safeJSON(row.metadata) : null;
671
+ const lease = metadata && metadata.lease;
672
+ const until = lease && (typeof lease.until === 'number' ? lease.until : Date.parse(lease.until));
673
+ let reason;
674
+ let leaseUntil;
675
+ if (lease && Number.isFinite(until) && until < timestamp) {
676
+ if (timestamp - until <= 12 * 3600000 && isAlive(lease.pid)) continue;
677
+ reason = `worker stopped without reporting back within ${lease.minutes ?? 60} minutes`;
678
+ leaseUntil = lease.until;
679
+ } else if (!lease && staleDays != null && row.last_seen_at != null && row.last_seen_at < timestamp - Number(staleDays) * 86400000 && (claimer ? row.claimed_by === claimer : String(row.claimed_by || '').startsWith('fleet-'))) {
680
+ reason = `stranded with no activity for over ${staleDays} days`;
681
+ leaseUntil = new Date(row.last_seen_at + 3600000).toISOString();
682
+ } else continue;
683
+ const lastSeen = lease && Number.isFinite(until) && Number.isFinite(Number(lease.minutes))
684
+ ? until - Number(lease.minutes) * 60000 : row.last_seen_at;
685
+ const payload = { claimed_by: row.claimed_by, lease_until: leaseUntil, last_seen_at: lastSeen == null ? null : new Date(lastSeen).toISOString(), reason };
686
+ const item = { id: row.id, title: row.title, ...payload };
687
+ if (dryRun) { expired.push(item); continue; }
688
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
689
+ try {
690
+ const nextMetadata = metadata && typeof metadata === 'object' ? { ...metadata } : {};
691
+ delete nextMetadata.lease;
692
+ const changed = db.prepare(`UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ?, metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND updated_at = ? AND metadata IS ? AND (SELECT MAX(created_at) FROM task_events WHERE task_id = tasks.id) IS ?`)
693
+ .run(Date.now(), JSON.stringify(nextMetadata), row.id, row.claimed_by, row.updated_at, row.metadata, row.last_seen_at);
694
+ if (changed.changes === 1) {
695
+ appendTaskEvent(db, { taskId: row.id, workspaceRoot: row.workspace_root, actor: row.claimed_by, eventType: 'claim_expired', payload });
696
+ expired.push(item);
697
+ }
698
+ db.exec('COMMIT');
699
+ } catch (err) { db.exec('ROLLBACK'); throw err; }
700
+ }
701
+ return expired;
702
+ }
703
+
704
+ function previewExpiredLeases(options = {}) {
705
+ const target = getDbPath();
706
+ if (!fs.existsSync(target)) return [];
707
+ const reader = new DatabaseSync(target, { readOnly: true });
708
+ try { return expireLeases(reader, { ...options, dryRun: true }); } finally { reader.close(); }
709
+ }
710
+
711
+ // Avoid creating or rewriting an idle database just to check for expired work.
712
+ function sweepExpiredLeases() {
713
+ try {
714
+ const target = getDbPath();
715
+ if (!fs.existsSync(target)) return [];
716
+ const reader = new DatabaseSync(target, { readOnly: true });
717
+ let candidates;
718
+ try { candidates = expireLeases(reader, { dryRun: true }); } finally { reader.close(); }
719
+ return candidates.length ? expireLeases(open(target)) : [];
720
+ } catch (err) {
721
+ if (process.env.ATRIS_DEBUG) process.stderr.write(`lease sweep failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
722
+ return [];
723
+ }
724
+ }
725
+
624
726
  function releaseTask(db, { id, actor, expectedUpdatedAt }) {
625
727
  if (!id) throw new Error('id required');
626
728
  if (!actor) throw new Error('actor required');
@@ -638,7 +740,8 @@ function releaseTask(db, { id, actor, expectedUpdatedAt }) {
638
740
  SET status = 'open',
639
741
  claimed_by = NULL,
640
742
  claimed_at = NULL,
641
- updated_at = ?
743
+ updated_at = ?,
744
+ metadata = CASE WHEN json_valid(metadata) THEN json_remove(metadata, '$.lease') ELSE metadata END
642
745
  WHERE id = ?
643
746
  AND status = 'claimed'
644
747
  AND claimed_by = ?
@@ -952,6 +1055,7 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
952
1055
  }
953
1056
  const now = Date.now();
954
1057
  const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1058
+ delete metadata.lease;
955
1059
  const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
956
1060
  metadata.approval_status = 'pending';
957
1061
  metadata.agent_review_pass_count = reviewPassCount;
@@ -993,9 +1097,9 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
993
1097
  updated_at = ?,
994
1098
  metadata = ?
995
1099
  WHERE id = ?
996
- AND status IN ('open', 'claimed', 'review')
997
- `).run(now, JSON.stringify(metadata), id));
998
- if (result.changes !== 1) return { ready: false, reason: 'not_open_claimed_or_review' };
1100
+ AND status = ? AND claimed_by IS ? AND claimed_at IS ?
1101
+ `).run(now, JSON.stringify(metadata), id, row.status, row.claimed_by, row.claimed_at));
1102
+ if (result.changes !== 1) return { ready: false, reason: 'claim changed hands' };
999
1103
  const updated = getTask(db, id);
1000
1104
  const payload = {
1001
1105
  proof: text,
@@ -2364,6 +2468,12 @@ module.exports = {
2364
2468
  listTasks,
2365
2469
  listTasksCached,
2366
2470
  claimTask,
2471
+ setLease,
2472
+ renewLease,
2473
+ clearLease,
2474
+ expireLeases,
2475
+ previewExpiredLeases,
2476
+ sweepExpiredLeases,
2367
2477
  releaseTask,
2368
2478
  reopenTask,
2369
2479
  backlogTask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.25",
3
+ "version": "3.74.26",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",