atris 3.74.25 → 3.74.27
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 +44 -7
- package/commands/worktree.js +183 -77
- package/lib/fleet.js +70 -3
- package/lib/gm-season.js +63 -16
- package/lib/task-db.js +142 -8
- 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
|
|
|
@@ -574,6 +575,20 @@ function landingFlags(args) {
|
|
|
574
575
|
};
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
// The pull request and commit a flight merged on its own, so the job record
|
|
579
|
+
// names what landed. Only a merge commit makes it count.
|
|
580
|
+
function readyMergeFlags(args) {
|
|
581
|
+
const commit = textFlag(args, ['--merge-commit']);
|
|
582
|
+
if (!commit) return null;
|
|
583
|
+
return {
|
|
584
|
+
merge_commit: commit,
|
|
585
|
+
number: textFlag(args, ['--pr-number']),
|
|
586
|
+
url: textFlag(args, ['--pr-url']),
|
|
587
|
+
merged_at: textFlag(args, ['--merged-at']),
|
|
588
|
+
self_landed: hasFlag(args, '--self-landed'),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
577
592
|
function normalizedLandingSentence(value) {
|
|
578
593
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
579
594
|
}
|
|
@@ -6252,7 +6267,10 @@ function cmdClaim(args) {
|
|
|
6252
6267
|
const taskDb = getTaskDb();
|
|
6253
6268
|
const db = taskDb.open();
|
|
6254
6269
|
const taskId = requireTaskId(taskDb, db, id, 'atris task claim');
|
|
6255
|
-
const
|
|
6270
|
+
const leaseMinutes = flag(args, '--lease-minutes');
|
|
6271
|
+
const leasePid = flag(args, '--lease-pid');
|
|
6272
|
+
if (leaseMinutes != null && (!Number.isFinite(Number(leaseMinutes)) || Number(leaseMinutes) <= 0 || !Number.isInteger(Number(leasePid)) || Number(leasePid) <= 0)) throw new Error('invalid claim lease');
|
|
6273
|
+
const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner), ...(leaseMinutes == null ? {} : { leaseMinutes: Number(leaseMinutes), leasePid: Number(leasePid) }) });
|
|
6256
6274
|
if (result.claimed) {
|
|
6257
6275
|
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
6258
6276
|
if (wantsJson(args)) {
|
|
@@ -9646,6 +9664,7 @@ function cmdReady(args) {
|
|
|
9646
9664
|
const lesson = flag(args, '--lesson') || '';
|
|
9647
9665
|
const nextTaskInput = normalizeReviewNextTaskInput(typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '');
|
|
9648
9666
|
const landing = landingFlags(args);
|
|
9667
|
+
const merge = readyMergeFlags(args);
|
|
9649
9668
|
guardExplicitActor('atris task ready', flag(args, '--as'));
|
|
9650
9669
|
const actor = String(flag(args, '--as') || DEFAULT_OWNER);
|
|
9651
9670
|
const resultFields = {
|
|
@@ -9693,6 +9712,7 @@ function cmdReady(args) {
|
|
|
9693
9712
|
nextTask: nextTaskInput.nextTask,
|
|
9694
9713
|
resultTrace: resultTrace && resultTrace.trace,
|
|
9695
9714
|
landing,
|
|
9715
|
+
merge,
|
|
9696
9716
|
result: resultSentence,
|
|
9697
9717
|
});
|
|
9698
9718
|
if (!result.ready) {
|
|
@@ -10801,11 +10821,23 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
|
|
|
10801
10821
|
|
|
10802
10822
|
function cmdSweep(args) {
|
|
10803
10823
|
if (!hasFlag(args, '--auto-accept')) {
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
);
|
|
10824
|
+
const taskDb = getTaskDb();
|
|
10825
|
+
const staleRaw = flag(args, '--stale-days');
|
|
10826
|
+
if (staleRaw === true || (staleRaw != null && (!Number.isFinite(Number(staleRaw)) || Number(staleRaw) < 0))) throw new Error('stale days must be a nonnegative number');
|
|
10827
|
+
const claimer = flag(args, '--claimer');
|
|
10828
|
+
if (claimer === true) throw new Error('claimer requires a name');
|
|
10829
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
10830
|
+
const options = { staleDays: staleRaw == null ? undefined : Number(staleRaw), claimer };
|
|
10831
|
+
const tasks = dryRun ? taskDb.previewExpiredLeases(options) : taskDb.expireLeases(taskDb.open(), options);
|
|
10832
|
+
const groups = {};
|
|
10833
|
+
for (const task of tasks) (groups[task.claimed_by] ||= []).push(task);
|
|
10834
|
+
if (wantsJson(args)) printJson({ dry_run: dryRun, groups, tasks });
|
|
10835
|
+
else if (!tasks.length) console.log(dryRun ? 'no claims would be released' : 'no claims released');
|
|
10836
|
+
else for (const [name, entries] of Object.entries(groups)) {
|
|
10837
|
+
console.log(`${name}: ${entries.length} ${dryRun ? 'would be released' : 'released'}`);
|
|
10838
|
+
for (const entry of entries) console.log(` ${entry.title}: ${entry.reason}`);
|
|
10839
|
+
}
|
|
10840
|
+
return;
|
|
10809
10841
|
}
|
|
10810
10842
|
// Sweep has no dry-run implementation: every successful invocation can
|
|
10811
10843
|
// write accepts, even when an unrecognized --dry-run flag is supplied.
|
|
@@ -13076,6 +13108,11 @@ function cmdServe(args) {
|
|
|
13076
13108
|
|
|
13077
13109
|
async function runTaskCommand(args) {
|
|
13078
13110
|
const raw = args || [];
|
|
13111
|
+
// A dry run must not mutate even already-expired leases.
|
|
13112
|
+
if (!raw.includes('--dry-run') && !(raw[0] === 'sweep' && !raw.includes('--auto-accept'))) {
|
|
13113
|
+
const taskDb = getTaskDb();
|
|
13114
|
+
taskDb.sweepExpiredLeases();
|
|
13115
|
+
}
|
|
13079
13116
|
if (raw.includes('--help') || raw.includes('-h')) return help();
|
|
13080
13117
|
const first = raw[0];
|
|
13081
13118
|
if (!first) return cmdFirstMinute();
|
|
@@ -13273,7 +13310,7 @@ async function run(args) {
|
|
|
13273
13310
|
return;
|
|
13274
13311
|
}
|
|
13275
13312
|
const result = await runTaskCommand(raw);
|
|
13276
|
-
const skipsRender =
|
|
13313
|
+
const skipsRender = hasFlag(raw, '--dry-run');
|
|
13277
13314
|
if (MUTATING_TASK_COMMANDS.has(sub) && !skipsRender) autoRenderTodoFromDb();
|
|
13278
13315
|
return result;
|
|
13279
13316
|
}
|
package/commands/worktree.js
CHANGED
|
@@ -1125,7 +1125,7 @@ function shipWorktree(args) {
|
|
|
1125
1125
|
}
|
|
1126
1126
|
// A merge queue accepts the request before merging. Deleting its head
|
|
1127
1127
|
// here closes the PR and cancels that request, so confirm the outcome.
|
|
1128
|
-
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt'], { cwd: root, encoding: 'utf8' });
|
|
1128
|
+
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt,number,url,mergeCommit'], { cwd: root, encoding: 'utf8' });
|
|
1129
1129
|
if (confirmation.status !== 0) {
|
|
1130
1130
|
throw new Error('could not confirm the merge; the source branch was kept');
|
|
1131
1131
|
}
|
|
@@ -1141,6 +1141,10 @@ function shipWorktree(args) {
|
|
|
1141
1141
|
throw new Error('the change was not merged; the source branch was kept');
|
|
1142
1142
|
}
|
|
1143
1143
|
console.log('merge: merged');
|
|
1144
|
+
// One machine-readable line, so a caller can save which pull request
|
|
1145
|
+
// and commit landed. The rating only counts a landing it can point at.
|
|
1146
|
+
const record = shipMergeRecord(state);
|
|
1147
|
+
if (record) console.log(`merge: record ${JSON.stringify(record)}`);
|
|
1144
1148
|
const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
|
|
1145
1149
|
if (deleted.status === 0) {
|
|
1146
1150
|
console.log(`merge: remote branch deleted ${branch}`);
|
|
@@ -1165,6 +1169,17 @@ function shipWorktree(args) {
|
|
|
1165
1169
|
return 0;
|
|
1166
1170
|
}
|
|
1167
1171
|
|
|
1172
|
+
// The landed pull request from `gh pr view --json`, or null without a merge commit.
|
|
1173
|
+
function shipMergeRecord(state) {
|
|
1174
|
+
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
1175
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
1176
|
+
const record = { merge_commit: commit };
|
|
1177
|
+
if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
|
|
1178
|
+
if (typeof state.url === 'string' && state.url) record.url = state.url;
|
|
1179
|
+
if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
|
|
1180
|
+
return record;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1168
1183
|
function guard(args) {
|
|
1169
1184
|
const root = repoRoot();
|
|
1170
1185
|
const worktrees = listWorktrees(root);
|
|
@@ -1201,7 +1216,6 @@ const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
|
1201
1216
|
// (zero commits yet), so without a grace window the janitor reaps it while
|
|
1202
1217
|
// the engine that requested it is still booting inside.
|
|
1203
1218
|
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
1204
|
-
const COMPLETED_UNMERGED_REAP_MS = WORKTREE_REAP_GRACE_MS;
|
|
1205
1219
|
const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
1206
1220
|
'.derivedData',
|
|
1207
1221
|
'.next',
|
|
@@ -1218,15 +1232,6 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
|
1218
1232
|
}
|
|
1219
1233
|
}
|
|
1220
1234
|
|
|
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
1235
|
function canonicalPath(value) {
|
|
1231
1236
|
try {
|
|
1232
1237
|
return fs.realpathSync(value);
|
|
@@ -1237,17 +1242,20 @@ function canonicalPath(value) {
|
|
|
1237
1242
|
|
|
1238
1243
|
// null means the scan failed: we cannot tell who is inside, so callers keep
|
|
1239
1244
|
// every folder. An empty list would read as "nobody is here" and delete.
|
|
1240
|
-
function
|
|
1241
|
-
const result = spawnSync('lsof', ['-n', '-
|
|
1245
|
+
function listActiveProcessFiles() {
|
|
1246
|
+
const result = spawnSync('lsof', ['-n', '-F', 'n'], {
|
|
1242
1247
|
encoding: 'utf8',
|
|
1243
1248
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1249
|
+
timeout: 20000,
|
|
1250
|
+
killSignal: 'SIGKILL',
|
|
1244
1251
|
});
|
|
1245
|
-
if (result.error || result.status !== 0) return null;
|
|
1246
|
-
|
|
1252
|
+
if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
|
|
1253
|
+
const paths = String(result.stdout || '')
|
|
1247
1254
|
.split(/\r?\n/)
|
|
1248
|
-
.filter((line) => line.startsWith('n'))
|
|
1255
|
+
.filter((line) => line.startsWith('n/'))
|
|
1249
1256
|
.map((line) => canonicalPath(line.slice(1)))
|
|
1250
1257
|
.filter(Boolean);
|
|
1258
|
+
return paths.length ? paths : null;
|
|
1251
1259
|
}
|
|
1252
1260
|
|
|
1253
1261
|
// Absolute paths named on each program's command line, including flag values
|
|
@@ -1274,17 +1282,20 @@ function listActiveProcessArgPaths() {
|
|
|
1274
1282
|
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1275
1283
|
encoding: 'utf8',
|
|
1276
1284
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1285
|
+
timeout: 5000,
|
|
1286
|
+
killSignal: 'SIGKILL',
|
|
1277
1287
|
});
|
|
1278
|
-
if (result.error || result.status !== 0) return
|
|
1288
|
+
if (result.error || result.status !== 0) return null;
|
|
1279
1289
|
return parseProcessArgPaths(result.stdout);
|
|
1280
1290
|
}
|
|
1281
1291
|
|
|
1282
|
-
//
|
|
1283
|
-
// script path on its command line.
|
|
1292
|
+
// Open files and working folders, plus script paths on command lines.
|
|
1284
1293
|
function listActiveProcessPaths() {
|
|
1285
|
-
const
|
|
1286
|
-
if (!Array.isArray(
|
|
1287
|
-
|
|
1294
|
+
const files = listActiveProcessFiles();
|
|
1295
|
+
if (!Array.isArray(files)) return null;
|
|
1296
|
+
const args = listActiveProcessArgPaths();
|
|
1297
|
+
if (!Array.isArray(args)) return null;
|
|
1298
|
+
return files.concat(args);
|
|
1288
1299
|
}
|
|
1289
1300
|
|
|
1290
1301
|
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
@@ -1311,7 +1322,44 @@ function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reaso
|
|
|
1311
1322
|
} catch {}
|
|
1312
1323
|
}
|
|
1313
1324
|
|
|
1314
|
-
|
|
1325
|
+
// Only webpack's disposable pack files have a sufficiently narrow contract
|
|
1326
|
+
// for unattended cleanup. Unknown files or links turn the folder into review.
|
|
1327
|
+
function webpackCacheSnapshot(worktreePath) {
|
|
1328
|
+
try {
|
|
1329
|
+
const root = fs.realpathSync(worktreePath);
|
|
1330
|
+
let current = root;
|
|
1331
|
+
for (const part of ['.next', 'cache', 'webpack']) {
|
|
1332
|
+
current = path.join(current, part);
|
|
1333
|
+
const stat = fs.lstatSync(current);
|
|
1334
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
1335
|
+
}
|
|
1336
|
+
if (fs.realpathSync(current) !== current) return null;
|
|
1337
|
+
const entries = [];
|
|
1338
|
+
function walk(dir) {
|
|
1339
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
1340
|
+
const file = path.join(dir, name);
|
|
1341
|
+
const stat = fs.lstatSync(file);
|
|
1342
|
+
if (stat.isSymbolicLink()) return false;
|
|
1343
|
+
if (stat.isDirectory()) {
|
|
1344
|
+
entries.push([file, 'd', stat.ino, stat.mtimeMs]);
|
|
1345
|
+
if (!walk(file)) return false;
|
|
1346
|
+
} else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
|
|
1347
|
+
entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
|
|
1348
|
+
} else return false;
|
|
1349
|
+
}
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
if (!walk(current)) return null;
|
|
1353
|
+
const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
1354
|
+
const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
|
|
1355
|
+
if (!match) return null;
|
|
1356
|
+
return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
|
|
1357
|
+
} catch {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
|
|
1315
1363
|
const candidates = [];
|
|
1316
1364
|
const pruned = [];
|
|
1317
1365
|
for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
|
|
@@ -1319,34 +1367,70 @@ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
|
|
|
1319
1367
|
if (!fs.existsSync(target)) continue;
|
|
1320
1368
|
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1321
1369
|
if (ignored.status !== 0) continue;
|
|
1322
|
-
const item = { worktree: worktreePath, path: target, relative };
|
|
1370
|
+
const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
|
|
1323
1371
|
candidates.push(item);
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1372
|
+
}
|
|
1373
|
+
const relative = '.next/cache/webpack';
|
|
1374
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1375
|
+
const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
|
|
1376
|
+
if (snapshot) {
|
|
1377
|
+
const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
|
|
1378
|
+
candidates.push(item);
|
|
1379
|
+
if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
|
|
1380
|
+
const active = listActiveProcessPaths();
|
|
1381
|
+
const fresh = webpackCacheSnapshot(worktreePath);
|
|
1382
|
+
const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1383
|
+
if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
|
|
1384
|
+
&& stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
|
|
1385
|
+
&& fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
|
|
1386
|
+
try {
|
|
1387
|
+
fs.rmSync(snapshot.path, { recursive: true });
|
|
1388
|
+
budget.count += 1;
|
|
1389
|
+
budget.kib += snapshot.kib;
|
|
1390
|
+
pruned.push(item);
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
item.error = String((error && error.message) || error);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1330
1395
|
}
|
|
1331
1396
|
}
|
|
1332
1397
|
return { candidates, pruned };
|
|
1333
1398
|
}
|
|
1334
1399
|
|
|
1400
|
+
function originBaseIsCurrent(root, base) {
|
|
1401
|
+
const prefix = base.startsWith('origin/') ? 'origin/'
|
|
1402
|
+
: base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
|
|
1403
|
+
if (!prefix) {
|
|
1404
|
+
if (base === 'HEAD') {
|
|
1405
|
+
const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
|
|
1406
|
+
return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
|
|
1407
|
+
}
|
|
1408
|
+
return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
|
|
1409
|
+
}
|
|
1410
|
+
const branch = base.slice(prefix.length);
|
|
1411
|
+
const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
|
|
1412
|
+
const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
|
|
1413
|
+
const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
|
|
1414
|
+
return local.status === 0 && remote.status === 0 && Boolean(match)
|
|
1415
|
+
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1335
1418
|
function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
|
|
1336
1419
|
const worktrees = listWorktrees(root);
|
|
1337
1420
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1338
1421
|
const current = path.resolve(root);
|
|
1339
1422
|
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1340
1423
|
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1341
|
-
refreshRemoteRef(root, base);
|
|
1424
|
+
const refreshError = refreshRemoteRef(root, base);
|
|
1342
1425
|
const candidates = [];
|
|
1343
1426
|
const kept = [];
|
|
1344
1427
|
const removed = [];
|
|
1345
1428
|
const cacheCandidates = [];
|
|
1346
1429
|
const cachePruned = [];
|
|
1430
|
+
const cacheBudget = { count: 0, kib: 0 };
|
|
1347
1431
|
const pruneCachesForKept = (wtPath) => {
|
|
1348
1432
|
if (worktreeWithinReapGrace(wtPath)) return;
|
|
1349
|
-
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply });
|
|
1433
|
+
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1350
1434
|
cacheCandidates.push(...caches.candidates);
|
|
1351
1435
|
cachePruned.push(...caches.pruned);
|
|
1352
1436
|
};
|
|
@@ -1374,9 +1458,8 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1374
1458
|
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1375
1459
|
continue;
|
|
1376
1460
|
}
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
if (!rawCounts || !counts) {
|
|
1461
|
+
const counts = statusCounts(wt.path);
|
|
1462
|
+
if (!counts) {
|
|
1380
1463
|
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1381
1464
|
continue;
|
|
1382
1465
|
}
|
|
@@ -1385,58 +1468,78 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1385
1468
|
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1386
1469
|
continue;
|
|
1387
1470
|
}
|
|
1471
|
+
// Git removes ignored files along with a worktree. They may include local
|
|
1472
|
+
// state, credentials, or user data, so a clean status is not enough proof.
|
|
1473
|
+
const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1474
|
+
if (ignored.status !== 0 || ignored.error) {
|
|
1475
|
+
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (ignored.stdout) {
|
|
1479
|
+
pruneCachesForKept(wtPath);
|
|
1480
|
+
kept.push({ ...item, reason: 'ignored_data' });
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1388
1483
|
if (!wt.head) {
|
|
1389
1484
|
pruneCachesForKept(wtPath);
|
|
1390
1485
|
kept.push({ ...item, reason: 'missing_head' });
|
|
1391
1486
|
continue;
|
|
1392
1487
|
}
|
|
1393
|
-
|
|
1394
|
-
(
|
|
1395
|
-
|
|
1396
|
-
|
|
1488
|
+
if (refreshError) {
|
|
1489
|
+
kept.push({ ...item, reason: 'base_refresh_failed' });
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1397
1492
|
const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
|
|
1398
1493
|
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
|
-
}
|
|
1494
|
+
pruneCachesForKept(wtPath);
|
|
1495
|
+
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1427
1496
|
continue;
|
|
1428
1497
|
}
|
|
1429
1498
|
if (worktreeWithinReapGrace(wtPath)) {
|
|
1430
1499
|
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1431
1500
|
continue;
|
|
1432
1501
|
}
|
|
1433
|
-
|
|
1502
|
+
if (!originBaseIsCurrent(root, base)) {
|
|
1503
|
+
kept.push({ ...item, reason: 'base_unverified' });
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
let real;
|
|
1507
|
+
try { real = fs.realpathSync(wtPath); } catch { real = ''; }
|
|
1508
|
+
if (!real || real !== wtPath) {
|
|
1509
|
+
kept.push({ ...item, reason: 'path_not_canonical' });
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1513
|
+
const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1514
|
+
const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
1515
|
+
if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
|
|
1516
|
+
kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
|
|
1434
1520
|
candidates.push(candidate);
|
|
1521
|
+
cacheBudget.count += 1;
|
|
1522
|
+
cacheBudget.kib += kib;
|
|
1435
1523
|
if (!apply) continue;
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
const
|
|
1524
|
+
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1525
|
+
const freshCounts = statusCounts(wt.path);
|
|
1526
|
+
const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1527
|
+
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1528
|
+
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1529
|
+
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1530
|
+
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1531
|
+
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1532
|
+
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1533
|
+
|| freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1534
|
+
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1535
|
+
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1536
|
+
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
1537
|
+
|| !originBaseIsCurrent(root, base)
|
|
1538
|
+
|| canonicalPath(wtPath) !== wtPath) {
|
|
1539
|
+
kept.push({ ...item, reason: 'changed_or_active_during_verification' });
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
|
|
1440
1543
|
if (removedResult.status === 0) {
|
|
1441
1544
|
removed.push(candidate);
|
|
1442
1545
|
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
@@ -1465,10 +1568,13 @@ function cleanup(args) {
|
|
|
1465
1568
|
for (const item of result.apply ? result.removed : result.candidates) {
|
|
1466
1569
|
console.log(`${action}: ${item.path} branch=${item.branch} reason=${item.reason}`);
|
|
1467
1570
|
}
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1571
|
+
const review = result.cacheCandidates.filter((item) => item.reason === 'manual_review_required');
|
|
1572
|
+
const safe = result.apply ? result.cachePruned : result.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
|
|
1573
|
+
console.log(`cache ${result.apply ? 'removed' : 'candidate'}s: ${safe.length}`);
|
|
1574
|
+
for (const item of safe) console.log(`cache: ${item.path} size_kib=${item.kib}`);
|
|
1575
|
+
console.log(`cache review needed: ${review.length}`);
|
|
1576
|
+
for (const item of review) {
|
|
1577
|
+
console.log(`review: ${item.path} reason=${item.reason}`);
|
|
1472
1578
|
}
|
|
1473
1579
|
console.log(`kept: ${result.kept.length}`);
|
|
1474
1580
|
if (!result.apply && result.candidates.length) console.log('next: atris worktree cleanup --apply');
|
|
@@ -1524,7 +1630,7 @@ function help() {
|
|
|
1524
1630
|
console.log(' atris worktree status');
|
|
1525
1631
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
1526
1632
|
console.log(' atris worktree prune [--apply]');
|
|
1527
|
-
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and
|
|
1633
|
+
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
|
|
1528
1634
|
}
|
|
1529
1635
|
|
|
1530
1636
|
function worktreeCommand(args = []) {
|
package/lib/fleet.js
CHANGED
|
@@ -1483,6 +1483,28 @@ function shipFailureDetail(shipped, { head = 400, tail = 500 } = {}) {
|
|
|
1483
1483
|
return `${cause}${headTail(combined.trim(), head, tail)}`;
|
|
1484
1484
|
}
|
|
1485
1485
|
|
|
1486
|
+
// The `merge: record {...}` line worktree ship prints after a confirmed merge.
|
|
1487
|
+
function shipMergeRecord(stdout) {
|
|
1488
|
+
const line = String(stdout || '').match(/^merge: record (\{.*\})\s*$/m);
|
|
1489
|
+
if (!line) return null;
|
|
1490
|
+
try {
|
|
1491
|
+
const record = JSON.parse(line[1]);
|
|
1492
|
+
return record && typeof record === 'object' && typeof record.merge_commit === 'string' ? record : null;
|
|
1493
|
+
} catch {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// task ready flags that name the pull request a flight merged by itself.
|
|
1499
|
+
function landedMergeFlags(merge) {
|
|
1500
|
+
if (!merge || !merge.merge_commit) return [];
|
|
1501
|
+
const flags = ['--merge-commit', merge.merge_commit, '--self-landed'];
|
|
1502
|
+
if (merge.number) flags.push('--pr-number', String(merge.number));
|
|
1503
|
+
if (merge.url) flags.push('--pr-url', merge.url);
|
|
1504
|
+
if (merge.merged_at) flags.push('--merged-at', merge.merged_at);
|
|
1505
|
+
return flags;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1486
1508
|
function fleetShipArgs(entry, check, targetRef = resolveDispatchLandTarget(entry.worktreePath || process.cwd())) {
|
|
1487
1509
|
return [
|
|
1488
1510
|
'worktree', 'ship',
|
|
@@ -1629,6 +1651,8 @@ module.exports = {
|
|
|
1629
1651
|
runInReapedProcessGroup,
|
|
1630
1652
|
shipWithRetry,
|
|
1631
1653
|
shipFailureDetail,
|
|
1654
|
+
shipMergeRecord,
|
|
1655
|
+
landedMergeFlags,
|
|
1632
1656
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
1633
1657
|
get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
|
|
1634
1658
|
get runFleetFlight() { return runFleetFlight; },
|
|
@@ -3208,14 +3232,27 @@ async function runFleetFlight({
|
|
|
3208
3232
|
// when the operator explicitly wants launcher-HEAD.
|
|
3209
3233
|
const startBaseArgs = ['--base', landTarget];
|
|
3210
3234
|
const prepared = [];
|
|
3235
|
+
const leaseDb = require('./task-db');
|
|
3236
|
+
const leaseConnection = leaseDb.open();
|
|
3237
|
+
const leased = new Map();
|
|
3238
|
+
const leaseTimer = setInterval(() => {
|
|
3239
|
+
for (const [id, holder] of leased) leaseDb.renewLease(leaseConnection, { id, holder, pid: process.pid });
|
|
3240
|
+
}, 5 * 60000);
|
|
3241
|
+
leaseTimer.unref();
|
|
3242
|
+
try {
|
|
3211
3243
|
for (const entry of staffed) {
|
|
3212
|
-
const claimed = cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}
|
|
3244
|
+
const claimed = cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3213
3245
|
if (!claimed || claimed.status !== 0) {
|
|
3214
3246
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3215
3247
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
|
|
3216
3248
|
log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at claim${detail ? `, ${detail}` : ''}`);
|
|
3217
3249
|
continue;
|
|
3218
3250
|
}
|
|
3251
|
+
const leaseId = entry.task.id || entry.task.display_id;
|
|
3252
|
+
const holder = `fleet-${entry.engine}`;
|
|
3253
|
+
const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder });
|
|
3254
|
+
if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3255
|
+
if (leaseSet) leased.set(leaseId, holder);
|
|
3219
3256
|
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`, ...startBaseArgs]);
|
|
3220
3257
|
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
3221
3258
|
if (!wt) {
|
|
@@ -3259,7 +3296,7 @@ async function runFleetFlight({
|
|
|
3259
3296
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
3260
3297
|
return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped) };
|
|
3261
3298
|
}
|
|
3262
|
-
return { ok: true, stage: 'shipped' };
|
|
3299
|
+
return { ok: true, stage: 'shipped', merge: shipMergeRecord(shipped.stdout) };
|
|
3263
3300
|
});
|
|
3264
3301
|
|
|
3265
3302
|
for (const { entry, result } of results) {
|
|
@@ -3296,6 +3333,7 @@ async function runFleetFlight({
|
|
|
3296
3333
|
'task', 'ready', String(entry.task.display_id),
|
|
3297
3334
|
'--proof', `Built by ${activeEngine} engine in fleet flight.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${headTail(String(result.report || '').replace(/\n/g, ' '))}`,
|
|
3298
3335
|
'--result', 'Operators can now review fleet-shipped work faster because the worktree was verified before landing.',
|
|
3336
|
+
...landedMergeFlags(landed.merge),
|
|
3299
3337
|
'--as', `fleet-${activeEngine}`,
|
|
3300
3338
|
], { flight, landedRow, worktree: entry.worktreePath, save: () => atomicWriteFlightReceipt(flight, receiptPath) });
|
|
3301
3339
|
if (!reviewRecorded) {
|
|
@@ -3340,6 +3378,13 @@ async function runFleetFlight({
|
|
|
3340
3378
|
log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
|
|
3341
3379
|
log('');
|
|
3342
3380
|
return flight;
|
|
3381
|
+
} finally {
|
|
3382
|
+
clearInterval(leaseTimer);
|
|
3383
|
+
for (const [id, holder] of leased) {
|
|
3384
|
+
const row = leaseDb.getTask(leaseConnection, id);
|
|
3385
|
+
if (row && row.status !== 'claimed') leaseDb.clearLease(leaseConnection, { id, holder });
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3343
3388
|
}
|
|
3344
3389
|
|
|
3345
3390
|
// ---------------------------------------------------------------------------
|
|
@@ -3474,6 +3519,9 @@ async function runDispatchFlight({
|
|
|
3474
3519
|
}
|
|
3475
3520
|
|
|
3476
3521
|
const cli = ownCli || defaultOwnCli(root);
|
|
3522
|
+
const leaseDb = require('./task-db');
|
|
3523
|
+
const leaseConnection = leaseDb.open();
|
|
3524
|
+
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
3525
|
// Cut worktrees from, and check self-landing against, the workspace's real
|
|
3478
3526
|
// protected branch, not a hard-coded origin/master (CLI-1298).
|
|
3479
3527
|
const localBase = reviewOnly ? '' : localDispatchBase(root);
|
|
@@ -3517,6 +3565,12 @@ async function runDispatchFlight({
|
|
|
3517
3565
|
// default so rebase-before-ship never replays a launcher feature branch.
|
|
3518
3566
|
const startBaseArgs = landTarget ? ['--base', landTarget] : [];
|
|
3519
3567
|
const prepared = [];
|
|
3568
|
+
const leasedIds = new Set();
|
|
3569
|
+
const leaseTimer = setInterval(() => {
|
|
3570
|
+
for (const id of leasedIds) leaseDb.renewLease(leaseConnection, { id, holder: taskActor, pid: process.pid });
|
|
3571
|
+
}, 5 * 60000);
|
|
3572
|
+
leaseTimer.unref();
|
|
3573
|
+
try {
|
|
3520
3574
|
for (const taskId of ids) {
|
|
3521
3575
|
const task = readTaskById(cli, taskId);
|
|
3522
3576
|
if (!task) {
|
|
@@ -3524,7 +3578,7 @@ async function runDispatchFlight({
|
|
|
3524
3578
|
log(` ✗ ${taskId} not found`);
|
|
3525
3579
|
continue;
|
|
3526
3580
|
}
|
|
3527
|
-
const claimed = cli(['task', 'claim', taskId, '--as', taskActor]);
|
|
3581
|
+
const claimed = cli(['task', 'claim', taskId, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3528
3582
|
if (!claimed || claimed.status !== 0) {
|
|
3529
3583
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3530
3584
|
flight.paused.push({ task: taskId, stage: 'claim', detail });
|
|
@@ -3532,6 +3586,10 @@ async function runDispatchFlight({
|
|
|
3532
3586
|
continue;
|
|
3533
3587
|
}
|
|
3534
3588
|
try {
|
|
3589
|
+
const leaseId = task.id || taskId;
|
|
3590
|
+
const leased = leaseDb.setLease(leaseConnection, { id: leaseId, holder: taskActor });
|
|
3591
|
+
if (!leased && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3592
|
+
if (leased) leasedIds.add(leaseId);
|
|
3535
3593
|
let landingWorktreePath = '';
|
|
3536
3594
|
let remoteBoundary = null;
|
|
3537
3595
|
if (enforceRemoteBoundary) {
|
|
@@ -3762,6 +3820,7 @@ async function runDispatchFlight({
|
|
|
3762
3820
|
return {
|
|
3763
3821
|
ok: true,
|
|
3764
3822
|
stage: 'shipped',
|
|
3823
|
+
merge: shipMergeRecord(shipped.stdout),
|
|
3765
3824
|
check,
|
|
3766
3825
|
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
3767
3826
|
verifier_result: verifierResult,
|
|
@@ -4162,6 +4221,7 @@ async function runDispatchFlight({
|
|
|
4162
4221
|
'--result', 'Operators can now review engine-built work faster because dispatch reran the verifier before landing.',
|
|
4163
4222
|
'--checked', `${landed.check} passed before landing`,
|
|
4164
4223
|
'--tested', 'The requested behavior passed its declared verifier.',
|
|
4224
|
+
...landedMergeFlags(landed.merge),
|
|
4165
4225
|
'--as', readyActor,
|
|
4166
4226
|
], { flight, landedRow, worktree: entry.worktreePath, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
|
|
4167
4227
|
if (!reviewRecorded) {
|
|
@@ -4212,4 +4272,11 @@ async function runDispatchFlight({
|
|
|
4212
4272
|
log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
|
|
4213
4273
|
log('');
|
|
4214
4274
|
return flight;
|
|
4275
|
+
} finally {
|
|
4276
|
+
clearInterval(leaseTimer);
|
|
4277
|
+
for (const id of leasedIds) {
|
|
4278
|
+
const row = leaseDb.getTask(leaseConnection, id);
|
|
4279
|
+
if (row && row.status !== 'claimed') leaseDb.clearLease(leaseConnection, { id, holder: taskActor });
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4215
4282
|
}
|
package/lib/gm-season.js
CHANGED
|
@@ -301,7 +301,7 @@ function parseJson(text) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
function landedBy(row, payload) {
|
|
304
|
-
if (payload && payload.auto_accepted === true) return 'autoland';
|
|
304
|
+
if (payload && (payload.auto_accepted === true || payload.self_landed === true)) return 'autoland';
|
|
305
305
|
return /auto/i.test(String(row.actor || '')) ? 'autoland' : 'you';
|
|
306
306
|
}
|
|
307
307
|
|
|
@@ -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 = ?
|
|
@@ -941,7 +1044,23 @@ function recordReviewPassActor(metadata, claimedBy, rawActor) {
|
|
|
941
1044
|
return { builder: builder || null, independent };
|
|
942
1045
|
}
|
|
943
1046
|
|
|
944
|
-
|
|
1047
|
+
// A merge the caller confirmed. The commit id is required; the other fields
|
|
1048
|
+
// are kept only when they look right.
|
|
1049
|
+
function cleanMergeRecord(merge) {
|
|
1050
|
+
if (!merge || typeof merge !== 'object') return null;
|
|
1051
|
+
const commit = String(merge.merge_commit || '').trim();
|
|
1052
|
+
if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
1053
|
+
const record = { merge_commit: commit.toLowerCase() };
|
|
1054
|
+
const number = Number(merge.number);
|
|
1055
|
+
if (Number.isInteger(number) && number > 0) record.number = number;
|
|
1056
|
+
const url = String(merge.url || '').trim();
|
|
1057
|
+
if (/^https?:\/\//i.test(url)) record.url = url;
|
|
1058
|
+
const mergedAt = String(merge.merged_at || '').trim();
|
|
1059
|
+
if (mergedAt && Number.isFinite(Date.parse(mergedAt))) record.merged_at = mergedAt;
|
|
1060
|
+
return record;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge = null, resultTrace, result: resultSentence, reason, workRoot }) {
|
|
945
1064
|
if (!id) throw new Error('id required');
|
|
946
1065
|
const text = String(proof || '').trim();
|
|
947
1066
|
if (!text) throw new Error('proof required');
|
|
@@ -952,6 +1071,7 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
952
1071
|
}
|
|
953
1072
|
const now = Date.now();
|
|
954
1073
|
const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
|
|
1074
|
+
delete metadata.lease;
|
|
955
1075
|
const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
|
|
956
1076
|
metadata.approval_status = 'pending';
|
|
957
1077
|
metadata.agent_review_pass_count = reviewPassCount;
|
|
@@ -969,6 +1089,8 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
969
1089
|
const cleaned = cleanLandingText(landingInput[key]);
|
|
970
1090
|
if (cleaned) metadata[`landing_${key}`] = cleaned;
|
|
971
1091
|
}
|
|
1092
|
+
const mergeRecord = cleanMergeRecord(merge);
|
|
1093
|
+
if (mergeRecord) metadata.merge_commit = mergeRecord.merge_commit;
|
|
972
1094
|
// Only the builder's own ready names the work's commit. A reviewer or the
|
|
973
1095
|
// autoland verifier re-runs the check from its own checkout; stamping that
|
|
974
1096
|
// checkout's HEAD would name a commit the builder never made. No actor, or
|
|
@@ -993,9 +1115,9 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
993
1115
|
updated_at = ?,
|
|
994
1116
|
metadata = ?
|
|
995
1117
|
WHERE id = ?
|
|
996
|
-
AND status
|
|
997
|
-
`).run(now, JSON.stringify(metadata), id));
|
|
998
|
-
if (result.changes !== 1) return { ready: false, reason: '
|
|
1118
|
+
AND status = ? AND claimed_by IS ? AND claimed_at IS ?
|
|
1119
|
+
`).run(now, JSON.stringify(metadata), id, row.status, row.claimed_by, row.claimed_at));
|
|
1120
|
+
if (result.changes !== 1) return { ready: false, reason: 'claim changed hands' };
|
|
999
1121
|
const updated = getTask(db, id);
|
|
1000
1122
|
const payload = {
|
|
1001
1123
|
proof: text,
|
|
@@ -1017,6 +1139,12 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
1017
1139
|
tested: metadata.landing_tested || null,
|
|
1018
1140
|
decision: metadata.landing_decision || null,
|
|
1019
1141
|
};
|
|
1142
|
+
if (mergeRecord) {
|
|
1143
|
+
payload.landing.merge_commit = mergeRecord.merge_commit;
|
|
1144
|
+
payload.pull_request = { state: 'merged', ...mergeRecord };
|
|
1145
|
+
// Landed by the flight with no person in the loop.
|
|
1146
|
+
if (merge.self_landed === true) payload.self_landed = true;
|
|
1147
|
+
}
|
|
1020
1148
|
const event = appendTaskEvent(db, {
|
|
1021
1149
|
taskId: id,
|
|
1022
1150
|
workspaceRoot: updated.workspace_root,
|
|
@@ -2364,6 +2492,12 @@ module.exports = {
|
|
|
2364
2492
|
listTasks,
|
|
2365
2493
|
listTasksCached,
|
|
2366
2494
|
claimTask,
|
|
2495
|
+
setLease,
|
|
2496
|
+
renewLease,
|
|
2497
|
+
clearLease,
|
|
2498
|
+
expireLeases,
|
|
2499
|
+
previewExpiredLeases,
|
|
2500
|
+
sweepExpiredLeases,
|
|
2367
2501
|
releaseTask,
|
|
2368
2502
|
reopenTask,
|
|
2369
2503
|
backlogTask,
|
package/package.json
CHANGED