atris 3.74.27 → 3.74.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/rating.js +7 -2
- package/commands/task.js +105 -15
- package/commands/worktree.js +510 -18
- package/lib/auto-accept-certified.js +56 -0
- package/lib/campaign-keep.js +5 -1
- package/lib/fleet.js +329 -48
- package/lib/gm-season.js +44 -8
- package/lib/release-kind.js +27 -0
- package/lib/review-integrity.js +7 -0
- package/lib/roster-runs.js +11 -5
- package/lib/roster-suggest.js +3 -2
- package/lib/roster-swap.js +3 -1
- package/lib/router-brain.js +7 -6
- package/lib/task-db.js +88 -2
- package/lib/voice-gate.js +1 -0
- package/lib/worktree-space.js +357 -0
- package/package.json +1 -1
package/commands/worktree.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
|
-
const { spawnSync } = require('child_process');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
6
7
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
7
8
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
9
|
+
const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
|
|
8
10
|
const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
|
|
9
11
|
const { fixMapDocs } = require('../lib/map-refs');
|
|
10
12
|
const { sleepSync } = require('../lib/sleep-sync');
|
|
13
|
+
const space = require('../lib/worktree-space');
|
|
11
14
|
const close = require('./close');
|
|
12
15
|
|
|
16
|
+
const ATRIS_BIN = path.resolve(__dirname, '..', 'bin', 'atris.js');
|
|
17
|
+
|
|
13
18
|
// Files atris regenerates on its own. Unstaged churn in them is restored
|
|
14
19
|
// before ship commits, unless the commit message names the file. The task
|
|
15
20
|
// projection is rewritten by any task command run in a work folder, and two
|
|
@@ -571,7 +576,12 @@ function printStatus() {
|
|
|
571
576
|
// 2026-07-04 (ship merged into the launcher's branch, never reached master).
|
|
572
577
|
// --base/--target still overrides both when the caller wants a different
|
|
573
578
|
// checkout point and ship target on purpose.
|
|
574
|
-
|
|
579
|
+
//
|
|
580
|
+
// spaceGuard and deps default to on outside temp folders: a low disk reaps
|
|
581
|
+
// finished copies across the workspace and then refuses (ATRIS_LOW_DISK),
|
|
582
|
+
// and a matching install is cloned in as node_modules. deps: false skips
|
|
583
|
+
// the clone; an object passes options through to seedNodeModules.
|
|
584
|
+
function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
|
|
575
585
|
const owner = member || agent;
|
|
576
586
|
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
577
587
|
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
@@ -605,6 +615,10 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
605
615
|
const checkoutBase = normalizeTargetRef(root, mainline);
|
|
606
616
|
const shipBase = checkoutBase;
|
|
607
617
|
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
618
|
+
// A full disk stops every flight at once. Reap first; refuse before cutting.
|
|
619
|
+
const room = (spaceGuard ?? !space.isScratchPath(target))
|
|
620
|
+
? ensureRoomForCopy({ root, target, freeSpace, env })
|
|
621
|
+
: null;
|
|
608
622
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
609
623
|
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
610
624
|
// A retry of the same job starts from the last run's unlanded commits; it
|
|
@@ -689,7 +703,64 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
689
703
|
discardFailedStart();
|
|
690
704
|
throw error;
|
|
691
705
|
}
|
|
692
|
-
|
|
706
|
+
const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
|
|
707
|
+
const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
|
|
708
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// node_modules as a clone of a matching install (see lib/worktree-space.js),
|
|
712
|
+
// so a web copy costs 12 MB and 2 seconds instead of npm ci.
|
|
713
|
+
function seedCopyDeps({ root, target, options = {} }) {
|
|
714
|
+
try {
|
|
715
|
+
const primaryRoot = findPrimaryRoot(root);
|
|
716
|
+
return space.seedNodeModules({
|
|
717
|
+
target,
|
|
718
|
+
workspace: path.dirname(primaryRoot),
|
|
719
|
+
repoName: path.basename(primaryRoot),
|
|
720
|
+
sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
|
|
721
|
+
isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
|
|
722
|
+
...options,
|
|
723
|
+
});
|
|
724
|
+
} catch (error) {
|
|
725
|
+
return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// When the disk runs low, reaps finished copies of every repo in the
|
|
730
|
+
// workspace (merged, clean, nothing running inside, past the fresh-copy
|
|
731
|
+
// grace). Below the floor it refuses with ATRIS_LOW_DISK, before any copy
|
|
732
|
+
// is cut. Limits: ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB.
|
|
733
|
+
// Even when the disk is low, a copy touched in the last ten minutes stays:
|
|
734
|
+
// the engine that just finished may still have its last message unread.
|
|
735
|
+
const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
|
|
736
|
+
|
|
737
|
+
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start' }) {
|
|
738
|
+
const limits = space.spaceLimits(env);
|
|
739
|
+
const before = freeSpace(target);
|
|
740
|
+
const room = { before, free: before, removed: [], cleared: [], ...limits };
|
|
741
|
+
if (typeof before !== 'number' || !Number.isFinite(before)) return room;
|
|
742
|
+
if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
|
|
743
|
+
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by });
|
|
744
|
+
for (const repo of swept.repos) {
|
|
745
|
+
room.removed.push(...(repo.removed || []));
|
|
746
|
+
room.cleared.push(...(repo.cachePruned || []));
|
|
747
|
+
}
|
|
748
|
+
const after = freeSpace(target);
|
|
749
|
+
room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
|
|
750
|
+
if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
|
|
751
|
+
const count = room.removed.length;
|
|
752
|
+
const reaped = count
|
|
753
|
+
? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
|
|
754
|
+
: 'No finished build copies were safe to remove.';
|
|
755
|
+
const error = new Error(
|
|
756
|
+
`only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
|
|
757
|
+
+ 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
|
|
758
|
+
);
|
|
759
|
+
error.code = 'ATRIS_LOW_DISK';
|
|
760
|
+
error.space = room;
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
return room;
|
|
693
764
|
}
|
|
694
765
|
|
|
695
766
|
function startWorktree(args) {
|
|
@@ -699,7 +770,7 @@ function startWorktree(args) {
|
|
|
699
770
|
const owner = member || agent;
|
|
700
771
|
const task = readFlag(args, '--task');
|
|
701
772
|
if (!owner || !task) {
|
|
702
|
-
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force]');
|
|
773
|
+
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force] [--no-deps]');
|
|
703
774
|
return 2;
|
|
704
775
|
}
|
|
705
776
|
const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
|
|
@@ -736,6 +807,7 @@ function startWorktree(args) {
|
|
|
736
807
|
branch: readFlag(args, '--branch'),
|
|
737
808
|
path: readFlag(args, '--path'),
|
|
738
809
|
base: readFlag(args, '--base') || readFlag(args, '--target'),
|
|
810
|
+
...(hasFlag(args, '--no-deps') ? { deps: false } : {}),
|
|
739
811
|
});
|
|
740
812
|
} catch (e) {
|
|
741
813
|
console.error(`refusing: ${e.message}`);
|
|
@@ -749,6 +821,17 @@ function startWorktree(args) {
|
|
|
749
821
|
if (created.cachePrunedBeforeStart.length) {
|
|
750
822
|
console.log(`cleanup: pruned ${created.cachePrunedBeforeStart.length} generated ${created.cachePrunedBeforeStart.length === 1 ? 'cache' : 'caches'}`);
|
|
751
823
|
}
|
|
824
|
+
if (created.space && created.space.removed.length) {
|
|
825
|
+
const count = created.space.removed.length;
|
|
826
|
+
console.log(`space: disk was low, removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} across the workspace; ${space.formatGb(created.space.free)} free now`);
|
|
827
|
+
} else if (created.space && created.space.free !== null && created.space.free < created.space.reapBelow) {
|
|
828
|
+
console.log(`space: ${space.formatGb(created.space.free)} free; no finished build copies were safe to remove`);
|
|
829
|
+
}
|
|
830
|
+
if (created.deps && created.deps.status === 'cloned') {
|
|
831
|
+
console.log(`deps: cloned node_modules from a matching install in ${(created.deps.ms / 1000).toFixed(1)}s; skip npm ci`);
|
|
832
|
+
} else if (created.deps && created.deps.reason !== 'no package-lock.json') {
|
|
833
|
+
console.log(`deps: ${created.deps.reason}`);
|
|
834
|
+
}
|
|
752
835
|
|
|
753
836
|
const counts = statusCounts(root);
|
|
754
837
|
if (counts && (counts.staged || counts.unstaged || counts.untracked)) {
|
|
@@ -824,6 +907,8 @@ function shipHelp() {
|
|
|
824
907
|
console.log('');
|
|
825
908
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
826
909
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
910
|
+
console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
|
|
911
|
+
console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
|
|
827
912
|
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
828
913
|
console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
|
|
829
914
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
@@ -831,7 +916,7 @@ function shipHelp() {
|
|
|
831
916
|
|
|
832
917
|
function deniedLaneForShip(root, branch, message) {
|
|
833
918
|
try {
|
|
834
|
-
const {
|
|
919
|
+
const { protectedLaneOfTask } = require('../lib/fleet');
|
|
835
920
|
const refs = new Set();
|
|
836
921
|
for (const source of [String(branch || ''), String(message || '')]) {
|
|
837
922
|
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
@@ -844,8 +929,7 @@ function deniedLaneForShip(root, branch, message) {
|
|
|
844
929
|
for (const task of projection.tasks || []) {
|
|
845
930
|
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
846
931
|
if (!ids.some(id => refs.has(id))) continue;
|
|
847
|
-
const
|
|
848
|
-
const hit = tags.find(t => DENIED_TAGS.includes(t));
|
|
932
|
+
const hit = protectedLaneOfTask(task);
|
|
849
933
|
if (hit) return { taskId: ids[0], tag: hit };
|
|
850
934
|
}
|
|
851
935
|
return null;
|
|
@@ -854,6 +938,55 @@ function deniedLaneForShip(root, branch, message) {
|
|
|
854
938
|
}
|
|
855
939
|
}
|
|
856
940
|
|
|
941
|
+
// Pushing a protected-lane branch for review lands nothing, unless the branch
|
|
942
|
+
// already has a pull request with GitHub auto-merge on: that request merges
|
|
943
|
+
// itself the moment its checks pass, and the push is what feeds it. A branch
|
|
944
|
+
// that shipped earlier with --merge and was retagged into a protected lane since
|
|
945
|
+
// would land with no person. One branch can have several open pull requests (one
|
|
946
|
+
// per base), and a single push feeds every one of them, so this asks for the whole
|
|
947
|
+
// list: `gh pr view` shows just one, and it may be the one without auto-merge.
|
|
948
|
+
// Returns the words to refuse with, or '' when the push is safe. It fails closed:
|
|
949
|
+
// if gh cannot say, or the list may be incomplete, nothing is pushed.
|
|
950
|
+
const OPEN_PR_LIST_LIMIT = 100;
|
|
951
|
+
|
|
952
|
+
function protectedPushRefusal(root, branch, run = spawnSync) {
|
|
953
|
+
const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
|
|
954
|
+
// A minute is plenty for one lookup; a gh that hangs must not hang the ship.
|
|
955
|
+
const listed = run('gh', [
|
|
956
|
+
'pr', 'list', '--head', branch, '--state', 'open',
|
|
957
|
+
'--json', 'number,url,baseRefName,autoMergeRequest',
|
|
958
|
+
'--limit', String(OPEN_PR_LIST_LIMIT),
|
|
959
|
+
], { cwd: root, encoding: 'utf8', timeout: 60000 });
|
|
960
|
+
if (listed.error) {
|
|
961
|
+
return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
|
|
962
|
+
}
|
|
963
|
+
if (listed.status !== 0) {
|
|
964
|
+
// No branch has an empty answer to read as "none": an empty list is an
|
|
965
|
+
// exit 0, so a failed lookup is never taken for a clear.
|
|
966
|
+
const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
|
|
967
|
+
const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
968
|
+
return unknown(firstLine || `gh exited ${listed.status}`);
|
|
969
|
+
}
|
|
970
|
+
let open;
|
|
971
|
+
try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
|
|
972
|
+
// Every entry must say whether auto-merge is on (null means off), or the
|
|
973
|
+
// answer is not one this check can lean on.
|
|
974
|
+
const readable = Array.isArray(open)
|
|
975
|
+
&& open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
|
|
976
|
+
if (!readable) return unknown("gh's answer could not be read");
|
|
977
|
+
// gh stops at its limit without saying so: a full page may be only part of the list.
|
|
978
|
+
if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
|
|
979
|
+
const armed = open.filter((pr) => pr.autoMergeRequest);
|
|
980
|
+
if (!armed.length) return '';
|
|
981
|
+
const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
|
|
982
|
+
const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
|
|
983
|
+
if (armed.length === 1) {
|
|
984
|
+
return `blocked: pull request ${name(armed[0])} has auto-merge on, so pushing ${branch} would land it with no person; turn auto-merge off first (${off(armed[0])}), then ship again`;
|
|
985
|
+
}
|
|
986
|
+
const names = armed.map(name);
|
|
987
|
+
return `blocked: pull requests ${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} have auto-merge on, so pushing ${branch} would land them with no person; turn auto-merge off on each first (${armed.map(off).join(', ')}), then ship again`;
|
|
988
|
+
}
|
|
989
|
+
|
|
857
990
|
function findPrimaryRoot(root) {
|
|
858
991
|
const worktrees = listWorktrees(root);
|
|
859
992
|
return worktrees[0]?.path || root;
|
|
@@ -974,13 +1107,21 @@ function shipWorktree(args) {
|
|
|
974
1107
|
// Denied lanes are checked at dispatch, but a flight that was staffed
|
|
975
1108
|
// before a task entered a denied lane (or killed mid-flight) could still
|
|
976
1109
|
// ship: a payments command self-landed twice this way on 2026-07-26. The
|
|
977
|
-
// ship gate is the last door, so it re-checks.
|
|
1110
|
+
// ship gate is the last door, so it re-checks. It guards landing only:
|
|
1111
|
+
// pushing the branch and opening a pull request lands nothing, and that is
|
|
1112
|
+
// how protected work reaches a person to review. The one exception is a
|
|
1113
|
+
// pull request that already has auto-merge on, which a push would feed.
|
|
978
1114
|
if (!hasFlag(args, '--allow-denied-lane')) {
|
|
979
1115
|
const denied = deniedLaneForShip(root, branch, message);
|
|
980
|
-
if (denied) {
|
|
1116
|
+
if (denied && (merge || localMode)) {
|
|
981
1117
|
console.error(`blocked: this branch carries ${denied.taskId} which sits in the protected ${denied.tag} lane; a human lands it (or pass --allow-denied-lane on written order)`);
|
|
982
1118
|
return 2;
|
|
983
1119
|
}
|
|
1120
|
+
const refusal = denied ? protectedPushRefusal(root, branch) : '';
|
|
1121
|
+
if (refusal) {
|
|
1122
|
+
console.error(refusal);
|
|
1123
|
+
return 2;
|
|
1124
|
+
}
|
|
984
1125
|
}
|
|
985
1126
|
|
|
986
1127
|
let oneLapProof = null;
|
|
@@ -1072,6 +1213,8 @@ function shipWorktree(args) {
|
|
|
1072
1213
|
}
|
|
1073
1214
|
console.log(`merge_check: ${targetRef} clean`);
|
|
1074
1215
|
|
|
1216
|
+
// Set once the change is confirmed merged; the build copy then goes away.
|
|
1217
|
+
let landedOn = '';
|
|
1075
1218
|
if (localMode) {
|
|
1076
1219
|
console.log('push: skipped (local mode)');
|
|
1077
1220
|
if (merge) {
|
|
@@ -1094,10 +1237,12 @@ function shipWorktree(args) {
|
|
|
1094
1237
|
}
|
|
1095
1238
|
runGit(['merge', '--no-ff', branch, '-m', `Merge ${branch}`], { cwd: primary });
|
|
1096
1239
|
console.log('merge: merged (local mode)');
|
|
1240
|
+
landedOn = `refs/heads/${targetBranch}`;
|
|
1097
1241
|
}
|
|
1098
1242
|
}
|
|
1099
1243
|
console.log('pr: skipped (local mode)');
|
|
1100
1244
|
if (merge && !dryRun) runShipHealthCheck(primary || root);
|
|
1245
|
+
if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
|
|
1101
1246
|
console.log('done: worktree shipped');
|
|
1102
1247
|
return 0;
|
|
1103
1248
|
}
|
|
@@ -1145,6 +1290,7 @@ function shipWorktree(args) {
|
|
|
1145
1290
|
// and commit landed. The rating only counts a landing it can point at.
|
|
1146
1291
|
const record = shipMergeRecord(state);
|
|
1147
1292
|
if (record) console.log(`merge: record ${JSON.stringify(record)}`);
|
|
1293
|
+
landedOn = targetRef;
|
|
1148
1294
|
const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
|
|
1149
1295
|
if (deleted.status === 0) {
|
|
1150
1296
|
console.log(`merge: remote branch deleted ${branch}`);
|
|
@@ -1165,10 +1311,106 @@ function shipWorktree(args) {
|
|
|
1165
1311
|
}, { worktree: root });
|
|
1166
1312
|
} catch {}
|
|
1167
1313
|
if (merge && !dryRun) runShipHealthCheck(primary || root);
|
|
1314
|
+
if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
|
|
1168
1315
|
console.log('done: worktree shipped');
|
|
1169
1316
|
return 0;
|
|
1170
1317
|
}
|
|
1171
1318
|
|
|
1319
|
+
// A shipped build copy is dead weight, but the engine that shipped it is
|
|
1320
|
+
// still inside and writes its last message there on exit. A detached
|
|
1321
|
+
// `atris worktree reap-shipped` waits until nothing has run in the copy for
|
|
1322
|
+
// SHIP_SETTLE_MS, then removes it if it is still merged and clean.
|
|
1323
|
+
const SHIP_SETTLE_MS = 10 * 60 * 1000;
|
|
1324
|
+
const SHIP_REAP_POLL_MS = 60 * 1000;
|
|
1325
|
+
const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
1326
|
+
// Right after a merge the local copy of the target can lag; allow a short
|
|
1327
|
+
// wait before treating the copy as unmerged and leaving it for the sweep.
|
|
1328
|
+
const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
|
|
1329
|
+
// Reasons that can clear on their own: someone is still inside, or the
|
|
1330
|
+
// main branch moved or could not be reached during the check.
|
|
1331
|
+
const SHIP_REAP_RETRY_REASONS = new Set([
|
|
1332
|
+
'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
|
|
1333
|
+
'ignored_scan_failed', 'changed_or_active_during_verification',
|
|
1334
|
+
]);
|
|
1335
|
+
|
|
1336
|
+
function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
|
|
1337
|
+
const copy = path.resolve(root);
|
|
1338
|
+
if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
|
|
1339
|
+
if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
|
|
1340
|
+
if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
|
|
1341
|
+
if (space.isScratchPath(copy)) return { skip: 'temp folder' };
|
|
1342
|
+
// The removal runs this same atris; one that lives in the copy would delete itself.
|
|
1343
|
+
if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
|
|
1344
|
+
return { copy };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
|
|
1348
|
+
const plan = shippedCopyRemovalPlan({ root, primary, env });
|
|
1349
|
+
if (plan.skip) return plan;
|
|
1350
|
+
try {
|
|
1351
|
+
// The copy rides in the environment, not the arguments, so the process
|
|
1352
|
+
// scan does not count this waiter as someone working in the copy.
|
|
1353
|
+
const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
|
|
1354
|
+
cwd: primary,
|
|
1355
|
+
detached: true,
|
|
1356
|
+
stdio: 'ignore',
|
|
1357
|
+
env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
|
|
1358
|
+
});
|
|
1359
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
1360
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
1361
|
+
return { ...plan, scheduled: true };
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function announceShippedCopyRemoval(plan) {
|
|
1368
|
+
if (plan.scheduled) {
|
|
1369
|
+
console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
|
|
1370
|
+
} else if (plan.announce) {
|
|
1371
|
+
console.log(`cleanup: kept this build copy (${plan.skip})`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function reapShippedCopy({
|
|
1376
|
+
root,
|
|
1377
|
+
target,
|
|
1378
|
+
base = '',
|
|
1379
|
+
settleMs = SHIP_SETTLE_MS,
|
|
1380
|
+
pollMs = SHIP_REAP_POLL_MS,
|
|
1381
|
+
giveUpMs = SHIP_REAP_GIVE_UP_MS,
|
|
1382
|
+
unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
|
|
1383
|
+
sleep = sleepSync,
|
|
1384
|
+
clock = Date.now,
|
|
1385
|
+
cleanup: cleanupFn = cleanupWorktrees,
|
|
1386
|
+
} = {}) {
|
|
1387
|
+
const startedAt = clock();
|
|
1388
|
+
sleep(settleMs);
|
|
1389
|
+
for (;;) {
|
|
1390
|
+
const result = cleanupFn({
|
|
1391
|
+
root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
|
|
1392
|
+
});
|
|
1393
|
+
if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
|
|
1394
|
+
const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
|
|
1395
|
+
if (!entry) return { removed: false, reason: 'not_registered' };
|
|
1396
|
+
const waited = clock() - startedAt;
|
|
1397
|
+
const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
|
|
1398
|
+
if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
|
|
1399
|
+
sleep(pollMs);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function reapShipped() {
|
|
1404
|
+
const target = process.env.ATRIS_REAP_WORKTREE || '';
|
|
1405
|
+
if (!target) {
|
|
1406
|
+
console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
|
|
1407
|
+
return 2;
|
|
1408
|
+
}
|
|
1409
|
+
const result = reapShippedCopy({ root: process.cwd(), target, base: process.env.ATRIS_REAP_BASE || '' });
|
|
1410
|
+
console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
|
|
1411
|
+
return result.removed ? 0 : 1;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1172
1414
|
// The landed pull request from `gh pr view --json`, or null without a merge commit.
|
|
1173
1415
|
function shipMergeRecord(state) {
|
|
1174
1416
|
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
@@ -1224,14 +1466,35 @@ const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
|
1224
1466
|
'node_modules/.cache',
|
|
1225
1467
|
];
|
|
1226
1468
|
|
|
1227
|
-
function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
1469
|
+
function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
|
|
1228
1470
|
try {
|
|
1229
|
-
return now - fs.statSync(worktreePath).mtimeMs <
|
|
1471
|
+
return now - fs.statSync(worktreePath).mtimeMs < graceMs;
|
|
1230
1472
|
} catch {
|
|
1231
1473
|
return false;
|
|
1232
1474
|
}
|
|
1233
1475
|
}
|
|
1234
1476
|
|
|
1477
|
+
// Newest sign of work in a copy: its folder, its branch tip and reflog, web
|
|
1478
|
+
// build output, and the engine's last message. The index is left out on
|
|
1479
|
+
// purpose: a plain `git status` rewrites it.
|
|
1480
|
+
function lastCopyActivityMs(worktreePath) {
|
|
1481
|
+
const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
|
|
1482
|
+
try {
|
|
1483
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
|
|
1484
|
+
if (pointer) {
|
|
1485
|
+
const gitDir = path.resolve(worktreePath, pointer[1].trim());
|
|
1486
|
+
marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
|
|
1487
|
+
}
|
|
1488
|
+
} catch {}
|
|
1489
|
+
let newest = 0;
|
|
1490
|
+
for (const mark of marks) {
|
|
1491
|
+
try {
|
|
1492
|
+
newest = Math.max(newest, fs.statSync(mark).mtimeMs);
|
|
1493
|
+
} catch {}
|
|
1494
|
+
}
|
|
1495
|
+
return newest;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1235
1498
|
function canonicalPath(value) {
|
|
1236
1499
|
try {
|
|
1237
1500
|
return fs.realpathSync(value);
|
|
@@ -1415,7 +1678,23 @@ function originBaseIsCurrent(root, base) {
|
|
|
1415
1678
|
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1416
1679
|
}
|
|
1417
1680
|
|
|
1418
|
-
|
|
1681
|
+
// Scope and pace options, all off by default:
|
|
1682
|
+
// onlyUnder / onlyPaths touch only copies inside that folder / those paths
|
|
1683
|
+
// idleMs keep merged copies with work newer than this, and
|
|
1684
|
+
// leave their caches alone
|
|
1685
|
+
// freshGraceMs how long a merged copy is kept after its start
|
|
1686
|
+
function cleanupWorktrees({
|
|
1687
|
+
root = repoRoot(),
|
|
1688
|
+
base: baseOverride = '',
|
|
1689
|
+
apply = false,
|
|
1690
|
+
activeCwds: activeCwdsOverride,
|
|
1691
|
+
by = 'atris worktree cleanup',
|
|
1692
|
+
onlyUnder = '',
|
|
1693
|
+
onlyPaths = null,
|
|
1694
|
+
idleMs = 0,
|
|
1695
|
+
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1696
|
+
now = Date.now(),
|
|
1697
|
+
} = {}) {
|
|
1419
1698
|
const worktrees = listWorktrees(root);
|
|
1420
1699
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1421
1700
|
const current = path.resolve(root);
|
|
@@ -1428,8 +1707,13 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1428
1707
|
const cacheCandidates = [];
|
|
1429
1708
|
const cachePruned = [];
|
|
1430
1709
|
const cacheBudget = { count: 0, kib: 0 };
|
|
1710
|
+
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1711
|
+
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
1712
|
+
&& (!onlyUnder || space.isInside(wtPath, onlyUnder));
|
|
1713
|
+
const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
|
|
1431
1714
|
const pruneCachesForKept = (wtPath) => {
|
|
1432
|
-
if (worktreeWithinReapGrace(wtPath)) return;
|
|
1715
|
+
if (worktreeWithinReapGrace(wtPath, now)) return;
|
|
1716
|
+
if (recentlyUsed(wtPath)) return;
|
|
1433
1717
|
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1434
1718
|
cacheCandidates.push(...caches.candidates);
|
|
1435
1719
|
cachePruned.push(...caches.pruned);
|
|
@@ -1446,6 +1730,10 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1446
1730
|
kept.push({ ...item, reason: 'current_checkout' });
|
|
1447
1731
|
continue;
|
|
1448
1732
|
}
|
|
1733
|
+
if (!inScope(wtPath)) {
|
|
1734
|
+
kept.push({ ...item, reason: 'out_of_scope' });
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1449
1737
|
if (wt.locked) {
|
|
1450
1738
|
kept.push({ ...item, reason: 'locked' });
|
|
1451
1739
|
continue;
|
|
@@ -1495,10 +1783,14 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1495
1783
|
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1496
1784
|
continue;
|
|
1497
1785
|
}
|
|
1498
|
-
if (worktreeWithinReapGrace(wtPath)) {
|
|
1786
|
+
if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
|
|
1499
1787
|
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1500
1788
|
continue;
|
|
1501
1789
|
}
|
|
1790
|
+
if (recentlyUsed(wtPath)) {
|
|
1791
|
+
kept.push({ ...item, reason: 'recent_activity' });
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1502
1794
|
if (!originBaseIsCurrent(root, base)) {
|
|
1503
1795
|
kept.push({ ...item, reason: 'base_unverified' });
|
|
1504
1796
|
continue;
|
|
@@ -1551,6 +1843,190 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1551
1843
|
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
|
|
1552
1844
|
}
|
|
1553
1845
|
|
|
1846
|
+
// The sweep: cleanup for every repo with build copies in the workspace.
|
|
1847
|
+
// Removes copies that pass every cleanup check (merged into origin's main
|
|
1848
|
+
// branch, nothing changed, untracked, or ignored, nothing running inside)
|
|
1849
|
+
// and are unused for idleMs; clears verified webpack caches in the idle
|
|
1850
|
+
// copies it keeps; trims the shared install store. Unmerged work always
|
|
1851
|
+
// stays.
|
|
1852
|
+
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1853
|
+
const SWEEP_EVERY_HOURS = 3;
|
|
1854
|
+
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1855
|
+
|
|
1856
|
+
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep' } = {}) {
|
|
1857
|
+
const root = path.resolve(workspace);
|
|
1858
|
+
const before = freeSpace(root);
|
|
1859
|
+
const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
|
|
1860
|
+
const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
|
|
1861
|
+
// Without a process list nobody can tell which copies are in use.
|
|
1862
|
+
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1863
|
+
const scope = path.join(root, space.AGENT_COPIES_DIR);
|
|
1864
|
+
for (const repo of space.agentCopyRepos(root)) {
|
|
1865
|
+
const entry = { name: repo.name, root: repo.root };
|
|
1866
|
+
try {
|
|
1867
|
+
const base = defaultMainlineBase(repo.root);
|
|
1868
|
+
if (!base.startsWith('origin/')) {
|
|
1869
|
+
report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
// A real removal scans again per repo and right before each copy goes;
|
|
1873
|
+
// the shared scan only serves previews and tests.
|
|
1874
|
+
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1875
|
+
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs });
|
|
1876
|
+
report.repos.push({
|
|
1877
|
+
...entry,
|
|
1878
|
+
base: result.base,
|
|
1879
|
+
removed: result.removed,
|
|
1880
|
+
candidates: result.candidates,
|
|
1881
|
+
cachePruned: result.cachePruned,
|
|
1882
|
+
cacheCandidates: result.cacheCandidates,
|
|
1883
|
+
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
1884
|
+
installsPruned: apply ? space.pruneDepsCache({ workspace: root, repoName: repo.name }) : [],
|
|
1885
|
+
});
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
report.repos.push({ ...entry, error: String((error && error.message) || error) });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
report.after = freeSpace(root);
|
|
1891
|
+
return report;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
const SWEEP_KEPT_LABELS = {
|
|
1895
|
+
active_process: 'in use',
|
|
1896
|
+
process_scan_failed: 'in use (could not check)',
|
|
1897
|
+
dirty: 'with changes',
|
|
1898
|
+
unmerged: 'not merged',
|
|
1899
|
+
detached_unmerged: 'not merged',
|
|
1900
|
+
ignored_data: 'holding files git ignores (kept for review)',
|
|
1901
|
+
ignored_scan_failed: 'unreadable',
|
|
1902
|
+
fresh_worktree_grace: 'new',
|
|
1903
|
+
recent_activity: 'used recently',
|
|
1904
|
+
locked: 'locked',
|
|
1905
|
+
protected_branch: 'on a main branch',
|
|
1906
|
+
missing_or_unreadable: 'unreadable',
|
|
1907
|
+
missing_head: 'without a commit',
|
|
1908
|
+
remove_failed: 'failed to remove',
|
|
1909
|
+
base_refresh_failed: 'main branch unreachable',
|
|
1910
|
+
base_unverified: 'main branch moved during the check',
|
|
1911
|
+
path_not_canonical: 'behind a link',
|
|
1912
|
+
size_unknown_or_cleanup_limit: 'over the per-run limit',
|
|
1913
|
+
changed_or_active_during_verification: 'changed during the check',
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1916
|
+
function plural(count, one, many) {
|
|
1917
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// The folder that holds .agent-worktrees: this folder if it has one, else
|
|
1921
|
+
// the one above the repo's main checkout.
|
|
1922
|
+
function defaultSweepWorkspace(cwd = process.cwd()) {
|
|
1923
|
+
if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
|
|
1924
|
+
try {
|
|
1925
|
+
return path.dirname(findPrimaryRoot(repoRoot(cwd)));
|
|
1926
|
+
} catch {
|
|
1927
|
+
return cwd;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
|
|
1932
|
+
return planKeep({
|
|
1933
|
+
root: workspace,
|
|
1934
|
+
home: path.join(homeDir, '.atris', 'worktree-sweep'),
|
|
1935
|
+
platform,
|
|
1936
|
+
homeDir,
|
|
1937
|
+
atrisBin,
|
|
1938
|
+
envPath,
|
|
1939
|
+
job: {
|
|
1940
|
+
name: 'worktree-sweep',
|
|
1941
|
+
args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
|
|
1942
|
+
every_seconds: Math.round(everyHours * 3600),
|
|
1943
|
+
marker: 'ATRIS_WORKTREE_SWEEP',
|
|
1944
|
+
},
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function sweepSchedule(args, workspace) {
|
|
1949
|
+
const everyHours = Number(readFlag(args, '--every', String(SWEEP_EVERY_HOURS)));
|
|
1950
|
+
if (!Number.isFinite(everyHours) || everyHours <= 0) {
|
|
1951
|
+
console.error('Usage: atris worktree sweep --install [--every <hours>] [--workspace <dir>]');
|
|
1952
|
+
return 2;
|
|
1953
|
+
}
|
|
1954
|
+
const plan = sweepSchedulePlan({ workspace, everyHours });
|
|
1955
|
+
if (hasFlag(args, '--uninstall')) {
|
|
1956
|
+
const removed = removeKeep(plan);
|
|
1957
|
+
if (!removed.ok) {
|
|
1958
|
+
console.error(`refusing: ${removed.error}`);
|
|
1959
|
+
return 1;
|
|
1960
|
+
}
|
|
1961
|
+
console.log(`sweep timer: ${removed.removed === false ? 'was not on' : 'off'} for ${workspace}`);
|
|
1962
|
+
return 0;
|
|
1963
|
+
}
|
|
1964
|
+
// The timer runs this atris file every few hours; a copy or temp folder vanishes.
|
|
1965
|
+
if (space.isAgentCopyPath(ATRIS_BIN) || space.isScratchPath(ATRIS_BIN)) {
|
|
1966
|
+
console.error(`refusing: this atris runs from ${path.dirname(path.dirname(ATRIS_BIN))}, a folder that gets removed; install the timer from a lasting atris install`);
|
|
1967
|
+
return 2;
|
|
1968
|
+
}
|
|
1969
|
+
const installed = installKeep(plan);
|
|
1970
|
+
if (!installed.ok) {
|
|
1971
|
+
console.error(`refusing: ${installed.error}`);
|
|
1972
|
+
return 1;
|
|
1973
|
+
}
|
|
1974
|
+
console.log(`sweep timer: on for ${workspace}, every ${everyHours} ${everyHours === 1 ? 'hour' : 'hours'}`);
|
|
1975
|
+
console.log(`log: ${plan.log_path}`);
|
|
1976
|
+
return 0;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function sweep(args) {
|
|
1980
|
+
const workspace = path.resolve(readFlag(args, '--workspace') || defaultSweepWorkspace());
|
|
1981
|
+
if (hasFlag(args, '--install') || hasFlag(args, '--uninstall')) return sweepSchedule(args, workspace);
|
|
1982
|
+
const idleHours = Number(readFlag(args, '--idle-hours', String(SWEEP_IDLE_MS / 3600000)));
|
|
1983
|
+
if (!Number.isFinite(idleHours) || idleHours < 0) {
|
|
1984
|
+
console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
|
|
1985
|
+
return 2;
|
|
1986
|
+
}
|
|
1987
|
+
const apply = hasFlag(args, '--apply');
|
|
1988
|
+
const report = sweepWorkspace({ workspace, apply, idleMs: idleHours * 3600000 });
|
|
1989
|
+
if (hasFlag(args, '--json')) {
|
|
1990
|
+
console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
|
|
1991
|
+
return report.error ? 1 : 0;
|
|
1992
|
+
}
|
|
1993
|
+
console.log(`sweep: ${apply ? 'applied' : 'dry run'} in ${workspace}, ${space.formatGb(report.before)} free`);
|
|
1994
|
+
if (report.error) {
|
|
1995
|
+
console.error(`refusing: ${report.error}`);
|
|
1996
|
+
return 1;
|
|
1997
|
+
}
|
|
1998
|
+
let removedTotal = 0;
|
|
1999
|
+
let clearedTotal = 0;
|
|
2000
|
+
let reviewTotal = 0;
|
|
2001
|
+
for (const repo of report.repos) {
|
|
2002
|
+
if (repo.error || repo.skipped) {
|
|
2003
|
+
console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
const gone = apply ? repo.removed : repo.candidates;
|
|
2007
|
+
const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
|
|
2008
|
+
removedTotal += gone.length;
|
|
2009
|
+
clearedTotal += cleared.length;
|
|
2010
|
+
reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
|
|
2011
|
+
const counts = {};
|
|
2012
|
+
for (const item of repo.kept) {
|
|
2013
|
+
const label = SWEEP_KEPT_LABELS[item.reason] || item.reason;
|
|
2014
|
+
counts[label] = (counts[label] || 0) + 1;
|
|
2015
|
+
}
|
|
2016
|
+
const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
|
|
2017
|
+
console.log(`${repo.name}: ${apply ? 'removed' : 'would remove'} ${plural(gone.length, 'finished copy', 'finished copies')}, ${apply ? 'cleared' : 'would clear'} ${plural(cleared.length, 'webpack cache', 'webpack caches')}; kept ${repo.kept.length}${keptText ? ` (${keptText})` : ''}`);
|
|
2018
|
+
for (const item of gone) console.log(` ${apply ? 'removed' : 'remove'}: ${item.path}`);
|
|
2019
|
+
for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
|
|
2020
|
+
}
|
|
2021
|
+
if (reviewTotal) console.log(`review: ${plural(reviewTotal, 'other build folder waits', 'other build folders wait')} for a person; \`atris worktree cleanup\` in each repo lists them`);
|
|
2022
|
+
if (apply) {
|
|
2023
|
+
console.log(`free: ${space.formatGb(report.before)} before, ${space.formatGb(report.after)} after`);
|
|
2024
|
+
} else if (removedTotal || clearedTotal) {
|
|
2025
|
+
console.log('next: atris worktree sweep --apply');
|
|
2026
|
+
}
|
|
2027
|
+
return 0;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1554
2030
|
function cleanup(args) {
|
|
1555
2031
|
const asJson = hasFlag(args, '--json');
|
|
1556
2032
|
const result = cleanupWorktrees({
|
|
@@ -1606,22 +2082,28 @@ function guide() {
|
|
|
1606
2082
|
console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
|
|
1607
2083
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
1608
2084
|
console.log('');
|
|
1609
|
-
console.log('5. Cleanup is part of creation and the
|
|
2085
|
+
console.log('5. Cleanup is part of creation, shipping, and the sweep; run it directly any time:');
|
|
1610
2086
|
console.log(' atris worktree cleanup');
|
|
1611
2087
|
console.log(' atris worktree cleanup --apply');
|
|
2088
|
+
console.log(' atris worktree sweep --apply every repo in the workspace');
|
|
2089
|
+
console.log(' atris worktree sweep --install run the sweep every 3 hours');
|
|
1612
2090
|
console.log('');
|
|
1613
2091
|
console.log('Notes: start uses the current upstream/default remote base, not dirty local HEAD.');
|
|
1614
|
-
console.log('Start reaps completed checkouts first. Cleanup preserves branches
|
|
2092
|
+
console.log('Start reaps completed checkouts first. Cleanup preserves branches, source changes, and files git ignores, skips live work, and prunes only verified webpack caches.');
|
|
2093
|
+
console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
|
|
2094
|
+
console.log('Below 25 GB free, start reaps finished copies in every repo; below 10 GB it refuses (ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB).');
|
|
2095
|
+
console.log('A copy shipped with --merge is removed once nothing has run in it for 10 minutes (ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
|
|
1615
2096
|
console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
|
|
1616
2097
|
return 0;
|
|
1617
2098
|
}
|
|
1618
2099
|
|
|
1619
2100
|
function help() {
|
|
1620
|
-
console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup>');
|
|
2101
|
+
console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup|sweep>');
|
|
1621
2102
|
console.log('');
|
|
1622
2103
|
console.log(' atris worktree guide');
|
|
1623
|
-
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force]');
|
|
2104
|
+
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force] [--no-deps]');
|
|
1624
2105
|
console.log(' --force start even when another recent flight shares a task word');
|
|
2106
|
+
console.log(' --no-deps do not clone node_modules from a matching install');
|
|
1625
2107
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
1626
2108
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
1627
2109
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
@@ -1631,6 +2113,8 @@ function help() {
|
|
|
1631
2113
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
1632
2114
|
console.log(' atris worktree prune [--apply]');
|
|
1633
2115
|
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
|
|
2116
|
+
console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] reap merged, idle copies in every repo');
|
|
2117
|
+
console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
|
|
1634
2118
|
}
|
|
1635
2119
|
|
|
1636
2120
|
function worktreeCommand(args = []) {
|
|
@@ -1650,6 +2134,8 @@ function worktreeCommand(args = []) {
|
|
|
1650
2134
|
if (sub === 'guard') return guard(rest);
|
|
1651
2135
|
if (sub === 'prune') return prune(rest);
|
|
1652
2136
|
if (sub === 'cleanup' || sub === 'clean') return cleanup(rest);
|
|
2137
|
+
if (sub === 'sweep') return sweep(rest);
|
|
2138
|
+
if (sub === 'reap-shipped') return reapShipped();
|
|
1653
2139
|
help();
|
|
1654
2140
|
return 2;
|
|
1655
2141
|
}
|
|
@@ -1660,8 +2146,14 @@ module.exports = {
|
|
|
1660
2146
|
mergePrWithRetry,
|
|
1661
2147
|
createAgentWorktree,
|
|
1662
2148
|
deniedLaneForShip,
|
|
2149
|
+
protectedPushRefusal,
|
|
1663
2150
|
createOrFindPr,
|
|
1664
2151
|
cleanupWorktrees,
|
|
2152
|
+
reapShippedCopy,
|
|
2153
|
+
scheduleShippedCopyRemoval,
|
|
2154
|
+
shippedCopyRemovalPlan,
|
|
2155
|
+
sweepSchedulePlan,
|
|
2156
|
+
sweepWorkspace,
|
|
1665
2157
|
defaultMainlineBase,
|
|
1666
2158
|
defaultStartBase,
|
|
1667
2159
|
gitFailureDetail,
|