atris 3.78.0 → 3.80.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
@@ -3106,6 +3143,13 @@ function safeJSON(s) {
3106
3143
  try { return JSON.parse(s); } catch { return null; }
3107
3144
  }
3108
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
+
3109
3153
  // Wrap a write op so SQLITE_BUSY (concurrent writers from other processes)
3110
3154
  // retries with exponential backoff. busy_timeout pragma alone leaks busy
3111
3155
  // errors under spawn-storm contention with node:sqlite (~3% raw lock rate
@@ -3118,10 +3162,7 @@ function withBusyRetry(fn, attempts = 8) {
3118
3162
  try { return fn(); }
3119
3163
  catch (e) {
3120
3164
  lastErr = e;
3121
- const msg = String(e && e.message || '');
3122
- const code = e && (e.code || e.errcode);
3123
- const busy = /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
3124
- if (!busy) throw e;
3165
+ if (!isBusyError(e)) throw e;
3125
3166
  // Sleep synchronously, node:sqlite is sync; matches the rest of the API
3126
3167
  sleepSync(delay + Math.floor(Math.random() * delay));
3127
3168
  delay = Math.min(delay * 2, 500);
@@ -3133,6 +3174,7 @@ function withBusyRetry(fn, attempts = 8) {
3133
3174
  module.exports = {
3134
3175
  open,
3135
3176
  withBusyRetry,
3177
+ isBusyError,
3136
3178
  close,
3137
3179
  getDbPath,
3138
3180
  workspaceRoot,
@@ -3144,6 +3186,7 @@ module.exports = {
3144
3186
  listTasks,
3145
3187
  listTasksCached,
3146
3188
  rowInWorkspace,
3189
+ lookUpLinkedWorktrees,
3147
3190
  claimTask,
3148
3191
  setLease,
3149
3192
  renewLease,
@@ -5,6 +5,8 @@
5
5
  // marker is needs-human (CLI-879); decision is the same hold under a clearer
6
6
  // name. Autonomous pickers must refuse these; list renderers must show them.
7
7
 
8
+ const { decisionChoiceText } = require('./task-explanation');
9
+
8
10
  const DECISION_HOLD_TAGS = new Set([
9
11
  'needs-human',
10
12
  'needshuman',
@@ -41,10 +43,74 @@ function decisionMarkerFor(task) {
41
43
  return isDecisionTask(task) ? DECISION_MARKER : '';
42
44
  }
43
45
 
46
+ function decisionRefFor(task) {
47
+ if (!task || typeof task !== 'object') return 'TASK';
48
+ const ref = task.display_id || task.legacy_ref || task.id || '';
49
+ const text = String(ref || '').trim();
50
+ if (!text) return 'TASK';
51
+ if (task.display_id || task.legacy_ref) return text;
52
+ return text.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 8) || 'TASK';
53
+ }
54
+
55
+ function decisionShowCommand(task) {
56
+ return `atris task show ${decisionRefFor(task)}`;
57
+ }
58
+
59
+ function summarizeDecisionTask(task) {
60
+ const ref = decisionRefFor(task);
61
+ const title = String((task && task.title) || '').replace(/\s+/g, ' ').trim();
62
+ const choice = decisionChoiceText(task);
63
+ const command = `atris task show ${ref}`;
64
+ return {
65
+ id: (task && task.id) || null,
66
+ task_id: (task && task.id) || null,
67
+ ref,
68
+ display_id: (task && task.display_id) || ref,
69
+ title,
70
+ choice,
71
+ command,
72
+ show_command: command,
73
+ };
74
+ }
75
+
76
+ function summarizeSkippedDecisions(tasks = []) {
77
+ const list = (Array.isArray(tasks) ? tasks : [])
78
+ .filter(Boolean)
79
+ .map(summarizeDecisionTask);
80
+ const first = list[0] || null;
81
+ return {
82
+ reason: DECISION_REFUSE_REASON,
83
+ skipped_decision_count: list.length,
84
+ skipped_decision_refs: list.map((item) => item.ref),
85
+ skipped_decisions: list,
86
+ decisions: list,
87
+ choice: first ? first.choice : null,
88
+ command: first ? first.command : null,
89
+ show_command: first ? first.command : null,
90
+ next_command: first ? first.command : null,
91
+ };
92
+ }
93
+
94
+ function formatDecisionRefusalLines(tasks = []) {
95
+ const summary = summarizeSkippedDecisions(tasks);
96
+ const lines = [DECISION_REFUSE_REASON];
97
+ for (const item of summary.skipped_decisions) {
98
+ lines.push(` ${item.ref}: ${item.title}`);
99
+ lines.push(` choice: ${item.choice}`);
100
+ lines.push(` show: ${item.command}`);
101
+ }
102
+ if (summary.command) {
103
+ lines.push(`next: ${summary.command}`);
104
+ }
105
+ return lines;
106
+ }
107
+
44
108
  module.exports = {
45
109
  DECISION_REFUSE_REASON,
46
110
  taskTagTokens,
47
111
  isDecisionHoldTag,
48
112
  isDecisionTask,
49
113
  decisionMarkerFor,
114
+ summarizeSkippedDecisions,
115
+ formatDecisionRefusalLines,
50
116
  };
@@ -213,6 +213,25 @@ function taskApprovalControls({
213
213
  };
214
214
  }
215
215
 
216
+ function decisionChoiceText(task) {
217
+ const metadata = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
218
+ const explicitChoice = cleanText(
219
+ (task && task.choice)
220
+ || metadata.choice
221
+ || metadata.decision
222
+ || metadata.question
223
+ );
224
+ if (explicitChoice) return endSentence(plainText(explicitChoice));
225
+ const explicit = explicitExplanationFields(metadata);
226
+ if (explicit.what_changes) return endSentence(plainText(explicit.what_changes));
227
+ if (explicit.why_it_matters) return endSentence(plainText(explicit.why_it_matters));
228
+ const goal = cleanText(explanationGoalText(task));
229
+ if (goal) return endSentence(plainText(goal));
230
+ const title = cleanText(task && task.title);
231
+ if (title) return endSentence(plainText(title));
232
+ return 'Decide whether to approve this work, revise it, or remove the decision hold.';
233
+ }
234
+
216
235
  function approvalLines(approval, { indent = '' } = {}) {
217
236
  const lines = [`${indent}${approval.question}`];
218
237
  if (approval.approve.enabled) lines.push(`${indent}Approve: ${approval.approve.command}`);
@@ -228,6 +247,7 @@ module.exports = {
228
247
  explicitDoneLooksLike,
229
248
  plainText,
230
249
  taskExplanation,
250
+ decisionChoiceText,
231
251
  explanationFieldsFromInput,
232
252
  explanationLines,
233
253
  explanationMarkdownLines,
package/lib/yes-lock.js CHANGED
@@ -593,7 +593,7 @@ const ATRIS_INTERNAL = new Set([
593
593
  'version', 'help', 'next', 'atris', 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'learn', 'lesson', 'taste', 'teach',
594
594
  'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'watch', 'live', 'align', 'terminal', 'computer', 'diff', 'youtube', 'x-search',
595
595
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'campaign', 'agents', 'probe', 'worktree', 'caretaker', 'aeo', 'slop', 'voice', 'strings', 'write',
596
- 'security-review', 'secure', 'theme', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'recap', 'report', 'clarity', 'interview', 'moves', 'unknowns', 'avail',
596
+ 'security-review', 'secure', 'sentinel', 'theme', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'recap', 'report', 'clarity', 'interview', 'moves', 'unknowns', 'avail',
597
597
  'scoreboard', 'sync-checkout', 'zen', 'ci', 'balance', 'usage', 'agent-client', 'transactions', 'hires', 'design', 'mcp', 'integrations', 'setup', 'clean-workspace', 'cw',
598
598
  'browse', 'sleep', 'wake', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'engine',
599
599
  'engines', 'link', 'playbook', 'brief', 'inbox', 'friends', 'rsi',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.78.0",
3
+ "version": "3.80.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",