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
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
7
|
+
const { hasFlag } = require('./arg-parser');
|
|
8
|
+
const { planKeep } = require('./campaign-keep');
|
|
9
|
+
const { isConductorArtifact, isConductorUntracked } = require('./conductor-artifacts');
|
|
10
|
+
const { fixMapDocs } = require('./map-refs');
|
|
11
|
+
const { sleepSync } = require('./sleep-sync');
|
|
12
|
+
const space = require('./worktree-space');
|
|
13
|
+
const close = require('../commands/close');
|
|
14
|
+
|
|
15
|
+
const ATRIS_BIN = path.resolve(__dirname, '..', 'bin', 'atris.js');
|
|
16
|
+
|
|
17
|
+
// Files atris regenerates on its own. Unstaged churn in them is restored
|
|
18
|
+
// before ship commits, unless the commit message names the file. The task
|
|
19
|
+
// projection is rewritten by any task command run in a work folder, and two
|
|
20
|
+
// parallel landings that each carry a copy always clash.
|
|
21
|
+
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
|
|
22
|
+
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
// Push also runs local hooks. Backend startup and regression checks alone can
|
|
24
|
+
// exceed the short network-only allowance used by fetch and discovery.
|
|
25
|
+
const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
27
|
+
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const SHIP_HEALTH_SOURCE = 'ship-health:experiments-daily';
|
|
30
|
+
|
|
31
|
+
function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
|
|
32
|
+
const env = { ...source, GIT_TERMINAL_PROMPT: '0' };
|
|
33
|
+
// Custom commands may be wrappers that do not accept OpenSSH flags.
|
|
34
|
+
// Leave Git's own command/config/program precedence intact.
|
|
35
|
+
if (source.GIT_SSH_COMMAND || source.GIT_SSH) return env;
|
|
36
|
+
const configured = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
37
|
+
cwd, encoding: 'utf8', env: source, timeout: 5000, killSignal: 'SIGKILL',
|
|
38
|
+
});
|
|
39
|
+
if (configured.status === 0 && String(configured.stdout || '').trim()) return env;
|
|
40
|
+
env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15';
|
|
41
|
+
return env;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A killed network call leaves stderr empty; name the timeout instead.
|
|
45
|
+
function gitFailureDetail(result) {
|
|
46
|
+
if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
|
|
47
|
+
return [result && result.stdout, result && result.stderr]
|
|
48
|
+
.map(output => String(output || '').trim()).filter(Boolean).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
52
|
+
const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
|
|
53
|
+
// Only network calls need the SSH probe; local calls skip its extra git spawn.
|
|
54
|
+
const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
|
|
55
|
+
const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
|
|
56
|
+
killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
|
|
57
|
+
let result = spawnSync('git', args, options);
|
|
58
|
+
// Linked checkouts share config. A concurrent start must not silently lose
|
|
59
|
+
// its owner/base metadata, but real config errors still stop immediately.
|
|
60
|
+
for (let retry = 1; args[0] === 'config' && retry < 3 && configLocked(result); retry += 1) {
|
|
61
|
+
sleepSync(100 * retry);
|
|
62
|
+
result = spawnSync('git', args, options);
|
|
63
|
+
}
|
|
64
|
+
if (check && result.status !== 0) {
|
|
65
|
+
const detail = gitFailureDetail(result);
|
|
66
|
+
throw new Error(detail === 'timed out' ? `git ${args[0]} timed out` : (detail || `git ${args.join(' ')} failed`));
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function configLocked(result) {
|
|
72
|
+
return result.status !== 0 && /could not lock config file/i.test(`${result.stderr || ''}${result.stdout || ''}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
|
|
76
|
+
const result = spawnSync(command, {
|
|
77
|
+
cwd,
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
shell: true,
|
|
80
|
+
stdio: 'pipe',
|
|
81
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
82
|
+
});
|
|
83
|
+
if (result.error) throw result.error;
|
|
84
|
+
if (check && result.status !== 0) {
|
|
85
|
+
const msg = (result.stderr || result.stdout || `${command} failed`).trim();
|
|
86
|
+
throw new Error(msg);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function statusSnapshot(root) {
|
|
92
|
+
const result = runGit(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, check: false });
|
|
93
|
+
if (result.status !== 0) return [];
|
|
94
|
+
return result.stdout
|
|
95
|
+
.split(/\r?\n/)
|
|
96
|
+
.map((line) => line.trimEnd())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function newStatusRows(before, after) {
|
|
101
|
+
const seen = new Set(before);
|
|
102
|
+
return after.filter((row) => !seen.has(row));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readOneLapProofBinding(root, branch) {
|
|
106
|
+
const sidecarPath = path.join(root, '.atris', 'agent-worktree.json');
|
|
107
|
+
let sidecar = null;
|
|
108
|
+
try {
|
|
109
|
+
sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error && error.code !== 'ENOENT') throw new Error('one-lap proof metadata is unreadable');
|
|
112
|
+
}
|
|
113
|
+
const metadata = sidecar && sidecar.one_lap_proof;
|
|
114
|
+
const configured = runGit(['config', '--get', `branch.${branch}.atris-proof-ref`], { cwd: root, check: false });
|
|
115
|
+
const configuredRef = configured.status === 0 ? String(configured.stdout || '').trim() : '';
|
|
116
|
+
if (!metadata && !configuredRef) return null;
|
|
117
|
+
if (!metadata || !configuredRef) throw new Error('one-lap proof binding is incomplete');
|
|
118
|
+
|
|
119
|
+
const proofRef = String(metadata.ref || '');
|
|
120
|
+
const sourceCommit = String(metadata.commit || '');
|
|
121
|
+
const sourceTree = String(metadata.tree || '');
|
|
122
|
+
const refMatch = proofRef.match(ONE_LAP_PROOF_REF_PATTERN);
|
|
123
|
+
if (!refMatch || proofRef !== configuredRef || !GIT_OID_PATTERN.test(sourceCommit) || !GIT_OID_PATTERN.test(sourceTree)) {
|
|
124
|
+
throw new Error('one-lap proof binding is invalid');
|
|
125
|
+
}
|
|
126
|
+
const resolvedCommit = runGit(['rev-parse', '--verify', `${proofRef}^{commit}`], { cwd: root, check: false });
|
|
127
|
+
const resolvedTree = runGit(['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: root, check: false });
|
|
128
|
+
if (resolvedCommit.status !== 0 || String(resolvedCommit.stdout || '').trim() !== sourceCommit || refMatch[1] !== sourceCommit) {
|
|
129
|
+
throw new Error('one-lap proof commit no longer matches its retained ref');
|
|
130
|
+
}
|
|
131
|
+
if (resolvedTree.status !== 0 || String(resolvedTree.stdout || '').trim() !== sourceTree) {
|
|
132
|
+
throw new Error('one-lap proof tree no longer matches its retained ref');
|
|
133
|
+
}
|
|
134
|
+
return { ref: proofRef, commit: sourceCommit, tree: sourceTree };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function worktreeTree(root, source) {
|
|
138
|
+
const result = runGit(source === 'index'
|
|
139
|
+
? ['write-tree']
|
|
140
|
+
: ['rev-parse', '--verify', 'HEAD^{tree}'], { cwd: root, check: false });
|
|
141
|
+
if (result.status !== 0) throw new Error(`could not resolve the ${source} tree for one-lap proof`);
|
|
142
|
+
return String(result.stdout || '').trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertOneLapProofTree(root, binding, source) {
|
|
146
|
+
if (!binding) return;
|
|
147
|
+
if (worktreeTree(root, source) !== binding.tree) {
|
|
148
|
+
throw new Error(`one-lap ${source} tree differs from the independently verified proof tree`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function repoRoot(cwd = process.cwd()) {
|
|
153
|
+
return runGit(['rev-parse', '--show-toplevel'], { cwd }).stdout.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function slugify(value, fallback = 'task', limit = 48) {
|
|
157
|
+
const slug = String(value || '')
|
|
158
|
+
.trim()
|
|
159
|
+
.toLowerCase()
|
|
160
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
161
|
+
.replace(/^-+|-+$/g, '')
|
|
162
|
+
.slice(0, limit)
|
|
163
|
+
.replace(/-+$/g, '');
|
|
164
|
+
return slug || fallback;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stamp(now = new Date()) {
|
|
168
|
+
return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function branchName(member, task, now = new Date()) {
|
|
172
|
+
return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The newest earlier run of this same member+job, if its commits never reached
|
|
176
|
+
// the base, so a retry continues that work instead of redoing it. Only the
|
|
177
|
+
// newest run counts: once it landed or did nothing, anything older is stale.
|
|
178
|
+
function lastRunBranch(root, owner, task, base, skip) {
|
|
179
|
+
const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
|
|
180
|
+
const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
|
|
181
|
+
if (listed.status !== 0) return null;
|
|
182
|
+
// Only exact runs of this job (stamp plus optional same-second number), so
|
|
183
|
+
// "fix-login" never matches "fix-login-page".
|
|
184
|
+
const runs = listed.stdout.split('\n').map((name) => name.trim())
|
|
185
|
+
.map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
|
|
186
|
+
.filter(({ name, run }) => run && name !== skip)
|
|
187
|
+
.sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
|
|
188
|
+
if (!runs.length) return null;
|
|
189
|
+
const newest = runs[0].name;
|
|
190
|
+
const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
|
|
191
|
+
if (cherry.status !== 0) return null;
|
|
192
|
+
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// True only when git proves the branch no longer merges cleanly with the base.
|
|
196
|
+
// merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
|
|
197
|
+
// resume as before.
|
|
198
|
+
function conflictsWithBase(root, base, branch) {
|
|
199
|
+
const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
|
|
200
|
+
return result.status === 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
204
|
+
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
205
|
+
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseWorktrees(text) {
|
|
209
|
+
const out = [];
|
|
210
|
+
let current = {};
|
|
211
|
+
for (const raw of `${text}\n`.split(/\r?\n/)) {
|
|
212
|
+
const line = raw.trim();
|
|
213
|
+
if (!line) {
|
|
214
|
+
if (current.worktree) {
|
|
215
|
+
let branch = current.branch || 'detached';
|
|
216
|
+
branch = branch.replace(/^refs\/heads\//, '');
|
|
217
|
+
const item = { path: current.worktree, branch, head: current.HEAD || '' };
|
|
218
|
+
if (current.locked) item.locked = true;
|
|
219
|
+
if (current.prunable) item.prunable = true;
|
|
220
|
+
out.push(item);
|
|
221
|
+
}
|
|
222
|
+
current = {};
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const idx = line.indexOf(' ');
|
|
226
|
+
if (idx === -1) {
|
|
227
|
+
current[line] = true;
|
|
228
|
+
} else {
|
|
229
|
+
current[line.slice(0, idx)] = line.slice(idx + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function listWorktrees(root = repoRoot()) {
|
|
236
|
+
return parseWorktrees(runGit(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
|
|
240
|
+
// same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
|
|
241
|
+
// direct `worktree start` had no pre-check beyond the target path existing.
|
|
242
|
+
// Refuse only the same task slug. A shared word on an unrelated flight is not
|
|
243
|
+
// a collision (dispatch-cli-900 must not block dispatch-cli-901).
|
|
244
|
+
const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
|
|
245
|
+
const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
246
|
+
const FLIGHT_STOPWORDS = new Set([
|
|
247
|
+
'fix', 'add', 'update', 'make', 'change', 'new', 'task', 'work', 'repo',
|
|
248
|
+
'cli', 'atris', 'backend', 'the', 'a', 'an', 'and', 'for', 'to', 'of', 'in', 'on',
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
function flightStampMs(stampText) {
|
|
252
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(stampText || ''));
|
|
253
|
+
if (!m) return null;
|
|
254
|
+
const [, year, month, day, hour, minute, second] = m.map(Number);
|
|
255
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null;
|
|
256
|
+
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function taskTokens(value) {
|
|
260
|
+
return String(value || '')
|
|
261
|
+
.toLowerCase()
|
|
262
|
+
.split(/[^a-z0-9]+/)
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.filter((token) => !FLIGHT_STOPWORDS.has(token));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// codex/<owner>-<task-slug>-<YYYYMMDD-HHMMSS>: drop the owner segment and the
|
|
268
|
+
// stamp, and what is left is the task the flight is working on.
|
|
269
|
+
function parseAgentFlightName(name) {
|
|
270
|
+
const text = String(name || '').trim().replace(/^refs\/heads\//, '');
|
|
271
|
+
const match = AGENT_FLIGHT_NAME_PATTERN.exec(text);
|
|
272
|
+
if (!match) return null;
|
|
273
|
+
const stampMs = flightStampMs(match[2]);
|
|
274
|
+
if (stampMs === null) return null;
|
|
275
|
+
const segments = match[1].split('-').filter(Boolean);
|
|
276
|
+
if (segments.length < 2) return null;
|
|
277
|
+
return {
|
|
278
|
+
name: text,
|
|
279
|
+
owner: segments[0],
|
|
280
|
+
taskSlug: segments.slice(1).join('-'),
|
|
281
|
+
stamp: match[2],
|
|
282
|
+
stampMs,
|
|
283
|
+
tokens: taskTokens(segments.slice(1).join('-')),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function remoteAgentBranchNames(root) {
|
|
288
|
+
// One ls-remote, best effort: offline dispatchers still get the local view.
|
|
289
|
+
const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false, timeout: 10000 });
|
|
290
|
+
if (result.status !== 0) return [];
|
|
291
|
+
return String(result.stdout || '')
|
|
292
|
+
.split(/\r?\n/)
|
|
293
|
+
.map((line) => (line.split(/\s+/)[1] || '').replace(/^refs\/heads\//, '').trim())
|
|
294
|
+
.filter(Boolean);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs = FLIGHT_WINDOW_MS } = {}) {
|
|
298
|
+
const nowMs = now.getTime();
|
|
299
|
+
const flights = new Map();
|
|
300
|
+
const add = (name, kind) => {
|
|
301
|
+
const flight = parseAgentFlightName(name);
|
|
302
|
+
if (!flight) return;
|
|
303
|
+
// Branch age comes from the stamp in the name, never a git log per branch.
|
|
304
|
+
if (kind !== 'worktree' && nowMs - flight.stampMs > windowMs) return;
|
|
305
|
+
const existing = flights.get(flight.name);
|
|
306
|
+
if (existing && existing.kind === 'worktree') return;
|
|
307
|
+
flights.set(flight.name, { ...flight, kind });
|
|
308
|
+
};
|
|
309
|
+
for (const wt of listWorktrees(root)) add(wt.branch, 'worktree');
|
|
310
|
+
const local = runGit(['branch', '--format=%(refname:short)'], { cwd: root, check: false });
|
|
311
|
+
if (local.status === 0) {
|
|
312
|
+
for (const name of String(local.stdout || '').split(/\r?\n/)) add(name, 'branch');
|
|
313
|
+
}
|
|
314
|
+
for (const name of remoteAgentBranchNames(root)) add(name, 'branch');
|
|
315
|
+
return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function collidingFlights(flights, taskSlug) {
|
|
319
|
+
const wanted = String(taskSlug || '').trim().toLowerCase();
|
|
320
|
+
if (!wanted) return [];
|
|
321
|
+
return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function describeFlightAge(flight, nowMs = Date.now()) {
|
|
325
|
+
const minutes = Math.max(0, Math.round((nowMs - flight.stampMs) / 60000));
|
|
326
|
+
if (minutes < 60) return `${minutes}m old`;
|
|
327
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m old`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function refExists(root, ref) {
|
|
331
|
+
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], { cwd: root, check: false }).status === 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function currentBranch(root = repoRoot()) {
|
|
335
|
+
return runGit(['branch', '--show-current'], { cwd: root, check: false }).stdout.trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function detachedShipBranch(root) {
|
|
339
|
+
return `codex/${path.basename(root)}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function createDetachedShipBranch(root, branch) {
|
|
343
|
+
if (refExists(root, `refs/heads/${branch}`) || refExists(root, `refs/remotes/origin/${branch}`)) {
|
|
344
|
+
console.error(`blocked: detached head branch already exists: ${branch}`);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const created = runGit(['switch', '-c', branch], { cwd: root, check: false });
|
|
348
|
+
if (created.status !== 0) {
|
|
349
|
+
console.error(`blocked: detached head branch create failed: ${branch}`);
|
|
350
|
+
console.error((created.stderr || created.stdout || '').trim());
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
console.log(`ship: detached head, created branch ${branch}`);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function currentUpstream(root) {
|
|
358
|
+
const result = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: root, check: false });
|
|
359
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function remoteHead(root) {
|
|
363
|
+
// The saved origin/HEAD survives branch renames. Ask the remote before
|
|
364
|
+
// trusting it, keeping the offline fallback when the remote is unavailable.
|
|
365
|
+
const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
|
|
366
|
+
if (remote.status === 0) {
|
|
367
|
+
const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
|
|
368
|
+
const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
|
|
369
|
+
if (match) {
|
|
370
|
+
const head = `origin/${match[1]}`;
|
|
371
|
+
if (!refExists(root, head)) {
|
|
372
|
+
const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
|
|
373
|
+
if (fetched.status !== 0 || !refExists(root, head)) {
|
|
374
|
+
throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return head;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
|
|
381
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeTargetRef(root, target) {
|
|
385
|
+
const value = String(target || '').trim();
|
|
386
|
+
if (!value) return '';
|
|
387
|
+
const remotes = runGit(['remote'], { cwd: root, check: false }).stdout.split(/\r?\n/);
|
|
388
|
+
const preferRemote =
|
|
389
|
+
!value.startsWith('origin/') &&
|
|
390
|
+
!value.startsWith('refs/') &&
|
|
391
|
+
value !== 'HEAD' &&
|
|
392
|
+
!/^[0-9a-f]{7,40}$/i.test(value) &&
|
|
393
|
+
remotes.includes('origin');
|
|
394
|
+
const remoteRef = value.startsWith('origin/') ? value : `origin/${value}`;
|
|
395
|
+
if (preferRemote && refExists(root, remoteRef)) return remoteRef;
|
|
396
|
+
if (preferRemote) return remoteRef;
|
|
397
|
+
if (refExists(root, value)) return value;
|
|
398
|
+
if (refExists(root, remoteRef)) return remoteRef;
|
|
399
|
+
if (value.startsWith('origin/') || remotes.includes('origin')) return remoteRef;
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function defaultMainlineBase(root) {
|
|
404
|
+
const head = remoteHead(root);
|
|
405
|
+
if (head && refExists(root, head)) return head;
|
|
406
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
407
|
+
if (refExists(root, candidate)) return candidate;
|
|
408
|
+
}
|
|
409
|
+
return 'HEAD';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function defaultStartBase(root) {
|
|
413
|
+
const upstream = currentUpstream(root);
|
|
414
|
+
const mainline = defaultMainlineBase(root);
|
|
415
|
+
// Always refresh before cutting a worktree: a stale origin/master base sent
|
|
416
|
+
// an engine off to rebuild work that had already landed (2026-07-09, packs
|
|
417
|
+
// one-click flight rebuilt the stars backend from a base two PRs behind).
|
|
418
|
+
refreshRemoteRef(root, mainline);
|
|
419
|
+
if (!upstream || !refExists(root, upstream)) return mainline;
|
|
420
|
+
if (mainline === 'HEAD' || mainline === upstream) return upstream;
|
|
421
|
+
// A launcher upstream that is behind the mainline is a stale cut point
|
|
422
|
+
// (2026-07-05: a stale origin/task branch sent two flights into rebase
|
|
423
|
+
// conflicts). Start from the mainline unless the upstream carries new work.
|
|
424
|
+
refreshRemoteRef(root, upstream);
|
|
425
|
+
const behind = runGit(['merge-base', '--is-ancestor', upstream, mainline], { cwd: root, check: false });
|
|
426
|
+
return behind.status === 0 ? mainline : upstream;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function defaultShipTarget(root) {
|
|
430
|
+
const branch = currentBranch(root);
|
|
431
|
+
if (branch) {
|
|
432
|
+
const configured = runGit(['config', '--get', `branch.${branch}.atris-base`], { cwd: root, check: false }).stdout.trim();
|
|
433
|
+
if (configured && refExists(root, configured)) return configured;
|
|
434
|
+
}
|
|
435
|
+
const head = remoteHead(root);
|
|
436
|
+
if (head && refExists(root, head)) return head;
|
|
437
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
438
|
+
if (refExists(root, candidate)) return candidate;
|
|
439
|
+
}
|
|
440
|
+
return 'HEAD';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function refreshRemoteRef(root, ref) {
|
|
444
|
+
if (!ref.startsWith('origin/')) return;
|
|
445
|
+
const remoteBranch = ref.slice('origin/'.length);
|
|
446
|
+
const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
|
|
447
|
+
return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function baseBranchName(ref) {
|
|
451
|
+
return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function statusCounts(root, {
|
|
455
|
+
ignoredUnstagedFiles = new Set(),
|
|
456
|
+
ignoredUntrackedFiles = new Set(),
|
|
457
|
+
ignoreUntracked = null,
|
|
458
|
+
ignoreAny = null,
|
|
459
|
+
} = {}) {
|
|
460
|
+
if (!fs.existsSync(root)) return null;
|
|
461
|
+
// -uall expands untracked directories to individual files so ignoredUntrackedFiles
|
|
462
|
+
// and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
|
|
463
|
+
const expandUntracked = ignoredUntrackedFiles.size
|
|
464
|
+
|| typeof ignoreUntracked === 'function'
|
|
465
|
+
|| typeof ignoreAny === 'function';
|
|
466
|
+
const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
|
|
467
|
+
const result = runGit(statusArgs, { cwd: root, check: false });
|
|
468
|
+
if (result.status !== 0) return null;
|
|
469
|
+
let staged = 0;
|
|
470
|
+
let unstaged = 0;
|
|
471
|
+
let untracked = 0;
|
|
472
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
473
|
+
const file = line.slice(3);
|
|
474
|
+
if (typeof ignoreAny === 'function' && ignoreAny(file)) continue;
|
|
475
|
+
if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
|
|
476
|
+
if (line.startsWith('??')) {
|
|
477
|
+
if (ignoredUntrackedFiles.has(file)) continue;
|
|
478
|
+
if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
|
|
479
|
+
untracked += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (line[0] !== ' ') staged += 1;
|
|
483
|
+
if (line[1] !== ' ') unstaged += 1;
|
|
484
|
+
}
|
|
485
|
+
return { staged, unstaged, untracked };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
489
|
+
// records engine output only. Paths that were never staged are simply not listed.
|
|
490
|
+
// GitHub refuses a merge with "Base branch was modified" when another PR
|
|
491
|
+
// merged into the same base a moment earlier; the same merge succeeds on a
|
|
492
|
+
// second try. On 2026-10-15 five parallel dispatches lost three landings to
|
|
493
|
+
// it. Retry only that message, a few times, with a short wait.
|
|
494
|
+
const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
|
|
495
|
+
function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
|
|
496
|
+
let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
497
|
+
for (let attempt = 1; attempt < attempts && merged.status !== 0
|
|
498
|
+
&& BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
|
|
499
|
+
console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
|
|
500
|
+
wait(1500 * attempt);
|
|
501
|
+
merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
502
|
+
}
|
|
503
|
+
return merged;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function unstageConductorArtifacts(root) {
|
|
507
|
+
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
508
|
+
if (staged.status !== 0) return [];
|
|
509
|
+
const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
|
|
510
|
+
const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
|
|
511
|
+
// A file this commit would add under .atris/ is the tool's own state, never work.
|
|
512
|
+
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
|
|
513
|
+
.filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
|
|
514
|
+
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
515
|
+
return drop;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function changedFiles(root, args) {
|
|
519
|
+
const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
|
|
520
|
+
if (result.status !== 0) return new Set();
|
|
521
|
+
return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {}) {
|
|
525
|
+
const unstaged = changedFiles(root, ['diff', '--name-only']);
|
|
526
|
+
const staged = changedFiles(root, ['diff', '--cached', '--name-only']);
|
|
527
|
+
const commitMessage = String(message || '');
|
|
528
|
+
const skipped = REGEN_ADAPTER_FILES.filter(
|
|
529
|
+
(file) => unstaged.has(file) && !staged.has(file) && !commitMessage.includes(file)
|
|
530
|
+
);
|
|
531
|
+
if (!skipped.length) return [];
|
|
532
|
+
console.log(`ship: skipped regenerated adapter churn: ${skipped.join(', ')}`);
|
|
533
|
+
if (!dryRun) runGit(['checkout', '--', ...skipped], { cwd: root });
|
|
534
|
+
return skipped;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function swarloClaim(root, { channel, taskKey, content }) {
|
|
538
|
+
const script = path.join(root, 'scripts', 'swarlo.py');
|
|
539
|
+
if (!fs.existsSync(script)) return 'skip: scripts/swarlo.py not found';
|
|
540
|
+
const result = spawnSync('python3', [script, 'claim', channel, taskKey, content], { cwd: root, encoding: 'utf8' });
|
|
541
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
542
|
+
if (result.status === 0) return output || `claimed ${taskKey}`;
|
|
543
|
+
return `skip: swarlo claim failed: ${output || result.status}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Programmatic core shared by `atris worktree start` and `atris mission start
|
|
547
|
+
// --worktree`: creates the branch + isolated checkout + identity sidecar and
|
|
548
|
+
// returns the facts. Throws on failure; callers own messaging and next steps.
|
|
549
|
+
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 } = {}) {
|
|
550
|
+
const owner = member || agent;
|
|
551
|
+
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
552
|
+
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
553
|
+
const mainline = baseOverride || defaultMainlineBase(root);
|
|
554
|
+
// Every new flight pays down completed checkout debt before adding another
|
|
555
|
+
// full repo copy. Branches and commits survive worktree removal, while real
|
|
556
|
+
// uncommitted changes remain protected by cleanupWorktrees.
|
|
557
|
+
const cleanupBudget = { count: 0, kib: 0 };
|
|
558
|
+
let reapedBeforeStart = [];
|
|
559
|
+
let cachePrunedBeforeStart = [];
|
|
560
|
+
try {
|
|
561
|
+
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start', cleanupBudget });
|
|
562
|
+
reapedBeforeStart = cleaned.removed;
|
|
563
|
+
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
564
|
+
} catch {}
|
|
565
|
+
let branch = branchOverride || branchName(owner, task, now);
|
|
566
|
+
let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
567
|
+
// Two runs of the same job in the same second get the same name; number
|
|
568
|
+
// the later one instead of failing it.
|
|
569
|
+
if (!pathOverride && !branchOverride) {
|
|
570
|
+
const baseBranch = branch;
|
|
571
|
+
const baseTarget = target;
|
|
572
|
+
for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
|
|
573
|
+
branch = `${baseBranch}-${n}`;
|
|
574
|
+
target = `${baseTarget}-${n}`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const checkoutBase = normalizeTargetRef(root, mainline);
|
|
578
|
+
const shipBase = checkoutBase;
|
|
579
|
+
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
580
|
+
// A full disk stops every flight at once. Reap first; refuse before cutting.
|
|
581
|
+
const room = checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget });
|
|
582
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
583
|
+
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
584
|
+
let resumedFrom = (!pathOverride && !branchOverride)
|
|
585
|
+
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
586
|
+
: null;
|
|
587
|
+
let skippedResume = null;
|
|
588
|
+
if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
|
|
589
|
+
skippedResume = resumedFrom;
|
|
590
|
+
resumedFrom = null;
|
|
591
|
+
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`);
|
|
592
|
+
}
|
|
593
|
+
const own = slugify(task, 'task', 36);
|
|
594
|
+
for (const former of Array.isArray(formerTasks) ? formerTasks : [formerTasks]) {
|
|
595
|
+
if (!former || !String(former).trim() || slugify(former, 'task', 36) === own) continue;
|
|
596
|
+
const olderRun = lastRunBranch(root, owner, former, checkoutBase, branch);
|
|
597
|
+
if (!olderRun) continue;
|
|
598
|
+
console.error(`note: older unfinished work may be on ${olderRun}; ${resumedFrom ? `continuing from ${resumedFrom} instead` : 'starting fresh'}`);
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
const startPoint = resumedFrom || checkoutBase;
|
|
602
|
+
const ref = `refs/heads/${branch}`;
|
|
603
|
+
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
604
|
+
const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
|
|
605
|
+
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
606
|
+
if (staleBase && startCommit) {
|
|
607
|
+
console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
|
|
608
|
+
}
|
|
609
|
+
const discardFailedStart = () => {
|
|
610
|
+
if (existed || !startCommit) return;
|
|
611
|
+
const registered = listWorktrees(root).find((wt) => wt.branch === branch);
|
|
612
|
+
if (registered) {
|
|
613
|
+
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
614
|
+
|| registered.head !== startCommit) return;
|
|
615
|
+
const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
|
|
616
|
+
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
617
|
+
runGit(['worktree', 'remove', target], { cwd: root });
|
|
618
|
+
}
|
|
619
|
+
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
620
|
+
};
|
|
621
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
622
|
+
const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
|
|
623
|
+
if (added.status === 0) break;
|
|
624
|
+
if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
|
|
625
|
+
if (!configLocked(added) || attempt === 2) {
|
|
626
|
+
throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
|
|
627
|
+
}
|
|
628
|
+
sleepSync(100 * (attempt + 1));
|
|
629
|
+
}
|
|
630
|
+
if (resumedFrom) {
|
|
631
|
+
const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
|
|
632
|
+
if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
636
|
+
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
637
|
+
runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
|
|
638
|
+
fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
|
|
639
|
+
fs.writeFileSync(
|
|
640
|
+
path.join(target, '.atris', 'agent-worktree.json'),
|
|
641
|
+
JSON.stringify({
|
|
642
|
+
agent: agent || null,
|
|
643
|
+
member: member || null,
|
|
644
|
+
owner,
|
|
645
|
+
task,
|
|
646
|
+
branch,
|
|
647
|
+
base: shipBase,
|
|
648
|
+
checkout_base: checkoutBase,
|
|
649
|
+
resumed_from: resumedFrom,
|
|
650
|
+
skipped_resume: skippedResume,
|
|
651
|
+
workspace_root: root,
|
|
652
|
+
created_at: now.toISOString(),
|
|
653
|
+
}, null, 2) + '\n',
|
|
654
|
+
'utf8'
|
|
655
|
+
);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
discardFailedStart();
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
|
|
661
|
+
const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
|
|
662
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function seedCopyDeps({ root, target, options = {} }) {
|
|
666
|
+
try {
|
|
667
|
+
const primaryRoot = findPrimaryRoot(root);
|
|
668
|
+
return space.seedNodeModules({
|
|
669
|
+
target,
|
|
670
|
+
workspace: path.dirname(primaryRoot),
|
|
671
|
+
repoName: path.basename(primaryRoot),
|
|
672
|
+
sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
|
|
673
|
+
isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
|
|
674
|
+
...options,
|
|
675
|
+
});
|
|
676
|
+
} catch (error) {
|
|
677
|
+
return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
|
|
682
|
+
|
|
683
|
+
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start', cleanupBudget = null }) {
|
|
684
|
+
const limits = space.spaceLimits(env);
|
|
685
|
+
const before = freeSpace(target);
|
|
686
|
+
const room = { before, free: before, removed: [], cleared: [], ...limits };
|
|
687
|
+
if (typeof before !== 'number' || !Number.isFinite(before)) return room;
|
|
688
|
+
if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
|
|
689
|
+
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by, cleanupBudget });
|
|
690
|
+
for (const repo of swept.repos) {
|
|
691
|
+
room.removed.push(...(repo.removed || []));
|
|
692
|
+
room.cleared.push(...(repo.cachePruned || []));
|
|
693
|
+
}
|
|
694
|
+
const after = freeSpace(target);
|
|
695
|
+
room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
|
|
696
|
+
if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
|
|
697
|
+
const count = room.removed.length;
|
|
698
|
+
const reaped = count
|
|
699
|
+
? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
|
|
700
|
+
: 'No finished build copies were safe to remove.';
|
|
701
|
+
const error = new Error(
|
|
702
|
+
`only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
|
|
703
|
+
+ 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
|
|
704
|
+
);
|
|
705
|
+
error.code = 'ATRIS_LOW_DISK';
|
|
706
|
+
error.space = room;
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
return room;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget = null }) {
|
|
713
|
+
return (spaceGuard ?? !space.isScratchPath(target))
|
|
714
|
+
? ensureRoomForCopy({ root, target, freeSpace, env, cleanupBudget })
|
|
715
|
+
: null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function lowDiskRefusal({ root = repoRoot(), member = '', agent = '', task = 'flight', now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, target = '' } = {}) {
|
|
719
|
+
try {
|
|
720
|
+
checkRoomForCopy({ root, target: target ? path.resolve(target) : path.resolve(defaultWorktreePath(root, member || agent, task, now)), freeSpace, env, spaceGuard });
|
|
721
|
+
return null;
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (!error || error.code !== 'ATRIS_LOW_DISK') return null;
|
|
724
|
+
return { message: error.message, free: error.space.free, needs: error.space.refuseBelow };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function createOrFindPr(root, branch, targetRef, title, dryRun) {
|
|
729
|
+
const targetBranch = baseBranchName(targetRef);
|
|
730
|
+
const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
|
|
731
|
+
cwd: root,
|
|
732
|
+
encoding: 'utf8',
|
|
733
|
+
});
|
|
734
|
+
if (existing.status === 0 && existing.stdout.trim()) {
|
|
735
|
+
const parts = existing.stdout.trim().split(/\s+/);
|
|
736
|
+
const state = parts.pop();
|
|
737
|
+
if (state === 'OPEN') return parts.join(' ');
|
|
738
|
+
}
|
|
739
|
+
const body = [
|
|
740
|
+
'Automated Atris worktree ship.',
|
|
741
|
+
'',
|
|
742
|
+
`Branch: ${branch}`,
|
|
743
|
+
`Target: ${targetBranch}`,
|
|
744
|
+
].join('\n');
|
|
745
|
+
if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
|
|
746
|
+
const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
|
|
747
|
+
if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
|
|
748
|
+
console.log('pr: skipped (no commits ahead of base)');
|
|
749
|
+
return '';
|
|
750
|
+
}
|
|
751
|
+
const created = spawnSync(
|
|
752
|
+
'gh',
|
|
753
|
+
['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
|
|
754
|
+
{ cwd: root, encoding: 'utf8' }
|
|
755
|
+
);
|
|
756
|
+
if (created.status !== 0) {
|
|
757
|
+
throw new Error((created.stderr || created.stdout || 'gh pr create failed').trim());
|
|
758
|
+
}
|
|
759
|
+
return created.stdout.trim();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function prMergeRef(prOutput) {
|
|
763
|
+
const text = String(prOutput || '').trim();
|
|
764
|
+
if (!text || text.startsWith('dry-run:')) return '';
|
|
765
|
+
return text.split(/\s+/)[0];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function deniedLaneForShip(root, branch, message) {
|
|
769
|
+
try {
|
|
770
|
+
const { protectedLaneOfTask } = require('./fleet');
|
|
771
|
+
const refs = new Set();
|
|
772
|
+
for (const source of [String(branch || ''), String(message || '')]) {
|
|
773
|
+
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
774
|
+
for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
|
|
775
|
+
}
|
|
776
|
+
const words = new Set(String(branch || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
777
|
+
const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
|
|
778
|
+
if (!fs.existsSync(projectionPath)) return null;
|
|
779
|
+
const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
|
|
780
|
+
for (const task of projection.tasks || []) {
|
|
781
|
+
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
782
|
+
const rowId = typeof task.id === 'string' && /^[A-Za-z0-9]{16,}$/.test(task.id) ? task.id.toLowerCase() : '';
|
|
783
|
+
if (!ids.some(id => refs.has(id)) && !(rowId && words.has(rowId))) continue;
|
|
784
|
+
const hit = protectedLaneOfTask(task);
|
|
785
|
+
if (hit) return { taskId: ids[0] || task.id, tag: hit };
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
} catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const OPEN_PR_LIST_LIMIT = 100;
|
|
794
|
+
|
|
795
|
+
function protectedPushRefusal(root, branch, run = spawnSync) {
|
|
796
|
+
const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
|
|
797
|
+
const listed = run('gh', [
|
|
798
|
+
'pr', 'list', '--head', branch, '--state', 'open',
|
|
799
|
+
'--json', 'number,url,baseRefName,autoMergeRequest',
|
|
800
|
+
'--limit', String(OPEN_PR_LIST_LIMIT),
|
|
801
|
+
], { cwd: root, encoding: 'utf8', timeout: 60000 });
|
|
802
|
+
if (listed.error) {
|
|
803
|
+
return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
|
|
804
|
+
}
|
|
805
|
+
if (listed.status !== 0) {
|
|
806
|
+
const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
|
|
807
|
+
const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
808
|
+
return unknown(firstLine || `gh exited ${listed.status}`);
|
|
809
|
+
}
|
|
810
|
+
let open;
|
|
811
|
+
try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
|
|
812
|
+
const readable = Array.isArray(open)
|
|
813
|
+
&& open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
|
|
814
|
+
if (!readable) return unknown("gh's answer could not be read");
|
|
815
|
+
if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
|
|
816
|
+
const armed = open.filter((pr) => pr.autoMergeRequest);
|
|
817
|
+
if (!armed.length) return '';
|
|
818
|
+
const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
|
|
819
|
+
const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
|
|
820
|
+
if (armed.length === 1) {
|
|
821
|
+
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`;
|
|
822
|
+
}
|
|
823
|
+
const names = armed.map(name);
|
|
824
|
+
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`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function findPrimaryRoot(root) {
|
|
828
|
+
const worktrees = listWorktrees(root);
|
|
829
|
+
return worktrees[0]?.path || root;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function shellQuote(value) {
|
|
833
|
+
const text = String(value || '');
|
|
834
|
+
if (/^[A-Za-z0-9_./:@+-]+$/.test(text)) return text;
|
|
835
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function experimentHealthProbe(statePath) {
|
|
839
|
+
const script = [
|
|
840
|
+
"const fs=require('fs')",
|
|
841
|
+
'const state=JSON.parse(fs.readFileSync(process.argv[1],\'utf8\'))',
|
|
842
|
+
'const last=Date.parse(state.last_run_date)',
|
|
843
|
+
`process.exit(Number.isFinite(last)&&Date.now()-last<=${DAY_MS}?0:1)`,
|
|
844
|
+
].join(';');
|
|
845
|
+
return `${shellQuote(process.execPath)} -e ${shellQuote(script)} ${shellQuote(statePath)}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function runShipHealthCheck(workspaceRoot, options = {}) {
|
|
849
|
+
try {
|
|
850
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
851
|
+
const statePath = path.join(workspaceRoot, '.atris', 'state', 'experiments-daily.json');
|
|
852
|
+
const state = fs.existsSync(statePath)
|
|
853
|
+
? JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
|
854
|
+
: {};
|
|
855
|
+
const rawLastRunDate = state && state.last_run_date;
|
|
856
|
+
const lastRunMs = rawLastRunDate ? Date.parse(rawLastRunDate) : null;
|
|
857
|
+
if (rawLastRunDate && !Number.isFinite(lastRunMs)) {
|
|
858
|
+
throw new Error('experiment state date is unreadable');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const ageMs = Number.isFinite(lastRunMs) ? now.getTime() - lastRunMs : null;
|
|
862
|
+
if (ageMs !== null && ageMs <= DAY_MS) return { healthy: true, filed: false };
|
|
863
|
+
|
|
864
|
+
const days = ageMs === null ? 1 : Math.max(1, Math.floor(ageMs / DAY_MS));
|
|
865
|
+
console.log(`health check failed: no verified experiment in ${days} days. shipping still works, the metabolism does not.`);
|
|
866
|
+
const filed = close.upsertSourceFlag({
|
|
867
|
+
what: 'no verified experiment has run in over a day',
|
|
868
|
+
owner: 'operator',
|
|
869
|
+
lane: 'code',
|
|
870
|
+
ttlDays: 1,
|
|
871
|
+
source: SHIP_HEALTH_SOURCE,
|
|
872
|
+
closeCondition: 'a verified experiment ran within 24 hours',
|
|
873
|
+
probe: experimentHealthProbe(statePath),
|
|
874
|
+
}, workspaceRoot, { now });
|
|
875
|
+
return { healthy: false, ...filed };
|
|
876
|
+
} catch (error) {
|
|
877
|
+
console.log(`health check skipped: ${String(error && error.message || error).toLowerCase()}. shipping still works.`);
|
|
878
|
+
return { healthy: null, filed: false, error };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
|
|
883
|
+
const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
884
|
+
|
|
885
|
+
function refreshMovedMapRefs(root, args = []) {
|
|
886
|
+
if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
|
|
887
|
+
try {
|
|
888
|
+
const { changes, left } = fixMapDocs(root);
|
|
889
|
+
if (changes.length) {
|
|
890
|
+
const docs = [...new Set(changes.map(change => change.doc))];
|
|
891
|
+
runGit(['add', '--', ...docs], { cwd: root });
|
|
892
|
+
runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
|
|
893
|
+
console.log(`map: refreshed ${changes.length} moved references`);
|
|
894
|
+
} else {
|
|
895
|
+
console.log('map: references current');
|
|
896
|
+
}
|
|
897
|
+
if (left.length) {
|
|
898
|
+
console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
|
|
899
|
+
}
|
|
900
|
+
return { skipped: false, refreshed: changes.length, left: left.length };
|
|
901
|
+
} catch (error) {
|
|
902
|
+
const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
903
|
+
console.log(`map: refresh skipped (${message}); the ship continues`);
|
|
904
|
+
return { skipped: false, refreshed: 0, left: 0, error };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const SHIP_SETTLE_MS = 10 * 60 * 1000;
|
|
909
|
+
const SHIP_REAP_POLL_MS = 60 * 1000;
|
|
910
|
+
const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
911
|
+
const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
|
|
912
|
+
const SHIP_REAP_RETRY_REASONS = new Set([
|
|
913
|
+
'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
|
|
914
|
+
'ignored_scan_failed', 'changed_or_active_during_verification',
|
|
915
|
+
]);
|
|
916
|
+
|
|
917
|
+
function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
|
|
918
|
+
const copy = path.resolve(root);
|
|
919
|
+
if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
|
|
920
|
+
if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
|
|
921
|
+
if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
|
|
922
|
+
if (space.isScratchPath(copy)) return { skip: 'temp folder' };
|
|
923
|
+
if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
|
|
924
|
+
return { copy };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
|
|
928
|
+
const plan = shippedCopyRemovalPlan({ root, primary, env });
|
|
929
|
+
if (plan.skip) return plan;
|
|
930
|
+
try {
|
|
931
|
+
const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
|
|
932
|
+
cwd: primary,
|
|
933
|
+
detached: true,
|
|
934
|
+
stdio: 'ignore',
|
|
935
|
+
env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
|
|
936
|
+
});
|
|
937
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
938
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
939
|
+
return { ...plan, scheduled: true };
|
|
940
|
+
} catch (error) {
|
|
941
|
+
return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function announceShippedCopyRemoval(plan) {
|
|
946
|
+
if (plan.scheduled) {
|
|
947
|
+
console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
|
|
948
|
+
} else if (plan.announce) {
|
|
949
|
+
console.log(`cleanup: kept this build copy (${plan.skip})`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function reapShippedCopy({
|
|
954
|
+
root,
|
|
955
|
+
target,
|
|
956
|
+
base = '',
|
|
957
|
+
settleMs = SHIP_SETTLE_MS,
|
|
958
|
+
pollMs = SHIP_REAP_POLL_MS,
|
|
959
|
+
giveUpMs = SHIP_REAP_GIVE_UP_MS,
|
|
960
|
+
unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
|
|
961
|
+
sleep = sleepSync,
|
|
962
|
+
clock = Date.now,
|
|
963
|
+
cleanup: cleanupFn = cleanupWorktrees,
|
|
964
|
+
} = {}) {
|
|
965
|
+
const startedAt = clock();
|
|
966
|
+
sleep(settleMs);
|
|
967
|
+
for (;;) {
|
|
968
|
+
const result = cleanupFn({
|
|
969
|
+
root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
|
|
970
|
+
});
|
|
971
|
+
if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
|
|
972
|
+
const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
|
|
973
|
+
if (!entry) return { removed: false, reason: 'not_registered' };
|
|
974
|
+
const waited = clock() - startedAt;
|
|
975
|
+
const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
|
|
976
|
+
if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
|
|
977
|
+
sleep(pollMs);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function shipMergeRecord(state) {
|
|
982
|
+
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
983
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
984
|
+
const record = { merge_commit: commit };
|
|
985
|
+
if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
|
|
986
|
+
if (typeof state.url === 'string' && state.url) record.url = state.url;
|
|
987
|
+
if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
|
|
988
|
+
return record;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
992
|
+
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
993
|
+
const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
994
|
+
'.derivedData',
|
|
995
|
+
'.next',
|
|
996
|
+
'.swiftpm',
|
|
997
|
+
'.cache/swiftpm',
|
|
998
|
+
'node_modules/.cache',
|
|
999
|
+
];
|
|
1000
|
+
|
|
1001
|
+
function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
|
|
1002
|
+
try {
|
|
1003
|
+
return now - fs.statSync(worktreePath).mtimeMs < graceMs;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function lastCopyActivityMs(worktreePath) {
|
|
1010
|
+
const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
|
|
1011
|
+
try {
|
|
1012
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
|
|
1013
|
+
if (pointer) {
|
|
1014
|
+
const gitDir = path.resolve(worktreePath, pointer[1].trim());
|
|
1015
|
+
marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
|
|
1016
|
+
}
|
|
1017
|
+
} catch {}
|
|
1018
|
+
let newest = 0;
|
|
1019
|
+
for (const mark of marks) {
|
|
1020
|
+
try {
|
|
1021
|
+
newest = Math.max(newest, fs.statSync(mark).mtimeMs);
|
|
1022
|
+
} catch {}
|
|
1023
|
+
}
|
|
1024
|
+
return newest;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function canonicalPath(value) {
|
|
1028
|
+
try {
|
|
1029
|
+
return fs.realpathSync(value);
|
|
1030
|
+
} catch {
|
|
1031
|
+
return path.resolve(value);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function listActiveProcessFiles() {
|
|
1036
|
+
const result = spawnSync('lsof', ['-n', '-F', 'n'], {
|
|
1037
|
+
encoding: 'utf8',
|
|
1038
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1039
|
+
timeout: 20000,
|
|
1040
|
+
killSignal: 'SIGKILL',
|
|
1041
|
+
});
|
|
1042
|
+
if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
|
|
1043
|
+
const paths = String(result.stdout || '')
|
|
1044
|
+
.split(/\r?\n/)
|
|
1045
|
+
.filter((line) => line.startsWith('n/'))
|
|
1046
|
+
.map((line) => canonicalPath(line.slice(1)))
|
|
1047
|
+
.filter(Boolean);
|
|
1048
|
+
return paths.length ? paths : null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function parseProcessArgPaths(psOutput) {
|
|
1052
|
+
const paths = new Set();
|
|
1053
|
+
const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
|
|
1054
|
+
for (const raw of String(psOutput || '').split(/\r?\n/)) {
|
|
1055
|
+
const line = raw.trim();
|
|
1056
|
+
for (const match of line.matchAll(starts)) {
|
|
1057
|
+
const rest = line.slice(match.index + match[0].length);
|
|
1058
|
+
const token = rest.split(/\s/, 1)[0];
|
|
1059
|
+
if (token.length > 1) paths.add(token);
|
|
1060
|
+
if (rest.length > token.length) paths.add(rest);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return [...paths];
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function listActiveProcessArgPaths() {
|
|
1067
|
+
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1068
|
+
encoding: 'utf8',
|
|
1069
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1070
|
+
timeout: 5000,
|
|
1071
|
+
killSignal: 'SIGKILL',
|
|
1072
|
+
});
|
|
1073
|
+
if (result.error || result.status !== 0) return null;
|
|
1074
|
+
return parseProcessArgPaths(result.stdout);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function listActiveProcessPaths() {
|
|
1078
|
+
const files = listActiveProcessFiles();
|
|
1079
|
+
if (!Array.isArray(files)) return null;
|
|
1080
|
+
const args = listActiveProcessArgPaths();
|
|
1081
|
+
if (!Array.isArray(args)) return null;
|
|
1082
|
+
return files.concat(args);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
1086
|
+
if (!Array.isArray(activeCwds)) return true;
|
|
1087
|
+
const resolved = canonicalPath(worktreePath);
|
|
1088
|
+
const prefix = `${resolved}${path.sep}`;
|
|
1089
|
+
return activeCwds.some((cwd) => {
|
|
1090
|
+
const active = canonicalPath(cwd);
|
|
1091
|
+
return active === resolved || active.startsWith(prefix);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
|
|
1096
|
+
try {
|
|
1097
|
+
const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
|
|
1098
|
+
const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
|
|
1099
|
+
const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
|
|
1100
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1101
|
+
fs.appendFileSync(file, `${JSON.stringify({
|
|
1102
|
+
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
|
|
1103
|
+
})}\n`);
|
|
1104
|
+
} catch {}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function webpackCacheSnapshot(worktreePath) {
|
|
1108
|
+
try {
|
|
1109
|
+
const root = fs.realpathSync(worktreePath);
|
|
1110
|
+
let current = root;
|
|
1111
|
+
for (const part of ['.next', 'cache', 'webpack']) {
|
|
1112
|
+
current = path.join(current, part);
|
|
1113
|
+
const stat = fs.lstatSync(current);
|
|
1114
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
1115
|
+
}
|
|
1116
|
+
if (fs.realpathSync(current) !== current) return null;
|
|
1117
|
+
const entries = [];
|
|
1118
|
+
function walk(dir) {
|
|
1119
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
1120
|
+
const file = path.join(dir, name);
|
|
1121
|
+
const stat = fs.lstatSync(file);
|
|
1122
|
+
if (stat.isSymbolicLink()) return false;
|
|
1123
|
+
if (stat.isDirectory()) {
|
|
1124
|
+
entries.push([file, 'd', stat.ino, stat.mtimeMs]);
|
|
1125
|
+
if (!walk(file)) return false;
|
|
1126
|
+
} else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
|
|
1127
|
+
entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
|
|
1128
|
+
} else return false;
|
|
1129
|
+
}
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
if (!walk(current)) return null;
|
|
1133
|
+
const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
1134
|
+
const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
|
|
1135
|
+
if (!match) return null;
|
|
1136
|
+
return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
|
|
1137
|
+
} catch {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
|
|
1143
|
+
const candidates = [];
|
|
1144
|
+
const pruned = [];
|
|
1145
|
+
for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
|
|
1146
|
+
const target = path.join(worktreePath, relative);
|
|
1147
|
+
if (!fs.existsSync(target)) continue;
|
|
1148
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1149
|
+
if (ignored.status !== 0) continue;
|
|
1150
|
+
const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
|
|
1151
|
+
candidates.push(item);
|
|
1152
|
+
}
|
|
1153
|
+
const relative = '.next/cache/webpack';
|
|
1154
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1155
|
+
const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
|
|
1156
|
+
if (snapshot) {
|
|
1157
|
+
const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
|
|
1158
|
+
candidates.push(item);
|
|
1159
|
+
if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
|
|
1160
|
+
const active = listActiveProcessPaths();
|
|
1161
|
+
const fresh = webpackCacheSnapshot(worktreePath);
|
|
1162
|
+
const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1163
|
+
if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
|
|
1164
|
+
&& stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
|
|
1165
|
+
&& fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
|
|
1166
|
+
try {
|
|
1167
|
+
fs.rmSync(snapshot.path, { recursive: true });
|
|
1168
|
+
budget.count += 1;
|
|
1169
|
+
budget.kib += snapshot.kib;
|
|
1170
|
+
pruned.push(item);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
item.error = String((error && error.message) || error);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return { candidates, pruned };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function originBaseIsCurrent(root, base) {
|
|
1181
|
+
const prefix = base.startsWith('origin/') ? 'origin/'
|
|
1182
|
+
: base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
|
|
1183
|
+
if (!prefix) {
|
|
1184
|
+
if (base === 'HEAD') {
|
|
1185
|
+
const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
|
|
1186
|
+
return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
|
|
1187
|
+
}
|
|
1188
|
+
return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
|
|
1189
|
+
}
|
|
1190
|
+
const branch = base.slice(prefix.length);
|
|
1191
|
+
const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
|
|
1192
|
+
const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
|
|
1193
|
+
const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
|
|
1194
|
+
return local.status === 0 && remote.status === 0 && Boolean(match)
|
|
1195
|
+
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function cleanupWorktrees({
|
|
1199
|
+
root = repoRoot(),
|
|
1200
|
+
base: baseOverride = '',
|
|
1201
|
+
apply = false,
|
|
1202
|
+
activeCwds: activeCwdsOverride,
|
|
1203
|
+
by = 'atris worktree cleanup',
|
|
1204
|
+
onlyUnder = '',
|
|
1205
|
+
onlyPaths = null,
|
|
1206
|
+
idleMs = 0,
|
|
1207
|
+
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1208
|
+
now = Date.now(),
|
|
1209
|
+
cleanupBudget = null,
|
|
1210
|
+
} = {}) {
|
|
1211
|
+
const worktrees = listWorktrees(root);
|
|
1212
|
+
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1213
|
+
const current = path.resolve(root);
|
|
1214
|
+
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1215
|
+
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1216
|
+
const refreshError = refreshRemoteRef(root, base);
|
|
1217
|
+
const candidates = [];
|
|
1218
|
+
const kept = [];
|
|
1219
|
+
const removed = [];
|
|
1220
|
+
const cacheCandidates = [];
|
|
1221
|
+
const cachePruned = [];
|
|
1222
|
+
const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1223
|
+
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1224
|
+
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
1225
|
+
&& (!onlyUnder || space.isInside(wtPath, onlyUnder));
|
|
1226
|
+
const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
|
|
1227
|
+
const pruneCachesForKept = (wtPath) => {
|
|
1228
|
+
if (worktreeWithinReapGrace(wtPath, now)) return;
|
|
1229
|
+
if (recentlyUsed(wtPath)) return;
|
|
1230
|
+
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1231
|
+
cacheCandidates.push(...caches.candidates);
|
|
1232
|
+
cachePruned.push(...caches.pruned);
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
for (const wt of worktrees) {
|
|
1236
|
+
const wtPath = path.resolve(wt.path);
|
|
1237
|
+
const item = { path: wt.path, branch: wt.branch, head: wt.head };
|
|
1238
|
+
if (wtPath === primary) {
|
|
1239
|
+
kept.push({ ...item, reason: 'primary_checkout' });
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (wtPath === current) {
|
|
1243
|
+
kept.push({ ...item, reason: 'current_checkout' });
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (!inScope(wtPath)) {
|
|
1247
|
+
kept.push({ ...item, reason: 'out_of_scope' });
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (wt.locked) {
|
|
1251
|
+
kept.push({ ...item, reason: 'locked' });
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
if (PROTECTED_BRANCHES.has(wt.branch)) {
|
|
1255
|
+
kept.push({ ...item, reason: 'protected_branch' });
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
if (worktreeHasActiveProcess(wtPath, activeCwds)) {
|
|
1259
|
+
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const counts = statusCounts(wt.path);
|
|
1263
|
+
if (!counts) {
|
|
1264
|
+
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (counts.staged || counts.unstaged || counts.untracked) {
|
|
1268
|
+
pruneCachesForKept(wtPath);
|
|
1269
|
+
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1273
|
+
if (ignored.status !== 0 || ignored.error) {
|
|
1274
|
+
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (ignored.stdout) {
|
|
1278
|
+
pruneCachesForKept(wtPath);
|
|
1279
|
+
kept.push({ ...item, reason: 'ignored_data' });
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
if (!wt.head) {
|
|
1283
|
+
pruneCachesForKept(wtPath);
|
|
1284
|
+
kept.push({ ...item, reason: 'missing_head' });
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (refreshError) {
|
|
1288
|
+
kept.push({ ...item, reason: 'base_refresh_failed' });
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
|
|
1292
|
+
if (!merged) {
|
|
1293
|
+
pruneCachesForKept(wtPath);
|
|
1294
|
+
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
|
|
1298
|
+
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (recentlyUsed(wtPath)) {
|
|
1302
|
+
kept.push({ ...item, reason: 'recent_activity' });
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (!originBaseIsCurrent(root, base)) {
|
|
1306
|
+
kept.push({ ...item, reason: 'base_unverified' });
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
let real;
|
|
1310
|
+
try { real = fs.realpathSync(wtPath); } catch { real = ''; }
|
|
1311
|
+
if (!real || real !== wtPath) {
|
|
1312
|
+
kept.push({ ...item, reason: 'path_not_canonical' });
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1316
|
+
const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1317
|
+
const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
1318
|
+
if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
|
|
1319
|
+
kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
|
|
1323
|
+
candidates.push(candidate);
|
|
1324
|
+
cacheBudget.count += 1;
|
|
1325
|
+
cacheBudget.kib += kib;
|
|
1326
|
+
if (!apply) continue;
|
|
1327
|
+
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1328
|
+
const freshCounts = statusCounts(wt.path);
|
|
1329
|
+
const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
|
|
1330
|
+
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1331
|
+
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1332
|
+
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1333
|
+
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1334
|
+
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1335
|
+
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1336
|
+
|| freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1337
|
+
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1338
|
+
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1339
|
+
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
1340
|
+
|| !originBaseIsCurrent(root, base)
|
|
1341
|
+
|| canonicalPath(wtPath) !== wtPath) {
|
|
1342
|
+
kept.push({ ...item, reason: 'changed_or_active_during_verification' });
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
|
|
1346
|
+
if (removedResult.status === 0) {
|
|
1347
|
+
removed.push(candidate);
|
|
1348
|
+
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1349
|
+
} else {
|
|
1350
|
+
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1358
|
+
const SWEEP_EVERY_HOURS = 3;
|
|
1359
|
+
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1360
|
+
|
|
1361
|
+
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
|
|
1362
|
+
const root = path.resolve(workspace);
|
|
1363
|
+
const before = freeSpace(root);
|
|
1364
|
+
const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
|
|
1365
|
+
const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
|
|
1366
|
+
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1367
|
+
const scope = path.join(root, space.AGENT_COPIES_DIR);
|
|
1368
|
+
cleanupBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1369
|
+
for (const repo of space.agentCopyRepos(root)) {
|
|
1370
|
+
const entry = { name: repo.name, root: repo.root };
|
|
1371
|
+
try {
|
|
1372
|
+
const base = defaultMainlineBase(repo.root);
|
|
1373
|
+
if (!base.startsWith('origin/')) {
|
|
1374
|
+
report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1378
|
+
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
|
|
1379
|
+
report.repos.push({
|
|
1380
|
+
...entry,
|
|
1381
|
+
base: result.base,
|
|
1382
|
+
removed: result.removed,
|
|
1383
|
+
candidates: result.candidates,
|
|
1384
|
+
cachePruned: result.cachePruned,
|
|
1385
|
+
cacheCandidates: result.cacheCandidates,
|
|
1386
|
+
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
1387
|
+
installsPruned: [],
|
|
1388
|
+
});
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
report.repos.push({ ...entry, error: String((error && error.message) || error) });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
report.after = freeSpace(root);
|
|
1394
|
+
return report;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const SWEEP_KEPT_LABELS = {
|
|
1398
|
+
active_process: 'in use',
|
|
1399
|
+
process_scan_failed: 'in use (could not check)',
|
|
1400
|
+
dirty: 'with changes',
|
|
1401
|
+
unmerged: 'not merged',
|
|
1402
|
+
detached_unmerged: 'not merged',
|
|
1403
|
+
ignored_data: 'holding files git ignores (kept for review)',
|
|
1404
|
+
ignored_scan_failed: 'unreadable',
|
|
1405
|
+
fresh_worktree_grace: 'new',
|
|
1406
|
+
recent_activity: 'used recently',
|
|
1407
|
+
locked: 'locked',
|
|
1408
|
+
protected_branch: 'on a main branch',
|
|
1409
|
+
missing_or_unreadable: 'unreadable',
|
|
1410
|
+
missing_head: 'without a commit',
|
|
1411
|
+
remove_failed: 'failed to remove',
|
|
1412
|
+
base_refresh_failed: 'main branch unreachable',
|
|
1413
|
+
base_unverified: 'main branch moved during the check',
|
|
1414
|
+
path_not_canonical: 'behind a link',
|
|
1415
|
+
size_unknown_or_cleanup_limit: 'over the per-run limit',
|
|
1416
|
+
changed_or_active_during_verification: 'changed during the check',
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
function plural(count, one, many) {
|
|
1420
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function defaultSweepWorkspace(cwd = process.cwd()) {
|
|
1424
|
+
if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
|
|
1425
|
+
try {
|
|
1426
|
+
return path.dirname(findPrimaryRoot(repoRoot(cwd)));
|
|
1427
|
+
} catch {
|
|
1428
|
+
return cwd;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
|
|
1433
|
+
return planKeep({
|
|
1434
|
+
root: workspace,
|
|
1435
|
+
home: path.join(homeDir, '.atris', 'worktree-sweep'),
|
|
1436
|
+
platform,
|
|
1437
|
+
homeDir,
|
|
1438
|
+
atrisBin,
|
|
1439
|
+
envPath,
|
|
1440
|
+
job: {
|
|
1441
|
+
name: 'worktree-sweep',
|
|
1442
|
+
args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
|
|
1443
|
+
every_seconds: Math.round(everyHours * 3600),
|
|
1444
|
+
marker: 'ATRIS_WORKTREE_SWEEP',
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
module.exports = {
|
|
1450
|
+
ATRIS_BIN,
|
|
1451
|
+
SWEEP_IDLE_MS,
|
|
1452
|
+
SWEEP_EVERY_HOURS,
|
|
1453
|
+
SWEEP_KEPT_LABELS,
|
|
1454
|
+
announceShippedCopyRemoval,
|
|
1455
|
+
assertOneLapProofTree,
|
|
1456
|
+
baseBranchName,
|
|
1457
|
+
branchName,
|
|
1458
|
+
canonicalPath,
|
|
1459
|
+
changedFiles,
|
|
1460
|
+
cleanupWorktrees,
|
|
1461
|
+
collidingFlights,
|
|
1462
|
+
createAgentWorktree,
|
|
1463
|
+
createDetachedShipBranch,
|
|
1464
|
+
createOrFindPr,
|
|
1465
|
+
currentBranch,
|
|
1466
|
+
defaultMainlineBase,
|
|
1467
|
+
defaultShipTarget,
|
|
1468
|
+
defaultStartBase,
|
|
1469
|
+
defaultSweepWorkspace,
|
|
1470
|
+
deniedLaneForShip,
|
|
1471
|
+
describeFlightAge,
|
|
1472
|
+
detachedShipBranch,
|
|
1473
|
+
findPrimaryRoot,
|
|
1474
|
+
flightStampMs,
|
|
1475
|
+
gitFailureDetail,
|
|
1476
|
+
gitNetworkEnvironment,
|
|
1477
|
+
inFlightAgentFlights,
|
|
1478
|
+
listActiveProcessPaths,
|
|
1479
|
+
listWorktrees,
|
|
1480
|
+
lowDiskRefusal,
|
|
1481
|
+
mergePrWithRetry,
|
|
1482
|
+
newStatusRows,
|
|
1483
|
+
normalizeTargetRef,
|
|
1484
|
+
parseAgentFlightName,
|
|
1485
|
+
parseProcessArgPaths,
|
|
1486
|
+
parseWorktrees,
|
|
1487
|
+
plural,
|
|
1488
|
+
prMergeRef,
|
|
1489
|
+
protectedPushRefusal,
|
|
1490
|
+
readOneLapProofBinding,
|
|
1491
|
+
reapShippedCopy,
|
|
1492
|
+
recordWorktreeRemoval,
|
|
1493
|
+
refExists,
|
|
1494
|
+
refreshMovedMapRefs,
|
|
1495
|
+
refreshRemoteRef,
|
|
1496
|
+
remoteHead,
|
|
1497
|
+
repoRoot,
|
|
1498
|
+
restoreRegeneratedAdapterChurn,
|
|
1499
|
+
runCommand,
|
|
1500
|
+
runGit,
|
|
1501
|
+
runShipHealthCheck,
|
|
1502
|
+
scheduleShippedCopyRemoval,
|
|
1503
|
+
shellQuote,
|
|
1504
|
+
shipMergeRecord,
|
|
1505
|
+
shippedCopyRemovalPlan,
|
|
1506
|
+
slugify,
|
|
1507
|
+
stamp,
|
|
1508
|
+
statusCounts,
|
|
1509
|
+
statusSnapshot,
|
|
1510
|
+
swarloClaim,
|
|
1511
|
+
sweepSchedulePlan,
|
|
1512
|
+
sweepWorkspace,
|
|
1513
|
+
taskTokens,
|
|
1514
|
+
unstageConductorArtifacts,
|
|
1515
|
+
worktreeHasActiveProcess,
|
|
1516
|
+
worktreeWithinReapGrace,
|
|
1517
|
+
};
|