atris 3.71.3 → 3.72.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/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +7 -1
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/lib/member-autoland.js
CHANGED
|
@@ -561,6 +561,9 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
const MAX_REQUEUES = 3;
|
|
564
|
+
// A merge nobody closed within a day may belong to an earlier week. Closing it
|
|
565
|
+
// later would count old work as a new landing, so a person decides.
|
|
566
|
+
const STALE_MERGE_MS = 24 * 60 * 60 * 1000;
|
|
564
567
|
|
|
565
568
|
// One queued branch: leave it, forget it, or catch it up and queue it again.
|
|
566
569
|
// Only the exact commit autoland queued (or its own catch-up) is re-queued; a
|
|
@@ -592,13 +595,13 @@ function inMergeQueue(number, cwd) {
|
|
|
592
595
|
return out.ok && out.stdout === 'true';
|
|
593
596
|
}
|
|
594
597
|
|
|
595
|
-
function requeueOne(entry, cwd, maxRequeues) {
|
|
598
|
+
function requeueOne(entry, cwd, maxRequeues, now = Date.now(), dryRun = false) {
|
|
596
599
|
const { branch, base, number } = entry;
|
|
597
600
|
const label = `pull request #${number} (${branch})`;
|
|
598
601
|
const keep = (state, line) => ({ branch, number, state, keep: true, line: `${label}: ${line}` });
|
|
599
602
|
const forget = (state, line) => ({ branch, number, state, keep: false, line: `${label}: ${line}` });
|
|
600
603
|
const viewed = run('gh', ['pr', 'view', String(number), '--json',
|
|
601
|
-
'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable'], cwd);
|
|
604
|
+
'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable,mergedAt'], cwd);
|
|
602
605
|
let pr = null;
|
|
603
606
|
try {
|
|
604
607
|
pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
|
|
@@ -610,6 +613,12 @@ function requeueOne(entry, cwd, maxRequeues) {
|
|
|
610
613
|
// that carried someone else's push leaves the job for a person.
|
|
611
614
|
if (pr.state === 'MERGED') {
|
|
612
615
|
if (pr.headRefOid !== entry.pushed) return forget('merged', 'merged with commits someone else pushed; its job is left for you');
|
|
616
|
+
// An old merge still closes its job, recorded as merged earlier so the
|
|
617
|
+
// weekly board does not count last week's work as this week's landing.
|
|
618
|
+
const mergedMs = Date.parse(pr.mergedAt || '');
|
|
619
|
+
if (Number.isFinite(mergedMs) && now - mergedMs > STALE_MERGE_MS) {
|
|
620
|
+
return { ...forget('merged', 'merged over a day ago; closed without counting it as a new landing'), own: true, stale: true };
|
|
621
|
+
}
|
|
613
622
|
return { ...forget('merged', 'merged'), own: true };
|
|
614
623
|
}
|
|
615
624
|
if (pr.state !== 'OPEN') return forget('closed', 'closed by someone; left alone');
|
|
@@ -625,6 +634,7 @@ function requeueOne(entry, cwd, maxRequeues) {
|
|
|
625
634
|
// failure fails twice and waits for a fix.
|
|
626
635
|
const failed = pr.headRefOid === entry.pushed ? failedCheckRuns(number, cwd) : [];
|
|
627
636
|
if (failed.length && entry.reran !== entry.pushed) {
|
|
637
|
+
if (dryRun) return keep('would-rerun', 'a check failed; would run the failed checks again once');
|
|
628
638
|
const reran = failed.filter((id) => run('gh', ['run', 'rerun', id, '--failed'], cwd).ok);
|
|
629
639
|
if (reran.length) return { ...keep('rerun', 'a check failed; ran the failed checks again once'), reran: entry.pushed };
|
|
630
640
|
return keep('queued', 'a check failed and could not be run again; will look again');
|
|
@@ -636,6 +646,12 @@ function requeueOne(entry, cwd, maxRequeues) {
|
|
|
636
646
|
if ((Number(entry.requeues) || 0) >= maxRequeues) {
|
|
637
647
|
return forget('gave-up', `dropped again after ${maxRequeues} catch-ups; left for you`);
|
|
638
648
|
}
|
|
649
|
+
// A dry run never fetches, pushes, or switches auto-merge back on. GitHub's
|
|
650
|
+
// own clash flag stands in for the catch-up it would try.
|
|
651
|
+
if (dryRun) {
|
|
652
|
+
if (pr.mergeable === 'CONFLICTING') return { ...forget('clash', 'clashes with the main branch; left for you'), why: 'it clashes with the main branch' };
|
|
653
|
+
return keep('would-requeue', 'would catch it up with the main branch and queue it again');
|
|
654
|
+
}
|
|
639
655
|
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], cwd);
|
|
640
656
|
const remote = run('git', ['rev-parse', `refs/remotes/origin/${branch}`], cwd);
|
|
641
657
|
if (!fetched.ok || !remote.ok || remote.stdout !== entry.pushed) {
|
|
@@ -664,7 +680,7 @@ function heldAtClose(row) {
|
|
|
664
680
|
return Boolean(require('./member-task-pick').heldForPerson(row));
|
|
665
681
|
}
|
|
666
682
|
|
|
667
|
-
function defaultCloseTask(id, { number }) {
|
|
683
|
+
function defaultCloseTask(id, { number, stale = false }) {
|
|
668
684
|
const taskDb = require('./task-db');
|
|
669
685
|
const db = taskDb.open();
|
|
670
686
|
// A hold put on the job while its change waited to merge still wins: that
|
|
@@ -673,7 +689,9 @@ function defaultCloseTask(id, { number }) {
|
|
|
673
689
|
if (heldAtClose(row)) return { updated: false };
|
|
674
690
|
const out = taskDb.doneTask(db, {
|
|
675
691
|
id, actor: 'autoland', allowReview: true, autoAccepted: true,
|
|
676
|
-
action: '
|
|
692
|
+
action: stale ? 'merged_earlier' : 'merged',
|
|
693
|
+
proof: stale ? `its own pull request #${number} merged over a day ago; closed without counting it as a new landing`
|
|
694
|
+
: `its own pull request #${number} merged`,
|
|
677
695
|
});
|
|
678
696
|
return { ...out, replaced: row.metadata && row.metadata.replaced_pull_requests };
|
|
679
697
|
}
|
|
@@ -681,14 +699,14 @@ function defaultCloseTask(id, { number }) {
|
|
|
681
699
|
// The member's own change merged: close every job it was queued for. A job
|
|
682
700
|
// already closed, or one that cannot be read, is left as it is. Each closed
|
|
683
701
|
// job hands back the clashing pull requests its redo replaced.
|
|
684
|
-
function closeMergedJobs(entry, closeTask) {
|
|
702
|
+
function closeMergedJobs(entry, closeTask, { stale = false } = {}) {
|
|
685
703
|
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
686
704
|
const closed = [];
|
|
687
705
|
const replaced = [];
|
|
688
706
|
for (const id of ids) {
|
|
689
707
|
let out = null;
|
|
690
708
|
try {
|
|
691
|
-
out = closeTask(id, { number: entry.number, branch: entry.branch });
|
|
709
|
+
out = closeTask(id, { number: entry.number, branch: entry.branch, stale });
|
|
692
710
|
} catch {
|
|
693
711
|
out = null;
|
|
694
712
|
}
|
|
@@ -746,6 +764,21 @@ function defaultSendBack(id, { number, pushed, why }) {
|
|
|
746
764
|
});
|
|
747
765
|
}
|
|
748
766
|
|
|
767
|
+
// The dry run's stand-ins: the same checks the real close and send-back make,
|
|
768
|
+
// read only.
|
|
769
|
+
function wouldClose(id) {
|
|
770
|
+
const taskDb = require('./task-db');
|
|
771
|
+
const row = taskDb.getTask(taskDb.open(), id);
|
|
772
|
+
return { updated: Boolean(row) && ['open', 'claimed', 'review'].includes(row.status) && !heldAtClose(row) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function wouldSendBack(id) {
|
|
776
|
+
const taskDb = require('./task-db');
|
|
777
|
+
const row = taskDb.getTask(taskDb.open(), id);
|
|
778
|
+
const restarts = Number(row && row.metadata && row.metadata.clash_restarts) || 0;
|
|
779
|
+
return { reopened: Boolean(row) && row.status === 'review' && !heldAtClose(row) && restarts < MAX_CLASH_RESTARTS };
|
|
780
|
+
}
|
|
781
|
+
|
|
749
782
|
function sendBackClashedJobs(entry, why, sendBack) {
|
|
750
783
|
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
751
784
|
const sent = [];
|
|
@@ -761,26 +794,45 @@ function sendBackClashedJobs(entry, why, sendBack) {
|
|
|
761
794
|
return sent;
|
|
762
795
|
}
|
|
763
796
|
|
|
797
|
+
// A mission can queue its change while a sweep is reading GitHub. Apply only
|
|
798
|
+
// the sweep's own changes to the list as it stands now: an entry someone
|
|
799
|
+
// added or rewrote meanwhile is kept as they left it.
|
|
800
|
+
function mergeQueueChanges(latest, snapshot, swept) {
|
|
801
|
+
const out = { ...latest };
|
|
802
|
+
for (const [key, before] of Object.entries(snapshot)) {
|
|
803
|
+
if (JSON.stringify(latest[key]) !== JSON.stringify(before)) continue;
|
|
804
|
+
if (key in swept) out[key] = swept[key];
|
|
805
|
+
else delete out[key];
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
|
|
764
810
|
// Look at every branch autoland queued and re-queue the ones GitHub dropped.
|
|
765
811
|
// 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
|
-
|
|
812
|
+
// clashes sends its jobs back on the list. dryRun reads GitHub and the job
|
|
813
|
+
// list, says what it would do, and changes nothing anywhere.
|
|
814
|
+
function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack, now = Date.now(), dryRun = false } = {}) {
|
|
768
815
|
const file = queueFile(cwd);
|
|
769
816
|
if (!file) return { checked: 0, results: [], line: 'not inside a git checkout' };
|
|
770
817
|
if (!policyOn(cwd)) return { checked: 0, results: [], line: 'self-landing is not switched on in the main checkout' };
|
|
818
|
+
if (dryRun) {
|
|
819
|
+
closeTask = wouldClose;
|
|
820
|
+
sendBack = wouldSendBack;
|
|
821
|
+
}
|
|
771
822
|
const entries = readQueue(file);
|
|
823
|
+
const snapshot = JSON.parse(JSON.stringify(entries));
|
|
772
824
|
const results = [];
|
|
773
825
|
for (const [key, entry] of Object.entries(entries)) {
|
|
774
826
|
const valid = entry && typeof entry.branch === 'string' && entry.branch === key && typeof entry.base === 'string'
|
|
775
827
|
&& Number.isInteger(entry.number) && entry.number > 0 && /^[0-9a-f]{40,64}$/.test(String(entry.pushed || ''));
|
|
776
|
-
const result = valid ? requeueOne(entry, cwd, maxRequeues)
|
|
828
|
+
const result = valid ? requeueOne(entry, cwd, maxRequeues, now, dryRun)
|
|
777
829
|
: { branch: key, state: 'unreadable', keep: false, line: `${key}: queue entry could not be read; dropped from the list` };
|
|
778
830
|
if (valid && result.own) {
|
|
779
|
-
const merged = closeMergedJobs(entry, closeTask);
|
|
831
|
+
const merged = closeMergedJobs(entry, closeTask, { stale: result.stale === true });
|
|
780
832
|
result.closed = merged.closed;
|
|
781
833
|
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);
|
|
834
|
+
if (n) result.line += `; ${dryRun ? 'would close' : 'closed'} ${n === 1 ? 'its job' : `its ${n} jobs`}`;
|
|
835
|
+
result.replaced = dryRun ? [] : closeReplacedPullRequests(merged.replaced, entry.number, cwd);
|
|
784
836
|
if (result.replaced.length) {
|
|
785
837
|
result.line += `; closed the clashing ${result.replaced.length === 1 ? 'change' : 'changes'} it replaced (${result.replaced.map((r) => `#${r}`).join(', ')})`;
|
|
786
838
|
}
|
|
@@ -788,7 +840,8 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
|
|
|
788
840
|
if (valid && result.state === 'clash') {
|
|
789
841
|
result.sent_back = sendBackClashedJobs(entry, result.why, sendBack);
|
|
790
842
|
const n = result.sent_back.length;
|
|
791
|
-
|
|
843
|
+
const went = dryRun ? 'would go' : 'went';
|
|
844
|
+
if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? `its job ${went}` : `its ${n} jobs ${went}`} back on the list`);
|
|
792
845
|
}
|
|
793
846
|
results.push(result);
|
|
794
847
|
if (!result.keep) delete entries[key];
|
|
@@ -798,7 +851,7 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
|
|
|
798
851
|
entries[key] = { ...entry, reran: result.reran, reran_at: new Date().toISOString() };
|
|
799
852
|
}
|
|
800
853
|
}
|
|
801
|
-
if (results.length) writeQueue(file, entries);
|
|
854
|
+
if (results.length && !dryRun) writeQueue(file, mergeQueueChanges(readQueue(file), snapshot, entries));
|
|
802
855
|
const requeued = results.filter((r) => r.state === 'requeued').length;
|
|
803
856
|
const rerun = results.filter((r) => r.state === 'rerun').length;
|
|
804
857
|
const closed = results.reduce((sum, r) => sum + (r.closed ? r.closed.length : 0), 0);
|
|
@@ -810,8 +863,46 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
|
|
|
810
863
|
return { checked: results.length, requeued, rerun, closed, results, line };
|
|
811
864
|
}
|
|
812
865
|
|
|
866
|
+
// The team loop is not the only thing running. On 2026-10-15 it swept for the
|
|
867
|
+
// last time at 07:03, two member changes merged at 07:05, and their jobs
|
|
868
|
+
// waited a week while mission runs kept going. A finished mission run looks at
|
|
869
|
+
// the merge line too, but only while the loop is down: a live loop does it
|
|
870
|
+
// along with its code refresh and two-hour warning, which a second caller
|
|
871
|
+
// would throw off. It takes the loop's lock and its own five-minute timer, so
|
|
872
|
+
// a loop starting mid-pass never overlaps it. Nothing queued, or self-landing
|
|
873
|
+
// off: GitHub is not called.
|
|
874
|
+
function teamLoopAlive(cwd) {
|
|
875
|
+
const root = mainCheckoutRoot(cwd);
|
|
876
|
+
if (!root) return false;
|
|
877
|
+
let pid = null;
|
|
878
|
+
try {
|
|
879
|
+
pid = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'state', 'member-up.json'), 'utf8')).pid;
|
|
880
|
+
} catch {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
884
|
+
try {
|
|
885
|
+
process.kill(pid, 0);
|
|
886
|
+
return true;
|
|
887
|
+
} catch (error) {
|
|
888
|
+
return error.code === 'EPERM';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function sweepAfterMissionRun({ cwd, now = Date.now(), requeue = requeueDroppedAutolands } = {}) {
|
|
893
|
+
if (!policyOn(cwd)) return null;
|
|
894
|
+
const file = queueFile(cwd);
|
|
895
|
+
if (!file || !Object.keys(readQueue(file)).length) return null;
|
|
896
|
+
if (teamLoopAlive(cwd)) return null;
|
|
897
|
+
return require('./member-up').withMaintenanceLock(
|
|
898
|
+
{ cwd, now, stateFile: 'mission-merge-pass.json' },
|
|
899
|
+
() => requeue({ cwd, now }),
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
813
903
|
module.exports = {
|
|
814
904
|
autolandMemberRun,
|
|
905
|
+
sweepAfterMissionRun,
|
|
815
906
|
landingDestination,
|
|
816
907
|
requeueDroppedAutolands,
|
|
817
908
|
codeStatus,
|
package/lib/member-progress.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// stderr and appended to <dir>/<member>.log so another shell can tail it.
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
-
const
|
|
7
|
+
const { atrisHome } = require('./state-home');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
|
|
10
10
|
const MAX_PROGRESS_BYTES = 256 * 1024;
|
|
@@ -135,7 +135,7 @@ function formatCodexEvent(event, cwd) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
function progressDir() {
|
|
138
|
-
return process.env.ATRIS_PROGRESS_DIR || path.join(
|
|
138
|
+
return process.env.ATRIS_PROGRESS_DIR || path.join(atrisHome(), 'progress');
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
function safeMember(member) {
|
package/lib/member-report.js
CHANGED
|
@@ -251,14 +251,24 @@ function sameTitle(a, b) {
|
|
|
251
251
|
function summarize(items) {
|
|
252
252
|
const groups = { merged: [], finished: [], turned_down: [], waiting: [] };
|
|
253
253
|
const sorted = [...items].sort((a, b) => b.at - a.at);
|
|
254
|
+
const indexed = sorted.every((item) => !item.title || typeof item.title === 'string');
|
|
255
|
+
const titles = new Set();
|
|
256
|
+
const links = new Set();
|
|
254
257
|
for (const item of sorted) {
|
|
255
258
|
if (!item.title) continue;
|
|
256
259
|
// Newest word on a piece of work wins, and merged beats finished. A pull
|
|
257
260
|
// request a diary already logged shares its link, so it counts once.
|
|
261
|
+
const title = indexed ? item.title.trim().toLowerCase() : null;
|
|
258
262
|
const seenBefore = (seen) => sameTitle(seen.title, item.title) || (item.link && seen.link === item.link);
|
|
259
|
-
if (
|
|
263
|
+
if (indexed
|
|
264
|
+
? titles.has(title) || (item.link && links.has(item.link))
|
|
265
|
+
: Object.values(groups).some((list) => list.some(seenBefore))) continue;
|
|
260
266
|
const { member, at, group, ...rest } = item;
|
|
261
267
|
groups[group].push(rest);
|
|
268
|
+
if (indexed) {
|
|
269
|
+
titles.add(title);
|
|
270
|
+
if (item.link) links.add(item.link);
|
|
271
|
+
}
|
|
262
272
|
}
|
|
263
273
|
return groups;
|
|
264
274
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// job is a normal open task: the North Star is the goal, and the mission's
|
|
6
6
|
// `## Check` section names the command that proves a run. A mission with no
|
|
7
7
|
// runnable check writes nothing, so the member stays idle as before. At most
|
|
8
|
-
// one self-written job is open per member, and at most MAX_PER_DAY are
|
|
8
|
+
// one self-written job is open, claimed, or in review per member, and at most MAX_PER_DAY are
|
|
9
9
|
// written per member per local day, so an idle team cannot spin forever.
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
@@ -75,6 +75,11 @@ function writeSelfNextJob(taskDb, db, { root, workspaceRoot, member, job, now =
|
|
|
75
75
|
if (mine.some((row) => row.status === 'open' || row.status === 'claimed')) {
|
|
76
76
|
return { skip: 'its last self-written job is still open' };
|
|
77
77
|
}
|
|
78
|
+
// A job waiting for review still holds the seat. Freeing it at review let
|
|
79
|
+
// atris-cli pile up 15 "next step toward" jobs in review at once.
|
|
80
|
+
if (mine.some((row) => row.status === 'review')) {
|
|
81
|
+
return { skip: 'its last self-written job is still waiting for review' };
|
|
82
|
+
}
|
|
78
83
|
const day = localDay(now);
|
|
79
84
|
const today = mine.filter((row) => String(row.source_key).startsWith(`${prefix}${day}:`)).length;
|
|
80
85
|
if (today >= MAX_PER_DAY) return { skip: `already wrote ${MAX_PER_DAY} jobs for itself today` };
|
package/lib/member-up.js
CHANGED
|
@@ -69,15 +69,17 @@ function refreshMemberCode(root) {
|
|
|
69
69
|
: { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
72
|
+
// One lock for every pass that reads GitHub's merge line, shared by all windows
|
|
73
|
+
// of a project, including linked work folders. Each caller keeps its own
|
|
74
|
+
// completed-pass file, so its timer survives release and a second window
|
|
75
|
+
// waits too. Busy lock or no git: nothing runs.
|
|
76
|
+
function withMaintenanceLock({ cwd, now = Date.now(), stateFile }, fn) {
|
|
75
77
|
const common = maintenanceGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
76
78
|
if (!common.ok) return { results: [] };
|
|
77
79
|
const dir = path.join(path.dirname(common.text), '.atris', 'state');
|
|
78
80
|
fs.mkdirSync(dir, { recursive: true });
|
|
79
81
|
const lock = path.join(dir, 'member-up-maintenance.lock');
|
|
80
|
-
const file = path.join(dir,
|
|
82
|
+
const file = path.join(dir, stateFile);
|
|
81
83
|
let fd;
|
|
82
84
|
try {
|
|
83
85
|
fd = fs.openSync(lock, 'wx');
|
|
@@ -110,6 +112,15 @@ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, wa
|
|
|
110
112
|
};
|
|
111
113
|
state.checked_at = now;
|
|
112
114
|
save();
|
|
115
|
+
return fn(state, save);
|
|
116
|
+
} finally {
|
|
117
|
+
fs.closeSync(fd);
|
|
118
|
+
fs.unlinkSync(lock);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, waitingTasks = () => [] }) {
|
|
123
|
+
return withMaintenanceLock({ cwd, now, stateFile: 'member-up-maintenance.json' }, (state, save) => {
|
|
113
124
|
const out = requeueDropped();
|
|
114
125
|
const results = [...(out?.results || [])];
|
|
115
126
|
const merged = results.some((r) => r.state === 'merged');
|
|
@@ -128,10 +139,7 @@ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, wa
|
|
|
128
139
|
state.refresh_failed = refresh?.state === 'refresh-failed';
|
|
129
140
|
save();
|
|
130
141
|
return { ...out, results };
|
|
131
|
-
}
|
|
132
|
-
fs.closeSync(fd);
|
|
133
|
-
fs.unlinkSync(lock);
|
|
134
|
-
}
|
|
142
|
+
});
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
function dayOf(ms) {
|
|
@@ -514,6 +522,7 @@ module.exports = {
|
|
|
514
522
|
idleWaitMs,
|
|
515
523
|
memberUpResult,
|
|
516
524
|
memberMaintenance,
|
|
525
|
+
withMaintenanceLock,
|
|
517
526
|
parseMemberUpArgs,
|
|
518
527
|
runMemberUp,
|
|
519
528
|
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Jobs whose change is already on the main branch close themselves, every
|
|
4
|
+
// hour, with no person and no team loop.
|
|
5
|
+
//
|
|
6
|
+
// On 2026-10-22 four jobs in this repo sat in review for a week after their
|
|
7
|
+
// change merged. Two were still on the autoland list (#1318, #1319); two had
|
|
8
|
+
// left it, so only the code itself could say they landed. This pass reads
|
|
9
|
+
// git, never GitHub, and closes a review job only on one of two exact facts:
|
|
10
|
+
//
|
|
11
|
+
// One. The commit the builder recorded for the job (work_commit, different
|
|
12
|
+
// from the commit it started on) is on the main branch, and it came in from
|
|
13
|
+
// a side branch. A commit on the main line itself proves nothing: a builder
|
|
14
|
+
// working in the main folder records whatever the main branch was at.
|
|
15
|
+
//
|
|
16
|
+
// Two. The job has no commit of its own, and every test file its check names
|
|
17
|
+
// was first added to the main branch after the job was written, did not exist
|
|
18
|
+
// where the job started, is still there, and no other review job names it.
|
|
19
|
+
//
|
|
20
|
+
// Either way, work a later commit reverted stays open, and so does a job
|
|
21
|
+
// whose row changed after the scan read it.
|
|
22
|
+
//
|
|
23
|
+
// A merge more than a day old still closes the job, but records it as
|
|
24
|
+
// merged earlier, so last week's work never counts as this week's landing.
|
|
25
|
+
|
|
26
|
+
const { spawnSync } = require('child_process');
|
|
27
|
+
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const SHA = /^[0-9a-f]{40,64}$/;
|
|
30
|
+
const TEST_FILE = /^(?!\/)(?!.*\.\.)[\w.@+-]+(?:\/[\w.@+-]+)+\.(?:c|m)?[jt]s$|^(?!\/)(?!.*\.\.)[\w.@+-]+(?:\/[\w.@+-]+)+\.py$/;
|
|
31
|
+
|
|
32
|
+
function git(args, cwd) {
|
|
33
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
|
|
34
|
+
return { ok: res.status === 0, status: res.status, out: String(res.stdout || '').trim() };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The branch merged work lands on: origin's default first, then the usual
|
|
38
|
+
// names, then a local branch for a project with no remote.
|
|
39
|
+
function mainBranchRef(root) {
|
|
40
|
+
const head = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], root);
|
|
41
|
+
const candidates = [head.ok ? head.out.replace(/^refs\/remotes\//, '') : '', 'origin/master', 'origin/main', 'master', 'main'];
|
|
42
|
+
for (const ref of candidates.filter(Boolean)) {
|
|
43
|
+
if (git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root).ok) return ref;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createdMs(value) {
|
|
49
|
+
if (typeof value === 'number') return value;
|
|
50
|
+
const asNumber = Number(value);
|
|
51
|
+
if (Number.isFinite(asNumber) && asNumber > 0) return asNumber;
|
|
52
|
+
const parsed = Date.parse(String(value || ''));
|
|
53
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function verifyTestFiles(verify) {
|
|
57
|
+
return [...new Set(String(verify || '').replace(/[`'"]/g, ' ').split(/\s+/).filter((t) => TEST_FILE.test(t)))];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One reader per pass: the main line is read once, and each answer is kept.
|
|
61
|
+
function createReader(root, base) {
|
|
62
|
+
const line = git(['log', '--first-parent', '--format=%H %ct', base], root).out
|
|
63
|
+
.split('\n').filter(Boolean).map((row) => {
|
|
64
|
+
const [sha, ct] = row.split(' ');
|
|
65
|
+
return { sha, at: Number(ct) * 1000 };
|
|
66
|
+
}).reverse();
|
|
67
|
+
const onLine = new Map(line.map((c, i) => [c.sha, i]));
|
|
68
|
+
const ancestor = (sha, of) => git(['merge-base', '--is-ancestor', sha, of], root).status === 0;
|
|
69
|
+
const commitExists = (sha) => SHA.test(sha) && git(['cat-file', '-e', `${sha}^{commit}`], root).ok;
|
|
70
|
+
|
|
71
|
+
// The main-line commit that brought `sha` in, and whether `sha` is itself
|
|
72
|
+
// on the main line. The main line is ordered, so a binary search holds.
|
|
73
|
+
function landing(sha) {
|
|
74
|
+
if (!commitExists(sha) || !line.length || !ancestor(sha, line[line.length - 1].sha)) return null;
|
|
75
|
+
if (onLine.has(sha)) return { sha, at: line[onLine.get(sha)].at, mainLine: true };
|
|
76
|
+
let lo = 0;
|
|
77
|
+
let hi = line.length - 1;
|
|
78
|
+
while (lo < hi) {
|
|
79
|
+
const mid = (lo + hi) >> 1;
|
|
80
|
+
if (ancestor(sha, line[mid].sha)) hi = mid;
|
|
81
|
+
else lo = mid + 1;
|
|
82
|
+
}
|
|
83
|
+
return { sha: line[lo].sha, at: line[lo].at, mainLine: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function firstAdded(file) {
|
|
87
|
+
const rows = git(['log', base, '--diff-filter=A', '--format=%H', '--', file], root).out.split('\n').filter(Boolean);
|
|
88
|
+
return rows.length ? rows[rows.length - 1] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const existsAt = (sha, file) => git(['cat-file', '-e', `${sha}:${file}`], root).ok;
|
|
92
|
+
const commitTime = (sha) => Number(git(['log', '-1', '--format=%ct', sha], root).out) * 1000;
|
|
93
|
+
// Taken back out: git's own revert message names the commit it reverts.
|
|
94
|
+
const reverted = (...shas) => shas.filter(Boolean).some((sha) => git(['log', base, '--format=%H', '-F',
|
|
95
|
+
`--grep=This reverts commit ${sha}`], root).out.length > 0);
|
|
96
|
+
const onBase = (file) => existsAt(base, file);
|
|
97
|
+
return { landing, firstAdded, existsAt, commitExists, commitTime, reverted, onBase };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Commit times are whole seconds, so compare at that grain.
|
|
101
|
+
const notBefore = (ms, created) => ms >= Math.floor(created / 1000) * 1000;
|
|
102
|
+
|
|
103
|
+
// Why this review job's change is on the main branch, or null. `shared`
|
|
104
|
+
// holds test files named by more than one review job: a new file two jobs
|
|
105
|
+
// both point at says nothing about which one landed it.
|
|
106
|
+
function mergedProof(task, reader, shared = new Set()) {
|
|
107
|
+
const metadata = (task && task.metadata) || {};
|
|
108
|
+
const work = String(metadata.work_commit || '').trim();
|
|
109
|
+
const start = String(metadata.start_commit || '').trim();
|
|
110
|
+
const created = createdMs(task.created_at);
|
|
111
|
+
if (SHA.test(work) && work !== start) {
|
|
112
|
+
// The job has code of its own: only that code can say it landed. A test
|
|
113
|
+
// file someone else merged does not close a redo that never merged.
|
|
114
|
+
const landed = reader.landing(work);
|
|
115
|
+
if (!landed || landed.mainLine) return null;
|
|
116
|
+
// A commit older than the job is not the job's work.
|
|
117
|
+
if (created && !notBefore(reader.commitTime(work), created)) return null;
|
|
118
|
+
if (reader.reverted(work, landed.sha)) return null;
|
|
119
|
+
return { how: 'work_commit', commit: work, landed_sha: landed.sha, landed_at: landed.at };
|
|
120
|
+
}
|
|
121
|
+
// A check run from another folder names paths relative to that folder.
|
|
122
|
+
if (/(^|[\s;&|(])cd\s/.test(String(metadata.verify || ''))) return null;
|
|
123
|
+
const files = verifyTestFiles(metadata.verify);
|
|
124
|
+
if (!files.length || !created || files.some((file) => shared.has(file))) return null;
|
|
125
|
+
let latest = null;
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const added = reader.firstAdded(file);
|
|
128
|
+
if (!added || !reader.onBase(file)) return null;
|
|
129
|
+
if (SHA.test(start) && reader.commitExists(start) && reader.existsAt(start, file)) return null;
|
|
130
|
+
const landed = reader.landing(added);
|
|
131
|
+
if (!landed) return null;
|
|
132
|
+
// Added before the job existed: the test belongs to earlier work.
|
|
133
|
+
if (!notBefore(reader.commitTime(added), created)) return null;
|
|
134
|
+
if (reader.reverted(added, landed.sha)) return null;
|
|
135
|
+
if (!latest || landed.at > latest.landed_at) latest = { commit: added, landed_sha: landed.sha, landed_at: landed.at, file };
|
|
136
|
+
}
|
|
137
|
+
return latest ? { how: 'verify_test', files, ...latest } : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function heldForPerson(row) {
|
|
141
|
+
if (!row || typeof row !== 'object') return true;
|
|
142
|
+
return Boolean(require('./member-task-pick').heldForPerson(row));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The row must still be the one the scan read: new proof since then (a newer
|
|
146
|
+
// updated_at) means newer work the old read knows nothing about.
|
|
147
|
+
function defaultClose(id, { action, proof, updatedAt }) {
|
|
148
|
+
const taskDb = require('./task-db');
|
|
149
|
+
const db = taskDb.open();
|
|
150
|
+
const row = taskDb.getTask(db, id);
|
|
151
|
+
if (!row || row.status !== 'review' || heldForPerson(row)) return { updated: false };
|
|
152
|
+
if (updatedAt != null && Number(row.updated_at) !== Number(updatedAt)) return { updated: false };
|
|
153
|
+
return taskDb.doneTask(db, { id, actor: 'autoland', allowReview: true, autoAccepted: true, action, proof });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function proofLine(found) {
|
|
157
|
+
const short = (sha) => String(sha || '').slice(0, 10);
|
|
158
|
+
const when = new Date(found.landed_at).toISOString().slice(0, 16).replace('T', ' ');
|
|
159
|
+
return found.how === 'work_commit'
|
|
160
|
+
? `its own commit ${short(found.commit)} is on the main branch, merged ${when} UTC (${short(found.landed_sha)})`
|
|
161
|
+
: `its check ${found.files.join(', ')} was added to the main branch after the job was written, merged ${when} UTC (${short(found.landed_sha)})`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Close every review job in `tasks` whose change is on the main branch.
|
|
165
|
+
// dryRun reports what would close and writes nothing.
|
|
166
|
+
function closeMergedReviewJobs({ root, tasks = [], now = Date.now(), dryRun = false, close = defaultClose } = {}) {
|
|
167
|
+
const base = mainBranchRef(root);
|
|
168
|
+
if (!base) return { base: null, checked: 0, matched: [], closed: [] };
|
|
169
|
+
const reader = createReader(root, base);
|
|
170
|
+
const candidates = tasks.filter((t) => t && t.status === 'review' && !heldForPerson(t)
|
|
171
|
+
&& String((t.review && t.review.approval_status) || (t.metadata && t.metadata.approval_status) || 'pending') === 'pending');
|
|
172
|
+
const named = new Map();
|
|
173
|
+
for (const task of tasks.filter((t) => t && t.status === 'review')) {
|
|
174
|
+
for (const file of verifyTestFiles(task.metadata && task.metadata.verify)) named.set(file, (named.get(file) || 0) + 1);
|
|
175
|
+
}
|
|
176
|
+
const shared = new Set([...named].filter(([, n]) => n > 1).map(([file]) => file));
|
|
177
|
+
const matched = [];
|
|
178
|
+
const closed = [];
|
|
179
|
+
for (const task of candidates) {
|
|
180
|
+
let found = null;
|
|
181
|
+
try {
|
|
182
|
+
found = mergedProof(task, reader, shared);
|
|
183
|
+
} catch {
|
|
184
|
+
found = null;
|
|
185
|
+
}
|
|
186
|
+
if (!found) continue;
|
|
187
|
+
const fresh = now - found.landed_at <= DAY_MS;
|
|
188
|
+
const action = fresh ? 'merged' : 'merged_earlier';
|
|
189
|
+
const proof = fresh ? `merged: ${proofLine(found)}` : `merged earlier, closed without counting it as a new landing: ${proofLine(found)}`;
|
|
190
|
+
const row = { id: task.id, ref: task.display_id || task.id, title: task.title, how: found.how, action, landed_at: new Date(found.landed_at).toISOString(), proof };
|
|
191
|
+
matched.push(row);
|
|
192
|
+
if (dryRun) continue;
|
|
193
|
+
let out = null;
|
|
194
|
+
try {
|
|
195
|
+
out = close(task.id, { action, proof, updatedAt: task.updated_at });
|
|
196
|
+
} catch {
|
|
197
|
+
out = null;
|
|
198
|
+
}
|
|
199
|
+
if (out && out.updated) closed.push(row);
|
|
200
|
+
}
|
|
201
|
+
return { base, checked: candidates.length, matched, closed };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
closeMergedReviewJobs,
|
|
206
|
+
};
|
|
@@ -235,11 +235,53 @@ function gitSubcommand(args) {
|
|
|
235
235
|
return '';
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// Tools that can publish, post, send, or merge. With a mission id, each one
|
|
239
|
+
// found on PATH gets a wrapper that checks the mission's your-yes lock first
|
|
240
|
+
// (lib/yes-lock.js); with no lock file the wrapper just runs the tool.
|
|
241
|
+
const YES_LOCK_TOOLS = ['npm', 'yarn', 'pnpm', 'npx', 'pnpx', 'bunx', 'gh', 'atris'];
|
|
242
|
+
|
|
243
|
+
// The lines every wrapper shares: ask the lock, refuse with its message, run
|
|
244
|
+
// the real tool, and close the steps a yes allowed once that run succeeds.
|
|
245
|
+
function yesLockLines(toolExpr, binExpr) {
|
|
246
|
+
return [
|
|
247
|
+
`const yesLock = require(${JSON.stringify(path.join(__dirname, 'yes-lock.js'))});`,
|
|
248
|
+
`const verdict = config.missionId ? yesLock.check({ stateDir: config.stateDir, missionId: config.missionId, seal: config.seal, tool: ${toolExpr}, args, gitBin: config.gitBin || null, cwd: process.cwd(), env: process.env }) : null;`,
|
|
249
|
+
`if (verdict && verdict.hold) { process.stderr.write(verdict.message + '\\n'); process.exit(yesLock.HELD_EXIT); }`,
|
|
250
|
+
`const runReal = () => { const child = spawnSync(${binExpr}, args, { cwd: process.cwd(), stdio: 'inherit' }); const ok = !child.error && child.status === 0; if (verdict && verdict.consume) yesLock.consume(config.stateDir, config.missionId, config.seal, verdict.consume, { ok, claims: verdict.claims }); if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); } process.exit(child.status == null ? 1 : child.status); };`,
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveTool(name, root, pathValue) {
|
|
255
|
+
const found = spawnSync('sh', ['-c', `command -v ${name}`], { cwd: root, encoding: 'utf8', timeout: 5000, env: { ...process.env, PATH: pathValue } });
|
|
256
|
+
const bin = String(found.stdout || '').trim();
|
|
257
|
+
return found.status === 0 && bin && path.isAbsolute(bin) ? bin : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function yesLockWrapper({ tool, realBin, missionId, seal, stateDir }) {
|
|
261
|
+
const config = { tool, realBin, missionId, seal, stateDir };
|
|
262
|
+
return [
|
|
263
|
+
'#!/usr/bin/env node',
|
|
264
|
+
"'use strict';",
|
|
265
|
+
`const { spawnSync } = require('child_process');`,
|
|
266
|
+
`const config = ${JSON.stringify(config)};`,
|
|
267
|
+
`const args = process.argv.slice(2);`,
|
|
268
|
+
...yesLockLines('config.tool', 'config.realBin'),
|
|
269
|
+
'runReal();',
|
|
270
|
+
'',
|
|
271
|
+
].join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
238
274
|
function prepareMissionGitGuard({
|
|
239
275
|
root = process.cwd(),
|
|
240
276
|
tags = [],
|
|
241
277
|
pathValue = process.env.PATH || '',
|
|
278
|
+
missionId = null,
|
|
279
|
+
// the sealed owner steps from the mission record, and where approvals live
|
|
280
|
+
yesLockSeal = [],
|
|
281
|
+
stateDir = null,
|
|
242
282
|
} = {}) {
|
|
283
|
+
const seal = Array.isArray(yesLockSeal) ? yesLockSeal : [];
|
|
284
|
+
const locked = Boolean(missionId && seal.length && stateDir);
|
|
243
285
|
const resolved = spawnSync('sh', ['-c', 'command -v git'], {
|
|
244
286
|
cwd: root,
|
|
245
287
|
encoding: 'utf8',
|
|
@@ -253,7 +295,7 @@ function prepareMissionGitGuard({
|
|
|
253
295
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-mission-git-'));
|
|
254
296
|
const wrapper = path.join(dir, 'git');
|
|
255
297
|
const modulePath = __filename;
|
|
256
|
-
const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin };
|
|
298
|
+
const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin, missionId: locked ? missionId : null, seal, stateDir };
|
|
257
299
|
const source = [
|
|
258
300
|
'#!/usr/bin/env node',
|
|
259
301
|
"'use strict';",
|
|
@@ -263,6 +305,7 @@ function prepareMissionGitGuard({
|
|
|
263
305
|
gitSubcommand.toString(),
|
|
264
306
|
defaultGit.toString(),
|
|
265
307
|
`const args = process.argv.slice(2);`,
|
|
308
|
+
...yesLockLines("'git'", 'config.gitBin'),
|
|
266
309
|
`if (gitSubcommand(args) === 'commit') {`,
|
|
267
310
|
` const result = guard.inspectMissionProtectedDiff({ root: config.root, tags: config.tags, includeUnstaged: false, git: defaultGit(config.gitBin) });`,
|
|
268
311
|
` if (!result.allowed) {`,
|
|
@@ -270,12 +313,17 @@ function prepareMissionGitGuard({
|
|
|
270
313
|
` process.exit(78);`,
|
|
271
314
|
` }`,
|
|
272
315
|
`}`,
|
|
273
|
-
|
|
274
|
-
`if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); }`,
|
|
275
|
-
`process.exit(child.status == null ? 1 : child.status);`,
|
|
316
|
+
'runReal();',
|
|
276
317
|
'',
|
|
277
318
|
].join('\n');
|
|
278
319
|
fs.writeFileSync(wrapper, source, { encoding: 'utf8', mode: 0o755 });
|
|
320
|
+
if (locked) {
|
|
321
|
+
for (const tool of YES_LOCK_TOOLS) {
|
|
322
|
+
const realBin = resolveTool(tool, root, pathValue);
|
|
323
|
+
if (!realBin) continue;
|
|
324
|
+
fs.writeFileSync(path.join(dir, tool), yesLockWrapper({ tool, realBin, missionId, seal, stateDir }), { encoding: 'utf8', mode: 0o755 });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
279
327
|
|
|
280
328
|
return {
|
|
281
329
|
env: { ...process.env, PATH: `${dir}${path.delimiter}${pathValue}` },
|