atris 3.74.28 → 3.75.0

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/gm-season.js CHANGED
@@ -11,7 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { atrisRating } = require('./atris-rating');
14
- const { isProofNotSaved } = require('./release-kind');
14
+ const { isProofNotSaved, isCouldNotStart } = require('./release-kind');
15
15
 
16
16
  const HOUR_MS = 60 * 60 * 1000;
17
17
  const DAY_MS = 24 * HOUR_MS;
@@ -22,13 +22,14 @@ const ACTIVE_MISSION_STATUSES = new Set(['running', 'ready']);
22
22
  const KEPT_ACTIONS = new Set(['accepted', 'merged']);
23
23
  const FINISHED = new Set(['landed', 'dropped', 'sent_back', 'abandoned']);
24
24
  const FORM_LETTER = { landed: 'k', dropped: 'd', sent_back: 'x', abandoned: 'a' };
25
- const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned', not_needed: 'not needed', held: 'held' };
25
+ const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned', not_needed: 'not needed', held: 'held', could_not_start: 'unstarted' };
26
26
  // A person has this long after a drop to say the job did not need doing.
27
27
  const NOT_NEEDED_WINDOW_MS = 7 * DAY_MS;
28
28
 
29
29
  // An outcome is one play:
30
30
  // { event_id, task_id, member, title, kind, at, claimed_at, landed_by }
31
- // kind is took | landed | dropped | sent_back | abandoned | not_needed | held.
31
+ // kind is took | landed | dropped | sent_back | abandoned | not_needed | held |
32
+ // could_not_start.
32
33
  // abandoned is a claim that ended archived without landing; a job still in
33
34
  // review is pending and not counted. not_needed is a drop or abandon that
34
35
  // someone other than the member later confirmed the job never needed; it is
@@ -36,8 +37,14 @@ const NOT_NEEDED_WINDOW_MS = 7 * DAY_MS;
36
37
  // because the work is done and its pull request waits for a person, but the
37
38
  // proof that says so could not be saved; the release says so in a field
38
39
  // (lib/release-kind.js), never in its words. It is not finished either, so it
39
- // is neither a landing nor a miss. at and claimed_at are epoch ms; claimed_at
40
- // may be null. landed_by is autoland | you, only on landed.
40
+ // is neither a landing nor a miss. could_not_start is a claim a flight let go
41
+ // before the engine ran, because the build machine could not start the job; the
42
+ // release says so in a field the same way. The member never touched the work, so
43
+ // it is not finished and not a miss either. The field checks itself: a claim the
44
+ // flight marked started (a build_started event after its claim, see
45
+ // markBuildStarted in task-db.js) had its engine asked, so a release that says
46
+ // could_not_start on it is a drop like any other. at and claimed_at are epoch
47
+ // ms; claimed_at may be null. landed_by is autoland | you, only on landed.
41
48
 
42
49
  function byTimeAsc(a, b) {
43
50
  return Number(a.at || 0) - Number(b.at || 0);
@@ -50,7 +57,7 @@ function isFinished(outcome) {
50
57
  // jobs = landed + dropped + sent back + abandoned in the window, the same
51
58
  // count the rating divides by. Jobs still in review are pending.
52
59
  function tally(outcomes) {
53
- const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0, not_needed: 0, held: 0 };
60
+ const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0, not_needed: 0, held: 0, could_not_start: 0 };
54
61
  for (const o of outcomes || []) {
55
62
  if (Object.prototype.hasOwnProperty.call(out, o.kind)) out[o.kind] += 1;
56
63
  }
@@ -330,8 +337,13 @@ function outcomeFromEvent(row) {
330
337
  member = row.actor || payload.claimed_by;
331
338
  } else if (row.event_type === 'claim_released') {
332
339
  // A release that carries the proof-not-saved fields is work waiting on a person,
333
- // not a drop. Only the fields say so: the same words with no fields stay a drop.
334
- kind = isProofNotSaved(payload) ? 'held' : 'dropped';
340
+ // not a drop. One that carries the could-not-start kind was let go before the
341
+ // engine ran, so it is not a drop either, unless its claim was marked started
342
+ // (engine_started): then the kind is false and it is a drop. Only the fields say
343
+ // so: the same words with no fields stay a drop.
344
+ kind = isProofNotSaved(payload) ? 'held'
345
+ : isCouldNotStart(payload) && Number(row.engine_started) !== 1 ? 'could_not_start'
346
+ : 'dropped';
335
347
  member = payload.previous_claimed_by || payload.released_by || row.actor;
336
348
  } else if (row.event_type === 'claim_expired') {
337
349
  kind = 'dropped';
@@ -420,7 +432,11 @@ function outcomesFromEvents(rows, since = -Infinity) {
420
432
  const ended = ['claim_released', 'claim_expired', 'completed', 'reopened', 'archived'].includes(row.event_type);
421
433
  if (!play) { if (ended) closed.add(key); continue; }
422
434
  if (play.kind === 'took') { plays.push(play); continue; }
423
- if (row.event_type === 'claim_released') {
435
+ // A release that another member's claim follows within two minutes reads as a hand-over, and is left out. That
436
+ // is a rule about old history, whose releases carry no mark, and it holds for them as it always did. A release
437
+ // on a claim the engine started (engine_started) is a real miss whoever claims next, and one that could not
438
+ // start has its own line: neither is a hand-over, so neither is erased.
439
+ if (row.event_type === 'claim_released' && Number(row.engine_started) !== 1 && play.kind !== 'could_not_start') {
424
440
  let next = null;
425
441
  for (let j = i + 1; j < ordered.length && ordered[j].task_id === row.task_id; j++) {
426
442
  if (ordered[j].event_type === 'claimed') { next = ordered[j]; break; }
@@ -555,6 +571,12 @@ function playEventsSql(extraWhere = '') {
555
571
  SELECT e.event_id, e.task_id, e.workspace_root, e.event_type, e.actor, e.payload, e.created_at, e.version,
556
572
  t.title, t.claimed_by, t.claimed_at,
557
573
  c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
574
+ CASE WHEN e.event_type = 'claim_released' AND c.event_id IS NOT NULL AND EXISTS (
575
+ SELECT 1 FROM task_events b
576
+ WHERE b.task_id = e.task_id AND b.version > c.version AND b.version < e.version
577
+ -- the flight marks a claim started right before it asks the assigned engine
578
+ AND b.event_type = 'build_started'
579
+ ) THEN 1 ELSE 0 END AS engine_started,
558
580
  CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
559
581
  SELECT 1 FROM task_events x
560
582
  WHERE x.task_id = e.task_id AND x.version > c.version AND x.version < e.version
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
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
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
- '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',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', '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
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -2,16 +2,33 @@
2
2
 
3
3
  // Why a claim was let go, as a field the rating can read without reading words.
4
4
  //
5
- // One kind exists. The work is done and its pull request is open for a person to
5
+ // Two kinds exist. Both ride on the release event as fields; the reason text is
6
+ // only for people, and nothing ever parses it.
7
+ //
8
+ // proof_not_saved. The work is done and its pull request is open for a person to
6
9
  // land, but the proof that says so could not be saved. The builder did its part,
7
- // so the release is neither a landing nor a drop. The kind and the pull request
8
- // ride on the release event as fields; the reason text is only for people, and
9
- // nothing ever parses it.
10
+ // so the release is neither a landing nor a drop. A release says this by passing
11
+ // the kind and the pull request together. The task db refuses half of a marker,
12
+ // and the rating reads a release as this kind only when both are there.
13
+ //
14
+ // could_not_start. The flight let the claim go before the engine ran, because the
15
+ // build machine could not start the job (no room on the disk, a build copy that
16
+ // would not open, a lease that would not set, a brief that would not save). The
17
+ // builder never touched the work, so the release is neither a drop nor a finished
18
+ // job. The kind goes alone, with no pull request, and only the flights pass it.
10
19
  //
11
- // A release says this by passing both fields. The task db refuses half of a
12
- // marker, and the rating reads a release as this kind only when both are there.
20
+ // The kind checks itself. A builder shares the task store with the flight, so
21
+ // the store and the rating take it only for a claim whose engine was never
22
+ // asked. Right before it asks the assigned engine, the flight writes a
23
+ // build_started event on the claim (see markBuildStarted in task-db.js). A
24
+ // release that carries could_not_start on a claim with that event is an
25
+ // ordinary release, and the rating reads it as a drop. Scout and prep passes
26
+ // run before the mark, so they never count as the build starting.
13
27
 
14
28
  const PROOF_NOT_SAVED = 'proof_not_saved';
29
+ const COULD_NOT_START = 'could_not_start';
30
+ // The event type of that mark.
31
+ const BUILD_STARTED = 'build_started';
15
32
 
16
33
  // True for a release event payload that carries the kind and an open pull
17
34
  // request it can point at. Anything less is an ordinary release.
@@ -24,4 +41,11 @@ function isProofNotSaved(payload) {
24
41
  return (Number.isInteger(pr.number) && pr.number > 0) || /^https?:\/\//i.test(String(pr.url || '').trim());
25
42
  }
26
43
 
27
- module.exports = { PROOF_NOT_SAVED, isProofNotSaved };
44
+ // True for a release event payload that carries the could-not-start kind.
45
+ // Anything else, including the same words with no field, is an ordinary release.
46
+ function isCouldNotStart(payload) {
47
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
48
+ return payload.kind === COULD_NOT_START;
49
+ }
50
+
51
+ module.exports = { PROOF_NOT_SAVED, COULD_NOT_START, BUILD_STARTED, isProofNotSaved, isCouldNotStart };
package/lib/task-db.js CHANGED
@@ -27,7 +27,7 @@ const path = require('path');
27
27
  const crypto = require('crypto');
28
28
  const { DatabaseSync } = require('node:sqlite');
29
29
  const reviewIntegrity = require('./review-integrity');
30
- const { PROOF_NOT_SAVED } = require('./release-kind');
30
+ const { PROOF_NOT_SAVED, COULD_NOT_START, BUILD_STARTED } = require('./release-kind');
31
31
  const dailyLog = require('./daily-log');
32
32
  const { isGenericScratchRoot } = require('./scratch-root');
33
33
  const { isDecisionTask } = require('./task-decision');
@@ -593,9 +593,50 @@ function workspaceHead(workspaceRoot) {
593
593
  }
594
594
  }
595
595
 
596
- // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
597
- // Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
598
- // writes; busy_timeout absorbs contention. Caller must check `.claimed`.
596
+ // One row change and its history entry are one transaction: both are in the store or neither is, so nobody can see,
597
+ // or compare against, a job whose row says one thing and whose history says another, and a history write that fails
598
+ // takes the row change back with it. BEGIN IMMEDIATE takes the write lock first, so what the writer read is still
599
+ // true when it writes. A caller already inside a transaction of its own joins that one (db.isTransaction says so on
600
+ // the Node versions that have it; on the others SQLite refuses to begin inside one, and that refusal says the same).
601
+ function inWriteTransaction(db, write) {
602
+ if (db.isTransaction) return write();
603
+ try {
604
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
605
+ } catch (err) {
606
+ if (/within a transaction/i.test(String(err && err.message))) return write();
607
+ throw err;
608
+ }
609
+ let result;
610
+ try {
611
+ result = write();
612
+ db.exec('COMMIT');
613
+ } catch (err) {
614
+ try { db.exec('ROLLBACK'); } catch { /* the transaction is already gone */ }
615
+ throw err;
616
+ }
617
+ return result;
618
+ }
619
+
620
+ // The part of a write that holds it to one claim. The caller names a claim by its claimed event (bound twice, once
621
+ // for the NULL test, which leaves a write that names none as it always was). The write goes through only when that
622
+ // event is the job's latest claimed event and is no older than the row's own claim time, which is how an older CLI,
623
+ // that saves the row first and its event a moment later, shows a claim that is not here yet. It sits inside the
624
+ // write itself, one statement, so a claim that lands after the caller's last look is refused too, and it reads the
625
+ // claim by its event, not by a time, which two claims can share.
626
+ const NAMED_CLAIM_SQL = `AND (? IS NULL OR EXISTS (
627
+ SELECT 1 FROM task_events e
628
+ WHERE e.task_id = tasks.id
629
+ AND e.event_type = 'claimed'
630
+ AND e.event_id = ?
631
+ AND (tasks.claimed_at IS NULL OR e.created_at >= tasks.claimed_at)
632
+ AND e.version = (SELECT MAX(version) FROM task_events WHERE task_id = tasks.id AND event_type = 'claimed')))`;
633
+
634
+ // Atomic claim. Returns { claimed: true, row, event } only if THIS call won the row; event is the claimed event
635
+ // this claim wrote, the one name for it (see latestClaim). Race-safe via single UPDATE with WHERE status='open'
636
+ // guard. SQLite serializes writes; busy_timeout absorbs contention. Caller must check `.claimed`.
637
+ //
638
+ // The row and its claimed event are written in one transaction. Before, the row was claimed first and the event
639
+ // followed a git call later, and in that gap a late release naming the claim before could pass for the new one.
599
640
  function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
600
641
  if (!id) throw new Error('id required');
601
642
  if (!claimedBy) throw new Error('claimedBy required');
@@ -611,27 +652,42 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
611
652
  AND status = 'open'
612
653
  `);
613
654
  const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
614
- const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
615
- if (result.changes === 1) {
655
+ const won = inWriteTransaction(db, () => {
656
+ const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
657
+ if (result.changes !== 1) return null;
616
658
  let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
617
- // First claim wins: a re-claim after a revision keeps the original start.
659
+ // The commit the workspace sits on is read here, with the write lock held, and only for a claim that won. A claim
660
+ // that has to wait for the lock is made when it gets it, and a commit that landed in the checkout while it waited
661
+ // is part of what was there before the job began: read earlier, the start would name a commit older than the claim,
662
+ // and a job that made no commit could be credited with the one that landed (lib/landing-proof.js). A git that is
663
+ // slow to answer holds the lock for no longer than workspaceHead lets it wait.
618
664
  const startCommit = workspaceHead(row.workspace_root);
665
+ // First claim wins: a re-claim after a revision keeps the original start.
619
666
  const claimedMetadata = row.metadata ? safeJSON(row.metadata) || {} : {};
620
667
  if (startCommit && !claimedMetadata.start_commit) {
621
668
  claimedMetadata.start_commit = startCommit;
622
669
  withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(JSON.stringify(claimedMetadata), id));
623
670
  row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
624
671
  }
625
- appendTaskEvent(db, {
672
+ const event = appendTaskEvent(db, {
626
673
  taskId: id,
627
674
  workspaceRoot: row.workspace_root,
628
675
  actor: claimedBy,
629
676
  eventType: 'claimed',
630
677
  payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
631
678
  });
632
- const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
633
- appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
634
- return { claimed: true, row: enriched };
679
+ return { row, event };
680
+ });
681
+ if (won) {
682
+ const enriched = { ...won.row, metadata: won.row.metadata ? safeJSON(won.row.metadata) : null };
683
+ // The work log is a note for people, written once the claim is saved. A note that cannot be written must not
684
+ // turn a claim that landed into an error: the caller would not learn which claim is its own.
685
+ try {
686
+ appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
687
+ } catch (err) {
688
+ if (process.env.ATRIS_DEBUG) process.stderr.write(`claim log failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
689
+ }
690
+ return { claimed: true, row: enriched, event: won.event };
635
691
  }
636
692
  // Either id doesn't exist or status != 'open'. Tell the caller which.
637
693
  const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
@@ -639,14 +695,19 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
639
695
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
640
696
  }
641
697
 
642
- // Lease updates are guarded by the holder and do not create activity events.
643
- function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now() }) {
698
+ // Lease updates are guarded by the holder and do not create activity events. A flight also names the claim it
699
+ // made (claimEventId, see NAMED_CLAIM_SQL): the lease is written, renewed, or cleared only while the job is
700
+ // still held under that claim, in the same statement, so a flight that lost its job cannot replace or clear the
701
+ // lease of the claim that came after it, even under the same builder name. One statement is one transaction,
702
+ // and a lease has no history entry, so there is nothing more to keep together.
703
+ function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now(), claimEventId }) {
644
704
  const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
645
705
  const row = getTask(db, id);
646
706
  if (!row || row.metadata_unreadable || row.status !== 'claimed' || row.claimed_by !== holder) return false;
707
+ const named = claimEventId ? String(claimEventId) : null;
647
708
  const metadata = { ...(row.metadata || {}), lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder, pid } };
648
- return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ?")
649
- .run(JSON.stringify(metadata), id, holder, raw.metadata)).changes === 1;
709
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
710
+ .run(JSON.stringify(metadata), id, holder, raw.metadata, named, named)).changes === 1;
650
711
  }
651
712
 
652
713
  function renewLease(db, options) {
@@ -654,21 +715,23 @@ function renewLease(db, options) {
654
715
  const metadata = row && row.metadata ? safeJSON(row.metadata) : null;
655
716
  const lease = metadata && metadata.lease;
656
717
  if (!row || row.status !== 'claimed' || row.claimed_by !== options.holder || !lease || lease.holder !== options.holder || (options.pid != null && lease.pid !== options.pid)) return false;
718
+ const named = options.claimEventId ? String(options.claimEventId) : null;
657
719
  const minutes = options.minutes ?? lease.minutes;
658
720
  const now = options.now ?? Date.now();
659
721
  const updated = { ...metadata, lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder: options.holder, pid: options.pid ?? lease.pid } };
660
- return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ?")
661
- .run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata)).changes === 1;
722
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
723
+ .run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata, named, named)).changes === 1;
662
724
  }
663
725
 
664
- function clearLease(db, { id, holder }) {
726
+ function clearLease(db, { id, holder, claimEventId }) {
665
727
  const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
666
728
  const row = getTask(db, id);
667
729
  if (!row || !row.metadata || !row.metadata.lease || row.metadata.lease.holder !== holder) return false;
730
+ const named = claimEventId ? String(claimEventId) : null;
668
731
  const metadata = { ...row.metadata };
669
732
  delete metadata.lease;
670
- return withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ?')
671
- .run(JSON.stringify(metadata), id, raw.metadata)).changes === 1;
733
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
734
+ .run(JSON.stringify(metadata), id, raw.metadata, named, named)).changes === 1;
672
735
  }
673
736
 
674
737
  function pidAlive(pid) {
@@ -743,23 +806,103 @@ function sweepExpiredLeases() {
743
806
  // line. A release that must not read as a drop (the work is done and its pull
744
807
  // request waits for a person, only the proof could not be saved) also carries a
745
808
  // kind and that pull request as fields, so the rating never has to read words.
746
- // Only the dispatch flight passes them, in its own process; `atris task release`
809
+ // Only the flights pass them, in their own process; `atris task release`
747
810
  // takes neither, so a worker cannot excuse its own miss with a flag. The two go
748
- // together or not at all; half a marker leaves the claim held.
811
+ // together or not at all; half a marker leaves the claim held. One kind goes
812
+ // alone: could_not_start, for a claim let go before the engine ran, takes no
813
+ // pull request and is refused with one.
749
814
  const RELEASE_REASON_MAX = 300;
750
815
 
751
816
  function releaseMarker(kind, pullRequest) {
752
817
  const named = kind !== undefined && kind !== null && String(kind).trim() !== '';
753
818
  if (!named && !pullRequest) return { marker: null };
819
+ if (named && String(kind).trim() === COULD_NOT_START && !pullRequest) return { marker: { kind: COULD_NOT_START } };
754
820
  const open = cleanOpenPullRequest(pullRequest);
755
821
  if (!named || String(kind).trim() !== PROOF_NOT_SAVED || !open) return { invalid: true };
756
822
  return { marker: { kind: PROOF_NOT_SAVED, pull_request: open } };
757
823
  }
758
824
 
759
- function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest }) {
825
+ // The claim a job is held under now: its latest claimed event. The event id names that one claim, so a
826
+ // flight that remembers it can tell later whether the job is still held under the claim it made, or was
827
+ // let go and claimed again, even under the same builder name.
828
+ function latestClaim(db, id) {
829
+ return db.prepare("SELECT event_id, version, actor, created_at FROM task_events WHERE task_id = ? AND event_type = 'claimed' ORDER BY version DESC LIMIT 1").get(id) || null;
830
+ }
831
+
832
+ // True when the claim event the caller names is the claim the job is held under now. claimTask writes the row and
833
+ // its claimed event in one transaction, so here the two always agree. A claim saved by an older CLI wrote the row
834
+ // first and its event a moment later, and in that gap the latest event is still the claim before it: an event
835
+ // written before the row's own claim time is that older claim, not the one the row holds.
836
+ function claimIsCurrent(row, claim, claimEventId) {
837
+ if (!claim || claim.event_id !== claimEventId) return false;
838
+ return row.claimed_at == null || Number(claim.created_at) >= Number(row.claimed_at);
839
+ }
840
+
841
+ // True when the engine was asked on the job's current claim: a build_started event sits after its latest
842
+ // claimed event (see markBuildStarted).
843
+ function claimMarkedStarted(db, id) {
844
+ const claim = latestClaim(db, id);
845
+ return Boolean(claim) && Boolean(db.prepare('SELECT 1 AS hit FROM task_events WHERE task_id = ? AND event_type = ? AND version > ? LIMIT 1').get(id, BUILD_STARTED, claim.version));
846
+ }
847
+
848
+ // A flight writes this right before it asks the assigned engine, on the claim it made. It is what lets the
849
+ // store and the rating tell a claim let go before the engine ran from one let go after it: a release that
850
+ // says could_not_start on a marked claim is an ordinary release (see releaseTask). Only the holder can mark,
851
+ // and claimEventId, when given, names the one claim the caller made, so a run that lost its claim cannot mark
852
+ // the next run's. Marking twice adds nothing. A refusal writes nothing.
853
+ function markBuildStarted(db, { id, actor, claimEventId }) {
854
+ if (!id) throw new Error('id required');
855
+ if (!actor) throw new Error('actor required');
856
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
857
+ try {
858
+ const row = getTask(db, id);
859
+ let result;
860
+ if (!row) result = { marked: false, reason: 'not_found' };
861
+ else if (row.status !== 'claimed') result = { marked: false, reason: `already_${row.status}`, claimed_by: row.claimed_by || null };
862
+ else if (row.claimed_by !== actor) result = { marked: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
863
+ else {
864
+ const claim = latestClaim(db, id);
865
+ if (!claim) result = { marked: false, reason: 'no_claim_event' };
866
+ else if (claimEventId && !claimIsCurrent(row, claim, claimEventId)) result = { marked: false, reason: 'claim_changed', claimed_by: row.claimed_by || null };
867
+ else if (claimMarkedStarted(db, id)) result = { marked: true, already: true };
868
+ else {
869
+ result = {
870
+ marked: true,
871
+ event: appendTaskEvent(db, {
872
+ taskId: id,
873
+ workspaceRoot: row.workspace_root,
874
+ actor,
875
+ eventType: BUILD_STARTED,
876
+ payload: { claimed_by: actor, claim_event_id: claim.event_id },
877
+ }),
878
+ };
879
+ }
880
+ }
881
+ db.exec('COMMIT');
882
+ return result;
883
+ } catch (err) { db.exec('ROLLBACK'); throw err; }
884
+ }
885
+
886
+ // claimEventId, when given, names the one claim the caller made (see latestClaim). A job that was let go
887
+ // and claimed again since, by anyone, is not the caller's to release: nothing is written. The check is part of
888
+ // the write itself, one statement (see NAMED_CLAIM_SQL), so a claim that lands between the caller's last look and
889
+ // the release is refused too, and it reads the claim by its event, not by a time, which two claims can share.
890
+ //
891
+ // The look, the row write, and the claim_released entry are one transaction (see inWriteTransaction): a history
892
+ // entry that cannot be written takes the release back with it, so a job is never left open with no record of how,
893
+ // and the check cannot pass against a claim whose own entry is not in the store yet.
894
+ //
895
+ // The could-not-start kind checks itself. It is taken only for a claim whose engine was never asked. On a
896
+ // claim marked started (see markBuildStarted) the release goes through as an ordinary release with no kind,
897
+ // and is not refused, so the claim is never left held.
898
+ function releaseTask(db, release) {
899
+ return inWriteTransaction(db, () => releaseTaskInTransaction(db, release));
900
+ }
901
+
902
+ function releaseTaskInTransaction(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest, claimEventId }) {
760
903
  if (!id) throw new Error('id required');
761
904
  if (!actor) throw new Error('actor required');
762
- const { marker, invalid } = releaseMarker(kind, pullRequest);
905
+ const { marker: asked, invalid } = releaseMarker(kind, pullRequest);
763
906
  if (invalid) return { released: false, reason: 'invalid_release_marker' };
764
907
  const row = getTask(db, id);
765
908
  if (!row) return { released: false, reason: 'not_found' };
@@ -769,8 +912,10 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
769
912
  if (row.claimed_by !== actor) {
770
913
  return { released: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
771
914
  }
915
+ const marker = asked && asked.kind === COULD_NOT_START && claimMarkedStarted(db, id) ? null : asked;
772
916
  const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
773
917
  const now = Date.now();
918
+ const named = claimEventId ? String(claimEventId) : null;
774
919
  const result = withBusyRetry(() => db.prepare(`
775
920
  UPDATE tasks
776
921
  SET status = 'open',
@@ -781,9 +926,21 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
781
926
  WHERE id = ?
782
927
  AND status = 'claimed'
783
928
  AND claimed_by = ?
929
+ AND claimed_at IS ?
784
930
  AND (? IS NULL OR updated_at = ?)
785
- `).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
786
- if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
931
+ ${NAMED_CLAIM_SQL}
932
+ `).run(now, id, actor, row.claimed_at ?? null, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null, named, named));
933
+ if (result.changes !== 1) {
934
+ // The job changed between the look and the write. A caller that named its claim is told what the job is now.
935
+ if (named) {
936
+ const current = getTask(db, id);
937
+ if (!current) return { released: false, reason: 'not_found' };
938
+ if (current.status !== 'claimed') return { released: false, reason: `already_${current.status}`, claimed_by: current.claimed_by || null };
939
+ if (current.claimed_by !== actor) return { released: false, reason: 'held_by_other', claimed_by: current.claimed_by || null };
940
+ if (!claimIsCurrent(current, latestClaim(db, id), named)) return { released: false, reason: 'claim_changed', claimed_by: current.claimed_by || null };
941
+ }
942
+ return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
943
+ }
787
944
  const updated = getTask(db, id);
788
945
  const event = appendTaskEvent(db, {
789
946
  taskId: id,
@@ -2584,6 +2741,8 @@ module.exports = {
2584
2741
  previewExpiredLeases,
2585
2742
  sweepExpiredLeases,
2586
2743
  releaseTask,
2744
+ latestClaim,
2745
+ markBuildStarted,
2587
2746
  reopenTask,
2588
2747
  backlogTask,
2589
2748
  clearPlanTasks,
@@ -26,8 +26,6 @@ const GB = 1e9;
26
26
  // fail (swap, updates, builds), so start refuses to add another copy.
27
27
  const DEFAULT_REAP_BELOW_GB = 25;
28
28
  const DEFAULT_REFUSE_BELOW_GB = 10;
29
- const DEPS_CACHE_KEEP = 3;
30
- const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
31
29
  const AGENT_COPIES_DIR = '.agent-worktrees';
32
30
  const DEPS_DIR = '.deps';
33
31
  const CLONE_TIMEOUT_MS = 10 * 60 * 1000;
@@ -224,6 +222,8 @@ function ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir, c
224
222
  const entry = depsCacheEntry(workspace, repoName, hash);
225
223
  const cached = path.join(entry, 'node_modules');
226
224
  if (installedMatchesLock(lockFile, cached)) return cached;
225
+ // A mismatched existing entry may contain local changes. Do not replace it.
226
+ if (pathExists(entry)) return null;
227
227
  if (!sourceDir || !installedMatchesLock(lockFile, sourceDir)) return null;
228
228
  const partial = path.join(depsRepoDir(workspace, repoName), `.partial-${hash}-${process.pid}-${Date.now()}`);
229
229
  try {
@@ -233,8 +233,6 @@ function ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir, c
233
233
  // Build caches are not part of the install and only grow the store.
234
234
  removeTree(path.join(partial, 'node_modules', '.cache'));
235
235
  if (!installedMatchesLock(lockFile, path.join(partial, 'node_modules'))) return null;
236
- // An entry that no longer matches its own lockfile was changed by hand.
237
- if (pathExists(entry) && !installedMatchesLock(lockFile, cached)) removeTree(entry);
238
236
  try {
239
237
  fs.renameSync(partial, entry);
240
238
  } catch {
@@ -253,49 +251,12 @@ function touch(target) {
253
251
  } catch {}
254
252
  }
255
253
 
256
- // Keeps the `keep` most recently used installs per repo and drops partial
257
- // entries a crashed start left behind.
258
- function pruneDepsCache({ workspace, repoName, keep = DEPS_CACHE_KEEP, now = Date.now() } = {}) {
259
- const dir = depsRepoDir(workspace, repoName);
260
- let names = [];
261
- try {
262
- names = fs.readdirSync(dir);
263
- } catch {
264
- return [];
265
- }
266
- const removed = [];
267
- const entries = [];
268
- for (const name of names) {
269
- const full = path.join(dir, name);
270
- let mtimeMs = 0;
271
- try {
272
- mtimeMs = fs.statSync(full).mtimeMs;
273
- } catch {
274
- continue;
275
- }
276
- if (name.startsWith('.partial-')) {
277
- if (now - mtimeMs > PARTIAL_MAX_AGE_MS) {
278
- removeTree(full);
279
- removed.push(full);
280
- }
281
- continue;
282
- }
283
- if (!name.startsWith('.')) entries.push({ full, mtimeMs });
284
- }
285
- entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
286
- for (const old of entries.slice(keep)) {
287
- removeTree(old.full);
288
- removed.push(old.full);
289
- }
290
- return removed;
291
- }
292
-
293
254
  // Gives a new build copy its node_modules as a clone of a matching install,
294
255
  // so the agent skips `npm ci`. Looks in the shared store first, then in the
295
256
  // main checkout and other copies of the same repo. Never fails the start:
296
257
  // any miss returns { status: 'skipped', reason } and the agent installs as
297
258
  // before.
298
- function seedNodeModules({ target, workspace, repoName, sources = [], clone = cloneTree, keep = DEPS_CACHE_KEEP, isIgnored = () => true } = {}) {
259
+ function seedNodeModules({ target, workspace, repoName, sources = [], clone = cloneTree, isIgnored = () => true } = {}) {
299
260
  const startedAt = Date.now();
300
261
  const result = (status, reason, extra = {}) => ({ status, reason, ms: Date.now() - startedAt, ...extra });
301
262
  const lockFile = path.join(target, 'package-lock.json');
@@ -327,14 +288,13 @@ function seedNodeModules({ target, workspace, repoName, sources = [], clone = cl
327
288
  }
328
289
  }
329
290
  if (!cached) return result('skipped', 'no matching install to clone yet; run npm ci, and later copies will clone it', { hash });
330
- // Mark the entry used before cloning so a parallel prune keeps it.
291
+ // Record use of the entry before cloning it.
331
292
  touch(path.dirname(cached));
332
293
  const cloned = clone(cached, modules);
333
294
  if (!cloned.ok || !installedMatchesLock(lockFile, modules)) {
334
295
  removeTree(modules);
335
296
  return result('skipped', `clone failed: ${cloned.error || 'install did not match after cloning'}`, { hash });
336
297
  }
337
- pruneDepsCache({ workspace, repoName, keep });
338
298
  return result('cloned', '', { hash, method: cloned.method, source: cached });
339
299
  }
340
300
 
@@ -351,7 +311,6 @@ module.exports = {
351
311
  isInside,
352
312
  isScratchPath,
353
313
  lockHash,
354
- pruneDepsCache,
355
314
  seedNodeModules,
356
315
  spaceLimits,
357
316
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.28",
3
+ "version": "3.75.0",
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",