atris 3.69.0 → 3.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +26 -6
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +759 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +433 -99
- package/commands/mission.js +332 -41
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +132 -16
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +148 -18
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +242 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +35 -4
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/engine-validate.js +189 -9
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +479 -10
- package/lib/member-board.js +86 -10
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +433 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +63 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/lib/member-autoland.js
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
// GitHub auto-merge. Every local gate fails closed: the branch stays put and
|
|
5
5
|
// one plain line says which gate held it. GitHub then waits for the branch to
|
|
6
6
|
// be up to date and CI green; this never merges, rebases, or polls itself.
|
|
7
|
+
// The one exception is a project with no remote: there the branch merges into
|
|
8
|
+
// the main checkout's branch on this computer, past the same gates.
|
|
7
9
|
|
|
8
10
|
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
9
12
|
const path = require('path');
|
|
10
13
|
const { spawnSync } = require('child_process');
|
|
11
14
|
const {
|
|
@@ -14,15 +17,18 @@ const {
|
|
|
14
17
|
sniffedProtectedLane,
|
|
15
18
|
} = require('./auto-accept-certified');
|
|
16
19
|
const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
|
|
20
|
+
const { inboxMapEdits } = require('./member-map-inbox');
|
|
17
21
|
|
|
18
22
|
// Git replacement refs can make a local check read different code than the
|
|
19
23
|
// commit that gets pushed; every git and gh call here ignores them.
|
|
20
|
-
function run(cmd, args, cwd) {
|
|
21
|
-
const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
24
|
+
function run(cmd, args, cwd, extraEnv = {}, { raw = false } = {}) {
|
|
25
|
+
const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
22
26
|
const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120000, env });
|
|
27
|
+
const stdout = String(res.stdout || '');
|
|
23
28
|
return {
|
|
24
29
|
ok: res.status === 0,
|
|
25
|
-
|
|
30
|
+
status: res.status,
|
|
31
|
+
stdout: raw ? stdout : stdout.trim(),
|
|
26
32
|
stderr: String(res.stderr || res.error?.message || '').trim(),
|
|
27
33
|
};
|
|
28
34
|
}
|
|
@@ -251,11 +257,193 @@ function unsavedHold(cwd) {
|
|
|
251
257
|
return null;
|
|
252
258
|
}
|
|
253
259
|
|
|
260
|
+
// Files where every side only adds lines: map files, the map notes file, and
|
|
261
|
+
// member logs. A clash in these keeps both sides' lines instead of holding.
|
|
262
|
+
const MAP_FILE = /(?:^|\/)MAP(?:-NOTES)?\.md$|^atris\/team\/[^/]+\/logs\/[^/]+\.md$/;
|
|
263
|
+
|
|
264
|
+
// Keep both sides' lines of one clashing map file, as a stored blob. A file
|
|
265
|
+
// both sides created, like the same day's member log, merges against empty.
|
|
266
|
+
function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
|
|
267
|
+
const sides = [];
|
|
268
|
+
for (const [name, rev] of [['base', mergeBase], ['ours', ours], ['theirs', theirs]]) {
|
|
269
|
+
const blob = run('git', ['cat-file', 'blob', `${rev}:${file}`], cwd, {}, { raw: true });
|
|
270
|
+
if (!blob.ok && name !== 'base') return null;
|
|
271
|
+
const out = path.join(tmp, name);
|
|
272
|
+
fs.writeFileSync(out, blob.ok ? blob.stdout : '');
|
|
273
|
+
sides.push(out);
|
|
274
|
+
}
|
|
275
|
+
const merged = run('git', ['merge-file', '-p', '--union', sides[1], sides[0], sides[2]], cwd, {}, { raw: true });
|
|
276
|
+
if (!merged.ok) return null;
|
|
277
|
+
const out = path.join(tmp, 'merged');
|
|
278
|
+
fs.writeFileSync(out, merged.stdout);
|
|
279
|
+
const blob = run('git', ['hash-object', '-w', out], cwd);
|
|
280
|
+
return blob.ok && /^[0-9a-f]{40,64}$/.test(blob.stdout) ? blob.stdout : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Merge the main branch into the reviewed commit without touching the
|
|
284
|
+
// checkout. Edits to the shared map files first move to the map inbox in a
|
|
285
|
+
// commit on top of the reviewed commit, so they never clash. A clean merge
|
|
286
|
+
// pushes that commit as is. When every clash is a map file or member log, both
|
|
287
|
+
// sides' lines are kept and a merge commit is pushed instead, so GitHub has
|
|
288
|
+
// nothing left to resolve. Any other clash holds. With catchUp, a clean merge
|
|
289
|
+
// against a main branch that moved also becomes a merge commit, so the branch
|
|
290
|
+
// is up to date. With localBase, the main branch is the local one and nothing
|
|
291
|
+
// is fetched.
|
|
292
|
+
function mergeWithBase(reviewedSha, base, branch, cwd, { catchUp = false, localBase = false } = {}) {
|
|
293
|
+
const baseRef = localBase ? `refs/heads/${base}` : `refs/remotes/origin/${base}`;
|
|
294
|
+
if (!localBase) {
|
|
295
|
+
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${base}:${baseRef}`], cwd);
|
|
296
|
+
if (!fetched.ok) return { hold: `the main branch could not be fetched (${fetched.stderr.split('\n')[0]})` };
|
|
297
|
+
}
|
|
298
|
+
const baseRead = run('git', ['rev-parse', baseRef], cwd);
|
|
299
|
+
const baseSha = baseRead.ok ? baseRead.stdout : '';
|
|
300
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { hold: 'the main branch could not be read' };
|
|
301
|
+
const inboxed = inboxMapEdits({ headSha: reviewedSha, baseSha, branch, cwd });
|
|
302
|
+
if (inboxed.hold) return { hold: inboxed.hold };
|
|
303
|
+
const headSha = inboxed.sha;
|
|
304
|
+
const mapInboxed = inboxed.moved;
|
|
305
|
+
const tree = run('git', ['merge-tree', '--write-tree', '--name-only', headSha, baseSha], cwd);
|
|
306
|
+
if (tree.status === 0) {
|
|
307
|
+
if (!catchUp) return { sha: headSha, mapInboxed };
|
|
308
|
+
if (headSha === reviewedSha && run('git', ['merge-base', '--is-ancestor', baseSha, headSha], cwd).ok) {
|
|
309
|
+
return { sha: headSha, current: true };
|
|
310
|
+
}
|
|
311
|
+
const commit = run('git', [
|
|
312
|
+
'commit-tree', tree.stdout.split(/\r?\n/)[0], '-p', headSha, '-p', baseSha,
|
|
313
|
+
'-m', `Merge ${base} into ${branch} to catch up`,
|
|
314
|
+
], cwd);
|
|
315
|
+
if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the catch-up merge could not be saved' };
|
|
316
|
+
return { sha: commit.stdout, mapInboxed };
|
|
317
|
+
}
|
|
318
|
+
if (tree.status !== 1) return { hold: 'the branch could not be compared with the main branch' };
|
|
319
|
+
const [treeId, ...files] = tree.stdout.split(/\r?\n\r?\n/)[0].split(/\r?\n/).filter(Boolean);
|
|
320
|
+
const clashes = files.filter((file) => !MAP_FILE.test(file));
|
|
321
|
+
if (!files.length || clashes.length) {
|
|
322
|
+
return { hold: `the branch clashes with the main branch in ${(clashes.length ? clashes : ['unknown files']).join(', ')}` };
|
|
323
|
+
}
|
|
324
|
+
const bases = run('git', ['merge-base', '--all', headSha, baseSha], cwd);
|
|
325
|
+
const mergeBase = bases.ok ? bases.stdout.split('\n').filter(Boolean) : [];
|
|
326
|
+
if (mergeBase.length !== 1) return { hold: 'the branch and the main branch share no single starting point' };
|
|
327
|
+
|
|
328
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-merge-'));
|
|
329
|
+
try {
|
|
330
|
+
const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
331
|
+
if (!run('git', ['read-tree', treeId], cwd, index).ok) return { hold: 'the merged map could not be built' };
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
const blob = unionMapBlob(file, mergeBase[0], headSha, baseSha, cwd, tmp);
|
|
334
|
+
const mode = (run('git', ['ls-tree', headSha, '--', file], cwd).stdout.match(/^(\d{6}) blob /) || [])[1];
|
|
335
|
+
if (!blob || !mode) return { hold: `the map file ${file} could not be merged line by line` };
|
|
336
|
+
if (!run('git', ['update-index', '--cacheinfo', `${mode},${blob},${file}`], cwd, index).ok) {
|
|
337
|
+
return { hold: 'the merged map could not be built' };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const merged = run('git', ['write-tree'], cwd, index);
|
|
341
|
+
if (!merged.ok) return { hold: 'the merged map could not be built' };
|
|
342
|
+
const commit = run('git', [
|
|
343
|
+
'commit-tree', merged.stdout, '-p', headSha, '-p', baseSha,
|
|
344
|
+
'-m', `Merge ${base} into ${branch}, keeping map lines from both sides`,
|
|
345
|
+
], cwd);
|
|
346
|
+
if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the map merge could not be saved' };
|
|
347
|
+
return { sha: commit.stdout, mapMerged: files, mapInboxed };
|
|
348
|
+
} finally {
|
|
349
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Every branch autoland handed to GitHub, so a sweep can tell when GitHub
|
|
354
|
+
// dropped one. Lives in the main checkout beside the policy.
|
|
355
|
+
function queueFile(cwd) {
|
|
356
|
+
const root = mainCheckoutRoot(cwd);
|
|
357
|
+
return root ? path.join(root, '.atris', 'state', 'autoland-queue.json') : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readQueue(file) {
|
|
361
|
+
try {
|
|
362
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
363
|
+
if (data && typeof data.entries === 'object' && !Array.isArray(data.entries)) return data.entries;
|
|
364
|
+
} catch {}
|
|
365
|
+
return {};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeQueue(file, entries) {
|
|
369
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
370
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
371
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ schema: 'atris.autoland_queue.v1', entries }, null, 2)}\n`);
|
|
372
|
+
fs.renameSync(tmp, file);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function recordQueued(cwd, entry) {
|
|
376
|
+
const file = queueFile(cwd);
|
|
377
|
+
if (!file) return;
|
|
378
|
+
try {
|
|
379
|
+
const entries = readQueue(file);
|
|
380
|
+
entries[entry.branch] = entry;
|
|
381
|
+
writeQueue(file, entries);
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
|
|
254
385
|
function held(gate, line) {
|
|
255
386
|
return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
|
|
256
387
|
}
|
|
257
388
|
|
|
258
|
-
function
|
|
389
|
+
function hasOrigin(cwd) {
|
|
390
|
+
return run('git', ['remote', 'get-url', 'origin'], cwd).ok;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The branch the main checkout is on, or '' when it is not on one.
|
|
394
|
+
function mainCheckoutBranch(cwd) {
|
|
395
|
+
const root = mainCheckoutRoot(cwd);
|
|
396
|
+
const read = root ? run('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], root) : null;
|
|
397
|
+
return read?.ok ? read.stdout : '';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Where a finished run's work goes, as one plain line for the start of a run.
|
|
401
|
+
function landingDestination(cwd) {
|
|
402
|
+
if (!policyOn(cwd)) return 'finished work stays on its own branch for you; self-landing is off';
|
|
403
|
+
if (!hasOrigin(cwd)) {
|
|
404
|
+
const base = mainCheckoutBranch(cwd);
|
|
405
|
+
return `finished work merges into ${base || 'the main branch'} on this computer once its check and review pass`;
|
|
406
|
+
}
|
|
407
|
+
return 'finished work goes to GitHub as a pull request that merges itself once checks pass';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A project with no remote has no GitHub to hand the branch to, so the main
|
|
411
|
+
// checkout's own branch is the target. The reviewed commit catches up with it
|
|
412
|
+
// without touching any checkout, then the main checkout fast-forwards to that
|
|
413
|
+
// commit, which refuses rather than overwrite anything there.
|
|
414
|
+
function landLocally({ headSha, branch, cwd, pinnedTree }) {
|
|
415
|
+
const root = mainCheckoutRoot(cwd);
|
|
416
|
+
const base = mainCheckoutBranch(cwd);
|
|
417
|
+
if (!root || !base) return held('base', 'the main checkout is not on a branch');
|
|
418
|
+
if (base === branch) return held('branch', 'the work is not on its own branch');
|
|
419
|
+
const mainState = gitCodeState(root);
|
|
420
|
+
if (mainState.hidden || codeStatus(mainState) !== '') {
|
|
421
|
+
return held('unsaved', 'the main checkout has unsaved changes; merging there could mix them in');
|
|
422
|
+
}
|
|
423
|
+
const landing = mergeWithBase(headSha, base, branch, cwd, { catchUp: true, localBase: true });
|
|
424
|
+
if (landing.hold) return held('conflict', landing.hold);
|
|
425
|
+
if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
|
|
426
|
+
const forward = run('git', ['merge', '--ff-only', '-q', landing.sha], root);
|
|
427
|
+
if (!forward.ok) return held('merge', `the main checkout could not move forward (${forward.stderr.split('\n')[0]})`);
|
|
428
|
+
const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
|
|
429
|
+
: landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
430
|
+
return {
|
|
431
|
+
merged: true,
|
|
432
|
+
auto_merge: false,
|
|
433
|
+
local: true,
|
|
434
|
+
gate: null,
|
|
435
|
+
base,
|
|
436
|
+
landed: landing.sha,
|
|
437
|
+
map_merged: landing.mapMerged || [],
|
|
438
|
+
map_inboxed: landing.mapInboxed || [],
|
|
439
|
+
line: `merged into ${base} on this computer${mapNote}; this project has no GitHub remote`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// closeTaskIds: jobs taken off the list for this run. They only ride along
|
|
444
|
+
// so the sweep can close them once the change merges; unlike the mission's
|
|
445
|
+
// own linked tasks they do not gate the landing.
|
|
446
|
+
function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask, closeTaskIds = [] } = {}) {
|
|
259
447
|
if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
|
|
260
448
|
if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
|
|
261
449
|
if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
|
|
@@ -274,6 +462,12 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
274
462
|
if (!task || typeof task !== 'object') return held('protected', `a linked task (${id}) could not be read`);
|
|
275
463
|
const lane = protectedLane(task);
|
|
276
464
|
if (lane) return held('protected', `a linked task sits in a protected area (${lane})`);
|
|
465
|
+
// A passing run cannot override a person's send-back. Read the current
|
|
466
|
+
// task, not historical events: submitting fresh proof resets it to pending.
|
|
467
|
+
const approvals = [task.review?.approval_status, task.metadata?.approval_status];
|
|
468
|
+
if (approvals.some((value) => ['revise', 'revised', 'rejected'].includes(String(value || '').trim().toLowerCase()))) {
|
|
469
|
+
return held('approval', 'a linked task was turned down; it needs fresh proof before it can go to github');
|
|
470
|
+
}
|
|
277
471
|
}
|
|
278
472
|
|
|
279
473
|
const unsaved = unsavedHold(cwd);
|
|
@@ -281,8 +475,9 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
281
475
|
const pin = {};
|
|
282
476
|
const reviewGap = reviewHold(mission, workerEngine, cwd, pin);
|
|
283
477
|
if (reviewGap) return held('review', reviewGap);
|
|
284
|
-
// Pin the commit the review passed on. Only this commit
|
|
285
|
-
// merged, so a commit made after this
|
|
478
|
+
// Pin the commit the review passed on. Only this commit (or a map-only
|
|
479
|
+
// merge built on it) is pushed and merged, so a commit made after this
|
|
480
|
+
// point never rides along.
|
|
286
481
|
const headRead = run('git', ['rev-parse', 'HEAD'], cwd);
|
|
287
482
|
const headSha = headRead.ok ? headRead.stdout : '';
|
|
288
483
|
if (!/^[0-9a-f]{40,64}$/.test(headSha)) return held('branch', 'the latest commit could not be read');
|
|
@@ -297,13 +492,18 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
297
492
|
if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
|
|
298
493
|
return held('branch', 'the work is not on its own branch');
|
|
299
494
|
}
|
|
300
|
-
|
|
495
|
+
if (!hasOrigin(cwd)) return landLocally({ headSha, branch, cwd, pinnedTree });
|
|
496
|
+
const remoteHead =run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
|
|
301
497
|
const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
|
|
302
498
|
if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
|
|
303
499
|
if (branch === base) return held('branch', 'the work is not on its own branch');
|
|
304
500
|
|
|
501
|
+
const landing = mergeWithBase(headSha, base, branch, cwd);
|
|
502
|
+
if (landing.hold) return held('conflict', landing.hold);
|
|
503
|
+
const pushSha = landing.sha;
|
|
504
|
+
|
|
305
505
|
if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
|
|
306
|
-
const push = run('git', ['push', 'origin', `${
|
|
506
|
+
const push = run('git', ['push', 'origin', `${pushSha}:refs/heads/${branch}`], cwd);
|
|
307
507
|
if (!push.ok) return held('push', `the branch could not be pushed (${push.stderr.split('\n')[0]})`);
|
|
308
508
|
|
|
309
509
|
// Find the open pull request from exactly this branch into the main
|
|
@@ -338,13 +538,282 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
338
538
|
}
|
|
339
539
|
const label = url || `pull request #${number}`;
|
|
340
540
|
|
|
341
|
-
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit',
|
|
541
|
+
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', pushSha], cwd);
|
|
342
542
|
if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
|
|
343
|
-
|
|
543
|
+
recordQueued(cwd, {
|
|
544
|
+
branch, base, number, pushed: pushSha, mission_id: mission.id || null,
|
|
545
|
+
task_ids: [...new Set([...taskIds, ...(Array.isArray(closeTaskIds) ? closeTaskIds : [])
|
|
546
|
+
.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())])],
|
|
547
|
+
requeues: 0, queued_at: new Date().toISOString(),
|
|
548
|
+
});
|
|
549
|
+
const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
|
|
550
|
+
: landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
551
|
+
return {
|
|
552
|
+
merged: false,
|
|
553
|
+
auto_merge: true,
|
|
554
|
+
gate: null,
|
|
555
|
+
url: label,
|
|
556
|
+
pushed: pushSha,
|
|
557
|
+
map_merged: landing.mapMerged || [],
|
|
558
|
+
map_inboxed: landing.mapInboxed || [],
|
|
559
|
+
line: `merges on its own once checks pass${mapNote}: ${label}`,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const MAX_REQUEUES = 3;
|
|
564
|
+
|
|
565
|
+
// One queued branch: leave it, forget it, or catch it up and queue it again.
|
|
566
|
+
// Only the exact commit autoland queued (or its own catch-up) is re-queued; a
|
|
567
|
+
// push by anyone else, a closed pull request, or a real clash leaves it.
|
|
568
|
+
// GitHub keeps auto-merge on when master moves under a queued branch and the
|
|
569
|
+
// two clash, so a clashing branch is caught up even while still queued.
|
|
570
|
+
// GitHub runs holding a failed check on this pull request. gh exits nonzero
|
|
571
|
+
// when any check failed, so the list is read either way.
|
|
572
|
+
function failedCheckRuns(number, cwd) {
|
|
573
|
+
const out = run('gh', ['pr', 'checks', String(number), '--json', 'bucket,link'], cwd);
|
|
574
|
+
let checks = [];
|
|
575
|
+
try {
|
|
576
|
+
checks = JSON.parse(out.stdout || '[]');
|
|
577
|
+
} catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
if (!Array.isArray(checks)) return [];
|
|
581
|
+
const ids = checks
|
|
582
|
+
.filter((check) => check && check.bucket === 'fail')
|
|
583
|
+
.map((check) => (String(check.link || '').match(/\/actions\/runs\/(\d+)/) || [])[1])
|
|
584
|
+
.filter(Boolean);
|
|
585
|
+
return [...new Set(ids)];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function inMergeQueue(number, cwd) {
|
|
589
|
+
const out = run('gh', ['api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `number=${number}`,
|
|
590
|
+
'-f', 'query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){isInMergeQueue}}}',
|
|
591
|
+
'--jq', '.data.repository.pullRequest.isInMergeQueue'], cwd);
|
|
592
|
+
return out.ok && out.stdout === 'true';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function requeueOne(entry, cwd, maxRequeues) {
|
|
596
|
+
const { branch, base, number } = entry;
|
|
597
|
+
const label = `pull request #${number} (${branch})`;
|
|
598
|
+
const keep = (state, line) => ({ branch, number, state, keep: true, line: `${label}: ${line}` });
|
|
599
|
+
const forget = (state, line) => ({ branch, number, state, keep: false, line: `${label}: ${line}` });
|
|
600
|
+
const viewed = run('gh', ['pr', 'view', String(number), '--json',
|
|
601
|
+
'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable'], cwd);
|
|
602
|
+
let pr = null;
|
|
603
|
+
try {
|
|
604
|
+
pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
|
|
605
|
+
} catch {
|
|
606
|
+
pr = null;
|
|
607
|
+
}
|
|
608
|
+
if (!pr || typeof pr !== 'object') return keep('unreadable', 'github could not be read; will look again');
|
|
609
|
+
// Only the commit autoland pushed counts as the member's own change; a merge
|
|
610
|
+
// that carried someone else's push leaves the job for a person.
|
|
611
|
+
if (pr.state === 'MERGED') {
|
|
612
|
+
if (pr.headRefOid !== entry.pushed) return forget('merged', 'merged with commits someone else pushed; its job is left for you');
|
|
613
|
+
return { ...forget('merged', 'merged'), own: true };
|
|
614
|
+
}
|
|
615
|
+
if (pr.state !== 'OPEN') return forget('closed', 'closed by someone; left alone');
|
|
616
|
+
if (pr.headRefName !== branch || pr.baseRefName !== base || pr.isCrossRepository !== false) {
|
|
617
|
+
return forget('moved', 'no longer points from this branch into the main branch; left for you');
|
|
618
|
+
}
|
|
619
|
+
// In GitHub's merge line a change shows no auto-merge request and its
|
|
620
|
+
// branch is locked, so a catch-up push there fails. Leave it to the line.
|
|
621
|
+
if (!pr.autoMergeRequest && inMergeQueue(number, cwd)) return keep('queued', 'in github\'s merge line');
|
|
622
|
+
if (pr.autoMergeRequest && pr.mergeable !== 'CONFLICTING') {
|
|
623
|
+
// A failed check holds the change forever. Run the failed checks again
|
|
624
|
+
// once per pushed commit: a test that fails now and then passes, a real
|
|
625
|
+
// failure fails twice and waits for a fix.
|
|
626
|
+
const failed = pr.headRefOid === entry.pushed ? failedCheckRuns(number, cwd) : [];
|
|
627
|
+
if (failed.length && entry.reran !== entry.pushed) {
|
|
628
|
+
const reran = failed.filter((id) => run('gh', ['run', 'rerun', id, '--failed'], cwd).ok);
|
|
629
|
+
if (reran.length) return { ...keep('rerun', 'a check failed; ran the failed checks again once'), reran: entry.pushed };
|
|
630
|
+
return keep('queued', 'a check failed and could not be run again; will look again');
|
|
631
|
+
}
|
|
632
|
+
if (failed.length) return keep('failing', 'its checks failed again after one retry; it needs a fix');
|
|
633
|
+
return keep('queued', 'still queued');
|
|
634
|
+
}
|
|
635
|
+
if (pr.headRefOid !== entry.pushed) return forget('changed', 'someone pushed to it after it was queued; left for you');
|
|
636
|
+
if ((Number(entry.requeues) || 0) >= maxRequeues) {
|
|
637
|
+
return forget('gave-up', `dropped again after ${maxRequeues} catch-ups; left for you`);
|
|
638
|
+
}
|
|
639
|
+
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], cwd);
|
|
640
|
+
const remote = run('git', ['rev-parse', `refs/remotes/origin/${branch}`], cwd);
|
|
641
|
+
if (!fetched.ok || !remote.ok || remote.stdout !== entry.pushed) {
|
|
642
|
+
return keep('unreadable', 'the branch on github could not be read; will look again');
|
|
643
|
+
}
|
|
644
|
+
const landing = mergeWithBase(entry.pushed, base, branch, cwd, { catchUp: true });
|
|
645
|
+
if (landing.hold) return { ...forget('clash', `${landing.hold}; left for you`), why: landing.hold };
|
|
646
|
+
if (!landing.current) {
|
|
647
|
+
const push = run('git', ['push', 'origin', `${landing.sha}:refs/heads/${branch}`], cwd);
|
|
648
|
+
if (!push.ok) return keep('push', `the catch-up could not be pushed (${push.stderr.split('\n')[0]})`);
|
|
649
|
+
}
|
|
650
|
+
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', landing.sha], cwd);
|
|
651
|
+
// The catch-up may already be on github; carry it so the next look does not
|
|
652
|
+
// mistake our own push for someone else's.
|
|
653
|
+
if (!merge.ok) return { ...keep('auto-merge', 'auto-merge could not be switched back on; will look again'), pushed: landing.sha };
|
|
654
|
+
return {
|
|
655
|
+
branch, number, state: 'requeued', keep: true, pushed: landing.sha,
|
|
656
|
+
line: `${label}: ${landing.current ? 'queued again' : 'caught up with the main branch and queued again'}`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The job picker's own hold test, so the two never drift apart. A row that
|
|
661
|
+
// cannot be read counts as held.
|
|
662
|
+
function heldAtClose(row) {
|
|
663
|
+
if (!row || typeof row !== 'object') return true;
|
|
664
|
+
return Boolean(require('./member-task-pick').heldForPerson(row));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function defaultCloseTask(id, { number }) {
|
|
668
|
+
const taskDb = require('./task-db');
|
|
669
|
+
const db = taskDb.open();
|
|
670
|
+
// A hold put on the job while its change waited to merge still wins: that
|
|
671
|
+
// job stays for a person.
|
|
672
|
+
const row = taskDb.getTask(db, id);
|
|
673
|
+
if (heldAtClose(row)) return { updated: false };
|
|
674
|
+
const out = taskDb.doneTask(db, {
|
|
675
|
+
id, actor: 'autoland', allowReview: true, autoAccepted: true,
|
|
676
|
+
action: 'merged', proof: `its own pull request #${number} merged`,
|
|
677
|
+
});
|
|
678
|
+
return { ...out, replaced: row.metadata && row.metadata.replaced_pull_requests };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// The member's own change merged: close every job it was queued for. A job
|
|
682
|
+
// already closed, or one that cannot be read, is left as it is. Each closed
|
|
683
|
+
// job hands back the clashing pull requests its redo replaced.
|
|
684
|
+
function closeMergedJobs(entry, closeTask) {
|
|
685
|
+
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
686
|
+
const closed = [];
|
|
687
|
+
const replaced = [];
|
|
688
|
+
for (const id of ids) {
|
|
689
|
+
let out = null;
|
|
690
|
+
try {
|
|
691
|
+
out = closeTask(id, { number: entry.number, branch: entry.branch });
|
|
692
|
+
} catch {
|
|
693
|
+
out = null;
|
|
694
|
+
}
|
|
695
|
+
if (!out || !out.updated) continue;
|
|
696
|
+
closed.push(id);
|
|
697
|
+
if (Array.isArray(out.replaced)) replaced.push(...out.replaced);
|
|
698
|
+
}
|
|
699
|
+
return { closed, replaced };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// A clashing pull request stayed open after its job's redo merged, one more
|
|
703
|
+
// stale change for a person to sweep (2026-10-15: 29 open, most of them
|
|
704
|
+
// replaced). Close it once the redo merges, unless someone else pushed to it
|
|
705
|
+
// or it is no longer open.
|
|
706
|
+
function closeReplacedPullRequests(replaced, number, cwd) {
|
|
707
|
+
const closed = [];
|
|
708
|
+
for (const old of replaced) {
|
|
709
|
+
if (!old || !Number.isInteger(old.number) || old.number <= 0 || old.number === number) continue;
|
|
710
|
+
if (closed.includes(old.number)) continue;
|
|
711
|
+
const viewed = run('gh', ['pr', 'view', String(old.number), '--json', 'state,headRefOid'], cwd);
|
|
712
|
+
let pr = null;
|
|
713
|
+
try {
|
|
714
|
+
pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
|
|
715
|
+
} catch {
|
|
716
|
+
pr = null;
|
|
717
|
+
}
|
|
718
|
+
if (!pr || pr.state !== 'OPEN' || pr.headRefOid !== old.pushed) continue;
|
|
719
|
+
const done = run('gh', ['pr', 'close', String(old.number), '--comment',
|
|
720
|
+
`Replaced by #${number}, which merged. Its job is done.`], cwd);
|
|
721
|
+
if (done.ok) closed.push(old.number);
|
|
722
|
+
}
|
|
723
|
+
return closed;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// A clashing change is dropped from the merge line, and its job used to sit in
|
|
727
|
+
// review forever with one line in a terminal (2026-10-15: fifteen green pull
|
|
728
|
+
// requests stuck clashing, none picked up again). The job goes back on the
|
|
729
|
+
// list instead; the member's next run sees the old branch clashes and starts
|
|
730
|
+
// from the main branch. After two such restarts it stays for a person.
|
|
731
|
+
const MAX_CLASH_RESTARTS = 2;
|
|
732
|
+
|
|
733
|
+
function defaultSendBack(id, { number, pushed, why }) {
|
|
734
|
+
const taskDb = require('./task-db');
|
|
735
|
+
const db = taskDb.open();
|
|
736
|
+
const row = taskDb.getTask(db, id);
|
|
737
|
+
if (!row || row.status !== 'review' || heldAtClose(row)) return { reopened: false };
|
|
738
|
+
const restarts = Number(row.metadata && row.metadata.clash_restarts) || 0;
|
|
739
|
+
if (restarts >= MAX_CLASH_RESTARTS) return { reopened: false };
|
|
740
|
+
// Remember the clashing change so the redo's merge can close it.
|
|
741
|
+
const prior = Array.isArray(row.metadata && row.metadata.replaced_pull_requests) ? row.metadata.replaced_pull_requests : [];
|
|
742
|
+
return taskDb.reopenTask(db, {
|
|
743
|
+
id, actor: 'autoland', fromReview: true,
|
|
744
|
+
metadata: { clash_restarts: restarts + 1, replaced_pull_requests: [...prior, { number, pushed }] },
|
|
745
|
+
reason: `pull request #${number} could not merge: ${why}; start again from the main branch`,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sendBackClashedJobs(entry, why, sendBack) {
|
|
750
|
+
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
751
|
+
const sent = [];
|
|
752
|
+
for (const id of ids) {
|
|
753
|
+
let out = null;
|
|
754
|
+
try {
|
|
755
|
+
out = sendBack(id, { number: entry.number, branch: entry.branch, pushed: entry.pushed, why });
|
|
756
|
+
} catch {
|
|
757
|
+
out = null;
|
|
758
|
+
}
|
|
759
|
+
if (out && out.reopened) sent.push(id);
|
|
760
|
+
}
|
|
761
|
+
return sent;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Look at every branch autoland queued and re-queue the ones GitHub dropped.
|
|
765
|
+
// A branch whose own change merged closes the jobs it was queued for; one that
|
|
766
|
+
// clashes sends its jobs back on the list.
|
|
767
|
+
function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack } = {}) {
|
|
768
|
+
const file = queueFile(cwd);
|
|
769
|
+
if (!file) return { checked: 0, results: [], line: 'not inside a git checkout' };
|
|
770
|
+
if (!policyOn(cwd)) return { checked: 0, results: [], line: 'self-landing is not switched on in the main checkout' };
|
|
771
|
+
const entries = readQueue(file);
|
|
772
|
+
const results = [];
|
|
773
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
774
|
+
const valid = entry && typeof entry.branch === 'string' && entry.branch === key && typeof entry.base === 'string'
|
|
775
|
+
&& Number.isInteger(entry.number) && entry.number > 0 && /^[0-9a-f]{40,64}$/.test(String(entry.pushed || ''));
|
|
776
|
+
const result = valid ? requeueOne(entry, cwd, maxRequeues)
|
|
777
|
+
: { branch: key, state: 'unreadable', keep: false, line: `${key}: queue entry could not be read; dropped from the list` };
|
|
778
|
+
if (valid && result.own) {
|
|
779
|
+
const merged = closeMergedJobs(entry, closeTask);
|
|
780
|
+
result.closed = merged.closed;
|
|
781
|
+
const n = result.closed.length;
|
|
782
|
+
if (n) result.line += `; closed ${n === 1 ? 'its job' : `its ${n} jobs`}`;
|
|
783
|
+
result.replaced = closeReplacedPullRequests(merged.replaced, entry.number, cwd);
|
|
784
|
+
if (result.replaced.length) {
|
|
785
|
+
result.line += `; closed the clashing ${result.replaced.length === 1 ? 'change' : 'changes'} it replaced (${result.replaced.map((r) => `#${r}`).join(', ')})`;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (valid && result.state === 'clash') {
|
|
789
|
+
result.sent_back = sendBackClashedJobs(entry, result.why, sendBack);
|
|
790
|
+
const n = result.sent_back.length;
|
|
791
|
+
if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? 'its job went' : `its ${n} jobs went`} back on the list`);
|
|
792
|
+
}
|
|
793
|
+
results.push(result);
|
|
794
|
+
if (!result.keep) delete entries[key];
|
|
795
|
+
else if (result.state === 'requeued' || (result.pushed && result.pushed !== entry.pushed)) {
|
|
796
|
+
entries[key] = { ...entry, pushed: result.pushed, requeues: (Number(entry.requeues) || 0) + 1, requeued_at: new Date().toISOString() };
|
|
797
|
+
} else if (result.reran) {
|
|
798
|
+
entries[key] = { ...entry, reran: result.reran, reran_at: new Date().toISOString() };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (results.length) writeQueue(file, entries);
|
|
802
|
+
const requeued = results.filter((r) => r.state === 'requeued').length;
|
|
803
|
+
const rerun = results.filter((r) => r.state === 'rerun').length;
|
|
804
|
+
const closed = results.reduce((sum, r) => sum + (r.closed ? r.closed.length : 0), 0);
|
|
805
|
+
const closedNote = (closed ? `; ${closed} finished ${closed === 1 ? 'job' : 'jobs'} closed after merging` : '')
|
|
806
|
+
+ (rerun ? `; ${rerun} failed ${rerun === 1 ? 'check' : 'checks'} run again` : '');
|
|
807
|
+
const line = results.length
|
|
808
|
+
? `${requeued} of ${results.length} queued ${results.length === 1 ? 'change' : 'changes'} put back in line${closedNote}`
|
|
809
|
+
: 'nothing is waiting in github\'s merge line';
|
|
810
|
+
return { checked: results.length, requeued, rerun, closed, results, line };
|
|
344
811
|
}
|
|
345
812
|
|
|
346
813
|
module.exports = {
|
|
347
814
|
autolandMemberRun,
|
|
815
|
+
landingDestination,
|
|
816
|
+
requeueDroppedAutolands,
|
|
348
817
|
codeStatus,
|
|
349
818
|
gitCodeState,
|
|
350
819
|
};
|