atris 3.74.26 → 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 +116 -10
- package/commands/worktree.js +526 -19
- package/lib/auto-accept-certified.js +56 -0
- package/lib/campaign-keep.js +5 -1
- package/lib/fleet.js +357 -49
- package/lib/gm-season.js +45 -9
- 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 +113 -3
- 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
|
}
|
|
@@ -1125,7 +1270,7 @@ function shipWorktree(args) {
|
|
|
1125
1270
|
}
|
|
1126
1271
|
// A merge queue accepts the request before merging. Deleting its head
|
|
1127
1272
|
// here closes the PR and cancels that request, so confirm the outcome.
|
|
1128
|
-
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt'], { cwd: root, encoding: 'utf8' });
|
|
1273
|
+
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt,number,url,mergeCommit'], { cwd: root, encoding: 'utf8' });
|
|
1129
1274
|
if (confirmation.status !== 0) {
|
|
1130
1275
|
throw new Error('could not confirm the merge; the source branch was kept');
|
|
1131
1276
|
}
|
|
@@ -1141,6 +1286,11 @@ function shipWorktree(args) {
|
|
|
1141
1286
|
throw new Error('the change was not merged; the source branch was kept');
|
|
1142
1287
|
}
|
|
1143
1288
|
console.log('merge: merged');
|
|
1289
|
+
// One machine-readable line, so a caller can save which pull request
|
|
1290
|
+
// and commit landed. The rating only counts a landing it can point at.
|
|
1291
|
+
const record = shipMergeRecord(state);
|
|
1292
|
+
if (record) console.log(`merge: record ${JSON.stringify(record)}`);
|
|
1293
|
+
landedOn = targetRef;
|
|
1144
1294
|
const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
|
|
1145
1295
|
if (deleted.status === 0) {
|
|
1146
1296
|
console.log(`merge: remote branch deleted ${branch}`);
|
|
@@ -1161,10 +1311,117 @@ function shipWorktree(args) {
|
|
|
1161
1311
|
}, { worktree: root });
|
|
1162
1312
|
} catch {}
|
|
1163
1313
|
if (merge && !dryRun) runShipHealthCheck(primary || root);
|
|
1314
|
+
if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
|
|
1164
1315
|
console.log('done: worktree shipped');
|
|
1165
1316
|
return 0;
|
|
1166
1317
|
}
|
|
1167
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
|
+
|
|
1414
|
+
// The landed pull request from `gh pr view --json`, or null without a merge commit.
|
|
1415
|
+
function shipMergeRecord(state) {
|
|
1416
|
+
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
1417
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
1418
|
+
const record = { merge_commit: commit };
|
|
1419
|
+
if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
|
|
1420
|
+
if (typeof state.url === 'string' && state.url) record.url = state.url;
|
|
1421
|
+
if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
|
|
1422
|
+
return record;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1168
1425
|
function guard(args) {
|
|
1169
1426
|
const root = repoRoot();
|
|
1170
1427
|
const worktrees = listWorktrees(root);
|
|
@@ -1209,14 +1466,35 @@ const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
|
1209
1466
|
'node_modules/.cache',
|
|
1210
1467
|
];
|
|
1211
1468
|
|
|
1212
|
-
function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
1469
|
+
function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
|
|
1213
1470
|
try {
|
|
1214
|
-
return now - fs.statSync(worktreePath).mtimeMs <
|
|
1471
|
+
return now - fs.statSync(worktreePath).mtimeMs < graceMs;
|
|
1215
1472
|
} catch {
|
|
1216
1473
|
return false;
|
|
1217
1474
|
}
|
|
1218
1475
|
}
|
|
1219
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
|
+
|
|
1220
1498
|
function canonicalPath(value) {
|
|
1221
1499
|
try {
|
|
1222
1500
|
return fs.realpathSync(value);
|
|
@@ -1400,7 +1678,23 @@ function originBaseIsCurrent(root, base) {
|
|
|
1400
1678
|
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1401
1679
|
}
|
|
1402
1680
|
|
|
1403
|
-
|
|
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
|
+
} = {}) {
|
|
1404
1698
|
const worktrees = listWorktrees(root);
|
|
1405
1699
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1406
1700
|
const current = path.resolve(root);
|
|
@@ -1413,8 +1707,13 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1413
1707
|
const cacheCandidates = [];
|
|
1414
1708
|
const cachePruned = [];
|
|
1415
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;
|
|
1416
1714
|
const pruneCachesForKept = (wtPath) => {
|
|
1417
|
-
if (worktreeWithinReapGrace(wtPath)) return;
|
|
1715
|
+
if (worktreeWithinReapGrace(wtPath, now)) return;
|
|
1716
|
+
if (recentlyUsed(wtPath)) return;
|
|
1418
1717
|
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1419
1718
|
cacheCandidates.push(...caches.candidates);
|
|
1420
1719
|
cachePruned.push(...caches.pruned);
|
|
@@ -1431,6 +1730,10 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1431
1730
|
kept.push({ ...item, reason: 'current_checkout' });
|
|
1432
1731
|
continue;
|
|
1433
1732
|
}
|
|
1733
|
+
if (!inScope(wtPath)) {
|
|
1734
|
+
kept.push({ ...item, reason: 'out_of_scope' });
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1434
1737
|
if (wt.locked) {
|
|
1435
1738
|
kept.push({ ...item, reason: 'locked' });
|
|
1436
1739
|
continue;
|
|
@@ -1480,10 +1783,14 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1480
1783
|
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1481
1784
|
continue;
|
|
1482
1785
|
}
|
|
1483
|
-
if (worktreeWithinReapGrace(wtPath)) {
|
|
1786
|
+
if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
|
|
1484
1787
|
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1485
1788
|
continue;
|
|
1486
1789
|
}
|
|
1790
|
+
if (recentlyUsed(wtPath)) {
|
|
1791
|
+
kept.push({ ...item, reason: 'recent_activity' });
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1487
1794
|
if (!originBaseIsCurrent(root, base)) {
|
|
1488
1795
|
kept.push({ ...item, reason: 'base_unverified' });
|
|
1489
1796
|
continue;
|
|
@@ -1536,6 +1843,190 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1536
1843
|
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
|
|
1537
1844
|
}
|
|
1538
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
|
+
|
|
1539
2030
|
function cleanup(args) {
|
|
1540
2031
|
const asJson = hasFlag(args, '--json');
|
|
1541
2032
|
const result = cleanupWorktrees({
|
|
@@ -1591,22 +2082,28 @@ function guide() {
|
|
|
1591
2082
|
console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
|
|
1592
2083
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
1593
2084
|
console.log('');
|
|
1594
|
-
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:');
|
|
1595
2086
|
console.log(' atris worktree cleanup');
|
|
1596
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');
|
|
1597
2090
|
console.log('');
|
|
1598
2091
|
console.log('Notes: start uses the current upstream/default remote base, not dirty local HEAD.');
|
|
1599
|
-
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).');
|
|
1600
2096
|
console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
|
|
1601
2097
|
return 0;
|
|
1602
2098
|
}
|
|
1603
2099
|
|
|
1604
2100
|
function help() {
|
|
1605
|
-
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>');
|
|
1606
2102
|
console.log('');
|
|
1607
2103
|
console.log(' atris worktree guide');
|
|
1608
|
-
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]');
|
|
1609
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');
|
|
1610
2107
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
1611
2108
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
1612
2109
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
@@ -1616,6 +2113,8 @@ function help() {
|
|
|
1616
2113
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
1617
2114
|
console.log(' atris worktree prune [--apply]');
|
|
1618
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');
|
|
1619
2118
|
}
|
|
1620
2119
|
|
|
1621
2120
|
function worktreeCommand(args = []) {
|
|
@@ -1635,6 +2134,8 @@ function worktreeCommand(args = []) {
|
|
|
1635
2134
|
if (sub === 'guard') return guard(rest);
|
|
1636
2135
|
if (sub === 'prune') return prune(rest);
|
|
1637
2136
|
if (sub === 'cleanup' || sub === 'clean') return cleanup(rest);
|
|
2137
|
+
if (sub === 'sweep') return sweep(rest);
|
|
2138
|
+
if (sub === 'reap-shipped') return reapShipped();
|
|
1638
2139
|
help();
|
|
1639
2140
|
return 2;
|
|
1640
2141
|
}
|
|
@@ -1645,8 +2146,14 @@ module.exports = {
|
|
|
1645
2146
|
mergePrWithRetry,
|
|
1646
2147
|
createAgentWorktree,
|
|
1647
2148
|
deniedLaneForShip,
|
|
2149
|
+
protectedPushRefusal,
|
|
1648
2150
|
createOrFindPr,
|
|
1649
2151
|
cleanupWorktrees,
|
|
2152
|
+
reapShippedCopy,
|
|
2153
|
+
scheduleShippedCopyRemoval,
|
|
2154
|
+
shippedCopyRemovalPlan,
|
|
2155
|
+
sweepSchedulePlan,
|
|
2156
|
+
sweepWorkspace,
|
|
1650
2157
|
defaultMainlineBase,
|
|
1651
2158
|
defaultStartBase,
|
|
1652
2159
|
gitFailureDetail,
|