atris 3.74.24 → 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/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-bet.js CHANGED
@@ -4573,8 +4573,8 @@ function createBetDesk(deps) {
4573
4573
 
4574
4574
  // The team page: every member on the team, one row each. role is a few
4575
4575
  // words from its MEMBER.md. ovr is the board's own rating (atris gm
4576
- // --board): 60 to 99 from the share of finished jobs that landed in the
4577
- // last 30 days, "new" with 1 or 2 finished, and two dots with none
4576
+ // --board): the Atris Rating, 25 to 99, of the finished jobs that landed
4577
+ // in the last 30 days, "new" with 1 or 2 finished, and two dots with none
4578
4578
  // finished in that window (a first claim alone is not a rating) or no
4579
4579
  // task list to read. engine is the tool and the exact model the roster
4580
4580
  // gives the member (atris engine roster), two dots when no model is
package/lib/gm-season.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const { atrisRating } = require('./atris-rating');
13
14
 
14
15
  const HOUR_MS = 60 * 60 * 1000;
15
16
  const DAY_MS = 24 * HOUR_MS;
@@ -49,17 +50,18 @@ function tally(outcomes) {
49
50
  return out;
50
51
  }
51
52
 
52
- // round(60 + 39 * landed / finished), rookie under 3 finished jobs. This is
53
+ // The Atris Rating of landed over finished (lib/atris-rating.js): 25 to 99,
54
+ // where 99 means 99 of every 100, rookie under 3 finished jobs. This is
53
55
  // stricter than the website rule (kept / graded) on purpose: drops and
54
56
  // abandoned jobs count.
55
57
  function ovr(outcomes) {
56
58
  const { landed, finished } = tally(outcomes);
57
59
  if (finished < ROOKIE_FINISHED) return null;
58
- return Math.round(60 + 39 * landed / finished);
60
+ return atrisRating(landed, finished);
59
61
  }
60
62
 
61
63
  // Each member's rating over one window of plays, the board's own numbers:
62
- // ovr is 60 to 99, or null for a rookie under 3 finished. A member with no
64
+ // ovr is 25 to 99, or null for a rookie under 3 finished. A member with no
63
65
  // plays in the window is not in the map.
64
66
  function memberRatings(outcomes = []) {
65
67
  const groups = new Map();
@@ -320,6 +322,12 @@ function outcomeFromEvent(row) {
320
322
  } else if (row.event_type === 'claim_released') {
321
323
  kind = 'dropped';
322
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;
323
331
  } else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
324
332
  kind = 'landed';
325
333
  member = holder;
@@ -338,6 +346,7 @@ function outcomeFromEvent(row) {
338
346
  const claimId = kind === 'took'
339
347
  ? (row.event_id || `${row.task_id}:${row.created_at}`)
340
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;
341
350
  return {
342
351
  event_id: row.event_id || null,
343
352
  claim_id: claimId,
@@ -345,14 +354,56 @@ function outcomeFromEvent(row) {
345
354
  member: String(member),
346
355
  title: row.title || '',
347
356
  kind,
348
- 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,
349
359
  claimed_at: claimedAt,
350
360
  landed_by: kind === 'landed' ? landedBy(row, payload) : null,
351
361
  };
352
362
  }
353
363
 
354
- function outcomesFromEvents(rows) {
355
- 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);
356
407
  }
357
408
 
358
409
  function emptySeason(workspaceRoot, now) {
@@ -465,7 +516,7 @@ function missionLastAt(mission) {
465
516
  // right holder. `extraWhere` narrows it (one workspace, or none for the league).
466
517
  function playEventsSql(extraWhere = '') {
467
518
  return `
468
- 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,
469
520
  t.title, t.claimed_by, t.claimed_at,
470
521
  c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
471
522
  CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
@@ -483,8 +534,8 @@ function playEventsSql(extraWhere = '') {
483
534
  SELECT p.event_id FROM task_events p
484
535
  WHERE p.task_id = e.task_id AND p.event_type = 'claimed' AND p.version < e.version
485
536
  ORDER BY p.version DESC LIMIT 1)
486
- WHERE e.created_at >= ?
487
- 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')
488
539
  ${extraWhere}
489
540
  ORDER BY e.created_at ASC`;
490
541
  }
@@ -529,10 +580,10 @@ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPat
529
580
  db = new DatabaseSync(target, { readOnly: true });
530
581
  const since = now - days * DAY_MS;
531
582
  const playsSince = db.prepare(playEventsSql('AND e.workspace_root = ?'));
532
- season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot));
583
+ season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot), since);
533
584
  const seenAt = Number(season.last_seen_at);
534
585
  season.recap_outcomes = Number.isFinite(seenAt) && seenAt > 0 && seenAt < since
535
- ? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot))
586
+ ? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot), seenAt)
536
587
  : null;
537
588
 
538
589
  const taskRows = db.prepare(`
@@ -586,7 +637,7 @@ function loadTargetView(db, workspaceRoot, { now, since, playsSince, outcomes, c
586
637
  const gmTarget = require('./gm-target');
587
638
  const store = gmTarget.readTargets(workspaceRoot, dir ? { dir } : {});
588
639
  const from = gmTarget.historyStart(store, now);
589
- const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot)) : outcomes;
640
+ const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot), from) : outcomes;
590
641
  const hasPastWeeks = Object.keys(store.weeks).some(k => k < gmTarget.weekKey(now));
591
642
  return {
592
643
  has_target: Object.keys(store.weeks).length > 0,
@@ -756,7 +807,7 @@ function loadLeague({ now = Date.now(), days = SEASON_DAYS, dbPath, isClub = isC
756
807
  }
757
808
  const gmTarget = require('./gm-target');
758
809
  league.clubs = [...byRoot.values()].map((club) => {
759
- const outcomes = outcomesFromEvents(club.events);
810
+ const outcomes = outcomesFromEvents(club.events, since);
760
811
  const store = gmTarget.readTargets(club.root, targetsDir ? { dir: targetsDir } : {});
761
812
  return {
762
813
  root: club.root,
@@ -1109,11 +1160,9 @@ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, lea
1109
1160
  // same unique labels as the league, so /one/app and /two/app stay apart
1110
1161
  const names = clubNames([...new Set(kept.map(row => row.workspace_root))]);
1111
1162
  const clubOf = root => names.get(root) || path.basename(root);
1112
- out.plays = kept
1113
- .map(row => ({ row, play: outcomeFromEvent(row) }))
1114
- .filter(x => x.play && Number(x.play.at) <= now)
1115
- .map(x => ({ ...x.play, club: clubOf(x.row.workspace_root) }))
1116
- .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)) }));
1117
1166
  const claimed = db.prepare(`
1118
1167
  SELECT id, workspace_root, claimed_by, claimed_at, updated_at,
1119
1168
  (SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'sync-checkout', 'zen',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
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,
@@ -6,6 +6,7 @@
6
6
 
7
7
  const { modelLabel } = require('./roster-models');
8
8
  const { rosterToolLabel } = require('./engine-registry');
9
+ const { bootRatingText } = require('./atris-rating');
9
10
  const { suggestionCount } = require('./roster-suggest');
10
11
 
11
12
  const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
@@ -52,6 +53,8 @@ function readLineupSafe(root = process.cwd(), now = new Date()) {
52
53
 
53
54
  // One boot line: the tool and model that runs each built-in job right now,
54
55
  // custom jobs only when all of them still fit. '' when nothing resolves.
56
+ // A build job's model shows its combine rating (atris rating), since the
57
+ // combine grades building only.
55
58
  // When the run record suggests a new order for any job, a short marker
56
59
  // points at the roster view; only the end of the record is read.
57
60
  function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
@@ -63,7 +66,9 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
63
66
  const ordered = orderJobs(rows);
64
67
  const part = (row) => {
65
68
  const text = shortRuns(row.now_runs);
66
- return text ? `${row.job} ${text}` : '';
69
+ if (!text) return '';
70
+ const rated = row.role === 'executor' ? bootRatingText(row.now_runs.model) : '';
71
+ return `${row.job} ${text}${rated}`;
67
72
  };
68
73
  const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
69
74
  const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.24",
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",