atris 3.80.1 → 3.82.2
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/LICENSE +21 -0
- package/README.md +11 -1
- package/atris/atris.md +14 -0
- package/atris/features/README.md +15 -0
- package/atris/policies/README.md +24 -0
- package/atris/skills/README.md +61 -45
- package/atris/skills/design/SKILL.md +6 -5
- package/atris/skills/taste/SKILL.md +39 -0
- package/atris/skills/taste/notes.md +29 -0
- package/atris/skills/taste/principles.md +23 -0
- package/atris/skills/wiki/SKILL.md +14 -2
- package/atris/skills/wiki/index-lines.md +30 -0
- package/atris/skills/wiki/measure.md +20 -0
- package/atris/skills/wiki/note-shape.md +16 -0
- package/atris/skills/wiki/upkeep.md +16 -0
- package/atris/team/README.md +38 -0
- package/atris.md +14 -0
- package/bin/atris.js +1 -1
- package/commands/activate.js +1 -1
- package/commands/autoland.js +25 -26
- package/commands/autopilot-front.js +46 -1
- package/commands/autopilot.js +55 -25
- package/commands/clean.js +22 -11
- package/commands/computer.js +1 -1
- package/commands/doc-health.js +48 -1
- package/commands/improve.js +16 -3
- package/commands/init.js +4 -0
- package/commands/integrations.js +69 -1616
- package/commands/mission.js +112 -14
- package/commands/release.js +41 -10
- package/commands/status.js +1 -1
- package/commands/sync.js +12 -8
- package/commands/task.js +150 -137
- package/commands/unknowns.js +9 -5
- package/commands/visualize.js +1 -12
- package/commands/workflow.js +4 -3
- package/commands/worktree.js +107 -1610
- package/commands/xp.js +23 -2113
- package/commands/youtube.js +192 -3110
- package/lib/business-core.js +3 -4
- package/lib/campaign-run-lock.js +12 -1
- package/lib/engine-ask.js +1 -1
- package/lib/engine-receipt-sweep.js +64 -0
- package/lib/file-lock.js +175 -0
- package/lib/first-minute.js +37 -4
- package/lib/fleet.js +1 -1
- package/lib/integrations-core.js +1695 -0
- package/lib/intents.js +7 -0
- package/lib/json-file.js +22 -1
- package/lib/loop-doctor.js +36 -2
- package/lib/map-refs.js +68 -8
- package/lib/member-context.js +20 -1
- package/lib/member-engine.js +1 -1
- package/lib/member-map-fold.js +49 -11
- package/lib/member-map-inbox.js +2 -1
- package/lib/mission-core.js +9 -2
- package/lib/next-work-context.js +190 -0
- package/lib/recall-exam.js +171 -0
- package/lib/roster-markdown.js +2 -2
- package/lib/runner-command.js +5 -5
- package/lib/task-db.js +107 -38
- package/lib/taste-notes.js +35 -0
- package/lib/team-lineup.js +3 -3
- package/lib/todo.js +2 -0
- package/lib/wiki.js +11 -3
- package/lib/worktree-core.js +2032 -0
- package/lib/xp-core.js +2172 -0
- package/lib/youtube-core.js +3178 -0
- package/lib/zen-apply.js +3 -1
- package/package.json +2 -2
package/commands/worktree.js
CHANGED
|
@@ -1,548 +1,80 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const os = require('os');
|
|
5
4
|
const path = require('path');
|
|
6
|
-
const {
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
7
6
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
8
7
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
9
|
-
const { installKeep,
|
|
10
|
-
const {
|
|
11
|
-
const { fixMapDocs } = require('../lib/map-refs');
|
|
12
|
-
const { sleepSync } = require('../lib/sleep-sync');
|
|
8
|
+
const { installKeep, removeKeep } = require('../lib/campaign-keep');
|
|
9
|
+
const { isConductorUntracked } = require('../lib/conductor-artifacts');
|
|
13
10
|
const space = require('../lib/worktree-space');
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
stdio: 'pipe',
|
|
82
|
-
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
83
|
-
});
|
|
84
|
-
if (result.error) throw result.error;
|
|
85
|
-
if (check && result.status !== 0) {
|
|
86
|
-
const msg = (result.stderr || result.stdout || `${command} failed`).trim();
|
|
87
|
-
throw new Error(msg);
|
|
88
|
-
}
|
|
89
|
-
return result;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function statusSnapshot(root) {
|
|
93
|
-
const result = runGit(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, check: false });
|
|
94
|
-
if (result.status !== 0) return [];
|
|
95
|
-
return result.stdout
|
|
96
|
-
.split(/\r?\n/)
|
|
97
|
-
.map((line) => line.trimEnd())
|
|
98
|
-
.filter(Boolean);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function newStatusRows(before, after) {
|
|
102
|
-
const seen = new Set(before);
|
|
103
|
-
return after.filter((row) => !seen.has(row));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function readOneLapProofBinding(root, branch) {
|
|
107
|
-
const sidecarPath = path.join(root, '.atris', 'agent-worktree.json');
|
|
108
|
-
let sidecar = null;
|
|
109
|
-
try {
|
|
110
|
-
sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
|
|
111
|
-
} catch (error) {
|
|
112
|
-
if (error && error.code !== 'ENOENT') throw new Error('one-lap proof metadata is unreadable');
|
|
113
|
-
}
|
|
114
|
-
const metadata = sidecar && sidecar.one_lap_proof;
|
|
115
|
-
const configured = runGit(['config', '--get', `branch.${branch}.atris-proof-ref`], { cwd: root, check: false });
|
|
116
|
-
const configuredRef = configured.status === 0 ? String(configured.stdout || '').trim() : '';
|
|
117
|
-
if (!metadata && !configuredRef) return null;
|
|
118
|
-
if (!metadata || !configuredRef) throw new Error('one-lap proof binding is incomplete');
|
|
119
|
-
|
|
120
|
-
const proofRef = String(metadata.ref || '');
|
|
121
|
-
const sourceCommit = String(metadata.commit || '');
|
|
122
|
-
const sourceTree = String(metadata.tree || '');
|
|
123
|
-
const refMatch = proofRef.match(ONE_LAP_PROOF_REF_PATTERN);
|
|
124
|
-
if (!refMatch || proofRef !== configuredRef || !GIT_OID_PATTERN.test(sourceCommit) || !GIT_OID_PATTERN.test(sourceTree)) {
|
|
125
|
-
throw new Error('one-lap proof binding is invalid');
|
|
126
|
-
}
|
|
127
|
-
const resolvedCommit = runGit(['rev-parse', '--verify', `${proofRef}^{commit}`], { cwd: root, check: false });
|
|
128
|
-
const resolvedTree = runGit(['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: root, check: false });
|
|
129
|
-
if (resolvedCommit.status !== 0 || String(resolvedCommit.stdout || '').trim() !== sourceCommit || refMatch[1] !== sourceCommit) {
|
|
130
|
-
throw new Error('one-lap proof commit no longer matches its retained ref');
|
|
131
|
-
}
|
|
132
|
-
if (resolvedTree.status !== 0 || String(resolvedTree.stdout || '').trim() !== sourceTree) {
|
|
133
|
-
throw new Error('one-lap proof tree no longer matches its retained ref');
|
|
134
|
-
}
|
|
135
|
-
return { ref: proofRef, commit: sourceCommit, tree: sourceTree };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function worktreeTree(root, source) {
|
|
139
|
-
const result = runGit(source === 'index'
|
|
140
|
-
? ['write-tree']
|
|
141
|
-
: ['rev-parse', '--verify', 'HEAD^{tree}'], { cwd: root, check: false });
|
|
142
|
-
if (result.status !== 0) throw new Error(`could not resolve the ${source} tree for one-lap proof`);
|
|
143
|
-
return String(result.stdout || '').trim();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function assertOneLapProofTree(root, binding, source) {
|
|
147
|
-
if (!binding) return;
|
|
148
|
-
if (worktreeTree(root, source) !== binding.tree) {
|
|
149
|
-
throw new Error(`one-lap ${source} tree differs from the independently verified proof tree`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function repoRoot(cwd = process.cwd()) {
|
|
154
|
-
return runGit(['rev-parse', '--show-toplevel'], { cwd }).stdout.trim();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function slugify(value, fallback = 'task', limit = 48) {
|
|
158
|
-
const slug = String(value || '')
|
|
159
|
-
.trim()
|
|
160
|
-
.toLowerCase()
|
|
161
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
162
|
-
.replace(/^-+|-+$/g, '')
|
|
163
|
-
.slice(0, limit)
|
|
164
|
-
.replace(/-+$/g, '');
|
|
165
|
-
return slug || fallback;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function stamp(now = new Date()) {
|
|
169
|
-
return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function branchName(member, task, now = new Date()) {
|
|
173
|
-
return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// The newest earlier run of this same member+job, if its commits never reached
|
|
177
|
-
// the base, so a retry continues that work instead of redoing it. Only the
|
|
178
|
-
// newest run counts: once it landed or did nothing, anything older is stale.
|
|
179
|
-
function lastRunBranch(root, owner, task, base, skip) {
|
|
180
|
-
const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
|
|
181
|
-
const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
|
|
182
|
-
if (listed.status !== 0) return null;
|
|
183
|
-
// Only exact runs of this job (stamp plus optional same-second number), so
|
|
184
|
-
// "fix-login" never matches "fix-login-page".
|
|
185
|
-
const runs = listed.stdout.split('\n').map((name) => name.trim())
|
|
186
|
-
.map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
|
|
187
|
-
.filter(({ name, run }) => run && name !== skip)
|
|
188
|
-
.sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
|
|
189
|
-
if (!runs.length) return null;
|
|
190
|
-
const newest = runs[0].name;
|
|
191
|
-
const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
|
|
192
|
-
if (cherry.status !== 0) return null;
|
|
193
|
-
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// True only when git proves the branch no longer merges cleanly with the base.
|
|
197
|
-
// merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
|
|
198
|
-
// resume as before.
|
|
199
|
-
function conflictsWithBase(root, base, branch) {
|
|
200
|
-
const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
|
|
201
|
-
return result.status === 1;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
205
|
-
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
206
|
-
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function parseWorktrees(text) {
|
|
210
|
-
const out = [];
|
|
211
|
-
let current = {};
|
|
212
|
-
for (const raw of `${text}\n`.split(/\r?\n/)) {
|
|
213
|
-
const line = raw.trim();
|
|
214
|
-
if (!line) {
|
|
215
|
-
if (current.worktree) {
|
|
216
|
-
let branch = current.branch || 'detached';
|
|
217
|
-
branch = branch.replace(/^refs\/heads\//, '');
|
|
218
|
-
const item = { path: current.worktree, branch, head: current.HEAD || '' };
|
|
219
|
-
if (current.locked) item.locked = true;
|
|
220
|
-
if (current.prunable) item.prunable = true;
|
|
221
|
-
out.push(item);
|
|
222
|
-
}
|
|
223
|
-
current = {};
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
const idx = line.indexOf(' ');
|
|
227
|
-
if (idx === -1) {
|
|
228
|
-
current[line] = true;
|
|
229
|
-
} else {
|
|
230
|
-
current[line.slice(0, idx)] = line.slice(idx + 1);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return out;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function listWorktrees(root = repoRoot()) {
|
|
237
|
-
return parseWorktrees(runGit(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
|
|
241
|
-
// same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
|
|
242
|
-
// direct `worktree start` had no pre-check beyond the target path existing.
|
|
243
|
-
// Refuse only the same task slug. A shared word on an unrelated flight is not
|
|
244
|
-
// a collision (dispatch-cli-900 must not block dispatch-cli-901).
|
|
245
|
-
const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
|
|
246
|
-
const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
247
|
-
const FLIGHT_STOPWORDS = new Set([
|
|
248
|
-
'fix', 'add', 'update', 'make', 'change', 'new', 'task', 'work', 'repo',
|
|
249
|
-
'cli', 'atris', 'backend', 'the', 'a', 'an', 'and', 'for', 'to', 'of', 'in', 'on',
|
|
250
|
-
]);
|
|
251
|
-
|
|
252
|
-
function flightStampMs(stampText) {
|
|
253
|
-
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(stampText || ''));
|
|
254
|
-
if (!m) return null;
|
|
255
|
-
const [, year, month, day, hour, minute, second] = m.map(Number);
|
|
256
|
-
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null;
|
|
257
|
-
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function taskTokens(value) {
|
|
261
|
-
return String(value || '')
|
|
262
|
-
.toLowerCase()
|
|
263
|
-
.split(/[^a-z0-9]+/)
|
|
264
|
-
.filter(Boolean)
|
|
265
|
-
.filter((token) => !FLIGHT_STOPWORDS.has(token));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// codex/<owner>-<task-slug>-<YYYYMMDD-HHMMSS>: drop the owner segment and the
|
|
269
|
-
// stamp, and what is left is the task the flight is working on.
|
|
270
|
-
function parseAgentFlightName(name) {
|
|
271
|
-
const text = String(name || '').trim().replace(/^refs\/heads\//, '');
|
|
272
|
-
const match = AGENT_FLIGHT_NAME_PATTERN.exec(text);
|
|
273
|
-
if (!match) return null;
|
|
274
|
-
const stampMs = flightStampMs(match[2]);
|
|
275
|
-
if (stampMs === null) return null;
|
|
276
|
-
const segments = match[1].split('-').filter(Boolean);
|
|
277
|
-
if (segments.length < 2) return null;
|
|
278
|
-
return {
|
|
279
|
-
name: text,
|
|
280
|
-
owner: segments[0],
|
|
281
|
-
taskSlug: segments.slice(1).join('-'),
|
|
282
|
-
stamp: match[2],
|
|
283
|
-
stampMs,
|
|
284
|
-
tokens: taskTokens(segments.slice(1).join('-')),
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function remoteAgentBranchNames(root) {
|
|
289
|
-
// One ls-remote, best effort: offline dispatchers still get the local view.
|
|
290
|
-
const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false, timeout: 10000 });
|
|
291
|
-
if (result.status !== 0) return [];
|
|
292
|
-
return String(result.stdout || '')
|
|
293
|
-
.split(/\r?\n/)
|
|
294
|
-
.map((line) => (line.split(/\s+/)[1] || '').replace(/^refs\/heads\//, '').trim())
|
|
295
|
-
.filter(Boolean);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs = FLIGHT_WINDOW_MS } = {}) {
|
|
299
|
-
const nowMs = now.getTime();
|
|
300
|
-
const flights = new Map();
|
|
301
|
-
const add = (name, kind) => {
|
|
302
|
-
const flight = parseAgentFlightName(name);
|
|
303
|
-
if (!flight) return;
|
|
304
|
-
// Branch age comes from the stamp in the name, never a git log per branch.
|
|
305
|
-
if (kind !== 'worktree' && nowMs - flight.stampMs > windowMs) return;
|
|
306
|
-
const existing = flights.get(flight.name);
|
|
307
|
-
if (existing && existing.kind === 'worktree') return;
|
|
308
|
-
flights.set(flight.name, { ...flight, kind });
|
|
309
|
-
};
|
|
310
|
-
for (const wt of listWorktrees(root)) add(wt.branch, 'worktree');
|
|
311
|
-
const local = runGit(['branch', '--format=%(refname:short)'], { cwd: root, check: false });
|
|
312
|
-
if (local.status === 0) {
|
|
313
|
-
for (const name of String(local.stdout || '').split(/\r?\n/)) add(name, 'branch');
|
|
314
|
-
}
|
|
315
|
-
for (const name of remoteAgentBranchNames(root)) add(name, 'branch');
|
|
316
|
-
return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function collidingFlights(flights, taskSlug) {
|
|
320
|
-
const wanted = String(taskSlug || '').trim().toLowerCase();
|
|
321
|
-
if (!wanted) return [];
|
|
322
|
-
return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function describeFlightAge(flight, nowMs = Date.now()) {
|
|
326
|
-
const minutes = Math.max(0, Math.round((nowMs - flight.stampMs) / 60000));
|
|
327
|
-
if (minutes < 60) return `${minutes}m old`;
|
|
328
|
-
return `${Math.floor(minutes / 60)}h${minutes % 60}m old`;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function refExists(root, ref) {
|
|
332
|
-
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], { cwd: root, check: false }).status === 0;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function currentBranch(root = repoRoot()) {
|
|
336
|
-
return runGit(['branch', '--show-current'], { cwd: root, check: false }).stdout.trim();
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function detachedShipBranch(root) {
|
|
340
|
-
return `codex/${path.basename(root)}`;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function createDetachedShipBranch(root, branch) {
|
|
344
|
-
if (refExists(root, `refs/heads/${branch}`) || refExists(root, `refs/remotes/origin/${branch}`)) {
|
|
345
|
-
console.error(`blocked: detached head branch already exists: ${branch}`);
|
|
346
|
-
return false;
|
|
347
|
-
}
|
|
348
|
-
const created = runGit(['switch', '-c', branch], { cwd: root, check: false });
|
|
349
|
-
if (created.status !== 0) {
|
|
350
|
-
console.error(`blocked: detached head branch create failed: ${branch}`);
|
|
351
|
-
console.error((created.stderr || created.stdout || '').trim());
|
|
352
|
-
return false;
|
|
353
|
-
}
|
|
354
|
-
console.log(`ship: detached head, created branch ${branch}`);
|
|
355
|
-
return true;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function currentUpstream(root) {
|
|
359
|
-
const result = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: root, check: false });
|
|
360
|
-
return result.status === 0 ? result.stdout.trim() : '';
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function remoteHead(root) {
|
|
364
|
-
// The saved origin/HEAD survives branch renames. Ask the remote before
|
|
365
|
-
// trusting it, keeping the offline fallback when the remote is unavailable.
|
|
366
|
-
const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
|
|
367
|
-
if (remote.status === 0) {
|
|
368
|
-
const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
|
|
369
|
-
const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
|
|
370
|
-
if (match) {
|
|
371
|
-
const head = `origin/${match[1]}`;
|
|
372
|
-
if (!refExists(root, head)) {
|
|
373
|
-
const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
|
|
374
|
-
if (fetched.status !== 0 || !refExists(root, head)) {
|
|
375
|
-
throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return head;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
|
|
382
|
-
return result.status === 0 ? result.stdout.trim() : '';
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function normalizeTargetRef(root, target) {
|
|
386
|
-
const value = String(target || '').trim();
|
|
387
|
-
if (!value) return '';
|
|
388
|
-
const remotes = runGit(['remote'], { cwd: root, check: false }).stdout.split(/\r?\n/);
|
|
389
|
-
const preferRemote =
|
|
390
|
-
!value.startsWith('origin/') &&
|
|
391
|
-
!value.startsWith('refs/') &&
|
|
392
|
-
value !== 'HEAD' &&
|
|
393
|
-
!/^[0-9a-f]{7,40}$/i.test(value) &&
|
|
394
|
-
remotes.includes('origin');
|
|
395
|
-
const remoteRef = value.startsWith('origin/') ? value : `origin/${value}`;
|
|
396
|
-
if (preferRemote && refExists(root, remoteRef)) return remoteRef;
|
|
397
|
-
if (preferRemote) return remoteRef;
|
|
398
|
-
if (refExists(root, value)) return value;
|
|
399
|
-
if (refExists(root, remoteRef)) return remoteRef;
|
|
400
|
-
if (value.startsWith('origin/') || remotes.includes('origin')) return remoteRef;
|
|
401
|
-
return value;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function defaultMainlineBase(root) {
|
|
405
|
-
const head = remoteHead(root);
|
|
406
|
-
if (head && refExists(root, head)) return head;
|
|
407
|
-
for (const candidate of ['origin/main', 'origin/master']) {
|
|
408
|
-
if (refExists(root, candidate)) return candidate;
|
|
409
|
-
}
|
|
410
|
-
return 'HEAD';
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function defaultStartBase(root) {
|
|
414
|
-
const upstream = currentUpstream(root);
|
|
415
|
-
const mainline = defaultMainlineBase(root);
|
|
416
|
-
// Always refresh before cutting a worktree: a stale origin/master base sent
|
|
417
|
-
// an engine off to rebuild work that had already landed (2026-07-09, packs
|
|
418
|
-
// one-click flight rebuilt the stars backend from a base two PRs behind).
|
|
419
|
-
refreshRemoteRef(root, mainline);
|
|
420
|
-
if (!upstream || !refExists(root, upstream)) return mainline;
|
|
421
|
-
if (mainline === 'HEAD' || mainline === upstream) return upstream;
|
|
422
|
-
// A launcher upstream that is behind the mainline is a stale cut point
|
|
423
|
-
// (2026-07-05: a stale origin/task branch sent two flights into rebase
|
|
424
|
-
// conflicts). Start from the mainline unless the upstream carries new work.
|
|
425
|
-
refreshRemoteRef(root, upstream);
|
|
426
|
-
const behind = runGit(['merge-base', '--is-ancestor', upstream, mainline], { cwd: root, check: false });
|
|
427
|
-
return behind.status === 0 ? mainline : upstream;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function defaultShipTarget(root) {
|
|
431
|
-
const branch = currentBranch(root);
|
|
432
|
-
if (branch) {
|
|
433
|
-
const configured = runGit(['config', '--get', `branch.${branch}.atris-base`], { cwd: root, check: false }).stdout.trim();
|
|
434
|
-
if (configured && refExists(root, configured)) return configured;
|
|
435
|
-
}
|
|
436
|
-
const head = remoteHead(root);
|
|
437
|
-
if (head && refExists(root, head)) return head;
|
|
438
|
-
for (const candidate of ['origin/main', 'origin/master']) {
|
|
439
|
-
if (refExists(root, candidate)) return candidate;
|
|
440
|
-
}
|
|
441
|
-
return 'HEAD';
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function refreshRemoteRef(root, ref) {
|
|
445
|
-
if (!ref.startsWith('origin/')) return;
|
|
446
|
-
const remoteBranch = ref.slice('origin/'.length);
|
|
447
|
-
const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
|
|
448
|
-
return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function baseBranchName(ref) {
|
|
452
|
-
return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function statusCounts(root, {
|
|
456
|
-
ignoredUnstagedFiles = new Set(),
|
|
457
|
-
ignoredUntrackedFiles = new Set(),
|
|
458
|
-
ignoreUntracked = null,
|
|
459
|
-
ignoreAny = null,
|
|
460
|
-
} = {}) {
|
|
461
|
-
if (!fs.existsSync(root)) return null;
|
|
462
|
-
// -uall expands untracked directories to individual files so ignoredUntrackedFiles
|
|
463
|
-
// and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
|
|
464
|
-
const expandUntracked = ignoredUntrackedFiles.size
|
|
465
|
-
|| typeof ignoreUntracked === 'function'
|
|
466
|
-
|| typeof ignoreAny === 'function';
|
|
467
|
-
const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
|
|
468
|
-
const result = runGit(statusArgs, { cwd: root, check: false });
|
|
469
|
-
if (result.status !== 0) return null;
|
|
470
|
-
let staged = 0;
|
|
471
|
-
let unstaged = 0;
|
|
472
|
-
let untracked = 0;
|
|
473
|
-
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
474
|
-
const file = line.slice(3);
|
|
475
|
-
if (typeof ignoreAny === 'function' && ignoreAny(file)) continue;
|
|
476
|
-
if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
|
|
477
|
-
if (line.startsWith('??')) {
|
|
478
|
-
if (ignoredUntrackedFiles.has(file)) continue;
|
|
479
|
-
if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
|
|
480
|
-
untracked += 1;
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
if (line[0] !== ' ') staged += 1;
|
|
484
|
-
if (line[1] !== ' ') unstaged += 1;
|
|
485
|
-
}
|
|
486
|
-
return { staged, unstaged, untracked };
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
490
|
-
// records engine output only. Paths that were never staged are simply not listed.
|
|
491
|
-
// GitHub refuses a merge with "Base branch was modified" when another PR
|
|
492
|
-
// merged into the same base a moment earlier; the same merge succeeds on a
|
|
493
|
-
// second try. On 2026-10-15 five parallel dispatches lost three landings to
|
|
494
|
-
// it. Retry only that message, a few times, with a short wait.
|
|
495
|
-
const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
|
|
496
|
-
function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
|
|
497
|
-
let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
498
|
-
for (let attempt = 1; attempt < attempts && merged.status !== 0
|
|
499
|
-
&& BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
|
|
500
|
-
console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
|
|
501
|
-
wait(1500 * attempt);
|
|
502
|
-
merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
503
|
-
}
|
|
504
|
-
return merged;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function unstageConductorArtifacts(root) {
|
|
508
|
-
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
509
|
-
if (staged.status !== 0) return [];
|
|
510
|
-
const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
|
|
511
|
-
const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
|
|
512
|
-
// A file this commit would add under .atris/ is the tool's own state, never work.
|
|
513
|
-
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
|
|
514
|
-
.filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
|
|
515
|
-
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
516
|
-
return drop;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function changedFiles(root, args) {
|
|
520
|
-
const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
|
|
521
|
-
if (result.status !== 0) return new Set();
|
|
522
|
-
return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {}) {
|
|
526
|
-
const unstaged = changedFiles(root, ['diff', '--name-only']);
|
|
527
|
-
const staged = changedFiles(root, ['diff', '--cached', '--name-only']);
|
|
528
|
-
const commitMessage = String(message || '');
|
|
529
|
-
const skipped = REGEN_ADAPTER_FILES.filter(
|
|
530
|
-
(file) => unstaged.has(file) && !staged.has(file) && !commitMessage.includes(file)
|
|
531
|
-
);
|
|
532
|
-
if (!skipped.length) return [];
|
|
533
|
-
console.log(`ship: skipped regenerated adapter churn: ${skipped.join(', ')}`);
|
|
534
|
-
if (!dryRun) runGit(['checkout', '--', ...skipped], { cwd: root });
|
|
535
|
-
return skipped;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function swarloClaim(root, { channel, taskKey, content }) {
|
|
539
|
-
const script = path.join(root, 'scripts', 'swarlo.py');
|
|
540
|
-
if (!fs.existsSync(script)) return 'skip: scripts/swarlo.py not found';
|
|
541
|
-
const result = spawnSync('python3', [script, 'claim', channel, taskKey, content], { cwd: root, encoding: 'utf8' });
|
|
542
|
-
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
543
|
-
if (result.status === 0) return output || `claimed ${taskKey}`;
|
|
544
|
-
return `skip: swarlo claim failed: ${output || result.status}`;
|
|
545
|
-
}
|
|
11
|
+
const worktreeCore = require('../lib/worktree-core');
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
ATRIS_BIN,
|
|
15
|
+
RESCUE_IDLE_MS,
|
|
16
|
+
finishShippedCopy,
|
|
17
|
+
formatKib,
|
|
18
|
+
SWEEP_IDLE_MS,
|
|
19
|
+
SWEEP_EVERY_HOURS,
|
|
20
|
+
SWEEP_KEPT_LABELS,
|
|
21
|
+
assertOneLapProofTree,
|
|
22
|
+
baseBranchName,
|
|
23
|
+
branchName,
|
|
24
|
+
cleanupWorktrees,
|
|
25
|
+
collidingFlights,
|
|
26
|
+
createAgentWorktree,
|
|
27
|
+
createDetachedShipBranch,
|
|
28
|
+
createOrFindPr,
|
|
29
|
+
currentBranch,
|
|
30
|
+
defaultMainlineBase,
|
|
31
|
+
defaultShipTarget,
|
|
32
|
+
defaultStartBase,
|
|
33
|
+
defaultSweepWorkspace,
|
|
34
|
+
deniedLaneForShip,
|
|
35
|
+
describeFlightAge,
|
|
36
|
+
detachedShipBranch,
|
|
37
|
+
findPrimaryRoot,
|
|
38
|
+
flightStampMs,
|
|
39
|
+
gitFailureDetail,
|
|
40
|
+
gitNetworkEnvironment,
|
|
41
|
+
inFlightAgentFlights,
|
|
42
|
+
listActiveProcessPaths,
|
|
43
|
+
listWorktrees,
|
|
44
|
+
lowDiskRefusal,
|
|
45
|
+
mergePrWithRetry,
|
|
46
|
+
newStatusRows,
|
|
47
|
+
normalizeTargetRef,
|
|
48
|
+
parseAgentFlightName,
|
|
49
|
+
parseProcessArgPaths,
|
|
50
|
+
parseWorktrees,
|
|
51
|
+
plural,
|
|
52
|
+
prMergeRef,
|
|
53
|
+
protectedPushRefusal,
|
|
54
|
+
readOneLapProofBinding,
|
|
55
|
+
reapPushedCopy,
|
|
56
|
+
reapShippedCopy,
|
|
57
|
+
recordWorktreeRemoval,
|
|
58
|
+
refreshMovedMapRefs,
|
|
59
|
+
refreshRemoteRef,
|
|
60
|
+
repoRoot,
|
|
61
|
+
restoreRegeneratedAdapterChurn,
|
|
62
|
+
runCommand,
|
|
63
|
+
runGit,
|
|
64
|
+
runShipHealthCheck,
|
|
65
|
+
scheduleShippedCopyRemoval,
|
|
66
|
+
shipMergeRecord,
|
|
67
|
+
shippedCopyRemovalPlan,
|
|
68
|
+
slugify,
|
|
69
|
+
statusCounts,
|
|
70
|
+
statusSnapshot,
|
|
71
|
+
swarloClaim,
|
|
72
|
+
sweepSchedulePlan,
|
|
73
|
+
sweepWorkspace,
|
|
74
|
+
taskTokens,
|
|
75
|
+
unstageConductorArtifacts,
|
|
76
|
+
worktreeHasActiveProcess,
|
|
77
|
+
} = worktreeCore;
|
|
546
78
|
|
|
547
79
|
function printStatus() {
|
|
548
80
|
const root = repoRoot();
|
|
@@ -562,241 +94,6 @@ function printStatus() {
|
|
|
562
94
|
}
|
|
563
95
|
}
|
|
564
96
|
|
|
565
|
-
// Programmatic core shared by `atris worktree start` and `atris mission start
|
|
566
|
-
// --worktree`: creates the branch + isolated checkout + identity sidecar and
|
|
567
|
-
// returns the facts. Throws on failure; callers own messaging and next steps.
|
|
568
|
-
//
|
|
569
|
-
// checkoutBase (what the branch is cut from) and shipBase (what `atris
|
|
570
|
-
// worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
|
|
571
|
-
// separate on purpose. checkoutBase defaults to the launcher's own
|
|
572
|
-
// upstream/default remote base, so the agent starts from current work.
|
|
573
|
-
// shipBase defaults to the remote mainline for agent/member worktrees regardless of
|
|
574
|
-
// what branch the launcher happened to have checked out. recording the
|
|
575
|
-
// launcher's own feature branch as the ship target false-landed two PRs on
|
|
576
|
-
// 2026-07-04 (ship merged into the launcher's branch, never reached master).
|
|
577
|
-
// --base/--target still overrides both when the caller wants a different
|
|
578
|
-
// checkout point and ship target on purpose.
|
|
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, formerTasks = [], branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
|
|
585
|
-
const owner = member || agent;
|
|
586
|
-
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
587
|
-
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
588
|
-
const mainline = baseOverride || defaultMainlineBase(root);
|
|
589
|
-
// Every new flight pays down completed checkout debt before adding another
|
|
590
|
-
// full repo copy. Branches and commits survive worktree removal, while real
|
|
591
|
-
// uncommitted changes remain protected by cleanupWorktrees.
|
|
592
|
-
const cleanupBudget = { count: 0, kib: 0 };
|
|
593
|
-
let reapedBeforeStart = [];
|
|
594
|
-
let cachePrunedBeforeStart = [];
|
|
595
|
-
try {
|
|
596
|
-
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start', cleanupBudget });
|
|
597
|
-
reapedBeforeStart = cleaned.removed;
|
|
598
|
-
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
599
|
-
} catch {}
|
|
600
|
-
let branch = branchOverride || branchName(owner, task, now);
|
|
601
|
-
let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
602
|
-
// Two runs of the same job in the same second get the same name; number
|
|
603
|
-
// the later one instead of failing it.
|
|
604
|
-
if (!pathOverride && !branchOverride) {
|
|
605
|
-
const baseBranch = branch;
|
|
606
|
-
const baseTarget = target;
|
|
607
|
-
for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
|
|
608
|
-
branch = `${baseBranch}-${n}`;
|
|
609
|
-
target = `${baseTarget}-${n}`;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
// Agent/member worktrees always cut from the mainline: honoring the
|
|
613
|
-
// launcher checkout's upstream let a dispatch inherit another session's
|
|
614
|
-
// in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
|
|
615
|
-
// unpushed commits from a parallel session and could not merge).
|
|
616
|
-
const checkoutBase = normalizeTargetRef(root, mainline);
|
|
617
|
-
const shipBase = checkoutBase;
|
|
618
|
-
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
619
|
-
// A full disk stops every flight at once. Reap first; refuse before cutting.
|
|
620
|
-
const room = checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget });
|
|
621
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
622
|
-
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
623
|
-
// A retry of the same job starts from the last run's unlanded commits; it
|
|
624
|
-
// still ships to the mainline.
|
|
625
|
-
let resumedFrom = (!pathOverride && !branchOverride)
|
|
626
|
-
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
627
|
-
: null;
|
|
628
|
-
// A last run that now conflicts with the mainline would hand its conflict
|
|
629
|
-
// to the retry, which then cannot land on the first try. Start fresh; the
|
|
630
|
-
// old branch keeps its commits.
|
|
631
|
-
let skippedResume = null;
|
|
632
|
-
if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
|
|
633
|
-
skippedResume = resumedFrom;
|
|
634
|
-
resumedFrom = null;
|
|
635
|
-
console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
|
|
636
|
-
}
|
|
637
|
-
// A display name is not an identity: it repeats across workspaces and changes when the task list
|
|
638
|
-
// changes, so a branch under a job's older name is never a starting point and never blocks a
|
|
639
|
-
// flight. It can still be this job's own unfinished work, so say where it is and leave the choice
|
|
640
|
-
// to a person; this run goes on from the job's own name.
|
|
641
|
-
const own = slugify(task, 'task', 36);
|
|
642
|
-
for (const former of Array.isArray(formerTasks) ? formerTasks : [formerTasks]) {
|
|
643
|
-
if (!former || !String(former).trim() || slugify(former, 'task', 36) === own) continue;
|
|
644
|
-
const olderRun = lastRunBranch(root, owner, former, checkoutBase, branch);
|
|
645
|
-
if (!olderRun) continue;
|
|
646
|
-
console.error(`note: older unfinished work may be on ${olderRun}; ${resumedFrom ? `continuing from ${resumedFrom} instead` : 'starting fresh'}`);
|
|
647
|
-
break;
|
|
648
|
-
}
|
|
649
|
-
const startPoint = resumedFrom || checkoutBase;
|
|
650
|
-
const ref = `refs/heads/${branch}`;
|
|
651
|
-
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
652
|
-
const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
|
|
653
|
-
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
654
|
-
// With no local copy the start fails on its own; only a real cut gets the warning.
|
|
655
|
-
if (staleBase && startCommit) {
|
|
656
|
-
console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
|
|
657
|
-
}
|
|
658
|
-
const discardFailedStart = () => {
|
|
659
|
-
if (existed || !startCommit) return;
|
|
660
|
-
const registered = listWorktrees(root).find((wt) => wt.branch === branch);
|
|
661
|
-
if (registered) {
|
|
662
|
-
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
663
|
-
|| registered.head !== startCommit) return;
|
|
664
|
-
const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
|
|
665
|
-
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
666
|
-
// No engine has started. Normal removal still refuses unexpected dirt.
|
|
667
|
-
runGit(['worktree', 'remove', target], { cwd: root });
|
|
668
|
-
}
|
|
669
|
-
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
670
|
-
};
|
|
671
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
672
|
-
const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
|
|
673
|
-
if (added.status === 0) break;
|
|
674
|
-
// Git can create the branch before failing to write its tracking config.
|
|
675
|
-
// Drop only that untouched ref, never an existing branch or a checkout.
|
|
676
|
-
if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
|
|
677
|
-
if (!configLocked(added) || attempt === 2) {
|
|
678
|
-
throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
|
|
679
|
-
}
|
|
680
|
-
sleepSync(100 * (attempt + 1));
|
|
681
|
-
}
|
|
682
|
-
// A resumed branch takes in what the mainline gained since, or a job that
|
|
683
|
-
// keeps resuming drifts further behind every run until it clashes
|
|
684
|
-
// (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
|
|
685
|
-
// check above proved this merge is clean; if git still refuses, the run
|
|
686
|
-
// keeps the old start instead of failing.
|
|
687
|
-
if (resumedFrom) {
|
|
688
|
-
const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
|
|
689
|
-
if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
|
|
690
|
-
}
|
|
691
|
-
try {
|
|
692
|
-
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
693
|
-
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
694
|
-
runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
|
|
695
|
-
fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
|
|
696
|
-
fs.writeFileSync(
|
|
697
|
-
path.join(target, '.atris', 'agent-worktree.json'),
|
|
698
|
-
JSON.stringify({
|
|
699
|
-
agent: agent || null,
|
|
700
|
-
member: member || null,
|
|
701
|
-
owner,
|
|
702
|
-
task,
|
|
703
|
-
branch,
|
|
704
|
-
base: shipBase,
|
|
705
|
-
checkout_base: checkoutBase,
|
|
706
|
-
resumed_from: resumedFrom,
|
|
707
|
-
skipped_resume: skippedResume,
|
|
708
|
-
workspace_root: root,
|
|
709
|
-
created_at: now.toISOString(),
|
|
710
|
-
}, null, 2) + '\n',
|
|
711
|
-
'utf8'
|
|
712
|
-
);
|
|
713
|
-
} catch (error) {
|
|
714
|
-
discardFailedStart();
|
|
715
|
-
throw error;
|
|
716
|
-
}
|
|
717
|
-
const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
|
|
718
|
-
const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
|
|
719
|
-
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// node_modules as a clone of a matching install (see lib/worktree-space.js),
|
|
723
|
-
// so a web copy costs 12 MB and 2 seconds instead of npm ci.
|
|
724
|
-
function seedCopyDeps({ root, target, options = {} }) {
|
|
725
|
-
try {
|
|
726
|
-
const primaryRoot = findPrimaryRoot(root);
|
|
727
|
-
return space.seedNodeModules({
|
|
728
|
-
target,
|
|
729
|
-
workspace: path.dirname(primaryRoot),
|
|
730
|
-
repoName: path.basename(primaryRoot),
|
|
731
|
-
sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
|
|
732
|
-
isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
|
|
733
|
-
...options,
|
|
734
|
-
});
|
|
735
|
-
} catch (error) {
|
|
736
|
-
return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// When the disk runs low, reaps finished copies of every repo in the
|
|
741
|
-
// workspace (merged, clean, nothing running inside, past the fresh-copy
|
|
742
|
-
// grace). Below the floor it refuses with ATRIS_LOW_DISK, before any copy
|
|
743
|
-
// is cut. Limits: ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB.
|
|
744
|
-
// Even when the disk is low, a copy touched in the last ten minutes stays:
|
|
745
|
-
// the engine that just finished may still have its last message unread.
|
|
746
|
-
const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
|
|
747
|
-
|
|
748
|
-
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start', cleanupBudget = null }) {
|
|
749
|
-
const limits = space.spaceLimits(env);
|
|
750
|
-
const before = freeSpace(target);
|
|
751
|
-
const room = { before, free: before, removed: [], cleared: [], ...limits };
|
|
752
|
-
if (typeof before !== 'number' || !Number.isFinite(before)) return room;
|
|
753
|
-
if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
|
|
754
|
-
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by, cleanupBudget });
|
|
755
|
-
for (const repo of swept.repos) {
|
|
756
|
-
room.removed.push(...(repo.removed || []));
|
|
757
|
-
room.cleared.push(...(repo.cachePruned || []));
|
|
758
|
-
}
|
|
759
|
-
const after = freeSpace(target);
|
|
760
|
-
room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
|
|
761
|
-
if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
|
|
762
|
-
const count = room.removed.length;
|
|
763
|
-
const reaped = count
|
|
764
|
-
? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
|
|
765
|
-
: 'No finished build copies were safe to remove.';
|
|
766
|
-
const error = new Error(
|
|
767
|
-
`only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
|
|
768
|
-
+ 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
|
|
769
|
-
);
|
|
770
|
-
error.code = 'ATRIS_LOW_DISK';
|
|
771
|
-
error.space = room;
|
|
772
|
-
throw error;
|
|
773
|
-
}
|
|
774
|
-
return room;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// When the disk is checked before a copy is cut: spaceGuard says so, else anywhere but a temp folder.
|
|
778
|
-
function checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget = null }) {
|
|
779
|
-
return (spaceGuard ?? !space.isScratchPath(target))
|
|
780
|
-
? ensureRoomForCopy({ root, target, freeSpace, env, cleanupBudget })
|
|
781
|
-
: null;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// The question worktree start asks about the disk, asked on its own so a flight can ask it once before it
|
|
785
|
-
// claims anything: the same floor, the same reaping of finished copies, the same words. null when there is
|
|
786
|
-
// room, when the disk cannot be read, or when a copy would land in a temp folder. Otherwise the sentence
|
|
787
|
-
// worktree start would refuse with, and the numbers behind it. Any other trouble is left for worktree start
|
|
788
|
-
// to report when it runs. target names the folder the build will use when it is not worktree start's usual
|
|
789
|
-
// one (a sealed review sandbox is cut in the temp folder).
|
|
790
|
-
function lowDiskRefusal({ root = repoRoot(), member = '', agent = '', task = 'flight', now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, target = '' } = {}) {
|
|
791
|
-
try {
|
|
792
|
-
checkRoomForCopy({ root, target: target ? path.resolve(target) : path.resolve(defaultWorktreePath(root, member || agent, task, now)), freeSpace, env, spaceGuard });
|
|
793
|
-
return null;
|
|
794
|
-
} catch (error) {
|
|
795
|
-
if (!error || error.code !== 'ATRIS_LOW_DISK') return null;
|
|
796
|
-
return { message: error.message, free: error.space.free, needs: error.space.refuseBelow };
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
97
|
function startWorktree(args) {
|
|
801
98
|
const root = repoRoot();
|
|
802
99
|
const member = readFlag(args, '--member');
|
|
@@ -807,8 +104,6 @@ function startWorktree(args) {
|
|
|
807
104
|
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--former-task "<older name of the task>"] [--claim] [--force] [--no-deps]');
|
|
808
105
|
return 2;
|
|
809
106
|
}
|
|
810
|
-
// The task's older name, when it has gone by one. It is only mentioned: an unfinished branch under
|
|
811
|
-
// it is named in one line, never resumed and never a collision (see createAgentWorktree).
|
|
812
107
|
const formerTask = readFlag(args, '--former-task');
|
|
813
108
|
const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
|
|
814
109
|
if (memberFile && !fs.existsSync(memberFile)) {
|
|
@@ -890,56 +185,6 @@ function startWorktree(args) {
|
|
|
890
185
|
return 0;
|
|
891
186
|
}
|
|
892
187
|
|
|
893
|
-
function createOrFindPr(root, branch, targetRef, title, dryRun) {
|
|
894
|
-
const targetBranch = baseBranchName(targetRef);
|
|
895
|
-
// `gh pr view` (no --state filter) returns ANY PR for this branch name,
|
|
896
|
-
// including one already MERGED or CLOSED. A worktree branch that ships
|
|
897
|
-
// more than once (this same branch, more commits, another `worktree ship`)
|
|
898
|
-
// would then reuse the dead PR number: `gh pr merge <dead pr> --merge`
|
|
899
|
-
// exits 0 with just a warning (never fails), so the new commits silently
|
|
900
|
-
// never land while the ship command still reports success. Only reuse a
|
|
901
|
-
// PR that is still OPEN; anything else gets a fresh one.
|
|
902
|
-
const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
|
|
903
|
-
cwd: root,
|
|
904
|
-
encoding: 'utf8',
|
|
905
|
-
});
|
|
906
|
-
if (existing.status === 0 && existing.stdout.trim()) {
|
|
907
|
-
const parts = existing.stdout.trim().split(/\s+/);
|
|
908
|
-
const state = parts.pop();
|
|
909
|
-
if (state === 'OPEN') return parts.join(' ');
|
|
910
|
-
}
|
|
911
|
-
const body = [
|
|
912
|
-
'Automated Atris worktree ship.',
|
|
913
|
-
'',
|
|
914
|
-
`Branch: ${branch}`,
|
|
915
|
-
`Target: ${targetBranch}`,
|
|
916
|
-
].join('\n');
|
|
917
|
-
if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
|
|
918
|
-
// An engine that produced nothing leaves a branch level with its base. Asking
|
|
919
|
-
// GitHub for a PR there fails with "No commits between ...", and that throw used
|
|
920
|
-
// to escape the flight and abort the whole run. Say it plainly instead.
|
|
921
|
-
const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
|
|
922
|
-
if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
|
|
923
|
-
console.log('pr: skipped (no commits ahead of base)');
|
|
924
|
-
return '';
|
|
925
|
-
}
|
|
926
|
-
const created = spawnSync(
|
|
927
|
-
'gh',
|
|
928
|
-
['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
|
|
929
|
-
{ cwd: root, encoding: 'utf8' }
|
|
930
|
-
);
|
|
931
|
-
if (created.status !== 0) {
|
|
932
|
-
throw new Error((created.stderr || created.stdout || 'gh pr create failed').trim());
|
|
933
|
-
}
|
|
934
|
-
return created.stdout.trim();
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function prMergeRef(prOutput) {
|
|
938
|
-
const text = String(prOutput || '').trim();
|
|
939
|
-
if (!text || text.startsWith('dry-run:')) return '';
|
|
940
|
-
return text.split(/\s+/)[0];
|
|
941
|
-
}
|
|
942
|
-
|
|
943
188
|
function shipHelp() {
|
|
944
189
|
console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
945
190
|
console.log('');
|
|
@@ -948,171 +193,11 @@ function shipHelp() {
|
|
|
948
193
|
console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
|
|
949
194
|
console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
|
|
950
195
|
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
196
|
+
console.log(' --keep-copy keep this build copy; by default it is removed once the work lands or is pushed');
|
|
951
197
|
console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
|
|
952
198
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
953
199
|
}
|
|
954
200
|
|
|
955
|
-
function deniedLaneForShip(root, branch, message) {
|
|
956
|
-
try {
|
|
957
|
-
const { protectedLaneOfTask } = require('../lib/fleet');
|
|
958
|
-
const refs = new Set();
|
|
959
|
-
for (const source of [String(branch || ''), String(message || '')]) {
|
|
960
|
-
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
961
|
-
for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
|
|
962
|
-
}
|
|
963
|
-
// A flight's branch names its job by row id, lowercased, not by the display id a person reads
|
|
964
|
-
// (flightTaskName in lib/fleet.js), so a branch also finds a job by its row id as a whole word.
|
|
965
|
-
const words = new Set(String(branch || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
966
|
-
const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
|
|
967
|
-
if (!fs.existsSync(projectionPath)) return null;
|
|
968
|
-
const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
|
|
969
|
-
for (const task of projection.tasks || []) {
|
|
970
|
-
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
971
|
-
// Only a real row id counts: a short or odd one could match any word in a branch name.
|
|
972
|
-
const rowId = typeof task.id === 'string' && /^[A-Za-z0-9]{16,}$/.test(task.id) ? task.id.toLowerCase() : '';
|
|
973
|
-
if (!ids.some(id => refs.has(id)) && !(rowId && words.has(rowId))) continue;
|
|
974
|
-
const hit = protectedLaneOfTask(task);
|
|
975
|
-
if (hit) return { taskId: ids[0] || task.id, tag: hit };
|
|
976
|
-
}
|
|
977
|
-
return null;
|
|
978
|
-
} catch {
|
|
979
|
-
return null;
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Pushing a protected-lane branch for review lands nothing, unless the branch
|
|
984
|
-
// already has a pull request with GitHub auto-merge on: that request merges
|
|
985
|
-
// itself the moment its checks pass, and the push is what feeds it. A branch
|
|
986
|
-
// that shipped earlier with --merge and was retagged into a protected lane since
|
|
987
|
-
// would land with no person. One branch can have several open pull requests (one
|
|
988
|
-
// per base), and a single push feeds every one of them, so this asks for the whole
|
|
989
|
-
// list: `gh pr view` shows just one, and it may be the one without auto-merge.
|
|
990
|
-
// Returns the words to refuse with, or '' when the push is safe. It fails closed:
|
|
991
|
-
// if gh cannot say, or the list may be incomplete, nothing is pushed.
|
|
992
|
-
const OPEN_PR_LIST_LIMIT = 100;
|
|
993
|
-
|
|
994
|
-
function protectedPushRefusal(root, branch, run = spawnSync) {
|
|
995
|
-
const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
|
|
996
|
-
// A minute is plenty for one lookup; a gh that hangs must not hang the ship.
|
|
997
|
-
const listed = run('gh', [
|
|
998
|
-
'pr', 'list', '--head', branch, '--state', 'open',
|
|
999
|
-
'--json', 'number,url,baseRefName,autoMergeRequest',
|
|
1000
|
-
'--limit', String(OPEN_PR_LIST_LIMIT),
|
|
1001
|
-
], { cwd: root, encoding: 'utf8', timeout: 60000 });
|
|
1002
|
-
if (listed.error) {
|
|
1003
|
-
return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
|
|
1004
|
-
}
|
|
1005
|
-
if (listed.status !== 0) {
|
|
1006
|
-
// No branch has an empty answer to read as "none": an empty list is an
|
|
1007
|
-
// exit 0, so a failed lookup is never taken for a clear.
|
|
1008
|
-
const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
|
|
1009
|
-
const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
1010
|
-
return unknown(firstLine || `gh exited ${listed.status}`);
|
|
1011
|
-
}
|
|
1012
|
-
let open;
|
|
1013
|
-
try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
|
|
1014
|
-
// Every entry must say whether auto-merge is on (null means off), or the
|
|
1015
|
-
// answer is not one this check can lean on.
|
|
1016
|
-
const readable = Array.isArray(open)
|
|
1017
|
-
&& open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
|
|
1018
|
-
if (!readable) return unknown("gh's answer could not be read");
|
|
1019
|
-
// gh stops at its limit without saying so: a full page may be only part of the list.
|
|
1020
|
-
if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
|
|
1021
|
-
const armed = open.filter((pr) => pr.autoMergeRequest);
|
|
1022
|
-
if (!armed.length) return '';
|
|
1023
|
-
const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
|
|
1024
|
-
const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
|
|
1025
|
-
if (armed.length === 1) {
|
|
1026
|
-
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`;
|
|
1027
|
-
}
|
|
1028
|
-
const names = armed.map(name);
|
|
1029
|
-
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`;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
function findPrimaryRoot(root) {
|
|
1033
|
-
const worktrees = listWorktrees(root);
|
|
1034
|
-
return worktrees[0]?.path || root;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function shellQuote(value) {
|
|
1038
|
-
const text = String(value || '');
|
|
1039
|
-
if (/^[A-Za-z0-9_./:@+-]+$/.test(text)) return text;
|
|
1040
|
-
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function experimentHealthProbe(statePath) {
|
|
1044
|
-
const script = [
|
|
1045
|
-
"const fs=require('fs')",
|
|
1046
|
-
'const state=JSON.parse(fs.readFileSync(process.argv[1],\'utf8\'))',
|
|
1047
|
-
'const last=Date.parse(state.last_run_date)',
|
|
1048
|
-
`process.exit(Number.isFinite(last)&&Date.now()-last<=${DAY_MS}?0:1)`,
|
|
1049
|
-
].join(';');
|
|
1050
|
-
return `${shellQuote(process.execPath)} -e ${shellQuote(script)} ${shellQuote(statePath)}`;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function runShipHealthCheck(workspaceRoot, options = {}) {
|
|
1054
|
-
try {
|
|
1055
|
-
const now = options.now ? new Date(options.now) : new Date();
|
|
1056
|
-
const statePath = path.join(workspaceRoot, '.atris', 'state', 'experiments-daily.json');
|
|
1057
|
-
const state = fs.existsSync(statePath)
|
|
1058
|
-
? JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
|
1059
|
-
: {};
|
|
1060
|
-
const rawLastRunDate = state && state.last_run_date;
|
|
1061
|
-
const lastRunMs = rawLastRunDate ? Date.parse(rawLastRunDate) : null;
|
|
1062
|
-
if (rawLastRunDate && !Number.isFinite(lastRunMs)) {
|
|
1063
|
-
throw new Error('experiment state date is unreadable');
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
const ageMs = Number.isFinite(lastRunMs) ? now.getTime() - lastRunMs : null;
|
|
1067
|
-
if (ageMs !== null && ageMs <= DAY_MS) return { healthy: true, filed: false };
|
|
1068
|
-
|
|
1069
|
-
const days = ageMs === null ? 1 : Math.max(1, Math.floor(ageMs / DAY_MS));
|
|
1070
|
-
console.log(`health check failed: no verified experiment in ${days} days. shipping still works, the metabolism does not.`);
|
|
1071
|
-
const filed = close.upsertSourceFlag({
|
|
1072
|
-
what: 'no verified experiment has run in over a day',
|
|
1073
|
-
owner: 'operator',
|
|
1074
|
-
lane: 'code',
|
|
1075
|
-
ttlDays: 1,
|
|
1076
|
-
source: SHIP_HEALTH_SOURCE,
|
|
1077
|
-
closeCondition: 'a verified experiment ran within 24 hours',
|
|
1078
|
-
probe: experimentHealthProbe(statePath),
|
|
1079
|
-
}, workspaceRoot, { now });
|
|
1080
|
-
return { healthy: false, ...filed };
|
|
1081
|
-
} catch (error) {
|
|
1082
|
-
console.log(`health check skipped: ${String(error && error.message || error).toLowerCase()}. shipping still works.`);
|
|
1083
|
-
return { healthy: null, filed: false, error };
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
|
|
1088
|
-
const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
1089
|
-
|
|
1090
|
-
// Rewrites map line refs whose code moved (the same fix `atris doc-health
|
|
1091
|
-
// --fix-refs` applies), then commits only the rewritten map files. A human-only
|
|
1092
|
-
// remainder or an error never blocks the ship; pass --no-map-refresh to skip.
|
|
1093
|
-
function refreshMovedMapRefs(root, args = []) {
|
|
1094
|
-
if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
|
|
1095
|
-
try {
|
|
1096
|
-
const { changes, left } = fixMapDocs(root);
|
|
1097
|
-
if (changes.length) {
|
|
1098
|
-
const docs = [...new Set(changes.map(change => change.doc))];
|
|
1099
|
-
runGit(['add', '--', ...docs], { cwd: root });
|
|
1100
|
-
runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
|
|
1101
|
-
console.log(`map: refreshed ${changes.length} moved references`);
|
|
1102
|
-
} else {
|
|
1103
|
-
console.log('map: references current');
|
|
1104
|
-
}
|
|
1105
|
-
if (left.length) {
|
|
1106
|
-
console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
|
|
1107
|
-
}
|
|
1108
|
-
return { skipped: false, refreshed: changes.length, left: left.length };
|
|
1109
|
-
} catch (error) {
|
|
1110
|
-
const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
1111
|
-
console.log(`map: refresh skipped (${message}); the ship continues`);
|
|
1112
|
-
return { skipped: false, refreshed: 0, left: 0, error };
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
201
|
function shipWorktree(args) {
|
|
1117
202
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
1118
203
|
shipHelp();
|
|
@@ -1123,6 +208,7 @@ function shipWorktree(args) {
|
|
|
1123
208
|
const noPr = hasFlag(args, '--no-pr');
|
|
1124
209
|
const localMode = hasFlag(args, '--local') || hasFlag(args, '--local-merge');
|
|
1125
210
|
const merge = hasFlag(args, '--merge');
|
|
211
|
+
const keepCopy = hasFlag(args, '--keep-copy');
|
|
1126
212
|
const message = readFlag(args, '--message') || readFlag(args, '-m');
|
|
1127
213
|
const verify = readFlag(args, '--verify');
|
|
1128
214
|
let branch = currentBranch(root);
|
|
@@ -1146,13 +232,6 @@ function shipWorktree(args) {
|
|
|
1146
232
|
console.error('blocked: ship from an isolated worktree, not the primary checkout');
|
|
1147
233
|
return 2;
|
|
1148
234
|
}
|
|
1149
|
-
// Denied lanes are checked at dispatch, but a flight that was staffed
|
|
1150
|
-
// before a task entered a denied lane (or killed mid-flight) could still
|
|
1151
|
-
// ship: a payments command self-landed twice this way on 2026-07-26. The
|
|
1152
|
-
// ship gate is the last door, so it re-checks. It guards landing only:
|
|
1153
|
-
// pushing the branch and opening a pull request lands nothing, and that is
|
|
1154
|
-
// how protected work reaches a person to review. The one exception is a
|
|
1155
|
-
// pull request that already has auto-merge on, which a push would feed.
|
|
1156
235
|
if (!hasFlag(args, '--allow-denied-lane')) {
|
|
1157
236
|
const denied = deniedLaneForShip(root, branch, message);
|
|
1158
237
|
if (denied && (merge || localMode)) {
|
|
@@ -1186,8 +265,6 @@ function shipWorktree(args) {
|
|
|
1186
265
|
refreshRemoteRef(root, targetRef);
|
|
1187
266
|
const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
|
|
1188
267
|
const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
|
|
1189
|
-
// Conductor plumbing must not make the checkout look dirty, or a flight where the
|
|
1190
|
-
// engine wrote nothing commits the fleet's own prompt file and calls it work.
|
|
1191
268
|
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
|
|
1192
269
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
1193
270
|
const dirty = counts.staged || counts.unstaged || counts.untracked;
|
|
@@ -1200,8 +277,6 @@ function shipWorktree(args) {
|
|
|
1200
277
|
console.log(`commit: ${message}`);
|
|
1201
278
|
if (!dryRun) {
|
|
1202
279
|
runGit(['add', '-A'], { cwd: root });
|
|
1203
|
-
// `add -A` sweeps in the fleet's own prompt file and friends. Drop them from the
|
|
1204
|
-
// index so a work commit carries work, not harness plumbing. Safe when absent.
|
|
1205
280
|
unstageConductorArtifacts(root);
|
|
1206
281
|
if (!proofTreeMatches('index')) return 3;
|
|
1207
282
|
runGit(['commit', '-m', message], { cwd: root });
|
|
@@ -1224,8 +299,6 @@ function shipWorktree(args) {
|
|
|
1224
299
|
if (!dryRun) runCommand(verify, { cwd: root });
|
|
1225
300
|
const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
|
|
1226
301
|
const verifyDelta = newStatusRows(beforeVerify, afterVerifyRows);
|
|
1227
|
-
// This check exists to catch a verifier that writes files. Conductor plumbing was
|
|
1228
|
-
// already present before the verifier ran, so counting it here blocks a clean ship.
|
|
1229
302
|
const afterVerify = dryRun
|
|
1230
303
|
? { staged: 0, unstaged: 0, untracked: 0 }
|
|
1231
304
|
: statusCounts(root, { ignoreUntracked: isConductorUntracked })
|
|
@@ -1255,7 +328,6 @@ function shipWorktree(args) {
|
|
|
1255
328
|
}
|
|
1256
329
|
console.log(`merge_check: ${targetRef} clean`);
|
|
1257
330
|
|
|
1258
|
-
// Set once the change is confirmed merged; the build copy then goes away.
|
|
1259
331
|
let landedOn = '';
|
|
1260
332
|
if (localMode) {
|
|
1261
333
|
console.log('push: skipped (local mode)');
|
|
@@ -1284,7 +356,7 @@ function shipWorktree(args) {
|
|
|
1284
356
|
}
|
|
1285
357
|
console.log('pr: skipped (local mode)');
|
|
1286
358
|
if (merge && !dryRun) runShipHealthCheck(primary || root);
|
|
1287
|
-
if (landedOn)
|
|
359
|
+
if (landedOn) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
|
|
1288
360
|
console.log('done: worktree shipped');
|
|
1289
361
|
return 0;
|
|
1290
362
|
}
|
|
@@ -1305,13 +377,9 @@ function shipWorktree(args) {
|
|
|
1305
377
|
mergeArgs.push('--merge');
|
|
1306
378
|
const merged = mergePrWithRetry(mergeArgs, root);
|
|
1307
379
|
if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
|
|
1308
|
-
// `gh pr merge` on an already-merged PR exits 0 with a warning
|
|
1309
|
-
// instead of failing, which would otherwise report a false landing.
|
|
1310
380
|
if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
|
|
1311
381
|
throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
|
|
1312
382
|
}
|
|
1313
|
-
// A merge queue accepts the request before merging. Deleting its head
|
|
1314
|
-
// here closes the PR and cancels that request, so confirm the outcome.
|
|
1315
383
|
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt,number,url,mergeCommit'], { cwd: root, encoding: 'utf8' });
|
|
1316
384
|
if (confirmation.status !== 0) {
|
|
1317
385
|
throw new Error('could not confirm the merge; the source branch was kept');
|
|
@@ -1322,14 +390,13 @@ function shipWorktree(args) {
|
|
|
1322
390
|
}
|
|
1323
391
|
if (state.state === 'OPEN' && !state.mergedAt) {
|
|
1324
392
|
console.log('merge: pending; kept the source branch while required checks finish');
|
|
393
|
+
finishShippedCopy({ root, primary, branch, keep: keepCopy });
|
|
1325
394
|
return 0;
|
|
1326
395
|
}
|
|
1327
396
|
if (state.state !== 'MERGED' || !state.mergedAt) {
|
|
1328
397
|
throw new Error('the change was not merged; the source branch was kept');
|
|
1329
398
|
}
|
|
1330
399
|
console.log('merge: merged');
|
|
1331
|
-
// One machine-readable line, so a caller can save which pull request
|
|
1332
|
-
// and commit landed. The rating only counts a landing it can point at.
|
|
1333
400
|
const record = shipMergeRecord(state);
|
|
1334
401
|
if (record) console.log(`merge: record ${JSON.stringify(record)}`);
|
|
1335
402
|
landedOn = targetRef;
|
|
@@ -1353,117 +420,25 @@ function shipWorktree(args) {
|
|
|
1353
420
|
}, { worktree: root });
|
|
1354
421
|
} catch {}
|
|
1355
422
|
if (merge && !dryRun) runShipHealthCheck(primary || root);
|
|
1356
|
-
if (
|
|
423
|
+
if (!dryRun) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
|
|
1357
424
|
console.log('done: worktree shipped');
|
|
1358
425
|
return 0;
|
|
1359
426
|
}
|
|
1360
427
|
|
|
1361
|
-
// A shipped build copy is dead weight, but the engine that shipped it is
|
|
1362
|
-
// still inside and writes its last message there on exit. A detached
|
|
1363
|
-
// `atris worktree reap-shipped` waits until nothing has run in the copy for
|
|
1364
|
-
// SHIP_SETTLE_MS, then removes it if it is still merged and clean.
|
|
1365
|
-
const SHIP_SETTLE_MS = 10 * 60 * 1000;
|
|
1366
|
-
const SHIP_REAP_POLL_MS = 60 * 1000;
|
|
1367
|
-
const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
1368
|
-
// Right after a merge the local copy of the target can lag; allow a short
|
|
1369
|
-
// wait before treating the copy as unmerged and leaving it for the sweep.
|
|
1370
|
-
const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
|
|
1371
|
-
// Reasons that can clear on their own: someone is still inside, or the
|
|
1372
|
-
// main branch moved or could not be reached during the check.
|
|
1373
|
-
const SHIP_REAP_RETRY_REASONS = new Set([
|
|
1374
|
-
'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
|
|
1375
|
-
'ignored_scan_failed', 'changed_or_active_during_verification',
|
|
1376
|
-
]);
|
|
1377
|
-
|
|
1378
|
-
function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
|
|
1379
|
-
const copy = path.resolve(root);
|
|
1380
|
-
if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
|
|
1381
|
-
if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
|
|
1382
|
-
if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
|
|
1383
|
-
if (space.isScratchPath(copy)) return { skip: 'temp folder' };
|
|
1384
|
-
// The removal runs this same atris; one that lives in the copy would delete itself.
|
|
1385
|
-
if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
|
|
1386
|
-
return { copy };
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
|
|
1390
|
-
const plan = shippedCopyRemovalPlan({ root, primary, env });
|
|
1391
|
-
if (plan.skip) return plan;
|
|
1392
|
-
try {
|
|
1393
|
-
// The copy rides in the environment, not the arguments, so the process
|
|
1394
|
-
// scan does not count this waiter as someone working in the copy.
|
|
1395
|
-
const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
|
|
1396
|
-
cwd: primary,
|
|
1397
|
-
detached: true,
|
|
1398
|
-
stdio: 'ignore',
|
|
1399
|
-
env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
|
|
1400
|
-
});
|
|
1401
|
-
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
1402
|
-
if (child && typeof child.unref === 'function') child.unref();
|
|
1403
|
-
return { ...plan, scheduled: true };
|
|
1404
|
-
} catch (error) {
|
|
1405
|
-
return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
function announceShippedCopyRemoval(plan) {
|
|
1410
|
-
if (plan.scheduled) {
|
|
1411
|
-
console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
|
|
1412
|
-
} else if (plan.announce) {
|
|
1413
|
-
console.log(`cleanup: kept this build copy (${plan.skip})`);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
function reapShippedCopy({
|
|
1418
|
-
root,
|
|
1419
|
-
target,
|
|
1420
|
-
base = '',
|
|
1421
|
-
settleMs = SHIP_SETTLE_MS,
|
|
1422
|
-
pollMs = SHIP_REAP_POLL_MS,
|
|
1423
|
-
giveUpMs = SHIP_REAP_GIVE_UP_MS,
|
|
1424
|
-
unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
|
|
1425
|
-
sleep = sleepSync,
|
|
1426
|
-
clock = Date.now,
|
|
1427
|
-
cleanup: cleanupFn = cleanupWorktrees,
|
|
1428
|
-
} = {}) {
|
|
1429
|
-
const startedAt = clock();
|
|
1430
|
-
sleep(settleMs);
|
|
1431
|
-
for (;;) {
|
|
1432
|
-
const result = cleanupFn({
|
|
1433
|
-
root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
|
|
1434
|
-
});
|
|
1435
|
-
if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
|
|
1436
|
-
const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
|
|
1437
|
-
if (!entry) return { removed: false, reason: 'not_registered' };
|
|
1438
|
-
const waited = clock() - startedAt;
|
|
1439
|
-
const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
|
|
1440
|
-
if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
|
|
1441
|
-
sleep(pollMs);
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
428
|
function reapShipped() {
|
|
1446
429
|
const target = process.env.ATRIS_REAP_WORKTREE || '';
|
|
1447
430
|
if (!target) {
|
|
1448
431
|
console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
|
|
1449
432
|
return 2;
|
|
1450
433
|
}
|
|
1451
|
-
const
|
|
434
|
+
const base = process.env.ATRIS_REAP_BASE || '';
|
|
435
|
+
const result = base
|
|
436
|
+
? reapShippedCopy({ root: process.cwd(), target, base })
|
|
437
|
+
: reapPushedCopy({ primary: process.cwd(), target, branch: process.env.ATRIS_REAP_BRANCH || '' });
|
|
1452
438
|
console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
|
|
1453
439
|
return result.removed ? 0 : 1;
|
|
1454
440
|
}
|
|
1455
441
|
|
|
1456
|
-
// The landed pull request from `gh pr view --json`, or null without a merge commit.
|
|
1457
|
-
function shipMergeRecord(state) {
|
|
1458
|
-
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
1459
|
-
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
1460
|
-
const record = { merge_commit: commit };
|
|
1461
|
-
if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
|
|
1462
|
-
if (typeof state.url === 'string' && state.url) record.url = state.url;
|
|
1463
|
-
if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
|
|
1464
|
-
return record;
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
442
|
function guard(args) {
|
|
1468
443
|
const root = repoRoot();
|
|
1469
444
|
const worktrees = listWorktrees(root);
|
|
@@ -1473,9 +448,6 @@ function guard(args) {
|
|
|
1473
448
|
console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
|
|
1474
449
|
return 2;
|
|
1475
450
|
}
|
|
1476
|
-
// Conductor plumbing (worktree metadata, the fleet prompt written for this very
|
|
1477
|
-
// engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
|
|
1478
|
-
// harness blocks the engine with the harness's own files.
|
|
1479
451
|
const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
|
|
1480
452
|
if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
|
|
1481
453
|
console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
|
|
@@ -1494,503 +466,6 @@ function prune(args) {
|
|
|
1494
466
|
return result.status || 0;
|
|
1495
467
|
}
|
|
1496
468
|
|
|
1497
|
-
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
1498
|
-
|
|
1499
|
-
// A worktree fresh off `worktree start` is merged into base by definition
|
|
1500
|
-
// (zero commits yet), so without a grace window the janitor reaps it while
|
|
1501
|
-
// the engine that requested it is still booting inside.
|
|
1502
|
-
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
1503
|
-
const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
1504
|
-
'.derivedData',
|
|
1505
|
-
'.next',
|
|
1506
|
-
'.swiftpm',
|
|
1507
|
-
'.cache/swiftpm',
|
|
1508
|
-
'node_modules/.cache',
|
|
1509
|
-
];
|
|
1510
|
-
|
|
1511
|
-
function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
|
|
1512
|
-
try {
|
|
1513
|
-
return now - fs.statSync(worktreePath).mtimeMs < graceMs;
|
|
1514
|
-
} catch {
|
|
1515
|
-
return false;
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// Newest sign of work in a copy: its folder, its branch tip and reflog, web
|
|
1520
|
-
// build output, and the engine's last message. The index is left out on
|
|
1521
|
-
// purpose: a plain `git status` rewrites it.
|
|
1522
|
-
function lastCopyActivityMs(worktreePath) {
|
|
1523
|
-
const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
|
|
1524
|
-
try {
|
|
1525
|
-
const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
|
|
1526
|
-
if (pointer) {
|
|
1527
|
-
const gitDir = path.resolve(worktreePath, pointer[1].trim());
|
|
1528
|
-
marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
|
|
1529
|
-
}
|
|
1530
|
-
} catch {}
|
|
1531
|
-
let newest = 0;
|
|
1532
|
-
for (const mark of marks) {
|
|
1533
|
-
try {
|
|
1534
|
-
newest = Math.max(newest, fs.statSync(mark).mtimeMs);
|
|
1535
|
-
} catch {}
|
|
1536
|
-
}
|
|
1537
|
-
return newest;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
function canonicalPath(value) {
|
|
1541
|
-
try {
|
|
1542
|
-
return fs.realpathSync(value);
|
|
1543
|
-
} catch {
|
|
1544
|
-
return path.resolve(value);
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
// null means the scan failed: we cannot tell who is inside, so callers keep
|
|
1549
|
-
// every folder. An empty list would read as "nobody is here" and delete.
|
|
1550
|
-
function listActiveProcessFiles() {
|
|
1551
|
-
const result = spawnSync('lsof', ['-n', '-F', 'n'], {
|
|
1552
|
-
encoding: 'utf8',
|
|
1553
|
-
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1554
|
-
timeout: 20000,
|
|
1555
|
-
killSignal: 'SIGKILL',
|
|
1556
|
-
});
|
|
1557
|
-
if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
|
|
1558
|
-
const paths = String(result.stdout || '')
|
|
1559
|
-
.split(/\r?\n/)
|
|
1560
|
-
.filter((line) => line.startsWith('n/'))
|
|
1561
|
-
.map((line) => canonicalPath(line.slice(1)))
|
|
1562
|
-
.filter(Boolean);
|
|
1563
|
-
return paths.length ? paths : null;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
// Absolute paths named on each program's command line, including flag values
|
|
1567
|
-
// like --require=/x.js. A program can run a script from a work folder while
|
|
1568
|
-
// its current folder is elsewhere; the folder scan alone misses it. The list
|
|
1569
|
-
// prints arguments unquoted, so a path with a space is also kept as the rest
|
|
1570
|
-
// of the line from where it starts; the folder match is a prefix check.
|
|
1571
|
-
function parseProcessArgPaths(psOutput) {
|
|
1572
|
-
const paths = new Set();
|
|
1573
|
-
const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
|
|
1574
|
-
for (const raw of String(psOutput || '').split(/\r?\n/)) {
|
|
1575
|
-
const line = raw.trim();
|
|
1576
|
-
for (const match of line.matchAll(starts)) {
|
|
1577
|
-
const rest = line.slice(match.index + match[0].length);
|
|
1578
|
-
const token = rest.split(/\s/, 1)[0];
|
|
1579
|
-
if (token.length > 1) paths.add(token);
|
|
1580
|
-
if (rest.length > token.length) paths.add(rest);
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
return [...paths];
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
function listActiveProcessArgPaths() {
|
|
1587
|
-
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1588
|
-
encoding: 'utf8',
|
|
1589
|
-
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1590
|
-
timeout: 5000,
|
|
1591
|
-
killSignal: 'SIGKILL',
|
|
1592
|
-
});
|
|
1593
|
-
if (result.error || result.status !== 0) return null;
|
|
1594
|
-
return parseProcessArgPaths(result.stdout);
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// Open files and working folders, plus script paths on command lines.
|
|
1598
|
-
function listActiveProcessPaths() {
|
|
1599
|
-
const files = listActiveProcessFiles();
|
|
1600
|
-
if (!Array.isArray(files)) return null;
|
|
1601
|
-
const args = listActiveProcessArgPaths();
|
|
1602
|
-
if (!Array.isArray(args)) return null;
|
|
1603
|
-
return files.concat(args);
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
1607
|
-
if (!Array.isArray(activeCwds)) return true;
|
|
1608
|
-
const resolved = canonicalPath(worktreePath);
|
|
1609
|
-
const prefix = `${resolved}${path.sep}`;
|
|
1610
|
-
return activeCwds.some((cwd) => {
|
|
1611
|
-
const active = canonicalPath(cwd);
|
|
1612
|
-
return active === resolved || active.startsWith(prefix);
|
|
1613
|
-
});
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
// Every work-folder removal leaves one line naming who removed it and why, in
|
|
1617
|
-
// the main checkout's state so the line outlives the folder it describes.
|
|
1618
|
-
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
|
|
1619
|
-
try {
|
|
1620
|
-
const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
|
|
1621
|
-
const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
|
|
1622
|
-
const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
|
|
1623
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1624
|
-
fs.appendFileSync(file, `${JSON.stringify({
|
|
1625
|
-
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
|
|
1626
|
-
})}\n`);
|
|
1627
|
-
} catch {}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
// Only webpack's disposable pack files have a sufficiently narrow contract
|
|
1631
|
-
// for unattended cleanup. Unknown files or links turn the folder into review.
|
|
1632
|
-
function webpackCacheSnapshot(worktreePath) {
|
|
1633
|
-
try {
|
|
1634
|
-
const root = fs.realpathSync(worktreePath);
|
|
1635
|
-
let current = root;
|
|
1636
|
-
for (const part of ['.next', 'cache', 'webpack']) {
|
|
1637
|
-
current = path.join(current, part);
|
|
1638
|
-
const stat = fs.lstatSync(current);
|
|
1639
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
1640
|
-
}
|
|
1641
|
-
if (fs.realpathSync(current) !== current) return null;
|
|
1642
|
-
const entries = [];
|
|
1643
|
-
function walk(dir) {
|
|
1644
|
-
for (const name of fs.readdirSync(dir).sort()) {
|
|
1645
|
-
const file = path.join(dir, name);
|
|
1646
|
-
const stat = fs.lstatSync(file);
|
|
1647
|
-
if (stat.isSymbolicLink()) return false;
|
|
1648
|
-
if (stat.isDirectory()) {
|
|
1649
|
-
entries.push([file, 'd', stat.ino, stat.mtimeMs]);
|
|
1650
|
-
if (!walk(file)) return false;
|
|
1651
|
-
} else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
|
|
1652
|
-
entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
|
|
1653
|
-
} else return false;
|
|
1654
|
-
}
|
|
1655
|
-
return true;
|
|
1656
|
-
}
|
|
1657
|
-
if (!walk(current)) return null;
|
|
1658
|
-
const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
1659
|
-
const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
|
|
1660
|
-
if (!match) return null;
|
|
1661
|
-
return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
|
|
1662
|
-
} catch {
|
|
1663
|
-
return null;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
|
|
1668
|
-
const candidates = [];
|
|
1669
|
-
const pruned = [];
|
|
1670
|
-
for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
|
|
1671
|
-
const target = path.join(worktreePath, relative);
|
|
1672
|
-
if (!fs.existsSync(target)) continue;
|
|
1673
|
-
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1674
|
-
if (ignored.status !== 0) continue;
|
|
1675
|
-
const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
|
|
1676
|
-
candidates.push(item);
|
|
1677
|
-
}
|
|
1678
|
-
const relative = '.next/cache/webpack';
|
|
1679
|
-
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1680
|
-
const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
|
|
1681
|
-
if (snapshot) {
|
|
1682
|
-
const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
|
|
1683
|
-
candidates.push(item);
|
|
1684
|
-
if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
|
|
1685
|
-
const active = listActiveProcessPaths();
|
|
1686
|
-
const fresh = webpackCacheSnapshot(worktreePath);
|
|
1687
|
-
const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1688
|
-
if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
|
|
1689
|
-
&& stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
|
|
1690
|
-
&& fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
|
|
1691
|
-
try {
|
|
1692
|
-
fs.rmSync(snapshot.path, { recursive: true });
|
|
1693
|
-
budget.count += 1;
|
|
1694
|
-
budget.kib += snapshot.kib;
|
|
1695
|
-
pruned.push(item);
|
|
1696
|
-
} catch (error) {
|
|
1697
|
-
item.error = String((error && error.message) || error);
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
return { candidates, pruned };
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
function originBaseIsCurrent(root, base) {
|
|
1706
|
-
const prefix = base.startsWith('origin/') ? 'origin/'
|
|
1707
|
-
: base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
|
|
1708
|
-
if (!prefix) {
|
|
1709
|
-
if (base === 'HEAD') {
|
|
1710
|
-
const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
|
|
1711
|
-
return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
|
|
1712
|
-
}
|
|
1713
|
-
return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
|
|
1714
|
-
}
|
|
1715
|
-
const branch = base.slice(prefix.length);
|
|
1716
|
-
const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
|
|
1717
|
-
const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
|
|
1718
|
-
const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
|
|
1719
|
-
return local.status === 0 && remote.status === 0 && Boolean(match)
|
|
1720
|
-
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Scope and pace options, all off by default:
|
|
1724
|
-
// onlyUnder / onlyPaths touch only copies inside that folder / those paths
|
|
1725
|
-
// idleMs keep merged copies with work newer than this, and
|
|
1726
|
-
// leave their caches alone
|
|
1727
|
-
// freshGraceMs how long a merged copy is kept after its start
|
|
1728
|
-
function cleanupWorktrees({
|
|
1729
|
-
root = repoRoot(),
|
|
1730
|
-
base: baseOverride = '',
|
|
1731
|
-
apply = false,
|
|
1732
|
-
activeCwds: activeCwdsOverride,
|
|
1733
|
-
by = 'atris worktree cleanup',
|
|
1734
|
-
onlyUnder = '',
|
|
1735
|
-
onlyPaths = null,
|
|
1736
|
-
idleMs = 0,
|
|
1737
|
-
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1738
|
-
now = Date.now(),
|
|
1739
|
-
cleanupBudget = null,
|
|
1740
|
-
} = {}) {
|
|
1741
|
-
const worktrees = listWorktrees(root);
|
|
1742
|
-
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1743
|
-
const current = path.resolve(root);
|
|
1744
|
-
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1745
|
-
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1746
|
-
const refreshError = refreshRemoteRef(root, base);
|
|
1747
|
-
const candidates = [];
|
|
1748
|
-
const kept = [];
|
|
1749
|
-
const removed = [];
|
|
1750
|
-
const cacheCandidates = [];
|
|
1751
|
-
const cachePruned = [];
|
|
1752
|
-
const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1753
|
-
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1754
|
-
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
1755
|
-
&& (!onlyUnder || space.isInside(wtPath, onlyUnder));
|
|
1756
|
-
const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
|
|
1757
|
-
const pruneCachesForKept = (wtPath) => {
|
|
1758
|
-
if (worktreeWithinReapGrace(wtPath, now)) return;
|
|
1759
|
-
if (recentlyUsed(wtPath)) return;
|
|
1760
|
-
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1761
|
-
cacheCandidates.push(...caches.candidates);
|
|
1762
|
-
cachePruned.push(...caches.pruned);
|
|
1763
|
-
};
|
|
1764
|
-
|
|
1765
|
-
for (const wt of worktrees) {
|
|
1766
|
-
const wtPath = path.resolve(wt.path);
|
|
1767
|
-
const item = { path: wt.path, branch: wt.branch, head: wt.head };
|
|
1768
|
-
if (wtPath === primary) {
|
|
1769
|
-
kept.push({ ...item, reason: 'primary_checkout' });
|
|
1770
|
-
continue;
|
|
1771
|
-
}
|
|
1772
|
-
if (wtPath === current) {
|
|
1773
|
-
kept.push({ ...item, reason: 'current_checkout' });
|
|
1774
|
-
continue;
|
|
1775
|
-
}
|
|
1776
|
-
if (!inScope(wtPath)) {
|
|
1777
|
-
kept.push({ ...item, reason: 'out_of_scope' });
|
|
1778
|
-
continue;
|
|
1779
|
-
}
|
|
1780
|
-
if (wt.locked) {
|
|
1781
|
-
kept.push({ ...item, reason: 'locked' });
|
|
1782
|
-
continue;
|
|
1783
|
-
}
|
|
1784
|
-
if (PROTECTED_BRANCHES.has(wt.branch)) {
|
|
1785
|
-
kept.push({ ...item, reason: 'protected_branch' });
|
|
1786
|
-
continue;
|
|
1787
|
-
}
|
|
1788
|
-
if (worktreeHasActiveProcess(wtPath, activeCwds)) {
|
|
1789
|
-
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1790
|
-
continue;
|
|
1791
|
-
}
|
|
1792
|
-
const counts = statusCounts(wt.path);
|
|
1793
|
-
if (!counts) {
|
|
1794
|
-
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1795
|
-
continue;
|
|
1796
|
-
}
|
|
1797
|
-
if (counts.staged || counts.unstaged || counts.untracked) {
|
|
1798
|
-
pruneCachesForKept(wtPath);
|
|
1799
|
-
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1800
|
-
continue;
|
|
1801
|
-
}
|
|
1802
|
-
// Git removes ignored files along with a worktree. They may include local
|
|
1803
|
-
// state, credentials, or user data, so a clean status is not enough proof.
|
|
1804
|
-
const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1805
|
-
if (ignored.status !== 0 || ignored.error) {
|
|
1806
|
-
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1807
|
-
continue;
|
|
1808
|
-
}
|
|
1809
|
-
if (ignored.stdout) {
|
|
1810
|
-
pruneCachesForKept(wtPath);
|
|
1811
|
-
kept.push({ ...item, reason: 'ignored_data' });
|
|
1812
|
-
continue;
|
|
1813
|
-
}
|
|
1814
|
-
if (!wt.head) {
|
|
1815
|
-
pruneCachesForKept(wtPath);
|
|
1816
|
-
kept.push({ ...item, reason: 'missing_head' });
|
|
1817
|
-
continue;
|
|
1818
|
-
}
|
|
1819
|
-
if (refreshError) {
|
|
1820
|
-
kept.push({ ...item, reason: 'base_refresh_failed' });
|
|
1821
|
-
continue;
|
|
1822
|
-
}
|
|
1823
|
-
const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
|
|
1824
|
-
if (!merged) {
|
|
1825
|
-
pruneCachesForKept(wtPath);
|
|
1826
|
-
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1827
|
-
continue;
|
|
1828
|
-
}
|
|
1829
|
-
if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
|
|
1830
|
-
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1831
|
-
continue;
|
|
1832
|
-
}
|
|
1833
|
-
if (recentlyUsed(wtPath)) {
|
|
1834
|
-
kept.push({ ...item, reason: 'recent_activity' });
|
|
1835
|
-
continue;
|
|
1836
|
-
}
|
|
1837
|
-
if (!originBaseIsCurrent(root, base)) {
|
|
1838
|
-
kept.push({ ...item, reason: 'base_unverified' });
|
|
1839
|
-
continue;
|
|
1840
|
-
}
|
|
1841
|
-
let real;
|
|
1842
|
-
try { real = fs.realpathSync(wtPath); } catch { real = ''; }
|
|
1843
|
-
if (!real || real !== wtPath) {
|
|
1844
|
-
kept.push({ ...item, reason: 'path_not_canonical' });
|
|
1845
|
-
continue;
|
|
1846
|
-
}
|
|
1847
|
-
const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1848
|
-
const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1849
|
-
const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
1850
|
-
if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
|
|
1851
|
-
kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
|
|
1852
|
-
continue;
|
|
1853
|
-
}
|
|
1854
|
-
const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
|
|
1855
|
-
candidates.push(candidate);
|
|
1856
|
-
cacheBudget.count += 1;
|
|
1857
|
-
cacheBudget.kib += kib;
|
|
1858
|
-
if (!apply) continue;
|
|
1859
|
-
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1860
|
-
const freshCounts = statusCounts(wt.path);
|
|
1861
|
-
const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1862
|
-
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1863
|
-
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1864
|
-
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1865
|
-
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1866
|
-
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1867
|
-
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1868
|
-
|| freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1869
|
-
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1870
|
-
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1871
|
-
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
1872
|
-
|| !originBaseIsCurrent(root, base)
|
|
1873
|
-
|| canonicalPath(wtPath) !== wtPath) {
|
|
1874
|
-
kept.push({ ...item, reason: 'changed_or_active_during_verification' });
|
|
1875
|
-
continue;
|
|
1876
|
-
}
|
|
1877
|
-
const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
|
|
1878
|
-
if (removedResult.status === 0) {
|
|
1879
|
-
removed.push(candidate);
|
|
1880
|
-
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1881
|
-
} else {
|
|
1882
|
-
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
// The sweep: cleanup for every repo with build copies in the workspace.
|
|
1890
|
-
// Removes copies that pass every cleanup check (merged into origin's main
|
|
1891
|
-
// branch, nothing changed, untracked, or ignored, nothing running inside)
|
|
1892
|
-
// and are unused for idleMs; clears verified webpack caches in the idle
|
|
1893
|
-
// copies it keeps. Shared installs and unmerged work stay for review.
|
|
1894
|
-
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1895
|
-
const SWEEP_EVERY_HOURS = 3;
|
|
1896
|
-
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1897
|
-
|
|
1898
|
-
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
|
|
1899
|
-
const root = path.resolve(workspace);
|
|
1900
|
-
const before = freeSpace(root);
|
|
1901
|
-
const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
|
|
1902
|
-
const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
|
|
1903
|
-
// Without a process list nobody can tell which copies are in use.
|
|
1904
|
-
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1905
|
-
const scope = path.join(root, space.AGENT_COPIES_DIR);
|
|
1906
|
-
// One limit covers the whole sweep, not each repository separately.
|
|
1907
|
-
cleanupBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1908
|
-
for (const repo of space.agentCopyRepos(root)) {
|
|
1909
|
-
const entry = { name: repo.name, root: repo.root };
|
|
1910
|
-
try {
|
|
1911
|
-
const base = defaultMainlineBase(repo.root);
|
|
1912
|
-
if (!base.startsWith('origin/')) {
|
|
1913
|
-
report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
|
|
1914
|
-
continue;
|
|
1915
|
-
}
|
|
1916
|
-
// A real removal scans again per repo and right before each copy goes;
|
|
1917
|
-
// the shared scan only serves previews and tests.
|
|
1918
|
-
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1919
|
-
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
|
|
1920
|
-
report.repos.push({
|
|
1921
|
-
...entry,
|
|
1922
|
-
base: result.base,
|
|
1923
|
-
removed: result.removed,
|
|
1924
|
-
candidates: result.candidates,
|
|
1925
|
-
cachePruned: result.cachePruned,
|
|
1926
|
-
cacheCandidates: result.cacheCandidates,
|
|
1927
|
-
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
1928
|
-
// Shared installs may be in use or contain local changes. Leave them
|
|
1929
|
-
// for explicit review rather than pruning by age.
|
|
1930
|
-
installsPruned: [],
|
|
1931
|
-
});
|
|
1932
|
-
} catch (error) {
|
|
1933
|
-
report.repos.push({ ...entry, error: String((error && error.message) || error) });
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
report.after = freeSpace(root);
|
|
1937
|
-
return report;
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
const SWEEP_KEPT_LABELS = {
|
|
1941
|
-
active_process: 'in use',
|
|
1942
|
-
process_scan_failed: 'in use (could not check)',
|
|
1943
|
-
dirty: 'with changes',
|
|
1944
|
-
unmerged: 'not merged',
|
|
1945
|
-
detached_unmerged: 'not merged',
|
|
1946
|
-
ignored_data: 'holding files git ignores (kept for review)',
|
|
1947
|
-
ignored_scan_failed: 'unreadable',
|
|
1948
|
-
fresh_worktree_grace: 'new',
|
|
1949
|
-
recent_activity: 'used recently',
|
|
1950
|
-
locked: 'locked',
|
|
1951
|
-
protected_branch: 'on a main branch',
|
|
1952
|
-
missing_or_unreadable: 'unreadable',
|
|
1953
|
-
missing_head: 'without a commit',
|
|
1954
|
-
remove_failed: 'failed to remove',
|
|
1955
|
-
base_refresh_failed: 'main branch unreachable',
|
|
1956
|
-
base_unverified: 'main branch moved during the check',
|
|
1957
|
-
path_not_canonical: 'behind a link',
|
|
1958
|
-
size_unknown_or_cleanup_limit: 'over the per-run limit',
|
|
1959
|
-
changed_or_active_during_verification: 'changed during the check',
|
|
1960
|
-
};
|
|
1961
|
-
|
|
1962
|
-
function plural(count, one, many) {
|
|
1963
|
-
return `${count} ${count === 1 ? one : many}`;
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// The folder that holds .agent-worktrees: this folder if it has one, else
|
|
1967
|
-
// the one above the repo's main checkout.
|
|
1968
|
-
function defaultSweepWorkspace(cwd = process.cwd()) {
|
|
1969
|
-
if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
|
|
1970
|
-
try {
|
|
1971
|
-
return path.dirname(findPrimaryRoot(repoRoot(cwd)));
|
|
1972
|
-
} catch {
|
|
1973
|
-
return cwd;
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
|
|
1978
|
-
return planKeep({
|
|
1979
|
-
root: workspace,
|
|
1980
|
-
home: path.join(homeDir, '.atris', 'worktree-sweep'),
|
|
1981
|
-
platform,
|
|
1982
|
-
homeDir,
|
|
1983
|
-
atrisBin,
|
|
1984
|
-
envPath,
|
|
1985
|
-
job: {
|
|
1986
|
-
name: 'worktree-sweep',
|
|
1987
|
-
args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
|
|
1988
|
-
every_seconds: Math.round(everyHours * 3600),
|
|
1989
|
-
marker: 'ATRIS_WORKTREE_SWEEP',
|
|
1990
|
-
},
|
|
1991
|
-
});
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
469
|
function sweepSchedule(args, workspace) {
|
|
1995
470
|
const everyHours = Number(readFlag(args, '--every', String(SWEEP_EVERY_HOURS)));
|
|
1996
471
|
if (!Number.isFinite(everyHours) || everyHours <= 0) {
|
|
@@ -2007,7 +482,6 @@ function sweepSchedule(args, workspace) {
|
|
|
2007
482
|
console.log(`sweep timer: ${removed.removed === false ? 'was not on' : 'off'} for ${workspace}`);
|
|
2008
483
|
return 0;
|
|
2009
484
|
}
|
|
2010
|
-
// The timer runs this atris file every few hours; a copy or temp folder vanishes.
|
|
2011
485
|
if (space.isAgentCopyPath(ATRIS_BIN) || space.isScratchPath(ATRIS_BIN)) {
|
|
2012
486
|
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`);
|
|
2013
487
|
return 2;
|
|
@@ -2030,8 +504,14 @@ function sweep(args) {
|
|
|
2030
504
|
console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
|
|
2031
505
|
return 2;
|
|
2032
506
|
}
|
|
507
|
+
const rescueDays = Number(readFlag(args, '--rescue-after-days', process.env.ATRIS_WORKTREE_RESCUE_DAYS || String(RESCUE_IDLE_MS / 86400000)));
|
|
508
|
+
if (!Number.isFinite(rescueDays) || rescueDays <= 0) {
|
|
509
|
+
console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>] [--rescue] [--rescue-after-days <n>]');
|
|
510
|
+
return 2;
|
|
511
|
+
}
|
|
2033
512
|
const apply = hasFlag(args, '--apply');
|
|
2034
|
-
const
|
|
513
|
+
const rescue = hasFlag(args, '--rescue');
|
|
514
|
+
const report = sweepWorkspace({ workspace, apply, rescue, idleMs: idleHours * 3600000, rescueIdleMs: rescueDays * 86400000 });
|
|
2035
515
|
if (hasFlag(args, '--json')) {
|
|
2036
516
|
console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
|
|
2037
517
|
return report.error ? 1 : 0;
|
|
@@ -2049,9 +529,11 @@ function sweep(args) {
|
|
|
2049
529
|
console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
|
|
2050
530
|
continue;
|
|
2051
531
|
}
|
|
2052
|
-
const
|
|
532
|
+
const saved = apply ? repo.rescued : repo.rescueCandidates;
|
|
533
|
+
const savedSet = new Set(saved);
|
|
534
|
+
const gone = (apply ? repo.removed : repo.candidates).filter((item) => !savedSet.has(item));
|
|
2053
535
|
const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
|
|
2054
|
-
removedTotal += gone.length;
|
|
536
|
+
removedTotal += gone.length + saved.length;
|
|
2055
537
|
clearedTotal += cleared.length;
|
|
2056
538
|
reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
|
|
2057
539
|
const counts = {};
|
|
@@ -2061,7 +543,18 @@ function sweep(args) {
|
|
|
2061
543
|
}
|
|
2062
544
|
const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
|
|
2063
545
|
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})` : ''}`);
|
|
546
|
+
if (saved.length) console.log(` ${apply ? 'saved and removed' : 'would save and remove'} ${plural(saved.length, 'abandoned copy', 'abandoned copies')}`);
|
|
547
|
+
for (const item of saved) {
|
|
548
|
+
const what = item.reason === 'pushed_branch' ? 'already on origin' : `${plural(item.changed_files || 0, 'changed file', 'changed files')}, idle ${item.idle_days} days`;
|
|
549
|
+
console.log(` ${apply ? 'saved' : 'save'}: ${item.path} -> origin/${item.rescue_branch} (${what}, ${formatKib(item.size_kib)})`);
|
|
550
|
+
}
|
|
2064
551
|
for (const item of gone) console.log(` ${apply ? 'removed' : 'remove'}: ${item.path}`);
|
|
552
|
+
for (const item of repo.kept) {
|
|
553
|
+
if (item.reason === 'rescue_push_failed') console.log(` kept: ${item.path} (could not push its work to origin/${item.rescue_branch}: ${item.error})`);
|
|
554
|
+
if (item.reason === 'unprovable') console.log(` kept: ${item.path} (${item.why})`);
|
|
555
|
+
if (item.reason === 'stale_dirty' || item.reason === 'stale_unpushed') console.log(` stale: ${item.path} (idle ${item.idle_days} days; atris worktree sweep --rescue --apply saves and removes it)`);
|
|
556
|
+
if (item.reason === 'ignored_data' && item.files) console.log(` kept: ${item.path} (ignored files atris cannot rebuild: ${item.files.join(', ')})`);
|
|
557
|
+
}
|
|
2065
558
|
for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
|
|
2066
559
|
}
|
|
2067
560
|
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`);
|
|
@@ -2138,7 +631,8 @@ function guide() {
|
|
|
2138
631
|
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.');
|
|
2139
632
|
console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
|
|
2140
633
|
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).');
|
|
2141
|
-
console.log('A copy
|
|
634
|
+
console.log('A shipped copy is removed as soon as its work is merged or on origin and nothing else runs in it; if something does, a merged copy goes once it has been quiet for 10 minutes (--keep-copy or ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
|
|
635
|
+
console.log('The sweep reports abandoned copies with unsaved work (idle 7 days). With --rescue it saves one to commits on a new atris-rescue/ branch, pushes it, and removes the copy only after origin has it; it removes a copy only when it can prove every byte is saved, and anything unusual keeps the copy for a person. The timer never rescues. Rescues are logged in .atris/state/worktree_removals.jsonl.');
|
|
2142
636
|
console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
|
|
2143
637
|
return 0;
|
|
2144
638
|
}
|
|
@@ -2155,12 +649,14 @@ function help() {
|
|
|
2155
649
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
2156
650
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
2157
651
|
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
652
|
+
console.log(' --keep-copy keep this build copy after it ships');
|
|
2158
653
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
2159
654
|
console.log(' atris worktree status');
|
|
2160
655
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
2161
656
|
console.log(' atris worktree prune [--apply]');
|
|
2162
657
|
console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
|
|
2163
|
-
console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2]
|
|
658
|
+
console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] [--rescue] [--rescue-after-days 7]');
|
|
659
|
+
console.log(' removes merged or pushed idle copies in every repo and reports abandoned ones; --rescue saves those to a new branch on origin, then removes them');
|
|
2164
660
|
console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
|
|
2165
661
|
}
|
|
2166
662
|
|
|
@@ -2188,6 +684,7 @@ function worktreeCommand(args = []) {
|
|
|
2188
684
|
}
|
|
2189
685
|
|
|
2190
686
|
module.exports = {
|
|
687
|
+
...worktreeCore,
|
|
2191
688
|
branchName,
|
|
2192
689
|
collidingFlights,
|
|
2193
690
|
mergePrWithRetry,
|