atris 3.77.0 → 3.79.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/task-db.js CHANGED
@@ -197,24 +197,39 @@ function getDbPath() {
197
197
  return process.env.ATRIS_TASKS_DB || path.join(atrisHome(), 'tasks.db');
198
198
  }
199
199
 
200
- function open(dbPath) {
200
+ // How many times each setup statement of a short-wait connection is tried while another process holds
201
+ // the write lock: with the connection's own busy wait left at nothing, about a second in all.
202
+ const SHORT_WAIT_TRIES = 7;
203
+
204
+ // shortWait is for a caller that must not hang behind other writers (the automatic held-job sweep). It
205
+ // gets a connection of its own, never the cached one, that the caller closes. The connection's own busy
206
+ // wait stays at nothing, so a store that stays locked is given up on after about a second, with the busy
207
+ // error (see isBusyError) for the caller to tell apart, and the handle is closed.
208
+ function open(dbPath, { shortWait = false } = {}) {
201
209
  const target = dbPath || getDbPath();
202
- if (_cachedDb && _cachedPath === target) return _cachedDb;
210
+ if (!shortWait && _cachedDb && _cachedPath === target) return _cachedDb;
203
211
  const dir = path.dirname(target);
204
212
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
205
213
  const db = new DatabaseSync(target);
206
- // Concurrency: WAL gives readers + one writer concurrency; busy_timeout
207
- // makes contended writers wait instead of returning SQLITE_BUSY at the
208
- // C library level. We additionally wrap the setup PRAGMAs + DDL in our
209
- // own retry, under heavy spawn-storm contention, node:sqlite leaks
210
- // SQLITE_BUSY past the busy_timeout for `db.exec()` calls.
211
- withBusyRetry(() => db.exec('PRAGMA journal_mode = WAL'));
212
- withBusyRetry(() => db.exec('PRAGMA busy_timeout = 30000'));
213
- withBusyRetry(() => db.exec('PRAGMA foreign_keys = ON'));
214
- withBusyRetry(() => db.exec(SCHEMA));
215
- // Schema version. Bump at every additive migration.
216
- // Future migrations read this and apply diffs idempotently.
217
- withBusyRetry(() => db.exec('PRAGMA user_version = 2'));
214
+ const tries = shortWait ? SHORT_WAIT_TRIES : undefined;
215
+ try {
216
+ // Concurrency: WAL gives readers + one writer concurrency; busy_timeout
217
+ // makes contended writers wait instead of returning SQLITE_BUSY at the
218
+ // C library level. We additionally wrap the setup PRAGMAs + DDL in our
219
+ // own retry, under heavy spawn-storm contention, node:sqlite leaks
220
+ // SQLITE_BUSY past the busy_timeout for `db.exec()` calls.
221
+ withBusyRetry(() => db.exec('PRAGMA journal_mode = WAL'), tries);
222
+ if (!shortWait) withBusyRetry(() => db.exec('PRAGMA busy_timeout = 30000'));
223
+ withBusyRetry(() => db.exec('PRAGMA foreign_keys = ON'), tries);
224
+ withBusyRetry(() => db.exec(SCHEMA), tries);
225
+ // Schema version. Bump at every additive migration.
226
+ // Future migrations read this and apply diffs idempotently.
227
+ withBusyRetry(() => db.exec('PRAGMA user_version = 2'), tries);
228
+ } catch (err) {
229
+ try { db.close(); } catch (_) {}
230
+ throw err;
231
+ }
232
+ if (shortWait) return db;
218
233
  _cachedDb = db;
219
234
  _cachedPath = target;
220
235
  return db;
@@ -476,21 +491,43 @@ function findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title }) {
476
491
  // A main checkout's view also covers its linked git worktrees, whose tasks keep
477
492
  // the worktree as workspace_root so checks run where the work lives. Only a
478
493
  // main checkout with linked worktrees spawns git; any error means exact match.
494
+ // A git that does not answer in time is ended outright (SIGKILL, because the
495
+ // polite stop can be ignored), so no git can hold a command for good.
496
+ const LINKED_WORKTREE_LOOKUP_MS = 5000;
479
497
  const linkedWorktreeCache = new Map();
480
- function linkedWorktreeRoots(ws) {
498
+
499
+ // { roots, failed }: the checkouts linked to ws, and whether git could not say (it
500
+ // did not answer within timeoutMs, could not be run, or exited with an error), in
501
+ // which case roots is empty. The task list ignores the failure and shows the
502
+ // workspace alone; a caller that must know asks here. A failure is remembered for the
503
+ // process, except one under a limit the caller chose that is shorter than the usual
504
+ // one: the task list that comes next still gets its own full try.
505
+ function lookUpLinkedWorktrees(ws, { timeoutMs = LINKED_WORKTREE_LOOKUP_MS } = {}) {
481
506
  if (linkedWorktreeCache.has(ws)) return linkedWorktreeCache.get(ws);
482
- let roots = [];
507
+ let looked = { roots: [], failed: false };
483
508
  try {
484
509
  if (fs.existsSync(path.join(ws, '.git', 'worktrees'))) {
510
+ // A limit of 0 would mean no limit at all.
511
+ if (!(timeoutMs > 0)) throw new Error('no time left to ask git');
485
512
  const out = require('child_process').execFileSync('git', ['worktree', 'list', '--porcelain'],
486
- { cwd: ws, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
487
- roots = out.split('\n').filter(l => l.startsWith('worktree '))
488
- .map(l => { const p = l.slice(9); try { return fs.realpathSync(p); } catch { return p; } })
489
- .filter(p => p !== ws);
513
+ { cwd: ws, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] });
514
+ looked = {
515
+ roots: out.split('\n').filter(l => l.startsWith('worktree '))
516
+ .map(l => { const p = l.slice(9); try { return fs.realpathSync(p); } catch { return p; } })
517
+ .filter(p => p !== ws),
518
+ failed: false,
519
+ };
490
520
  }
491
- } catch { roots = []; }
492
- linkedWorktreeCache.set(ws, roots);
493
- return roots;
521
+ } catch {
522
+ looked = { roots: [], failed: true };
523
+ if (timeoutMs < LINKED_WORKTREE_LOOKUP_MS) return looked;
524
+ }
525
+ linkedWorktreeCache.set(ws, looked);
526
+ return looked;
527
+ }
528
+
529
+ function linkedWorktreeRoots(ws) {
530
+ return lookUpLinkedWorktrees(ws).roots;
494
531
  }
495
532
 
496
533
  // The workspaces a workspace's task listing covers: the workspace itself and, for a main
@@ -645,13 +682,34 @@ const NAMED_CLAIM_SQL = `AND (? IS NULL OR EXISTS (
645
682
  AND (tasks.claimed_at IS NULL OR e.created_at >= tasks.claimed_at)
646
683
  AND e.version = (SELECT MAX(version) FROM task_events WHERE task_id = tasks.id AND event_type = 'claimed')))`;
647
684
 
685
+ // The job as it stands when a claim is made: its title and tags, saved on the claim itself. The builder picker
686
+ // (lib/builder-pick.js) gives a play the lane its job was in when the claim was made, and tagging or renaming a
687
+ // finished job later must not change that. Every way of claiming a job writes it (claimTask, and the claim that
688
+ // starting work on an open or unowned job makes); a claim made before this existed has none and is never rebuilt
689
+ // from the job's history.
690
+ function jobAtClaim(row, metadata) {
691
+ const meta = metadata && typeof metadata === 'object' ? metadata : {};
692
+ return {
693
+ title: String(row.title == null ? '' : row.title),
694
+ tag: row.tag || null,
695
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
696
+ todo_tags: Array.isArray(meta.todo_tags) ? meta.todo_tags : [],
697
+ };
698
+ }
699
+
648
700
  // Atomic claim. Returns { claimed: true, row, event } only if THIS call won the row; event is the claimed event
649
701
  // this claim wrote, the one name for it (see latestClaim). Race-safe via single UPDATE with WHERE status='open'
650
702
  // guard. SQLite serializes writes; busy_timeout absorbs contention. Caller must check `.claimed`.
651
703
  //
652
704
  // The row and its claimed event are written in one transaction. Before, the row was claimed first and the event
653
705
  // followed a git call later, and in that gap a late release naming the claim before could pass for the new one.
654
- function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
706
+ //
707
+ // refuseIf is a check the caller makes on the job as it stands when the claim is made: it is asked with the open job
708
+ // (as getTask shows it) inside the same transaction, with the write lock held, so nothing can change the job between
709
+ // the look and the claim. It answers with a reason to refuse, and the job is then not claimed and nothing is written
710
+ // ({ claimed: false, reason }); an empty answer lets the claim go on. A job that is not open is not asked about, and
711
+ // answers as it always did. A check that throws takes the claim with it: nothing is claimed.
712
+ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid, refuseIf = null }) {
655
713
  if (!id) throw new Error('id required');
656
714
  if (!claimedBy) throw new Error('claimedBy required');
657
715
  const now = Date.now();
@@ -672,6 +730,11 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
672
730
  `);
673
731
  const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
674
732
  const won = inWriteTransaction(db, () => {
733
+ if (refuseIf) {
734
+ const current = getTask(db, id);
735
+ const why = current && current.status === 'open' ? refuseIf(current) : '';
736
+ if (why) return { refused: String(why) };
737
+ }
675
738
  const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
676
739
  if (result.changes !== 1) return null;
677
740
  let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
@@ -693,10 +756,16 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
693
756
  workspaceRoot: row.workspace_root,
694
757
  actor: claimedBy,
695
758
  eventType: 'claimed',
696
- payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
759
+ payload: {
760
+ claimed_by: claimedBy,
761
+ ...(startCommit ? { start_commit: startCommit } : {}),
762
+ // Written in this transaction, so a claim never exists without it.
763
+ job_at_claim: jobAtClaim(row, claimedMetadata),
764
+ },
697
765
  });
698
766
  return { row, event };
699
767
  });
768
+ if (won && won.refused) return { claimed: false, reason: won.refused };
700
769
  if (won) {
701
770
  const enriched = { ...won.row, metadata: won.row.metadata ? safeJSON(won.row.metadata) : null };
702
771
  // The work log is a note for people, written once the claim is saved. A note that cannot be written must not
@@ -2300,7 +2369,7 @@ function stageTaskSteps(db, {
2300
2369
  workspaceRoot: updated.workspace_root,
2301
2370
  actor: claimedWorker,
2302
2371
  eventType: 'claimed',
2303
- payload: { claimed_by: claimedWorker },
2372
+ payload: { claimed_by: claimedWorker, job_at_claim: jobAtClaim(updated, updated.metadata) },
2304
2373
  });
2305
2374
  }
2306
2375
  const event = appendTaskEvent(db, {
@@ -3074,6 +3143,13 @@ function safeJSON(s) {
3074
3143
  try { return JSON.parse(s); } catch { return null; }
3075
3144
  }
3076
3145
 
3146
+ // Whether an error is the store being busy: another process holds the write lock.
3147
+ function isBusyError(e) {
3148
+ const msg = String(e && e.message || '');
3149
+ const code = e && (e.code || e.errcode);
3150
+ return /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
3151
+ }
3152
+
3077
3153
  // Wrap a write op so SQLITE_BUSY (concurrent writers from other processes)
3078
3154
  // retries with exponential backoff. busy_timeout pragma alone leaks busy
3079
3155
  // errors under spawn-storm contention with node:sqlite (~3% raw lock rate
@@ -3086,10 +3162,7 @@ function withBusyRetry(fn, attempts = 8) {
3086
3162
  try { return fn(); }
3087
3163
  catch (e) {
3088
3164
  lastErr = e;
3089
- const msg = String(e && e.message || '');
3090
- const code = e && (e.code || e.errcode);
3091
- const busy = /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
3092
- if (!busy) throw e;
3165
+ if (!isBusyError(e)) throw e;
3093
3166
  // Sleep synchronously, node:sqlite is sync; matches the rest of the API
3094
3167
  sleepSync(delay + Math.floor(Math.random() * delay));
3095
3168
  delay = Math.min(delay * 2, 500);
@@ -3101,6 +3174,7 @@ function withBusyRetry(fn, attempts = 8) {
3101
3174
  module.exports = {
3102
3175
  open,
3103
3176
  withBusyRetry,
3177
+ isBusyError,
3104
3178
  close,
3105
3179
  getDbPath,
3106
3180
  workspaceRoot,
@@ -3112,6 +3186,7 @@ module.exports = {
3112
3186
  listTasks,
3113
3187
  listTasksCached,
3114
3188
  rowInWorkspace,
3189
+ lookUpLinkedWorktrees,
3115
3190
  claimTask,
3116
3191
  setLease,
3117
3192
  renewLease,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.77.0",
3
+ "version": "3.79.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",