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/README.md +3 -0
- package/commands/brief.js +8 -2
- package/commands/engine.js +14 -4
- package/commands/gm.js +3 -0
- package/commands/mission.js +23 -2
- package/commands/rating.js +7 -1
- package/lib/builder-pick.js +888 -0
- package/lib/claude-spend-guard.js +24 -0
- package/lib/fleet.js +145 -19
- package/lib/gm-season.js +33 -4
- package/lib/held-autosweep.js +559 -0
- package/lib/held-pull-requests.js +42 -15
- package/lib/landing-check.js +296 -0
- package/lib/runner-command.js +78 -38
- package/lib/task-db.js +105 -30
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
487
|
-
|
|
488
|
-
.
|
|
489
|
-
|
|
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 {
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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