atris 3.74.25 → 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/commands/gm.js +2 -0
- package/commands/rating.js +15 -1
- package/commands/task.js +28 -7
- package/commands/worktree.js +167 -76
- package/lib/fleet.js +42 -2
- package/lib/gm-season.js +62 -15
- package/lib/task-db.js +117 -7
- package/package.json +1 -1
package/commands/gm.js
CHANGED
|
@@ -1895,6 +1895,8 @@ function wakeableMember(args) {
|
|
|
1895
1895
|
}
|
|
1896
1896
|
|
|
1897
1897
|
async function gmCommand(...args) {
|
|
1898
|
+
const leaseDb = require('../lib/task-db');
|
|
1899
|
+
try { leaseDb.sweepExpiredLeases(); } catch (_) { /* Existing views report unreadable databases. */ }
|
|
1898
1900
|
// the walk has its own help
|
|
1899
1901
|
if (positional(args)[0] === 'walk') {
|
|
1900
1902
|
const at = args.indexOf('walk');
|
package/commands/rating.js
CHANGED
|
@@ -46,7 +46,15 @@ function readHere(deps = {}) {
|
|
|
46
46
|
const team = new Map(((lineup && lineup.ok && lineup.team) || []).map((row) => [String(row.member || '').toLowerCase(), row]));
|
|
47
47
|
const members = rows.map((row) => {
|
|
48
48
|
const seat = team.get(row.name.toLowerCase()) || {};
|
|
49
|
-
|
|
49
|
+
const reasons = new Map();
|
|
50
|
+
for (const play of played.outcomes || []) {
|
|
51
|
+
if (play.member === row.name && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
|
|
52
|
+
reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
56
|
+
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
|
|
57
|
+
return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
|
|
50
58
|
});
|
|
51
59
|
const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
|
|
52
60
|
const buildModel = buildJob && buildJob.now_runs ? buildJob.now_runs.model : null;
|
|
@@ -104,12 +112,18 @@ function renderMember(m, card) {
|
|
|
104
112
|
` landed ${m.landed} of ${m.finished} finished jobs in the last 30 days`,
|
|
105
113
|
];
|
|
106
114
|
if (m.ovr == null) lines.push(' rated once it has 3 finished jobs');
|
|
115
|
+
if (m.miss_reasons && m.miss_reasons.length) {
|
|
116
|
+
lines.push(' top miss reasons:');
|
|
117
|
+
for (const item of m.miss_reasons) lines.push(` ${item.count}: ${item.reason}`);
|
|
118
|
+
}
|
|
107
119
|
if (card && m.job === 'build') lines.push(` its engine, ${card.label}, is ${rated(card.ovr)} in the combine: atris rating ${card.label.split(' ')[0]}`);
|
|
108
120
|
lines.push(` every play: atris gm card ${m.member}`);
|
|
109
121
|
return lines.join('\n');
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
function ratingCommand(args = [], deps = {}) {
|
|
125
|
+
const taskDb = require('../lib/task-db');
|
|
126
|
+
taskDb.sweepExpiredLeases();
|
|
113
127
|
const write = deps.write || ((text) => process.stdout.write(text));
|
|
114
128
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
115
129
|
write(`${USAGE}\n`);
|
package/commands/task.js
CHANGED
|
@@ -208,6 +208,7 @@ atris task - durable local task state (SQLite, gitignored)
|
|
|
208
208
|
atris task auto-accept-certified --dry-run [--strict-verify] [--all] [--limit <n>]
|
|
209
209
|
Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
|
|
210
210
|
atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
|
|
211
|
+
atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
|
|
211
212
|
atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
|
|
212
213
|
atris task revise <id> --note "..." Send reviewed work back to Do
|
|
213
214
|
|
|
@@ -6252,7 +6253,10 @@ function cmdClaim(args) {
|
|
|
6252
6253
|
const taskDb = getTaskDb();
|
|
6253
6254
|
const db = taskDb.open();
|
|
6254
6255
|
const taskId = requireTaskId(taskDb, db, id, 'atris task claim');
|
|
6255
|
-
const
|
|
6256
|
+
const leaseMinutes = flag(args, '--lease-minutes');
|
|
6257
|
+
const leasePid = flag(args, '--lease-pid');
|
|
6258
|
+
if (leaseMinutes != null && (!Number.isFinite(Number(leaseMinutes)) || Number(leaseMinutes) <= 0 || !Number.isInteger(Number(leasePid)) || Number(leasePid) <= 0)) throw new Error('invalid claim lease');
|
|
6259
|
+
const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner), ...(leaseMinutes == null ? {} : { leaseMinutes: Number(leaseMinutes), leasePid: Number(leasePid) }) });
|
|
6256
6260
|
if (result.claimed) {
|
|
6257
6261
|
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
6258
6262
|
if (wantsJson(args)) {
|
|
@@ -10801,11 +10805,23 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
|
|
|
10801
10805
|
|
|
10802
10806
|
function cmdSweep(args) {
|
|
10803
10807
|
if (!hasFlag(args, '--auto-accept')) {
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
);
|
|
10808
|
+
const taskDb = getTaskDb();
|
|
10809
|
+
const staleRaw = flag(args, '--stale-days');
|
|
10810
|
+
if (staleRaw === true || (staleRaw != null && (!Number.isFinite(Number(staleRaw)) || Number(staleRaw) < 0))) throw new Error('stale days must be a nonnegative number');
|
|
10811
|
+
const claimer = flag(args, '--claimer');
|
|
10812
|
+
if (claimer === true) throw new Error('claimer requires a name');
|
|
10813
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
10814
|
+
const options = { staleDays: staleRaw == null ? undefined : Number(staleRaw), claimer };
|
|
10815
|
+
const tasks = dryRun ? taskDb.previewExpiredLeases(options) : taskDb.expireLeases(taskDb.open(), options);
|
|
10816
|
+
const groups = {};
|
|
10817
|
+
for (const task of tasks) (groups[task.claimed_by] ||= []).push(task);
|
|
10818
|
+
if (wantsJson(args)) printJson({ dry_run: dryRun, groups, tasks });
|
|
10819
|
+
else if (!tasks.length) console.log(dryRun ? 'no claims would be released' : 'no claims released');
|
|
10820
|
+
else for (const [name, entries] of Object.entries(groups)) {
|
|
10821
|
+
console.log(`${name}: ${entries.length} ${dryRun ? 'would be released' : 'released'}`);
|
|
10822
|
+
for (const entry of entries) console.log(` ${entry.title}: ${entry.reason}`);
|
|
10823
|
+
}
|
|
10824
|
+
return;
|
|
10809
10825
|
}
|
|
10810
10826
|
// Sweep has no dry-run implementation: every successful invocation can
|
|
10811
10827
|
// write accepts, even when an unrecognized --dry-run flag is supplied.
|
|
@@ -13076,6 +13092,11 @@ function cmdServe(args) {
|
|
|
13076
13092
|
|
|
13077
13093
|
async function runTaskCommand(args) {
|
|
13078
13094
|
const raw = args || [];
|
|
13095
|
+
// A dry run must not mutate even already-expired leases.
|
|
13096
|
+
if (!raw.includes('--dry-run') && !(raw[0] === 'sweep' && !raw.includes('--auto-accept'))) {
|
|
13097
|
+
const taskDb = getTaskDb();
|
|
13098
|
+
taskDb.sweepExpiredLeases();
|
|
13099
|
+
}
|
|
13079
13100
|
if (raw.includes('--help') || raw.includes('-h')) return help();
|
|
13080
13101
|
const first = raw[0];
|
|
13081
13102
|
if (!first) return cmdFirstMinute();
|
|
@@ -13273,7 +13294,7 @@ async function run(args) {
|
|
|
13273
13294
|
return;
|
|
13274
13295
|
}
|
|
13275
13296
|
const result = await runTaskCommand(raw);
|
|
13276
|
-
const skipsRender =
|
|
13297
|
+
const skipsRender = hasFlag(raw, '--dry-run');
|
|
13277
13298
|
if (MUTATING_TASK_COMMANDS.has(sub) && !skipsRender) autoRenderTodoFromDb();
|
|
13278
13299
|
return result;
|
|
13279
13300
|
}
|
package/commands/worktree.js
CHANGED
|
@@ -1201,7 +1201,6 @@ const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
|
1201
1201
|
// (zero commits yet), so without a grace window the janitor reaps it while
|
|
1202
1202
|
// the engine that requested it is still booting inside.
|
|
1203
1203
|
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
1204
|
-
const COMPLETED_UNMERGED_REAP_MS = WORKTREE_REAP_GRACE_MS;
|
|
1205
1204
|
const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
1206
1205
|
'.derivedData',
|
|
1207
1206
|
'.next',
|
|
@@ -1218,15 +1217,6 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
|
1218
1217
|
}
|
|
1219
1218
|
}
|
|
1220
1219
|
|
|
1221
|
-
function completedAgentOutputAgeMs(worktreePath, now = Date.now()) {
|
|
1222
|
-
try {
|
|
1223
|
-
const output = path.join(worktreePath, '.codex-last-message.txt');
|
|
1224
|
-
return now - fs.statSync(output).mtimeMs;
|
|
1225
|
-
} catch {
|
|
1226
|
-
return null;
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
1220
|
function canonicalPath(value) {
|
|
1231
1221
|
try {
|
|
1232
1222
|
return fs.realpathSync(value);
|
|
@@ -1237,17 +1227,20 @@ function canonicalPath(value) {
|
|
|
1237
1227
|
|
|
1238
1228
|
// null means the scan failed: we cannot tell who is inside, so callers keep
|
|
1239
1229
|
// every folder. An empty list would read as "nobody is here" and delete.
|
|
1240
|
-
function
|
|
1241
|
-
const result = spawnSync('lsof', ['-n', '-
|
|
1230
|
+
function listActiveProcessFiles() {
|
|
1231
|
+
const result = spawnSync('lsof', ['-n', '-F', 'n'], {
|
|
1242
1232
|
encoding: 'utf8',
|
|
1243
1233
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1234
|
+
timeout: 20000,
|
|
1235
|
+
killSignal: 'SIGKILL',
|
|
1244
1236
|
});
|
|
1245
|
-
if (result.error || result.status !== 0) return null;
|
|
1246
|
-
|
|
1237
|
+
if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
|
|
1238
|
+
const paths = String(result.stdout || '')
|
|
1247
1239
|
.split(/\r?\n/)
|
|
1248
|
-
.filter((line) => line.startsWith('n'))
|
|
1240
|
+
.filter((line) => line.startsWith('n/'))
|
|
1249
1241
|
.map((line) => canonicalPath(line.slice(1)))
|
|
1250
1242
|
.filter(Boolean);
|
|
1243
|
+
return paths.length ? paths : null;
|
|
1251
1244
|
}
|
|
1252
1245
|
|
|
1253
1246
|
// Absolute paths named on each program's command line, including flag values
|
|
@@ -1274,17 +1267,20 @@ function listActiveProcessArgPaths() {
|
|
|
1274
1267
|
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1275
1268
|
encoding: 'utf8',
|
|
1276
1269
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1270
|
+
timeout: 5000,
|
|
1271
|
+
killSignal: 'SIGKILL',
|
|
1277
1272
|
});
|
|
1278
|
-
if (result.error || result.status !== 0) return
|
|
1273
|
+
if (result.error || result.status !== 0) return null;
|
|
1279
1274
|
return parseProcessArgPaths(result.stdout);
|
|
1280
1275
|
}
|
|
1281
1276
|
|
|
1282
|
-
//
|
|
1283
|
-
// script path on its command line.
|
|
1277
|
+
// Open files and working folders, plus script paths on command lines.
|
|
1284
1278
|
function listActiveProcessPaths() {
|
|
1285
|
-
const
|
|
1286
|
-
if (!Array.isArray(
|
|
1287
|
-
|
|
1279
|
+
const files = listActiveProcessFiles();
|
|
1280
|
+
if (!Array.isArray(files)) return null;
|
|
1281
|
+
const args = listActiveProcessArgPaths();
|
|
1282
|
+
if (!Array.isArray(args)) return null;
|
|
1283
|
+
return files.concat(args);
|
|
1288
1284
|
}
|
|
1289
1285
|
|
|
1290
1286
|
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
@@ -1311,7 +1307,44 @@ function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reaso
|
|
|
1311
1307
|
} catch {}
|
|
1312
1308
|
}
|
|
1313
1309
|
|
|
1314
|
-
|
|
1310
|
+
// Only webpack's disposable pack files have a sufficiently narrow contract
|
|
1311
|
+
// for unattended cleanup. Unknown files or links turn the folder into review.
|
|
1312
|
+
function webpackCacheSnapshot(worktreePath) {
|
|
1313
|
+
try {
|
|
1314
|
+
const root = fs.realpathSync(worktreePath);
|
|
1315
|
+
let current = root;
|
|
1316
|
+
for (const part of ['.next', 'cache', 'webpack']) {
|
|
1317
|
+
current = path.join(current, part);
|
|
1318
|
+
const stat = fs.lstatSync(current);
|
|
1319
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
1320
|
+
}
|
|
1321
|
+
if (fs.realpathSync(current) !== current) return null;
|
|
1322
|
+
const entries = [];
|
|
1323
|
+
function walk(dir) {
|
|
1324
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
1325
|
+
const file = path.join(dir, name);
|
|
1326
|
+
const stat = fs.lstatSync(file);
|
|
1327
|
+
if (stat.isSymbolicLink()) return false;
|
|
1328
|
+
if (stat.isDirectory()) {
|
|
1329
|
+
entries.push([file, 'd', stat.ino, stat.mtimeMs]);
|
|
1330
|
+
if (!walk(file)) return false;
|
|
1331
|
+
} else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
|
|
1332
|
+
entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
|
|
1333
|
+
} else return false;
|
|
1334
|
+
}
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
if (!walk(current)) return null;
|
|
1338
|
+
const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
1339
|
+
const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
|
|
1340
|
+
if (!match) return null;
|
|
1341
|
+
return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
|
|
1342
|
+
} catch {
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
|
|
1315
1348
|
const candidates = [];
|
|
1316
1349
|
const pruned = [];
|
|
1317
1350
|
for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
|
|
@@ -1319,34 +1352,70 @@ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
|
|
|
1319
1352
|
if (!fs.existsSync(target)) continue;
|
|
1320
1353
|
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1321
1354
|
if (ignored.status !== 0) continue;
|
|
1322
|
-
const item = { worktree: worktreePath, path: target, relative };
|
|
1355
|
+
const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
|
|
1323
1356
|
candidates.push(item);
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1357
|
+
}
|
|
1358
|
+
const relative = '.next/cache/webpack';
|
|
1359
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1360
|
+
const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
|
|
1361
|
+
if (snapshot) {
|
|
1362
|
+
const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
|
|
1363
|
+
candidates.push(item);
|
|
1364
|
+
if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
|
|
1365
|
+
const active = listActiveProcessPaths();
|
|
1366
|
+
const fresh = webpackCacheSnapshot(worktreePath);
|
|
1367
|
+
const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1368
|
+
if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
|
|
1369
|
+
&& stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
|
|
1370
|
+
&& fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
|
|
1371
|
+
try {
|
|
1372
|
+
fs.rmSync(snapshot.path, { recursive: true });
|
|
1373
|
+
budget.count += 1;
|
|
1374
|
+
budget.kib += snapshot.kib;
|
|
1375
|
+
pruned.push(item);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
item.error = String((error && error.message) || error);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1330
1380
|
}
|
|
1331
1381
|
}
|
|
1332
1382
|
return { candidates, pruned };
|
|
1333
1383
|
}
|
|
1334
1384
|
|
|
1385
|
+
function originBaseIsCurrent(root, base) {
|
|
1386
|
+
const prefix = base.startsWith('origin/') ? 'origin/'
|
|
1387
|
+
: base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
|
|
1388
|
+
if (!prefix) {
|
|
1389
|
+
if (base === 'HEAD') {
|
|
1390
|
+
const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
|
|
1391
|
+
return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
|
|
1392
|
+
}
|
|
1393
|
+
return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
|
|
1394
|
+
}
|
|
1395
|
+
const branch = base.slice(prefix.length);
|
|
1396
|
+
const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
|
|
1397
|
+
const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
|
|
1398
|
+
const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
|
|
1399
|
+
return local.status === 0 && remote.status === 0 && Boolean(match)
|
|
1400
|
+
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1335
1403
|
function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
|
|
1336
1404
|
const worktrees = listWorktrees(root);
|
|
1337
1405
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1338
1406
|
const current = path.resolve(root);
|
|
1339
1407
|
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1340
1408
|
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1341
|
-
refreshRemoteRef(root, base);
|
|
1409
|
+
const refreshError = refreshRemoteRef(root, base);
|
|
1342
1410
|
const candidates = [];
|
|
1343
1411
|
const kept = [];
|
|
1344
1412
|
const removed = [];
|
|
1345
1413
|
const cacheCandidates = [];
|
|
1346
1414
|
const cachePruned = [];
|
|
1415
|
+
const cacheBudget = { count: 0, kib: 0 };
|
|
1347
1416
|
const pruneCachesForKept = (wtPath) => {
|
|
1348
1417
|
if (worktreeWithinReapGrace(wtPath)) return;
|
|
1349
|
-
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply });
|
|
1418
|
+
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1350
1419
|
cacheCandidates.push(...caches.candidates);
|
|
1351
1420
|
cachePruned.push(...caches.pruned);
|
|
1352
1421
|
};
|
|
@@ -1374,9 +1443,8 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1374
1443
|
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1375
1444
|
continue;
|
|
1376
1445
|
}
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
if (!rawCounts || !counts) {
|
|
1446
|
+
const counts = statusCounts(wt.path);
|
|
1447
|
+
if (!counts) {
|
|
1380
1448
|
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1381
1449
|
continue;
|
|
1382
1450
|
}
|
|
@@ -1385,58 +1453,78 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1385
1453
|
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1386
1454
|
continue;
|
|
1387
1455
|
}
|
|
1456
|
+
// Git removes ignored files along with a worktree. They may include local
|
|
1457
|
+
// state, credentials, or user data, so a clean status is not enough proof.
|
|
1458
|
+
const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1459
|
+
if (ignored.status !== 0 || ignored.error) {
|
|
1460
|
+
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (ignored.stdout) {
|
|
1464
|
+
pruneCachesForKept(wtPath);
|
|
1465
|
+
kept.push({ ...item, reason: 'ignored_data' });
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1388
1468
|
if (!wt.head) {
|
|
1389
1469
|
pruneCachesForKept(wtPath);
|
|
1390
1470
|
kept.push({ ...item, reason: 'missing_head' });
|
|
1391
1471
|
continue;
|
|
1392
1472
|
}
|
|
1393
|
-
|
|
1394
|
-
(
|
|
1395
|
-
|
|
1396
|
-
|
|
1473
|
+
if (refreshError) {
|
|
1474
|
+
kept.push({ ...item, reason: 'base_refresh_failed' });
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1397
1477
|
const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
|
|
1398
1478
|
if (!merged) {
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
pruneCachesForKept(wtPath);
|
|
1402
|
-
kept.push({
|
|
1403
|
-
...item,
|
|
1404
|
-
reason: completedAgeMs === null ? 'unmerged' : 'completed_unmerged_retention',
|
|
1405
|
-
...(completedAgeMs === null ? {} : { retention_hours: 1 }),
|
|
1406
|
-
});
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
const candidate = {
|
|
1410
|
-
...item,
|
|
1411
|
-
reason: 'completed_unmerged_checkout_expired',
|
|
1412
|
-
branch_preserved: true,
|
|
1413
|
-
artifact_only_dirty: artifactOnlyDirty,
|
|
1414
|
-
};
|
|
1415
|
-
candidates.push(candidate);
|
|
1416
|
-
if (!apply) continue;
|
|
1417
|
-
const removeArgs = ['worktree', 'remove'];
|
|
1418
|
-
if (artifactOnlyDirty) removeArgs.push('--force');
|
|
1419
|
-
removeArgs.push(wt.path);
|
|
1420
|
-
const removedResult = runGit(removeArgs, { cwd: root, check: false });
|
|
1421
|
-
if (removedResult.status === 0) {
|
|
1422
|
-
removed.push(candidate);
|
|
1423
|
-
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1424
|
-
} else {
|
|
1425
|
-
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1426
|
-
}
|
|
1479
|
+
pruneCachesForKept(wtPath);
|
|
1480
|
+
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1427
1481
|
continue;
|
|
1428
1482
|
}
|
|
1429
1483
|
if (worktreeWithinReapGrace(wtPath)) {
|
|
1430
1484
|
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1431
1485
|
continue;
|
|
1432
1486
|
}
|
|
1433
|
-
|
|
1487
|
+
if (!originBaseIsCurrent(root, base)) {
|
|
1488
|
+
kept.push({ ...item, reason: 'base_unverified' });
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
let real;
|
|
1492
|
+
try { real = fs.realpathSync(wtPath); } catch { real = ''; }
|
|
1493
|
+
if (!real || real !== wtPath) {
|
|
1494
|
+
kept.push({ ...item, reason: 'path_not_canonical' });
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1498
|
+
const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1499
|
+
const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
1500
|
+
if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
|
|
1501
|
+
kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
|
|
1434
1505
|
candidates.push(candidate);
|
|
1506
|
+
cacheBudget.count += 1;
|
|
1507
|
+
cacheBudget.kib += kib;
|
|
1435
1508
|
if (!apply) continue;
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
const
|
|
1509
|
+
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1510
|
+
const freshCounts = statusCounts(wt.path);
|
|
1511
|
+
const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1512
|
+
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1513
|
+
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1514
|
+
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1515
|
+
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1516
|
+
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1517
|
+
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1518
|
+
|| freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1519
|
+
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1520
|
+
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1521
|
+
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
1522
|
+
|| !originBaseIsCurrent(root, base)
|
|
1523
|
+
|| canonicalPath(wtPath) !== wtPath) {
|
|
1524
|
+
kept.push({ ...item, reason: 'changed_or_active_during_verification' });
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
|
|
1440
1528
|
if (removedResult.status === 0) {
|
|
1441
1529
|
removed.push(candidate);
|
|
1442
1530
|
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
@@ -1465,10 +1553,13 @@ function cleanup(args) {
|
|
|
1465
1553
|
for (const item of result.apply ? result.removed : result.candidates) {
|
|
1466
1554
|
console.log(`${action}: ${item.path} branch=${item.branch} reason=${item.reason}`);
|
|
1467
1555
|
}
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1556
|
+
const review = result.cacheCandidates.filter((item) => item.reason === 'manual_review_required');
|
|
1557
|
+
const safe = result.apply ? result.cachePruned : result.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
|
|
1558
|
+
console.log(`cache ${result.apply ? 'removed' : 'candidate'}s: ${safe.length}`);
|
|
1559
|
+
for (const item of safe) console.log(`cache: ${item.path} size_kib=${item.kib}`);
|
|
1560
|
+
console.log(`cache review needed: ${review.length}`);
|
|
1561
|
+
for (const item of review) {
|
|
1562
|
+
console.log(`review: ${item.path} reason=${item.reason}`);
|
|
1472
1563
|
}
|
|
1473
1564
|
console.log(`kept: ${result.kept.length}`);
|
|
1474
1565
|
if (!result.apply && result.candidates.length) console.log('next: atris worktree cleanup --apply');
|
|
@@ -1524,7 +1615,7 @@ function help() {
|
|
|
1524
1615
|
console.log(' atris worktree status');
|
|
1525
1616
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
1526
1617
|
console.log(' atris worktree prune [--apply]');
|
|
1527
|
-
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and
|
|
1618
|
+
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
|
|
1528
1619
|
}
|
|
1529
1620
|
|
|
1530
1621
|
function worktreeCommand(args = []) {
|
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-season.js
CHANGED
|
@@ -322,6 +322,12 @@ function outcomeFromEvent(row) {
|
|
|
322
322
|
} else if (row.event_type === 'claim_released') {
|
|
323
323
|
kind = 'dropped';
|
|
324
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;
|
|
325
331
|
} else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
|
|
326
332
|
kind = 'landed';
|
|
327
333
|
member = holder;
|
|
@@ -340,6 +346,7 @@ function outcomeFromEvent(row) {
|
|
|
340
346
|
const claimId = kind === 'took'
|
|
341
347
|
? (row.event_id || `${row.task_id}:${row.created_at}`)
|
|
342
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;
|
|
343
350
|
return {
|
|
344
351
|
event_id: row.event_id || null,
|
|
345
352
|
claim_id: claimId,
|
|
@@ -347,14 +354,56 @@ function outcomeFromEvent(row) {
|
|
|
347
354
|
member: String(member),
|
|
348
355
|
title: row.title || '',
|
|
349
356
|
kind,
|
|
350
|
-
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,
|
|
351
359
|
claimed_at: claimedAt,
|
|
352
360
|
landed_by: kind === 'landed' ? landedBy(row, payload) : null,
|
|
353
361
|
};
|
|
354
362
|
}
|
|
355
363
|
|
|
356
|
-
function
|
|
357
|
-
|
|
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);
|
|
358
407
|
}
|
|
359
408
|
|
|
360
409
|
function emptySeason(workspaceRoot, now) {
|
|
@@ -467,7 +516,7 @@ function missionLastAt(mission) {
|
|
|
467
516
|
// right holder. `extraWhere` narrows it (one workspace, or none for the league).
|
|
468
517
|
function playEventsSql(extraWhere = '') {
|
|
469
518
|
return `
|
|
470
|
-
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,
|
|
471
520
|
t.title, t.claimed_by, t.claimed_at,
|
|
472
521
|
c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
|
|
473
522
|
CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
|
|
@@ -485,8 +534,8 @@ function playEventsSql(extraWhere = '') {
|
|
|
485
534
|
SELECT p.event_id FROM task_events p
|
|
486
535
|
WHERE p.task_id = e.task_id AND p.event_type = 'claimed' AND p.version < e.version
|
|
487
536
|
ORDER BY p.version DESC LIMIT 1)
|
|
488
|
-
WHERE e.created_at >= ?
|
|
489
|
-
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')
|
|
490
539
|
${extraWhere}
|
|
491
540
|
ORDER BY e.created_at ASC`;
|
|
492
541
|
}
|
|
@@ -531,10 +580,10 @@ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPat
|
|
|
531
580
|
db = new DatabaseSync(target, { readOnly: true });
|
|
532
581
|
const since = now - days * DAY_MS;
|
|
533
582
|
const playsSince = db.prepare(playEventsSql('AND e.workspace_root = ?'));
|
|
534
|
-
season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot));
|
|
583
|
+
season.outcomes = outcomesFromEvents(playsSince.all(since, workspaceRoot), since);
|
|
535
584
|
const seenAt = Number(season.last_seen_at);
|
|
536
585
|
season.recap_outcomes = Number.isFinite(seenAt) && seenAt > 0 && seenAt < since
|
|
537
|
-
? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot))
|
|
586
|
+
? outcomesFromEvents(playsSince.all(seenAt, workspaceRoot), seenAt)
|
|
538
587
|
: null;
|
|
539
588
|
|
|
540
589
|
const taskRows = db.prepare(`
|
|
@@ -588,7 +637,7 @@ function loadTargetView(db, workspaceRoot, { now, since, playsSince, outcomes, c
|
|
|
588
637
|
const gmTarget = require('./gm-target');
|
|
589
638
|
const store = gmTarget.readTargets(workspaceRoot, dir ? { dir } : {});
|
|
590
639
|
const from = gmTarget.historyStart(store, now);
|
|
591
|
-
const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot)) : outcomes;
|
|
640
|
+
const history = db && from < since ? outcomesFromEvents(playsSince.all(from, workspaceRoot), from) : outcomes;
|
|
592
641
|
const hasPastWeeks = Object.keys(store.weeks).some(k => k < gmTarget.weekKey(now));
|
|
593
642
|
return {
|
|
594
643
|
has_target: Object.keys(store.weeks).length > 0,
|
|
@@ -758,7 +807,7 @@ function loadLeague({ now = Date.now(), days = SEASON_DAYS, dbPath, isClub = isC
|
|
|
758
807
|
}
|
|
759
808
|
const gmTarget = require('./gm-target');
|
|
760
809
|
league.clubs = [...byRoot.values()].map((club) => {
|
|
761
|
-
const outcomes = outcomesFromEvents(club.events);
|
|
810
|
+
const outcomes = outcomesFromEvents(club.events, since);
|
|
762
811
|
const store = gmTarget.readTargets(club.root, targetsDir ? { dir: targetsDir } : {});
|
|
763
812
|
return {
|
|
764
813
|
root: club.root,
|
|
@@ -1111,11 +1160,9 @@ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, lea
|
|
|
1111
1160
|
// same unique labels as the league, so /one/app and /two/app stay apart
|
|
1112
1161
|
const names = clubNames([...new Set(kept.map(row => row.workspace_root))]);
|
|
1113
1162
|
const clubOf = root => names.get(root) || path.basename(root);
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
.
|
|
1117
|
-
.map(x => ({ ...x.play, club: clubOf(x.row.workspace_root) }))
|
|
1118
|
-
.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)) }));
|
|
1119
1166
|
const claimed = db.prepare(`
|
|
1120
1167
|
SELECT id, workspace_root, claimed_by, claimed_at, updated_at,
|
|
1121
1168
|
(SELECT MAX(e.created_at) FROM task_events e WHERE e.task_id = tasks.id) AS last_event_at
|
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
|
|
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
|
|
997
|
-
`).run(now, JSON.stringify(metadata), id));
|
|
998
|
-
if (result.changes !== 1) return { ready: false, reason: '
|
|
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,
|
package/package.json
CHANGED