atris 3.80.1 → 3.82.1
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 +10 -0
- 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 +109 -13
- package/commands/release.js +41 -10
- package/commands/status.js +1 -1
- package/commands/sync.js +12 -8
- package/commands/task.js +144 -118
- package/commands/unknowns.js +9 -5
- package/commands/visualize.js +1 -12
- package/commands/workflow.js +4 -3
- package/commands/worktree.js +68 -1602
- 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/fleet.js +1 -1
- package/lib/integrations-core.js +1695 -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-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/recall-exam.js +171 -0
- package/lib/roster-markdown.js +2 -2
- package/lib/runner-command.js +5 -5
- package/lib/task-db.js +105 -37
- 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 +1517 -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,77 @@
|
|
|
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
|
-
cwd,
|
|
79
|
-
encoding: 'utf8',
|
|
80
|
-
shell: true,
|
|
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
|
+
SWEEP_IDLE_MS,
|
|
16
|
+
SWEEP_EVERY_HOURS,
|
|
17
|
+
SWEEP_KEPT_LABELS,
|
|
18
|
+
announceShippedCopyRemoval,
|
|
19
|
+
assertOneLapProofTree,
|
|
20
|
+
baseBranchName,
|
|
21
|
+
branchName,
|
|
22
|
+
cleanupWorktrees,
|
|
23
|
+
collidingFlights,
|
|
24
|
+
createAgentWorktree,
|
|
25
|
+
createDetachedShipBranch,
|
|
26
|
+
createOrFindPr,
|
|
27
|
+
currentBranch,
|
|
28
|
+
defaultMainlineBase,
|
|
29
|
+
defaultShipTarget,
|
|
30
|
+
defaultStartBase,
|
|
31
|
+
defaultSweepWorkspace,
|
|
32
|
+
deniedLaneForShip,
|
|
33
|
+
describeFlightAge,
|
|
34
|
+
detachedShipBranch,
|
|
35
|
+
findPrimaryRoot,
|
|
36
|
+
flightStampMs,
|
|
37
|
+
gitFailureDetail,
|
|
38
|
+
gitNetworkEnvironment,
|
|
39
|
+
inFlightAgentFlights,
|
|
40
|
+
listActiveProcessPaths,
|
|
41
|
+
listWorktrees,
|
|
42
|
+
lowDiskRefusal,
|
|
43
|
+
mergePrWithRetry,
|
|
44
|
+
newStatusRows,
|
|
45
|
+
normalizeTargetRef,
|
|
46
|
+
parseAgentFlightName,
|
|
47
|
+
parseProcessArgPaths,
|
|
48
|
+
parseWorktrees,
|
|
49
|
+
plural,
|
|
50
|
+
prMergeRef,
|
|
51
|
+
protectedPushRefusal,
|
|
52
|
+
readOneLapProofBinding,
|
|
53
|
+
reapShippedCopy,
|
|
54
|
+
recordWorktreeRemoval,
|
|
55
|
+
refreshMovedMapRefs,
|
|
56
|
+
refreshRemoteRef,
|
|
57
|
+
repoRoot,
|
|
58
|
+
restoreRegeneratedAdapterChurn,
|
|
59
|
+
runCommand,
|
|
60
|
+
runGit,
|
|
61
|
+
runShipHealthCheck,
|
|
62
|
+
scheduleShippedCopyRemoval,
|
|
63
|
+
shipMergeRecord,
|
|
64
|
+
shippedCopyRemovalPlan,
|
|
65
|
+
slugify,
|
|
66
|
+
statusCounts,
|
|
67
|
+
statusSnapshot,
|
|
68
|
+
swarloClaim,
|
|
69
|
+
sweepSchedulePlan,
|
|
70
|
+
sweepWorkspace,
|
|
71
|
+
taskTokens,
|
|
72
|
+
unstageConductorArtifacts,
|
|
73
|
+
worktreeHasActiveProcess,
|
|
74
|
+
} = worktreeCore;
|
|
546
75
|
|
|
547
76
|
function printStatus() {
|
|
548
77
|
const root = repoRoot();
|
|
@@ -562,241 +91,6 @@ function printStatus() {
|
|
|
562
91
|
}
|
|
563
92
|
}
|
|
564
93
|
|
|
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
94
|
function startWorktree(args) {
|
|
801
95
|
const root = repoRoot();
|
|
802
96
|
const member = readFlag(args, '--member');
|
|
@@ -807,8 +101,6 @@ function startWorktree(args) {
|
|
|
807
101
|
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
102
|
return 2;
|
|
809
103
|
}
|
|
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
104
|
const formerTask = readFlag(args, '--former-task');
|
|
813
105
|
const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
|
|
814
106
|
if (memberFile && !fs.existsSync(memberFile)) {
|
|
@@ -890,56 +182,6 @@ function startWorktree(args) {
|
|
|
890
182
|
return 0;
|
|
891
183
|
}
|
|
892
184
|
|
|
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
185
|
function shipHelp() {
|
|
944
186
|
console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
945
187
|
console.log('');
|
|
@@ -952,167 +194,6 @@ function shipHelp() {
|
|
|
952
194
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
953
195
|
}
|
|
954
196
|
|
|
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
197
|
function shipWorktree(args) {
|
|
1117
198
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
1118
199
|
shipHelp();
|
|
@@ -1146,13 +227,6 @@ function shipWorktree(args) {
|
|
|
1146
227
|
console.error('blocked: ship from an isolated worktree, not the primary checkout');
|
|
1147
228
|
return 2;
|
|
1148
229
|
}
|
|
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
230
|
if (!hasFlag(args, '--allow-denied-lane')) {
|
|
1157
231
|
const denied = deniedLaneForShip(root, branch, message);
|
|
1158
232
|
if (denied && (merge || localMode)) {
|
|
@@ -1186,8 +260,6 @@ function shipWorktree(args) {
|
|
|
1186
260
|
refreshRemoteRef(root, targetRef);
|
|
1187
261
|
const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
|
|
1188
262
|
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
263
|
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
|
|
1192
264
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
1193
265
|
const dirty = counts.staged || counts.unstaged || counts.untracked;
|
|
@@ -1200,8 +272,6 @@ function shipWorktree(args) {
|
|
|
1200
272
|
console.log(`commit: ${message}`);
|
|
1201
273
|
if (!dryRun) {
|
|
1202
274
|
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
275
|
unstageConductorArtifacts(root);
|
|
1206
276
|
if (!proofTreeMatches('index')) return 3;
|
|
1207
277
|
runGit(['commit', '-m', message], { cwd: root });
|
|
@@ -1224,8 +294,6 @@ function shipWorktree(args) {
|
|
|
1224
294
|
if (!dryRun) runCommand(verify, { cwd: root });
|
|
1225
295
|
const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
|
|
1226
296
|
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
297
|
const afterVerify = dryRun
|
|
1230
298
|
? { staged: 0, unstaged: 0, untracked: 0 }
|
|
1231
299
|
: statusCounts(root, { ignoreUntracked: isConductorUntracked })
|
|
@@ -1255,7 +323,6 @@ function shipWorktree(args) {
|
|
|
1255
323
|
}
|
|
1256
324
|
console.log(`merge_check: ${targetRef} clean`);
|
|
1257
325
|
|
|
1258
|
-
// Set once the change is confirmed merged; the build copy then goes away.
|
|
1259
326
|
let landedOn = '';
|
|
1260
327
|
if (localMode) {
|
|
1261
328
|
console.log('push: skipped (local mode)');
|
|
@@ -1305,13 +372,9 @@ function shipWorktree(args) {
|
|
|
1305
372
|
mergeArgs.push('--merge');
|
|
1306
373
|
const merged = mergePrWithRetry(mergeArgs, root);
|
|
1307
374
|
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
375
|
if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
|
|
1311
376
|
throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
|
|
1312
377
|
}
|
|
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
378
|
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt,number,url,mergeCommit'], { cwd: root, encoding: 'utf8' });
|
|
1316
379
|
if (confirmation.status !== 0) {
|
|
1317
380
|
throw new Error('could not confirm the merge; the source branch was kept');
|
|
@@ -1328,8 +391,6 @@ function shipWorktree(args) {
|
|
|
1328
391
|
throw new Error('the change was not merged; the source branch was kept');
|
|
1329
392
|
}
|
|
1330
393
|
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
394
|
const record = shipMergeRecord(state);
|
|
1334
395
|
if (record) console.log(`merge: record ${JSON.stringify(record)}`);
|
|
1335
396
|
landedOn = targetRef;
|
|
@@ -1358,90 +419,6 @@ function shipWorktree(args) {
|
|
|
1358
419
|
return 0;
|
|
1359
420
|
}
|
|
1360
421
|
|
|
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
422
|
function reapShipped() {
|
|
1446
423
|
const target = process.env.ATRIS_REAP_WORKTREE || '';
|
|
1447
424
|
if (!target) {
|
|
@@ -1453,17 +430,6 @@ function reapShipped() {
|
|
|
1453
430
|
return result.removed ? 0 : 1;
|
|
1454
431
|
}
|
|
1455
432
|
|
|
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
433
|
function guard(args) {
|
|
1468
434
|
const root = repoRoot();
|
|
1469
435
|
const worktrees = listWorktrees(root);
|
|
@@ -1473,9 +439,6 @@ function guard(args) {
|
|
|
1473
439
|
console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
|
|
1474
440
|
return 2;
|
|
1475
441
|
}
|
|
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
442
|
const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
|
|
1480
443
|
if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
|
|
1481
444
|
console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
|
|
@@ -1494,503 +457,6 @@ function prune(args) {
|
|
|
1494
457
|
return result.status || 0;
|
|
1495
458
|
}
|
|
1496
459
|
|
|
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
460
|
function sweepSchedule(args, workspace) {
|
|
1995
461
|
const everyHours = Number(readFlag(args, '--every', String(SWEEP_EVERY_HOURS)));
|
|
1996
462
|
if (!Number.isFinite(everyHours) || everyHours <= 0) {
|
|
@@ -2007,7 +473,6 @@ function sweepSchedule(args, workspace) {
|
|
|
2007
473
|
console.log(`sweep timer: ${removed.removed === false ? 'was not on' : 'off'} for ${workspace}`);
|
|
2008
474
|
return 0;
|
|
2009
475
|
}
|
|
2010
|
-
// The timer runs this atris file every few hours; a copy or temp folder vanishes.
|
|
2011
476
|
if (space.isAgentCopyPath(ATRIS_BIN) || space.isScratchPath(ATRIS_BIN)) {
|
|
2012
477
|
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
478
|
return 2;
|
|
@@ -2188,6 +653,7 @@ function worktreeCommand(args = []) {
|
|
|
2188
653
|
}
|
|
2189
654
|
|
|
2190
655
|
module.exports = {
|
|
656
|
+
...worktreeCore,
|
|
2191
657
|
branchName,
|
|
2192
658
|
collidingFlights,
|
|
2193
659
|
mergePrWithRetry,
|