atris 3.74.29 → 3.76.0
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/atris/skills/calendar/SKILL.md +9 -0
- package/atris/skills/drive/SKILL.md +7 -0
- package/atris/skills/email-agent/SKILL.md +7 -0
- package/atris/skills/magic-inbox/SKILL.md +7 -0
- package/atris/skills/slides/SKILL.md +7 -0
- package/bin/atris.js +14 -2
- package/commands/engine.js +2 -2
- package/commands/integrations.js +539 -54
- package/commands/mission.js +2 -2
- package/commands/rating.js +29 -5
- package/commands/task.js +72 -5
- package/commands/worktree.js +66 -18
- package/lib/fleet.js +602 -55
- package/lib/gm-season.js +47 -10
- package/lib/held-pull-requests.js +589 -0
- package/lib/known-commands.js +1 -1
- package/lib/pull-request-link.js +25 -0
- package/lib/release-kind.js +31 -7
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +703 -160
- package/lib/worktree-space.js +4 -45
- package/package.json +1 -1
package/commands/mission.js
CHANGED
|
@@ -10256,7 +10256,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10256
10256
|
// safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
|
|
10257
10257
|
// full loop, see lib/fleet.js. --dry-run previews the staffing only.
|
|
10258
10258
|
if (hasFlag(args, '--fleet')) {
|
|
10259
|
-
const { runFleetFlight } = require('../lib/fleet');
|
|
10259
|
+
const { runFleetFlight, flightExitCode } = require('../lib/fleet');
|
|
10260
10260
|
const slots = Math.max(1, Number(readFlag(args, '--slots', '')) || 3);
|
|
10261
10261
|
// Default: cut build worktrees from origin/master (checkoutBase). --base
|
|
10262
10262
|
// keeps launcher-HEAD only when the operator asks for it explicitly.
|
|
@@ -10268,7 +10268,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10268
10268
|
...(baseOverride ? { checkoutBase: baseOverride } : {}),
|
|
10269
10269
|
});
|
|
10270
10270
|
if (asJson) console.log(JSON.stringify(flight, null, 2));
|
|
10271
|
-
process.exitCode = flight
|
|
10271
|
+
process.exitCode = flightExitCode(flight);
|
|
10272
10272
|
context.handled = true;
|
|
10273
10273
|
return context;
|
|
10274
10274
|
}
|
package/commands/rating.js
CHANGED
|
@@ -24,6 +24,12 @@ function rated(n) {
|
|
|
24
24
|
return `${/^(8|11$|18$)/.test(String(n)) ? 'an' : 'a'} ${n}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Jobs the build machine could not start (no room on the disk, a build copy that would not open) were let
|
|
28
|
+
// go before any engine ran. They are no member's miss and not finished, and they say so in a line of their own.
|
|
29
|
+
function couldNotStartLine(n) {
|
|
30
|
+
return `${n} ${n === 1 ? 'job' : 'jobs'} could not start on this machine. ${n === 1 ? 'It doesn\'t' : 'They don\'t'} count against anyone.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
// Members rated on the last 30 days of jobs here, best first, each with the
|
|
28
34
|
// job and model the roster gives it, plus the build line: what the build
|
|
29
35
|
// engine rates in the combine and what members doing build landed here.
|
|
@@ -38,11 +44,21 @@ function readHere(deps = {}) {
|
|
|
38
44
|
return null;
|
|
39
45
|
}
|
|
40
46
|
if (!played || played.error || played.no_history) return null;
|
|
41
|
-
const { rosterRows } = require('../lib/gm-season');
|
|
47
|
+
const { rosterRows, tally } = require('../lib/gm-season');
|
|
48
|
+
// Jobs still in review whose pull request is open, per builder. These do not depend on the
|
|
49
|
+
// last 30 days of plays: a job can wait longer than that, and its builder still has it.
|
|
50
|
+
const waiting = new Map();
|
|
51
|
+
for (const review of played.reviews || []) {
|
|
52
|
+
if (review && review.waiting_on_pull_request && review.member) waiting.set(review.member, (waiting.get(review.member) || 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
const roster = rosterRows({ outcomes: played.outcomes || [], limit: Number.MAX_SAFE_INTEGER });
|
|
42
55
|
// A member whose only misses were later confirmed not needed, or whose only jobs
|
|
43
|
-
// wait on a pull request, has no finished job, but still shows,
|
|
44
|
-
// have somewhere to be counted.
|
|
45
|
-
const rows =
|
|
56
|
+
// wait on a pull request or could not start, has no finished job, but still shows,
|
|
57
|
+
// so those jobs have somewhere to be counted.
|
|
58
|
+
const rows = roster.filter((row) => row.finished > 0 || row.not_needed > 0 || row.held > 0 || row.could_not_start > 0 || waiting.has(row.name));
|
|
59
|
+
for (const name of waiting.keys()) {
|
|
60
|
+
if (!roster.some((row) => row.name === name)) rows.push({ name, ...tally([]), ovr: null });
|
|
61
|
+
}
|
|
46
62
|
if (!rows.length) return null;
|
|
47
63
|
let lineup = deps.lineup;
|
|
48
64
|
if (lineup === undefined) lineup = require('../lib/team-lineup').readLineupSafe(root);
|
|
@@ -57,7 +73,7 @@ function readHere(deps = {}) {
|
|
|
57
73
|
}
|
|
58
74
|
const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
59
75
|
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
|
|
60
|
-
return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, ...(row.not_needed ? { not_needed: row.not_needed } : {}), ...(row.held ? { held: row.held } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
|
|
76
|
+
return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, ...(row.not_needed ? { not_needed: row.not_needed } : {}), ...(row.held ? { held: row.held } : {}), ...(row.could_not_start ? { could_not_start: row.could_not_start } : {}), ...(waiting.has(row.name) ? { waiting_on_pull_request: waiting.get(row.name) } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
|
|
61
77
|
});
|
|
62
78
|
const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
|
|
63
79
|
const buildModel = buildJob && buildJob.now_runs ? buildJob.now_runs.model : null;
|
|
@@ -65,9 +81,11 @@ function readHere(deps = {}) {
|
|
|
65
81
|
const builders = members.filter((m) => m.job === 'build');
|
|
66
82
|
const landed = builders.reduce((sum, m) => sum + m.landed, 0);
|
|
67
83
|
const finished = builders.reduce((sum, m) => sum + m.finished, 0);
|
|
84
|
+
const couldNotStart = members.reduce((sum, m) => sum + (m.could_not_start || 0), 0);
|
|
68
85
|
return {
|
|
69
86
|
workspace: played.workspace_name || null,
|
|
70
87
|
members,
|
|
88
|
+
...(couldNotStart ? { could_not_start: couldNotStart } : {}),
|
|
71
89
|
build: card && finished ? { model: buildModel, combine_ovr: card.ovr, landed, finished, ovr: rating.atrisRating(landed, finished) } : null,
|
|
72
90
|
};
|
|
73
91
|
}
|
|
@@ -98,6 +116,7 @@ function renderHere(here) {
|
|
|
98
116
|
lines.push('', `${head}${shown.join(', ')}${rest ? `, and ${rest} more` : ''}`);
|
|
99
117
|
}
|
|
100
118
|
lines.push('landed: accepted or merged. drops and abandons count against a member.');
|
|
119
|
+
if (here.could_not_start) lines.push(couldNotStartLine(here.could_not_start));
|
|
101
120
|
if (here.build) {
|
|
102
121
|
const b = here.build;
|
|
103
122
|
lines.push('', `build runs ${modelLabel(b.model)}, ${rated(b.combine_ovr)} in the combine.`,
|
|
@@ -116,6 +135,11 @@ function renderMember(m, card) {
|
|
|
116
135
|
];
|
|
117
136
|
if (m.not_needed) lines.push(` not needed: ${m.not_needed} (confirmed by someone else, so ${m.not_needed === 1 ? 'it does' : 'they do'} not count either way)`);
|
|
118
137
|
if (m.held) lines.push(` waiting on a pull request: ${m.held} (the work is done and a person has to land it, so ${m.held === 1 ? 'it does' : 'they do'} not count either way)`);
|
|
138
|
+
if (m.could_not_start) lines.push(` ${couldNotStartLine(m.could_not_start)}`);
|
|
139
|
+
if (m.waiting_on_pull_request) {
|
|
140
|
+
const one = m.waiting_on_pull_request === 1;
|
|
141
|
+
lines.push(` ${m.waiting_on_pull_request} ${one ? 'job waits' : 'jobs wait'} on an open pull request. Check GitHub and credit ${one ? 'it' : 'them'}: atris task sweep --held`);
|
|
142
|
+
}
|
|
119
143
|
if (m.ovr == null) lines.push(' rated once it has 3 finished jobs');
|
|
120
144
|
if (m.miss_reasons && m.miss_reasons.length) {
|
|
121
145
|
lines.push(' top miss reasons:');
|
package/commands/task.js
CHANGED
|
@@ -210,6 +210,7 @@ atris task - durable local task state (SQLite, gitignored)
|
|
|
210
210
|
Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
|
|
211
211
|
atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
|
|
212
212
|
atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
|
|
213
|
+
atris task sweep --held [--everywhere] [--dry-run] [--json] Check held jobs against GitHub: credit a merge, release a closed pull request
|
|
213
214
|
atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
|
|
214
215
|
atris task revise <id> --note "..." Send reviewed work back to Do
|
|
215
216
|
|
|
@@ -400,8 +401,10 @@ function failAgentProofOnly(label, detail) {
|
|
|
400
401
|
);
|
|
401
402
|
}
|
|
402
403
|
|
|
403
|
-
|
|
404
|
-
|
|
404
|
+
// Write straight to stdout and wait until it is all out, so a command that exits right after
|
|
405
|
+
// printing (process.exit drops whatever a pipe still holds) never loses the end of its output.
|
|
406
|
+
function writeStdoutSync(text) {
|
|
407
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
405
408
|
const retryWait = new Int32Array(new SharedArrayBuffer(4));
|
|
406
409
|
let offset = 0;
|
|
407
410
|
while (offset < buffer.length) {
|
|
@@ -417,6 +420,10 @@ function printJson(value) {
|
|
|
417
420
|
}
|
|
418
421
|
}
|
|
419
422
|
|
|
423
|
+
function printJson(value) {
|
|
424
|
+
writeStdoutSync(`${JSON.stringify(value, null, 2)}\n`);
|
|
425
|
+
}
|
|
426
|
+
|
|
420
427
|
function refreshCareerXpProjection(workspaceRoot) {
|
|
421
428
|
if (!workspaceRoot) return null;
|
|
422
429
|
try {
|
|
@@ -9743,6 +9750,9 @@ function cmdReady(args) {
|
|
|
9743
9750
|
const merge = readyMergeFlags(args);
|
|
9744
9751
|
guardExplicitActor('atris task ready', flag(args, '--as'));
|
|
9745
9752
|
const actor = String(flag(args, '--as') || DEFAULT_OWNER);
|
|
9753
|
+
// The others who built the work with --as, by name, comma apart: a flight that ran for a member
|
|
9754
|
+
// hands in under the member's name and names the engines that ran here. Only a hand-in uses it.
|
|
9755
|
+
const builtWith = textFlag(args, ['--built-with']).split(',').map((name) => name.trim()).filter(Boolean);
|
|
9746
9756
|
const resultFields = {
|
|
9747
9757
|
changed: textFlag(args, ['--changed', '--done']),
|
|
9748
9758
|
checked: textFlag(args, ['--checked', '--check', '--verified']),
|
|
@@ -9790,6 +9800,7 @@ function cmdReady(args) {
|
|
|
9790
9800
|
landing,
|
|
9791
9801
|
merge,
|
|
9792
9802
|
result: resultSentence,
|
|
9803
|
+
builtWith,
|
|
9793
9804
|
});
|
|
9794
9805
|
if (!result.ready) {
|
|
9795
9806
|
console.error(`ready failed: ${result.reason}`);
|
|
@@ -10419,10 +10430,12 @@ function cmdCertifyVerified(args, options = {}) {
|
|
|
10419
10430
|
results.push({ ref, action: 'skipped', reason: evaluation.reason });
|
|
10420
10431
|
continue;
|
|
10421
10432
|
}
|
|
10422
|
-
// judge != worker: the re-run only counts as an independent check when
|
|
10423
|
-
//
|
|
10433
|
+
// judge != worker: the re-run only counts as an independent check when its actor never built
|
|
10434
|
+
// the row it is judging: not its builder now, and not anyone who built it before (the store
|
|
10435
|
+
// reads the whole history, so a job sent back, let go or reopened does not forget its builders).
|
|
10424
10436
|
const builder = reviewIntegrity.taskBuilder(task);
|
|
10425
|
-
|
|
10437
|
+
const verifier = reviewIntegrity.normalizeActor(actor);
|
|
10438
|
+
if (verifier && ((builder && verifier === builder) || taskDb.authorsOfTask(db, task).has(verifier))) {
|
|
10426
10439
|
results.push({ ref, action: 'skipped', reason: 'verifier_is_builder' });
|
|
10427
10440
|
continue;
|
|
10428
10441
|
}
|
|
@@ -10906,7 +10919,61 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
|
|
|
10906
10919
|
return { eligible: false, ref, reason: verifier.reason, evidence: verifier.evidence };
|
|
10907
10920
|
}
|
|
10908
10921
|
|
|
10922
|
+
// atris task sweep --held: ask GitHub about each job waiting on an open pull request and write
|
|
10923
|
+
// down what happened (lib/held-pull-requests.js). Anyone may run it, agents included: GitHub is
|
|
10924
|
+
// the only source and the run grants nothing, so the agent block that guards --auto-accept does
|
|
10925
|
+
// not apply here. It takes none of the expired-claims flags.
|
|
10926
|
+
function cmdSweepHeld(args) {
|
|
10927
|
+
for (const name of ['--auto-accept', '--stale-days', '--claimer']) {
|
|
10928
|
+
if (hasFlag(args, name)) {
|
|
10929
|
+
failTask('atris task sweep', 'held_flags_conflict', `atris task sweep: --held cannot be combined with ${name}`);
|
|
10930
|
+
}
|
|
10931
|
+
}
|
|
10932
|
+
const named = flag(args, '--as');
|
|
10933
|
+
if (named === true) failTask('atris task sweep', 'missing_actor', 'atris task sweep: --as needs a name');
|
|
10934
|
+
guardExplicitActor('atris task sweep', named);
|
|
10935
|
+
const actor = String(named || DEFAULT_OWNER);
|
|
10936
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
10937
|
+
const json = wantsJson(args);
|
|
10938
|
+
const taskDb = getTaskDb();
|
|
10939
|
+
const workspaceRoot = scopedWorkspaceRoot(taskDb, args);
|
|
10940
|
+
const { sweepHeldJobs, describeHeldSweep } = require('../lib/held-pull-requests');
|
|
10941
|
+
|
|
10942
|
+
// A dry run only reads: it opens the task list read only and never creates one.
|
|
10943
|
+
let db = null;
|
|
10944
|
+
let reader = null;
|
|
10945
|
+
if (!dryRun) {
|
|
10946
|
+
db = taskDb.open();
|
|
10947
|
+
} else if (fs.existsSync(taskDb.getDbPath())) {
|
|
10948
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
10949
|
+
reader = new DatabaseSync(taskDb.getDbPath(), { readOnly: true });
|
|
10950
|
+
db = reader;
|
|
10951
|
+
}
|
|
10952
|
+
let report;
|
|
10953
|
+
try {
|
|
10954
|
+
report = sweepHeldJobs(taskDb, db, { workspaceRoot, actor, dryRun });
|
|
10955
|
+
} finally {
|
|
10956
|
+
if (reader) reader.close();
|
|
10957
|
+
}
|
|
10958
|
+
|
|
10959
|
+
const changed = report.summary.landed + report.summary.released > 0;
|
|
10960
|
+
const projectionPath = changed ? writeDefaultProjection(taskDb, db).outPath : null;
|
|
10961
|
+
if (json) printJson({ ...report, ...(projectionPath ? { projection_path: projectionPath } : {}) });
|
|
10962
|
+
else writeStdoutSync(`${describeHeldSweep(report).join('\n')}\n`);
|
|
10963
|
+
if (report.ok) return;
|
|
10964
|
+
|
|
10965
|
+
// GitHub could not be reached, so the rest were not asked and nothing more changed. Whatever
|
|
10966
|
+
// was settled before that is still refreshed on the task list.
|
|
10967
|
+
if (changed) autoRenderTodoFromDb();
|
|
10968
|
+
if (!json) {
|
|
10969
|
+
const left = report.summary.not_asked;
|
|
10970
|
+
console.error(`stopped: ${report.outage.detail}${left ? `. ${left} held ${left === 1 ? 'job was' : 'jobs were'} not asked` : ''}`);
|
|
10971
|
+
}
|
|
10972
|
+
process.exit(1);
|
|
10973
|
+
}
|
|
10974
|
+
|
|
10909
10975
|
function cmdSweep(args) {
|
|
10976
|
+
if (hasFlag(args, '--held')) return cmdSweepHeld(args);
|
|
10910
10977
|
if (!hasFlag(args, '--auto-accept')) {
|
|
10911
10978
|
const taskDb = getTaskDb();
|
|
10912
10979
|
const staleRaw = flag(args, '--stale-days');
|
package/commands/worktree.js
CHANGED
|
@@ -581,7 +581,7 @@ function printStatus() {
|
|
|
581
581
|
// finished copies across the workspace and then refuses (ATRIS_LOW_DISK),
|
|
582
582
|
// and a matching install is cloned in as node_modules. deps: false skips
|
|
583
583
|
// the clone; an object passes options through to seedNodeModules.
|
|
584
|
-
function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
|
|
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
585
|
const owner = member || agent;
|
|
586
586
|
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
587
587
|
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
@@ -589,10 +589,11 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
589
589
|
// Every new flight pays down completed checkout debt before adding another
|
|
590
590
|
// full repo copy. Branches and commits survive worktree removal, while real
|
|
591
591
|
// uncommitted changes remain protected by cleanupWorktrees.
|
|
592
|
+
const cleanupBudget = { count: 0, kib: 0 };
|
|
592
593
|
let reapedBeforeStart = [];
|
|
593
594
|
let cachePrunedBeforeStart = [];
|
|
594
595
|
try {
|
|
595
|
-
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start' });
|
|
596
|
+
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start', cleanupBudget });
|
|
596
597
|
reapedBeforeStart = cleaned.removed;
|
|
597
598
|
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
598
599
|
} catch {}
|
|
@@ -616,9 +617,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
616
617
|
const shipBase = checkoutBase;
|
|
617
618
|
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
618
619
|
// A full disk stops every flight at once. Reap first; refuse before cutting.
|
|
619
|
-
const room = (spaceGuard
|
|
620
|
-
? ensureRoomForCopy({ root, target, freeSpace, env })
|
|
621
|
-
: null;
|
|
620
|
+
const room = checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget });
|
|
622
621
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
623
622
|
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
624
623
|
// A retry of the same job starts from the last run's unlanded commits; it
|
|
@@ -635,6 +634,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
635
634
|
resumedFrom = null;
|
|
636
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`);
|
|
637
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
|
+
}
|
|
638
649
|
const startPoint = resumedFrom || checkoutBase;
|
|
639
650
|
const ref = `refs/heads/${branch}`;
|
|
640
651
|
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
@@ -734,13 +745,13 @@ function seedCopyDeps({ root, target, options = {} }) {
|
|
|
734
745
|
// the engine that just finished may still have its last message unread.
|
|
735
746
|
const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
|
|
736
747
|
|
|
737
|
-
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start' }) {
|
|
748
|
+
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start', cleanupBudget = null }) {
|
|
738
749
|
const limits = space.spaceLimits(env);
|
|
739
750
|
const before = freeSpace(target);
|
|
740
751
|
const room = { before, free: before, removed: [], cleared: [], ...limits };
|
|
741
752
|
if (typeof before !== 'number' || !Number.isFinite(before)) return room;
|
|
742
753
|
if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
|
|
743
|
-
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by });
|
|
754
|
+
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by, cleanupBudget });
|
|
744
755
|
for (const repo of swept.repos) {
|
|
745
756
|
room.removed.push(...(repo.removed || []));
|
|
746
757
|
room.cleared.push(...(repo.cachePruned || []));
|
|
@@ -763,6 +774,29 @@ function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = pr
|
|
|
763
774
|
return room;
|
|
764
775
|
}
|
|
765
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
|
+
|
|
766
800
|
function startWorktree(args) {
|
|
767
801
|
const root = repoRoot();
|
|
768
802
|
const member = readFlag(args, '--member');
|
|
@@ -770,9 +804,12 @@ function startWorktree(args) {
|
|
|
770
804
|
const owner = member || agent;
|
|
771
805
|
const task = readFlag(args, '--task');
|
|
772
806
|
if (!owner || !task) {
|
|
773
|
-
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force] [--no-deps]');
|
|
807
|
+
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--former-task "<older name of the task>"] [--claim] [--force] [--no-deps]');
|
|
774
808
|
return 2;
|
|
775
809
|
}
|
|
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
|
+
const formerTask = readFlag(args, '--former-task');
|
|
776
813
|
const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
|
|
777
814
|
if (memberFile && !fs.existsSync(memberFile)) {
|
|
778
815
|
console.error(`warning: no member persona at ${path.relative(root, memberFile)}`);
|
|
@@ -804,6 +841,7 @@ function startWorktree(args) {
|
|
|
804
841
|
member,
|
|
805
842
|
agent,
|
|
806
843
|
task,
|
|
844
|
+
formerTasks: formerTask ? [formerTask] : [],
|
|
807
845
|
branch: readFlag(args, '--branch'),
|
|
808
846
|
path: readFlag(args, '--path'),
|
|
809
847
|
base: readFlag(args, '--base') || readFlag(args, '--target'),
|
|
@@ -922,15 +960,19 @@ function deniedLaneForShip(root, branch, message) {
|
|
|
922
960
|
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
923
961
|
for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
|
|
924
962
|
}
|
|
925
|
-
|
|
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));
|
|
926
966
|
const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
|
|
927
967
|
if (!fs.existsSync(projectionPath)) return null;
|
|
928
968
|
const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
|
|
929
969
|
for (const task of projection.tasks || []) {
|
|
930
970
|
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
931
|
-
|
|
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;
|
|
932
974
|
const hit = protectedLaneOfTask(task);
|
|
933
|
-
if (hit) return { taskId: ids[0], tag: hit };
|
|
975
|
+
if (hit) return { taskId: ids[0] || task.id, tag: hit };
|
|
934
976
|
}
|
|
935
977
|
return null;
|
|
936
978
|
} catch {
|
|
@@ -1694,6 +1736,7 @@ function cleanupWorktrees({
|
|
|
1694
1736
|
idleMs = 0,
|
|
1695
1737
|
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1696
1738
|
now = Date.now(),
|
|
1739
|
+
cleanupBudget = null,
|
|
1697
1740
|
} = {}) {
|
|
1698
1741
|
const worktrees = listWorktrees(root);
|
|
1699
1742
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
@@ -1706,7 +1749,7 @@ function cleanupWorktrees({
|
|
|
1706
1749
|
const removed = [];
|
|
1707
1750
|
const cacheCandidates = [];
|
|
1708
1751
|
const cachePruned = [];
|
|
1709
|
-
const cacheBudget = { count: 0, kib: 0 };
|
|
1752
|
+
const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1710
1753
|
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1711
1754
|
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
1712
1755
|
&& (!onlyUnder || space.isInside(wtPath, onlyUnder));
|
|
@@ -1847,13 +1890,12 @@ function cleanupWorktrees({
|
|
|
1847
1890
|
// Removes copies that pass every cleanup check (merged into origin's main
|
|
1848
1891
|
// branch, nothing changed, untracked, or ignored, nothing running inside)
|
|
1849
1892
|
// and are unused for idleMs; clears verified webpack caches in the idle
|
|
1850
|
-
// copies it keeps
|
|
1851
|
-
// stays.
|
|
1893
|
+
// copies it keeps. Shared installs and unmerged work stay for review.
|
|
1852
1894
|
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1853
1895
|
const SWEEP_EVERY_HOURS = 3;
|
|
1854
1896
|
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1855
1897
|
|
|
1856
|
-
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep' } = {}) {
|
|
1898
|
+
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
|
|
1857
1899
|
const root = path.resolve(workspace);
|
|
1858
1900
|
const before = freeSpace(root);
|
|
1859
1901
|
const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
|
|
@@ -1861,6 +1903,8 @@ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, acti
|
|
|
1861
1903
|
// Without a process list nobody can tell which copies are in use.
|
|
1862
1904
|
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1863
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 };
|
|
1864
1908
|
for (const repo of space.agentCopyRepos(root)) {
|
|
1865
1909
|
const entry = { name: repo.name, root: repo.root };
|
|
1866
1910
|
try {
|
|
@@ -1872,7 +1916,7 @@ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, acti
|
|
|
1872
1916
|
// A real removal scans again per repo and right before each copy goes;
|
|
1873
1917
|
// the shared scan only serves previews and tests.
|
|
1874
1918
|
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1875
|
-
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs });
|
|
1919
|
+
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
|
|
1876
1920
|
report.repos.push({
|
|
1877
1921
|
...entry,
|
|
1878
1922
|
base: result.base,
|
|
@@ -1881,7 +1925,9 @@ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, acti
|
|
|
1881
1925
|
cachePruned: result.cachePruned,
|
|
1882
1926
|
cacheCandidates: result.cacheCandidates,
|
|
1883
1927
|
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
1884
|
-
|
|
1928
|
+
// Shared installs may be in use or contain local changes. Leave them
|
|
1929
|
+
// for explicit review rather than pruning by age.
|
|
1930
|
+
installsPruned: [],
|
|
1885
1931
|
});
|
|
1886
1932
|
} catch (error) {
|
|
1887
1933
|
report.repos.push({ ...entry, error: String((error && error.message) || error) });
|
|
@@ -2101,8 +2147,9 @@ function help() {
|
|
|
2101
2147
|
console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup|sweep>');
|
|
2102
2148
|
console.log('');
|
|
2103
2149
|
console.log(' atris worktree guide');
|
|
2104
|
-
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force] [--no-deps]');
|
|
2150
|
+
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--former-task "<older name>"] [--claim] [--force] [--no-deps]');
|
|
2105
2151
|
console.log(' --force start even when another recent flight shares a task word');
|
|
2152
|
+
console.log(' --former-task the task\'s older name; a branch left unfinished under it is named, never resumed');
|
|
2106
2153
|
console.log(' --no-deps do not clone node_modules from a matching install');
|
|
2107
2154
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
2108
2155
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
@@ -2145,6 +2192,7 @@ module.exports = {
|
|
|
2145
2192
|
collidingFlights,
|
|
2146
2193
|
mergePrWithRetry,
|
|
2147
2194
|
createAgentWorktree,
|
|
2195
|
+
lowDiskRefusal,
|
|
2148
2196
|
deniedLaneForShip,
|
|
2149
2197
|
protectedPushRefusal,
|
|
2150
2198
|
createOrFindPr,
|