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.
Files changed (79) hide show
  1. package/README.md +40 -1
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +63 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -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
- stdout: String(res.stdout || '').trim(),
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 autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask } = {}) {
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 is pushed and
285
- // merged, so a commit made after this point never rides along.
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
- const remoteHead = run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
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', `${headSha}:refs/heads/${branch}`], cwd);
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', headSha], cwd);
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
- return { merged: false, auto_merge: true, gate: null, url: label, line: `merges on its own once checks pass: ${label}` };
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
  };