crewly 1.20.359 → 1.20.360
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/config/roles/developer/prompt.md +3 -1
- package/config/skills/registry.json +125 -1
- package/dist/backend/backend/src/constants.d.ts +130 -2
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +141 -3
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +37 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +103 -10
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/boot-pressure.d.ts +147 -0
- package/dist/backend/backend/src/services/agent/boot-pressure.d.ts.map +1 -0
- package/dist/backend/backend/src/services/agent/boot-pressure.js +212 -0
- package/dist/backend/backend/src/services/agent/boot-pressure.js.map +1 -0
- package/dist/backend/backend/src/services/agent/restore-queue.d.ts +50 -1
- package/dist/backend/backend/src/services/agent/restore-queue.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/restore-queue.js +100 -5
- package/dist/backend/backend/src/services/agent/restore-queue.js.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.d.ts +11 -0
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js +23 -2
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/deferred-start-retry.d.ts +8 -0
- package/dist/backend/backend/src/services/messaging/deferred-start-retry.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/deferred-start-retry.js +8 -0
- package/dist/backend/backend/src/services/messaging/deferred-start-retry.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-entries.d.ts +7 -0
- package/dist/backend/backend/src/services/wiki/wiki-log-entries.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-entries.js +10 -0
- package/dist/backend/backend/src/services/wiki/wiki-log-entries.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.d.ts +24 -0
- package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.js +75 -9
- package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.d.ts +19 -0
- package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.js +23 -2
- package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/work-record.service.d.ts +23 -1
- package/dist/backend/backend/src/services/wiki/work-record.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/work-record.service.js +33 -5
- package/dist/backend/backend/src/services/wiki/work-record.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts +92 -8
- package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/low-disk-guard.js +98 -14
- package/dist/backend/backend/src/services/worktree/low-disk-guard.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/tmp-janitor.d.ts +138 -0
- package/dist/backend/backend/src/services/worktree/tmp-janitor.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/tmp-janitor.js +199 -0
- package/dist/backend/backend/src/services/worktree/tmp-janitor.js.map +1 -0
- package/dist/backend/backend/src/services/worktree/transcript-janitor.d.ts +154 -0
- package/dist/backend/backend/src/services/worktree/transcript-janitor.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/transcript-janitor.js +357 -0
- package/dist/backend/backend/src/services/worktree/transcript-janitor.js.map +1 -0
- package/dist/backend/backend/src/services/worktree/workitem-worktree.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/workitem-worktree.service.js +7 -2
- package/dist/backend/backend/src/services/worktree/workitem-worktree.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts +16 -4
- package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-dirty.js +21 -5
- package/dist/backend/backend/src/services/worktree/worktree-dirty.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-git.d.ts +18 -0
- package/dist/backend/backend/src/services/worktree/worktree-git.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-git.js +60 -0
- package/dist/backend/backend/src/services/worktree/worktree-git.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +164 -25
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +494 -55
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-tiers.d.ts +186 -0
- package/dist/backend/backend/src/services/worktree/worktree-tiers.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/worktree-tiers.js +279 -0
- package/dist/backend/backend/src/services/worktree/worktree-tiers.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +130 -2
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +141 -3
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/boot-pressure.d.ts +147 -0
- package/dist/cli/backend/src/services/agent/boot-pressure.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/boot-pressure.js +212 -0
- package/dist/cli/backend/src/services/agent/boot-pressure.js.map +1 -0
- package/dist/cli/backend/src/services/agent/restore-queue.d.ts +50 -1
- package/dist/cli/backend/src/services/agent/restore-queue.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/restore-queue.js +100 -5
- package/dist/cli/backend/src/services/agent/restore-queue.js.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-log-entries.d.ts +7 -0
- package/dist/cli/backend/src/services/wiki/wiki-log-entries.d.ts.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-log-entries.js +10 -0
- package/dist/cli/backend/src/services/wiki/wiki-log-entries.js.map +1 -1
- package/dist/cli/backend/src/services/worktree/low-disk-guard.d.ts +92 -8
- package/dist/cli/backend/src/services/worktree/low-disk-guard.d.ts.map +1 -1
- package/dist/cli/backend/src/services/worktree/low-disk-guard.js +98 -14
- package/dist/cli/backend/src/services/worktree/low-disk-guard.js.map +1 -1
- package/dist/cli/backend/src/services/worktree/worktree-git.d.ts +18 -0
- package/dist/cli/backend/src/services/worktree/worktree-git.d.ts.map +1 -1
- package/dist/cli/backend/src/services/worktree/worktree-git.js +60 -0
- package/dist/cli/backend/src/services/worktree/worktree-git.js.map +1 -1
- package/package.json +1 -1
|
@@ -57,16 +57,36 @@
|
|
|
57
57
|
* no submodules, it is retried once with `--force`. Then the local branch is
|
|
58
58
|
* deleted (never a protected name), and `git worktree prune` runs per repo.
|
|
59
59
|
*
|
|
60
|
-
* ##
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `
|
|
60
|
+
* ## Age tiers for kept worktrees (crewly#1354)
|
|
61
|
+
* A worktree kept as `dirty` or `not-merged` is not kept as it is forever.
|
|
62
|
+
* Untouched (shallow tree mtime, git dir, newest commit) for 3 days — 1 day
|
|
63
|
+
* under low disk or once its WorkItem finished — it loses its regenerable dirs
|
|
64
|
+
* (`node_modules`, `.next`, `dist`, `.turbo`, `.cache`; never a dir git
|
|
65
|
+
* tracks anything under); the verdict carries `reclaimed` / `tier`, the lead
|
|
66
|
+
* is told once. Untouched for 14 days it is saved as a patch under
|
|
67
|
+
* `<CREWLY_HOME>/worktree-archive/<repo>/<name>-<date>.patch` (tracked diff +
|
|
68
|
+
* untracked files, size-capped), its branch is kept, the worktree is removed
|
|
69
|
+
* and the lead is told once. See worktree-tiers.ts.
|
|
65
70
|
*
|
|
66
71
|
* ## Work completion
|
|
67
|
-
* A WorkItem reaching
|
|
68
|
-
* (2 min) with a 10 min idle threshold, so a finished worktree
|
|
69
|
-
* soon as its change has landed, not at the next 30-minute
|
|
72
|
+
* A WorkItem reaching a terminal state calls `requestTidy(workItemId)`: one
|
|
73
|
+
* debounced pass (2 min) with a 10 min idle threshold, so a finished worktree
|
|
74
|
+
* is removed as soon as its change has landed, not at the next 30-minute
|
|
75
|
+
* tick; the worktree recorded on the WorkItem (`metadata.worktree`) is noted
|
|
76
|
+
* as finished, which drops its strip tier to 1 day.
|
|
77
|
+
*
|
|
78
|
+
* ## Transcripts, tmp and logs (crewly#1354)
|
|
79
|
+
* Once a day the pass prunes Claude Code transcripts of conversations Crewly
|
|
80
|
+
* launched that are untouched for 14 days (transcript-janitor.ts; the owner's
|
|
81
|
+
* own conversations are never touched). Every 6 hours it removes stale entries
|
|
82
|
+
* of `<CREWLY_HOME>/tmp` and caps `logs/archive` (300 MB) and `logs/sessions`
|
|
83
|
+
* (100 MB) (tmp-janitor.ts).
|
|
84
|
+
*
|
|
85
|
+
* ## Arming
|
|
86
|
+
* Every delete added in crewly#1354 (strip — low disk included — archive,
|
|
87
|
+
* transcripts, tmp, logs) is dry-run only until one day after the first real
|
|
88
|
+
* pass that ran this code: that pass logs what it would do; the next day it
|
|
89
|
+
* does it. State: `retentionArmedAt` in disk-janitor-state.json.
|
|
70
90
|
*
|
|
71
91
|
* ## Scratch sweep
|
|
72
92
|
* After the worktrees, stale Claude Code session dirs
|
|
@@ -76,12 +96,13 @@
|
|
|
76
96
|
*
|
|
77
97
|
* ## Low-disk guard
|
|
78
98
|
* Every 10 minutes the free space of the volume holding CREWLY_HOME is read.
|
|
79
|
-
* Below 15 GB a pass runs at once (at most one per 30
|
|
80
|
-
* idle threshold halved (never below 2 h); every pass
|
|
81
|
-
* low uses the halved thresholds. If space is still
|
|
82
|
-
* told, at most once per 24 h, with the free space
|
|
83
|
-
*
|
|
84
|
-
*
|
|
99
|
+
* Below max(15 GB, 8 % of the disk) a pass runs at once (at most one per 30
|
|
100
|
+
* minutes) with every idle threshold halved (never below 2 h); every pass
|
|
101
|
+
* started while space is low uses the halved thresholds. If space is still
|
|
102
|
+
* low the owner is told, at most once per 24 h, with the free space, what was
|
|
103
|
+
* freed by kind, the 5 biggest items the janitor left and why, and that the
|
|
104
|
+
* owner's own files are never touched; below max(5 GB, 3 %) the notice is
|
|
105
|
+
* urgent and repeats at most every 6 h.
|
|
85
106
|
*
|
|
86
107
|
* ## Unmerged work
|
|
87
108
|
* A worktree kept as `not-merged` whose newest commit is older than 24 h is
|
|
@@ -109,19 +130,22 @@
|
|
|
109
130
|
import * as fs from 'fs';
|
|
110
131
|
import * as os from 'os';
|
|
111
132
|
import * as path from 'path';
|
|
112
|
-
import { ORCHESTRATOR_SESSION_NAME, WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
|
|
133
|
+
import { LOG_ROTATION_CONSTANTS, ORCHESTRATOR_SESSION_NAME, WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
|
|
113
134
|
import { LoggerService } from '../core/logger.service.js';
|
|
114
135
|
import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
|
|
115
136
|
import { safeReadJson, modifyJsonFile } from '../../utils/file-io.utils.js';
|
|
116
|
-
import { buildLowDiskNotice, diskLevel, idleThreshold, noticeToSend, readFreeBytes, recordNotice, } from './low-disk-guard.js';
|
|
137
|
+
import { buildLowDiskNotice, criticalDiskThresholdBytes, diskLevel, idleThreshold, lowDiskThresholdBytes, noticeToSend, readDiskSpace, readFreeBytes, recordNotice, retentionArmed, } from './low-disk-guard.js';
|
|
117
138
|
import { defaultScratchRoots, sweepScratch } from './scratch-janitor.js';
|
|
118
139
|
import { reclaimDocker } from './docker-reclaim.js';
|
|
119
140
|
import { RunawayFileWatcher, runawayWatchRoots } from './runaway-file-watcher.js';
|
|
120
141
|
import { getTraceStore } from '../trace/trace-store.js';
|
|
121
|
-
import { canonicalPath, diskUsageBytes, discoverLinkedWorktreeRepos, formatBytes, isJanitorDisabled, isPathInside, latestWorktreeMtimeMs, listProcessCwds, parsePorcelainWorktrees, runCommand, } from './worktree-janitor.git.js';
|
|
142
|
+
import { canonicalPath, diskUsageBytes, discoverLinkedWorktreeRepos, formatBytes, isJanitorDisabled, isPathInside, latestTreeMtimeMs, latestWorktreeMtimeMs, listProcessCwds, parsePorcelainWorktrees, runCommand, } from './worktree-janitor.git.js';
|
|
122
143
|
import { buildUnmergedNotice, defaultResolveLeadSessions, pruneUnmergedState, shouldNotifyUnmerged, } from './worktree-unmerged-notice.js';
|
|
123
144
|
import { contentLandedSignal, isNonGithubRemote } from './worktree-landed.js';
|
|
124
145
|
import { dirtyStatusLines, reclaimRegenerableDirs } from './worktree-dirty.js';
|
|
146
|
+
import { archiveKeepRef, archivePatchPath, buildArchiveNotice, buildStripNotice, emptyTierState, normalizeTierState, pruneTierState, tierFor, tierIdleMs, writeArchivePatch, } from './worktree-tiers.js';
|
|
147
|
+
import { defaultClaudeHomes, readCrewlyRecords, sweepTranscripts, } from './transcript-janitor.js';
|
|
148
|
+
import { capDirectoryBytes, sweepTmp } from './tmp-janitor.js';
|
|
125
149
|
/** Milliseconds per minute (for the `recent` detail text). */
|
|
126
150
|
const MS_PER_MINUTE = 60 * 1000;
|
|
127
151
|
/**
|
|
@@ -156,6 +180,7 @@ export class WorktreeJanitorService {
|
|
|
156
180
|
runawayWatcher = null;
|
|
157
181
|
runawayInFlight = false;
|
|
158
182
|
memoryUnmergedState = {};
|
|
183
|
+
memoryTierState = emptyTierState();
|
|
159
184
|
/**
|
|
160
185
|
* @param options - Injectable dependencies; defaults use the live backend
|
|
161
186
|
*/
|
|
@@ -306,8 +331,16 @@ export class WorktreeJanitorService {
|
|
|
306
331
|
*/
|
|
307
332
|
async diskStatus() {
|
|
308
333
|
const p = this.diskPath();
|
|
309
|
-
const
|
|
310
|
-
|
|
334
|
+
const space = await readDiskSpace(p, this.opts.statfs);
|
|
335
|
+
const total = space?.totalBytes ?? null;
|
|
336
|
+
return {
|
|
337
|
+
path: p,
|
|
338
|
+
freeBytes: space?.freeBytes ?? null,
|
|
339
|
+
totalBytes: total,
|
|
340
|
+
lowBytes: lowDiskThresholdBytes(total),
|
|
341
|
+
criticalBytes: criticalDiskThresholdBytes(total),
|
|
342
|
+
level: space === null ? 'unknown' : diskLevel(space.freeBytes, total),
|
|
343
|
+
};
|
|
311
344
|
}
|
|
312
345
|
/**
|
|
313
346
|
* Low-disk check: read free space; below LOW_DISK_BYTES run a pass now
|
|
@@ -350,8 +383,12 @@ export class WorktreeJanitorService {
|
|
|
350
383
|
* Only worktrees that pass every safety rule are removed (landed, clean, nobody
|
|
351
384
|
* inside) — unmerged work stays for the next pass. No-op when disabled. Never throws.
|
|
352
385
|
*/
|
|
353
|
-
requestTidy() {
|
|
354
|
-
if (this.isDisabled()
|
|
386
|
+
requestTidy(workItemId) {
|
|
387
|
+
if (this.isDisabled())
|
|
388
|
+
return;
|
|
389
|
+
if (workItemId)
|
|
390
|
+
void this.noteFinishedWorkItem(workItemId);
|
|
391
|
+
if (this.tidyTimer)
|
|
355
392
|
return;
|
|
356
393
|
this.tidyTimer = setTimeout(() => {
|
|
357
394
|
this.tidyTimer = null;
|
|
@@ -377,6 +414,35 @@ export class WorktreeJanitorService {
|
|
|
377
414
|
}, this.opts.tidyDelayMs ?? WORKTREE_JANITOR_CONSTANTS.COMPLETION_TIDY_DELAY_MS);
|
|
378
415
|
this.tidyTimer.unref?.();
|
|
379
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Task-end hook: remember that the WorkItem working in `worktreePath`
|
|
419
|
+
* finished, so its strip tier needs 1 day untouched instead of 3. Never
|
|
420
|
+
* throws.
|
|
421
|
+
*
|
|
422
|
+
* @param worktreePath - The worktree (any path form; canonicalised)
|
|
423
|
+
*/
|
|
424
|
+
async noteFinishedWorktree(worktreePath) {
|
|
425
|
+
try {
|
|
426
|
+
const state = await this.readTierState();
|
|
427
|
+
state.finished[canonicalPath(worktreePath)] = this.now();
|
|
428
|
+
await this.writeTierState(state);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
this.logger.warn('Could not record finished worktree (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Resolve a WorkItem's worktree (`metadata.worktree.path`) and note it as finished. */
|
|
435
|
+
async noteFinishedWorkItem(workItemId) {
|
|
436
|
+
try {
|
|
437
|
+
const find = this.opts.findWorkItemWorktree ?? defaultFindWorkItemWorktree;
|
|
438
|
+
const p = await find(workItemId);
|
|
439
|
+
if (p)
|
|
440
|
+
await this.noteFinishedWorktree(p);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// no task pool — nothing to note
|
|
444
|
+
}
|
|
445
|
+
}
|
|
380
446
|
/**
|
|
381
447
|
* One real pass. Concurrent calls share the pass in flight. Never throws.
|
|
382
448
|
* Honours the kill switch.
|
|
@@ -429,7 +495,14 @@ export class WorktreeJanitorService {
|
|
|
429
495
|
const file = (await safeReadJson(p, null)) ?? {};
|
|
430
496
|
// Memory wins when a save failed (full disk), so the owner is not re-told every check.
|
|
431
497
|
const latest = (a, b) => a === undefined ? b : b === undefined ? a : Math.max(a, b);
|
|
432
|
-
|
|
498
|
+
const earliest = (a, b) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
|
|
499
|
+
return {
|
|
500
|
+
lastNoticeAt: latest(file.lastNoticeAt, mem.lastNoticeAt),
|
|
501
|
+
lastUrgentAt: latest(file.lastUrgentAt, mem.lastUrgentAt),
|
|
502
|
+
retentionArmedAt: earliest(file.retentionArmedAt, mem.retentionArmedAt),
|
|
503
|
+
lastTranscriptSweepAt: latest(file.lastTranscriptSweepAt, mem.lastTranscriptSweepAt),
|
|
504
|
+
lastTmpSweepAt: latest(file.lastTmpSweepAt, mem.lastTmpSweepAt),
|
|
505
|
+
};
|
|
433
506
|
}
|
|
434
507
|
async writeNoticeState(state) {
|
|
435
508
|
const p = this.statePath();
|
|
@@ -444,15 +517,47 @@ export class WorktreeJanitorService {
|
|
|
444
517
|
this.logger.warn('Could not save disk janitor state', { error: err instanceof Error ? err.message : String(err) });
|
|
445
518
|
}
|
|
446
519
|
}
|
|
520
|
+
tierStatePath() {
|
|
521
|
+
if (this.opts.tierStatePath !== undefined)
|
|
522
|
+
return this.opts.tierStatePath;
|
|
523
|
+
return path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TIER_STATE_FILENAME);
|
|
524
|
+
}
|
|
525
|
+
async readTierState() {
|
|
526
|
+
const p = this.tierStatePath();
|
|
527
|
+
if (!p)
|
|
528
|
+
return this.memoryTierState;
|
|
529
|
+
const file = normalizeTierState(await safeReadJson(p, null));
|
|
530
|
+
// Memory wins when a save failed (full disk).
|
|
531
|
+
const mem = this.memoryTierState;
|
|
532
|
+
for (const key of ['stripped', 'archived', 'finished']) {
|
|
533
|
+
for (const [k, v] of Object.entries(mem[key]))
|
|
534
|
+
file[key][k] = Math.max(file[key][k] ?? 0, v);
|
|
535
|
+
}
|
|
536
|
+
return file;
|
|
537
|
+
}
|
|
538
|
+
async writeTierState(state) {
|
|
539
|
+
this.memoryTierState = state;
|
|
540
|
+
const p = this.tierStatePath();
|
|
541
|
+
if (!p)
|
|
542
|
+
return;
|
|
543
|
+
try {
|
|
544
|
+
await modifyJsonFile(p, null, () => state);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
this.logger.warn('Could not save worktree tier state', { error: err instanceof Error ? err.message : String(err) });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
447
550
|
async doCheckDisk() {
|
|
448
551
|
const result = { freeBytesBefore: null, freeBytesAfter: null, ranPass: false, notified: null };
|
|
449
552
|
try {
|
|
450
553
|
if (this.isDisabled())
|
|
451
554
|
return result;
|
|
452
|
-
const
|
|
555
|
+
const space = await readDiskSpace(this.diskPath(), this.opts.statfs);
|
|
556
|
+
const before = space?.freeBytes ?? null;
|
|
557
|
+
const total = space?.totalBytes ?? null;
|
|
453
558
|
result.freeBytesBefore = before;
|
|
454
559
|
result.freeBytesAfter = before;
|
|
455
|
-
if (before === null || diskLevel(before) === 'ok')
|
|
560
|
+
if (before === null || diskLevel(before, total) === 'ok')
|
|
456
561
|
return result;
|
|
457
562
|
let summary = this.lastSummary;
|
|
458
563
|
const gapOk = this.lastPassAt === null || this.now() - this.lastPassAt >= WORKTREE_JANITOR_CONSTANTS.LOW_DISK_PASS_GAP_MS;
|
|
@@ -466,13 +571,14 @@ export class WorktreeJanitorService {
|
|
|
466
571
|
if (after === null || !this.notifier)
|
|
467
572
|
return result;
|
|
468
573
|
const state = await this.readNoticeState();
|
|
469
|
-
const kind = noticeToSend(diskLevel(after), state, this.now());
|
|
574
|
+
const kind = noticeToSend(diskLevel(after, total), state, this.now());
|
|
470
575
|
if (!kind)
|
|
471
576
|
return result;
|
|
472
577
|
const items = summary ? await this.keptItems(summary) : [];
|
|
473
578
|
// What the volume actually gained covers worktrees and scratch alike.
|
|
474
579
|
const freed = result.ranPass ? Math.max(0, after - before) : 0;
|
|
475
|
-
const
|
|
580
|
+
const breakdown = result.ranPass && summary ? summary.freed : undefined;
|
|
581
|
+
const notice = buildLowDiskNotice({ freeBytes: after, urgent: kind === 'urgent', freedBytes: freed, items, freed: breakdown, totalBytes: total });
|
|
476
582
|
const delivered = await this.notifier({ ...notice, urgent: kind === 'urgent' }).catch(() => false);
|
|
477
583
|
if (delivered) {
|
|
478
584
|
await this.writeNoticeState(recordNotice(state, kind, this.now()));
|
|
@@ -543,12 +649,20 @@ export class WorktreeJanitorService {
|
|
|
543
649
|
wouldRemove: 0,
|
|
544
650
|
kept: 0,
|
|
545
651
|
reclaimedBytes: 0,
|
|
652
|
+
archived: 0,
|
|
653
|
+
archivedBytes: 0,
|
|
654
|
+
retentionArmed: false,
|
|
655
|
+
totalBytes: null,
|
|
546
656
|
keptReasons: {},
|
|
547
657
|
worktrees: [],
|
|
548
658
|
lowDisk: false,
|
|
549
659
|
freeBytes: null,
|
|
550
660
|
scratch: null,
|
|
551
661
|
traces: null,
|
|
662
|
+
transcripts: null,
|
|
663
|
+
tmp: null,
|
|
664
|
+
logs: null,
|
|
665
|
+
freed: { worktrees: 0, stripped: 0, archived: 0, transcripts: 0, tmp: 0 },
|
|
552
666
|
};
|
|
553
667
|
if (!dryRun && this.isDisabled()) {
|
|
554
668
|
summary.disabled = true;
|
|
@@ -556,31 +670,42 @@ export class WorktreeJanitorService {
|
|
|
556
670
|
}
|
|
557
671
|
try {
|
|
558
672
|
this.ghUsable = undefined;
|
|
559
|
-
|
|
560
|
-
summary.
|
|
673
|
+
const space = await readDiskSpace(this.diskPath(), this.opts.statfs);
|
|
674
|
+
summary.freeBytes = space?.freeBytes ?? null;
|
|
675
|
+
summary.totalBytes = space?.totalBytes ?? null;
|
|
676
|
+
summary.lowDisk = summary.freeBytes !== null && diskLevel(summary.freeBytes, summary.totalBytes) !== 'ok';
|
|
677
|
+
// Arming: the first real pass with the crewly#1354 deletes only reports them.
|
|
678
|
+
let noticeState = await this.readNoticeState();
|
|
679
|
+
if (!dryRun && noticeState.retentionArmedAt === undefined) {
|
|
680
|
+
noticeState = { ...noticeState, retentionArmedAt: this.now() };
|
|
681
|
+
await this.writeNoticeState(noticeState);
|
|
682
|
+
this.logger.info('Disk janitor: first pass with worktree tiers and transcript/tmp/log retention — reporting only today, deleting from tomorrow');
|
|
683
|
+
}
|
|
684
|
+
summary.retentionArmed = this.opts.armed ?? retentionArmed(noticeState, this.now());
|
|
561
685
|
const repos = await this.resolveRepos();
|
|
562
686
|
summary.repos = repos.map((r) => r.main);
|
|
563
687
|
const busy = await this.safeBusyCwds();
|
|
688
|
+
let tierState = await this.readTierState();
|
|
689
|
+
const existing = new Set();
|
|
564
690
|
for (const repo of repos) {
|
|
565
691
|
let removedHere = 0;
|
|
566
692
|
const unmerged = [];
|
|
567
693
|
for (const entry of repo.entries) {
|
|
694
|
+
existing.add(canonicalPath(entry.path));
|
|
568
695
|
const verdict = await this.evaluate(repo, entry, busy, summary.lowDisk, idleOverrideMs);
|
|
569
|
-
if (summary.lowDisk && verdict.decision === 'keep' && (verdict.reason === 'dirty' || verdict.reason === 'not-merged')) {
|
|
570
|
-
const rec = await this.reclaimKept(entry.path, verdict.idleMs, dryRun);
|
|
571
|
-
if (rec) {
|
|
572
|
-
verdict.reclaimed = rec;
|
|
573
|
-
summary.reclaimedBytes += rec.freedBytes;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
696
|
const outcome = { ...verdict, removed: false, forced: false, branchDeleted: false };
|
|
697
|
+
if (verdict.decision === 'keep') {
|
|
698
|
+
tierState = await this.applyTier(repo, entry, outcome, busy, tierState, summary, dryRun);
|
|
699
|
+
if (outcome.removed)
|
|
700
|
+
removedHere++;
|
|
701
|
+
}
|
|
577
702
|
if (verdict.decision === 'remove' && !dryRun) {
|
|
578
703
|
await this.remove(repo, outcome);
|
|
579
704
|
if (outcome.removed)
|
|
580
705
|
removedHere++;
|
|
581
706
|
}
|
|
582
707
|
summary.worktrees.push(outcome);
|
|
583
|
-
if (!dryRun && verdict.decision === 'keep' && verdict.reason === 'not-merged')
|
|
708
|
+
if (!dryRun && !outcome.removed && verdict.decision === 'keep' && verdict.reason === 'not-merged')
|
|
584
709
|
unmerged.push(outcome);
|
|
585
710
|
if (outcome.removed)
|
|
586
711
|
summary.removed++;
|
|
@@ -610,7 +735,11 @@ export class WorktreeJanitorService {
|
|
|
610
735
|
git: (args) => this.git(args),
|
|
611
736
|
sizeOf: this.opts.sizeOf,
|
|
612
737
|
});
|
|
738
|
+
summary.freed.worktrees += summary.scratch?.freedBytes ?? 0;
|
|
739
|
+
if (!dryRun)
|
|
740
|
+
await this.writeTierState(pruneTierState(tierState, this.now(), existing));
|
|
613
741
|
summary.traces = await getTraceStore().enforceTotalCap({ dryRun });
|
|
742
|
+
await this.sweepRetention(summary, busy, noticeState, dryRun);
|
|
614
743
|
}
|
|
615
744
|
catch (err) {
|
|
616
745
|
this.logger.warn('Worktree janitor pass failed', { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -621,7 +750,12 @@ export class WorktreeJanitorService {
|
|
|
621
750
|
.sort((a, b) => b[1] - a[1])
|
|
622
751
|
.map(([k, v]) => `${k}: ${v}`)
|
|
623
752
|
.join(', ');
|
|
624
|
-
|
|
753
|
+
const would = summary.worktrees.filter((w) => w.tier?.action === 'would');
|
|
754
|
+
const wouldStrip = would.filter((w) => w.tier?.tier === 'strip');
|
|
755
|
+
const wouldText = would.length > 0
|
|
756
|
+
? `; not yet armed: would strip ${wouldStrip.length} (${formatBytes(wouldStrip.reduce((n, w) => n + (w.tier?.freedBytes ?? 0), 0))}), archive ${would.length - wouldStrip.length}`
|
|
757
|
+
: '';
|
|
758
|
+
this.logger.info(`Worktree janitor: removed ${summary.removed}${summary.archived > 0 ? ` (${summary.archived} archived as patches, ${formatBytes(summary.archivedBytes)})` : ''}, kept ${summary.kept}${reasons ? ` (${reasons})` : ''}${summary.reclaimedBytes > 0 ? `, stripped ${formatBytes(summary.reclaimedBytes)} of node_modules/build output from idle kept worktrees` : ''}${wouldText} across ${summary.repos.length} repo(s) in ${summary.durationMs}ms`, { removed: summary.worktrees.filter((w) => w.removed).map((w) => w.path), lowDisk: summary.lowDisk, retentionArmed: summary.retentionArmed });
|
|
625
759
|
const sc = summary.scratch;
|
|
626
760
|
if (sc) {
|
|
627
761
|
const scReasons = Object.entries(sc.keptReasons)
|
|
@@ -634,9 +768,238 @@ export class WorktreeJanitorService {
|
|
|
634
768
|
if (tr && tr.files > 0) {
|
|
635
769
|
this.logger.info(`Trace janitor: traces folder ${formatBytes(tr.bytes)} in ${tr.files} trace(s), cap ${formatBytes(tr.capBytes)}${tr.prunedTraces ? `, pruned ${tr.prunedTraces} oldest (${formatBytes(tr.freedBytes)})` : ''}`, { ...tr });
|
|
636
770
|
}
|
|
771
|
+
this.logRetention(summary);
|
|
637
772
|
}
|
|
638
773
|
return summary;
|
|
639
774
|
}
|
|
775
|
+
/** One log line each for the transcript, tmp and log sweeps of a real pass. */
|
|
776
|
+
logRetention(summary) {
|
|
777
|
+
const days = Math.round(WORKTREE_JANITOR_CONSTANTS.TRANSCRIPT_MAX_AGE_MS / 86_400_000);
|
|
778
|
+
const armed = summary.retentionArmed;
|
|
779
|
+
const ts = summary.transcripts;
|
|
780
|
+
if (ts) {
|
|
781
|
+
const kept = Object.entries(ts.keptReasons).map(([k, v]) => `${k}: ${v}`).join(', ') || 'none';
|
|
782
|
+
this.logger.info(armed
|
|
783
|
+
? `Transcript janitor: pruned ${ts.pruned} agent transcript(s) older than ${days} days, freed ${formatBytes(ts.freedBytes)}; scanned ${ts.scanned} in ${ts.homes.length} home(s), kept ${kept}`
|
|
784
|
+
: `Transcript janitor (dry run, not yet armed): would prune ${ts.wouldPrune} agent transcript(s) older than ${days} days, ${formatBytes(ts.wouldFreeBytes)}; scanned ${ts.scanned} in ${ts.homes.length} home(s), kept ${kept}`, { homes: ts.homes, pruned: ts.transcripts.filter((t) => t.removed).map((t) => t.path) });
|
|
785
|
+
}
|
|
786
|
+
const tm = summary.tmp;
|
|
787
|
+
if (tm) {
|
|
788
|
+
const kept = Object.entries(tm.keptReasons).map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
789
|
+
this.logger.info(armed
|
|
790
|
+
? `Tmp janitor: removed ${tm.removed} entr${tm.removed === 1 ? 'y' : 'ies'} of ${tm.root}, freed ${formatBytes(tm.freedBytes)}, kept ${tm.kept}${kept ? ` (${kept})` : ''}`
|
|
791
|
+
: `Tmp janitor (dry run, not yet armed): would remove ${tm.wouldRemove} entr${tm.wouldRemove === 1 ? 'y' : 'ies'} of ${tm.root}, ${formatBytes(tm.wouldFreeBytes)}, kept ${tm.kept}${kept ? ` (${kept})` : ''}`, { removed: tm.entries.filter((e) => e.removed).map((e) => e.path) });
|
|
792
|
+
}
|
|
793
|
+
const lg = summary.logs;
|
|
794
|
+
if (lg) {
|
|
795
|
+
for (const c of [lg.archive, lg.sessions]) {
|
|
796
|
+
if (c.deleted.length === 0 && c.wouldDelete.length === 0 && c.overCapBytes === 0)
|
|
797
|
+
continue;
|
|
798
|
+
this.logger.info(armed
|
|
799
|
+
? `Log janitor: ${c.dir} was ${formatBytes(c.totalBytes)} (cap ${formatBytes(c.capBytes)}), deleted ${c.deleted.length} oldest file(s), freed ${formatBytes(c.freedBytes)}${c.overCapBytes > 0 ? `, still ${formatBytes(c.overCapBytes)} over the cap (live or fresh logs)` : ''}`
|
|
800
|
+
: `Log janitor (dry run, not yet armed): ${c.dir} is ${formatBytes(c.totalBytes)} (cap ${formatBytes(c.capBytes)}), would delete ${c.wouldDelete.length} oldest file(s), ${formatBytes(c.wouldFreeBytes)}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Age tiers for a kept worktree (crewly#1354): strip its regenerable dirs
|
|
806
|
+
* after 3 days untouched (1 day under low disk / after a finished WorkItem),
|
|
807
|
+
* archive it as a patch and remove it after 14 days. Managed per-WorkItem
|
|
808
|
+
* worktrees are only stripped (keeping their shared symlinks), never
|
|
809
|
+
* archived — their own service owns them. Never throws.
|
|
810
|
+
*
|
|
811
|
+
* @returns The (possibly updated) tier state
|
|
812
|
+
*/
|
|
813
|
+
async applyTier(repo, entry, outcome, busy, state, summary, dryRun) {
|
|
814
|
+
const managed = outcome.reason === 'managed-by-workitem-worktrees';
|
|
815
|
+
if (!managed && outcome.reason !== 'dirty' && outcome.reason !== 'not-merged')
|
|
816
|
+
return state;
|
|
817
|
+
try {
|
|
818
|
+
const key = canonicalPath(entry.path);
|
|
819
|
+
const finishedAt = state.finished[key];
|
|
820
|
+
if (managed) {
|
|
821
|
+
// Only a finished WorkItem's managed worktree is touched, and only to strip it.
|
|
822
|
+
if (finishedAt === undefined)
|
|
823
|
+
return state;
|
|
824
|
+
if (busy === null || busy.some((b) => isPathInside(b.cwd, entry.path)))
|
|
825
|
+
return state;
|
|
826
|
+
}
|
|
827
|
+
const gitDirR = await this.git(['-C', entry.path, 'rev-parse', '--absolute-git-dir']);
|
|
828
|
+
const gitDir = gitDirR.code === 0 ? gitDirR.stdout.trim() : null;
|
|
829
|
+
const log = await this.git(['-C', entry.path, 'log', '-1', '--format=%ct', 'HEAD']);
|
|
830
|
+
const newestCommitMs = log.code === 0 && /^\d+$/.test(log.stdout.trim()) ? Number(log.stdout.trim()) * 1000 : null;
|
|
831
|
+
const idleMs = tierIdleMs({
|
|
832
|
+
now: this.now(),
|
|
833
|
+
treeMtimeMs: await latestTreeMtimeMs(entry.path, WORKTREE_JANITOR_CONSTANTS.TIER_MTIME_DEPTH),
|
|
834
|
+
gitMtimeMs: latestWorktreeMtimeMs(entry.path, gitDir),
|
|
835
|
+
newestCommitMs,
|
|
836
|
+
});
|
|
837
|
+
let tier = tierFor({ idleMs, lowDisk: summary.lowDisk, finishedAt });
|
|
838
|
+
if (tier === null)
|
|
839
|
+
return state;
|
|
840
|
+
if (managed && tier === 'archive')
|
|
841
|
+
tier = 'strip';
|
|
842
|
+
const idle = idleMs ?? 0;
|
|
843
|
+
if (tier === 'strip') {
|
|
844
|
+
const act = !dryRun && summary.retentionArmed;
|
|
845
|
+
const rec = await reclaimRegenerableDirs(entry.path, this.opts.sizeOf ?? diskUsageBytes, !act, {
|
|
846
|
+
isTracked: (dir) => this.isTracked(entry.path, dir),
|
|
847
|
+
keepSymlinks: managed,
|
|
848
|
+
});
|
|
849
|
+
if (rec.dirs.length === 0)
|
|
850
|
+
return state;
|
|
851
|
+
// `reclaimed` keeps its pre-#1354 meaning (low-disk reclaim, done or planned); the tier carries the rest.
|
|
852
|
+
if (act || (summary.lowDisk && dryRun))
|
|
853
|
+
outcome.reclaimed = rec;
|
|
854
|
+
outcome.tier = { tier, idleMs: idle, action: act ? 'done' : 'would', dirs: rec.dirs, freedBytes: rec.freedBytes };
|
|
855
|
+
if (act) {
|
|
856
|
+
summary.reclaimedBytes += rec.freedBytes;
|
|
857
|
+
summary.freed.stripped += rec.freedBytes;
|
|
858
|
+
if (state.stripped[key] === undefined) {
|
|
859
|
+
state = { ...state, stripped: { ...state.stripped, [key]: this.now() } };
|
|
860
|
+
await this.tellLead(repo, entry.path, buildStripNotice({ path: entry.path, reason: outcome.reason, idleMs: idle, dirs: rec.dirs, freedBytes: rec.freedBytes }));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return state;
|
|
864
|
+
}
|
|
865
|
+
const act = !dryRun && summary.retentionArmed;
|
|
866
|
+
const bytes = (await (this.opts.sizeOf ?? diskUsageBytes)(entry.path)) ?? 0;
|
|
867
|
+
if (!act) {
|
|
868
|
+
outcome.tier = { tier, idleMs: idle, action: 'would', freedBytes: bytes };
|
|
869
|
+
return state;
|
|
870
|
+
}
|
|
871
|
+
const defaultRef = await this.defaultRef(repo);
|
|
872
|
+
let commits = null;
|
|
873
|
+
if (entry.head && defaultRef) {
|
|
874
|
+
const c = await this.git(['-C', repo.main, 'rev-list', '--count', entry.head, `^${defaultRef}`]);
|
|
875
|
+
commits = c.code === 0 && /^\d+$/.test(c.stdout.trim()) ? Number(c.stdout.trim()) : null;
|
|
876
|
+
}
|
|
877
|
+
let keepRef = null;
|
|
878
|
+
if (!entry.branch && entry.head && (commits ?? 1) > 0) {
|
|
879
|
+
keepRef = archiveKeepRef(entry.path, this.now());
|
|
880
|
+
const u = await this.git(['-C', repo.main, 'update-ref', keepRef, entry.head]);
|
|
881
|
+
if (u.code !== 0)
|
|
882
|
+
keepRef = null;
|
|
883
|
+
}
|
|
884
|
+
const file = archivePatchPath(this.opts.archiveRoot ?? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.ARCHIVE_DIR), repo.main, entry.path, this.now());
|
|
885
|
+
let patch;
|
|
886
|
+
try {
|
|
887
|
+
patch = await writeArchivePatch((args, options) => this.git(args, options), { repoMain: repo.main, worktreePath: entry.path, branch: entry.branch, head: entry.head, defaultRef, commitsNotOnDefault: commits, keepRef }, file, this.now());
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
outcome.tier = { tier, idleMs: idle, action: 'failed', freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
|
|
891
|
+
this.logger.warn('Worktree archive failed; worktree kept', { path: entry.path, error: outcome.tier.error });
|
|
892
|
+
return state;
|
|
893
|
+
}
|
|
894
|
+
const r = await this.git(['-C', repo.main, 'worktree', 'remove', '--force', entry.path]);
|
|
895
|
+
if (r.code !== 0) {
|
|
896
|
+
outcome.tier = { tier, idleMs: idle, action: 'failed', freedBytes: 0, patch: file, error: (r.stderr || 'git worktree remove failed').trim().slice(0, 300) };
|
|
897
|
+
this.logger.warn('Worktree archive: patch saved but removal failed; worktree kept', { path: entry.path, patch: file, error: outcome.tier.error });
|
|
898
|
+
return state;
|
|
899
|
+
}
|
|
900
|
+
outcome.removed = true;
|
|
901
|
+
outcome.forced = true;
|
|
902
|
+
outcome.decision = 'remove';
|
|
903
|
+
outcome.reason = 'archived';
|
|
904
|
+
outcome.detail = `untouched ${Math.floor(idle / 86_400_000)} days; patch ${file}`;
|
|
905
|
+
outcome.tier = { tier, idleMs: idle, action: 'done', freedBytes: bytes, patch: file };
|
|
906
|
+
summary.archived += 1;
|
|
907
|
+
summary.archivedBytes += bytes;
|
|
908
|
+
summary.freed.archived += bytes;
|
|
909
|
+
state = { ...state, archived: { ...state.archived, [key]: this.now() } };
|
|
910
|
+
await this.tellLead(repo, entry.path, buildArchiveNotice({ path: entry.path, branch: entry.branch, patch, idleMs: idle, commitsNotOnDefault: commits, keepRef }));
|
|
911
|
+
this.logger.info('Archived and removed an untouched worktree', { path: entry.path, patch: file, bytes, branch: entry.branch });
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
this.logger.warn('Worktree tier failed (non-fatal)', { path: entry.path, error: err instanceof Error ? err.message : String(err) });
|
|
915
|
+
}
|
|
916
|
+
return state;
|
|
917
|
+
}
|
|
918
|
+
/** Whether git tracks anything under `dir` of the worktree (null = could not tell). */
|
|
919
|
+
async isTracked(worktreePath, dir) {
|
|
920
|
+
const rel = path.relative(worktreePath, dir);
|
|
921
|
+
const r = await this.git(['-C', worktreePath, 'ls-files', '--', rel]);
|
|
922
|
+
if (r.code !== 0)
|
|
923
|
+
return null;
|
|
924
|
+
return r.stdout.trim() !== '';
|
|
925
|
+
}
|
|
926
|
+
/** Tell the owning team's lead (else the orchestrator) one message. Never throws. */
|
|
927
|
+
async tellLead(repo, worktreePath, message) {
|
|
928
|
+
const notifier = this.agentNotifier;
|
|
929
|
+
if (!notifier)
|
|
930
|
+
return;
|
|
931
|
+
try {
|
|
932
|
+
const leads = await (this.opts.resolveLeadSessions ?? defaultResolveLeadSessions)(repo.main, worktreePath).catch(() => []);
|
|
933
|
+
for (const session of leads.length > 0 ? leads : [ORCHESTRATOR_SESSION_NAME])
|
|
934
|
+
await notifier(session, message).catch(() => false);
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
// best effort
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Transcript, tmp and log retention (crewly#1354), each on its own
|
|
942
|
+
* cadence; all dry-run until armed. Never throws.
|
|
943
|
+
*/
|
|
944
|
+
async sweepRetention(summary, busy, noticeState, dryRun) {
|
|
945
|
+
const now = this.now();
|
|
946
|
+
const effectiveDry = dryRun || !summary.retentionArmed;
|
|
947
|
+
const due = (last, interval) => this.opts.forceSweeps === true || last === undefined || now - last >= interval;
|
|
948
|
+
let stateChanged = false;
|
|
949
|
+
try {
|
|
950
|
+
// Under jest the real ~/.claude is never scanned unless a test asks for it.
|
|
951
|
+
const homes = this.opts.claudeHomes ?? ((this.opts.env ?? process.env).JEST_WORKER_ID ? [] : defaultClaudeHomes(getCrewlyHomePath()));
|
|
952
|
+
if (homes.length > 0 && due(noticeState.lastTranscriptSweepAt, WORKTREE_JANITOR_CONSTANTS.TRANSCRIPT_SWEEP_INTERVAL_MS)) {
|
|
953
|
+
const sources = await (this.opts.transcriptSources ?? (() => defaultTranscriptSources(now)))();
|
|
954
|
+
summary.transcripts = await sweepTranscripts({ claudeHomes: homes, sources, now, dryRun: effectiveDry, sizeOf: this.opts.sizeOf });
|
|
955
|
+
summary.freed.transcripts += summary.transcripts.freedBytes;
|
|
956
|
+
if (!effectiveDry) {
|
|
957
|
+
noticeState.lastTranscriptSweepAt = now;
|
|
958
|
+
stateChanged = true;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
this.logger.warn('Transcript sweep failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
|
|
964
|
+
}
|
|
965
|
+
try {
|
|
966
|
+
const tmpRoot = this.opts.tmpRoot === undefined ? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TMP_DIR) : this.opts.tmpRoot;
|
|
967
|
+
const logsDir = this.opts.logsDir === undefined ? path.join(getCrewlyHomePath(), LOG_ROTATION_CONSTANTS.LOGS_DIR) : this.opts.logsDir;
|
|
968
|
+
if ((tmpRoot || logsDir) && due(noticeState.lastTmpSweepAt, WORKTREE_JANITOR_CONSTANTS.TMP_SWEEP_INTERVAL_MS)) {
|
|
969
|
+
if (tmpRoot) {
|
|
970
|
+
summary.tmp = await sweepTmp({ root: tmpRoot, busy, now, dryRun: effectiveDry, sizeOf: this.opts.sizeOf });
|
|
971
|
+
summary.freed.tmp += summary.tmp.freedBytes;
|
|
972
|
+
}
|
|
973
|
+
if (logsDir) {
|
|
974
|
+
const live = new Set(await (this.opts.liveSessionNames ?? defaultLiveSessionNames)().catch(() => []));
|
|
975
|
+
const archive = await capDirectoryBytes({
|
|
976
|
+
dir: path.join(logsDir, LOG_ROTATION_CONSTANTS.ARCHIVE_DIR),
|
|
977
|
+
capBytes: WORKTREE_JANITOR_CONSTANTS.LOG_ARCHIVE_CAP_BYTES,
|
|
978
|
+
now,
|
|
979
|
+
dryRun: effectiveDry,
|
|
980
|
+
});
|
|
981
|
+
const sessions = await capDirectoryBytes({
|
|
982
|
+
dir: path.join(logsDir, LOG_ROTATION_CONSTANTS.SESSIONS_LOG_DIR),
|
|
983
|
+
capBytes: WORKTREE_JANITOR_CONSTANTS.LOG_SESSIONS_CAP_BYTES,
|
|
984
|
+
now,
|
|
985
|
+
dryRun: effectiveDry,
|
|
986
|
+
protect: (name) => live.has(name.replace(/\.log$/, '')),
|
|
987
|
+
});
|
|
988
|
+
summary.logs = { archive, sessions };
|
|
989
|
+
summary.freed.tmp += archive.freedBytes + sessions.freedBytes;
|
|
990
|
+
}
|
|
991
|
+
if (!effectiveDry) {
|
|
992
|
+
noticeState.lastTmpSweepAt = now;
|
|
993
|
+
stateChanged = true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
catch (err) {
|
|
998
|
+
this.logger.warn('Tmp/log sweep failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
|
|
999
|
+
}
|
|
1000
|
+
if (stateChanged)
|
|
1001
|
+
await this.writeNoticeState(noticeState);
|
|
1002
|
+
}
|
|
640
1003
|
/**
|
|
641
1004
|
* Tell the owning team's lead (else the orchestrator) about kept worktrees
|
|
642
1005
|
* with unmerged work, once per worktree per interval. Never throws.
|
|
@@ -727,7 +1090,9 @@ export class WorktreeJanitorService {
|
|
|
727
1090
|
const registered = await (this.opts.listRepoPaths ?? defaultListRepoPaths)().catch(() => []);
|
|
728
1091
|
let discovered = [];
|
|
729
1092
|
try {
|
|
730
|
-
|
|
1093
|
+
// CREWLY_HOME/tmp too: agents put worktrees there, and the tmp sweep leaves git checkouts to these rules.
|
|
1094
|
+
const crewlyTmp = this.opts.tmpRoot === undefined ? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TMP_DIR) : this.opts.tmpRoot;
|
|
1095
|
+
discovered = await discoverLinkedWorktreeRepos([...this.tmpRoots(), ...this.scratchRoots(), ...(crewlyTmp ? [crewlyTmp] : [])]);
|
|
731
1096
|
}
|
|
732
1097
|
catch {
|
|
733
1098
|
// discovery is best effort
|
|
@@ -840,23 +1205,6 @@ export class WorktreeJanitorService {
|
|
|
840
1205
|
}
|
|
841
1206
|
return keep('not-merged', defaultRef ? `not in ${defaultRef} (history or content), no merged PR` : 'no origin default branch, no merged PR', idleMs);
|
|
842
1207
|
}
|
|
843
|
-
/**
|
|
844
|
-
* Low disk: delete the regenerable dirs (node_modules, .next) of a kept
|
|
845
|
-
* worktree that has been idle for a day. Reversible; never touches
|
|
846
|
-
* tracked or source files. Never throws.
|
|
847
|
-
*/
|
|
848
|
-
async reclaimKept(worktreePath, idleMs, dryRun) {
|
|
849
|
-
if (idleMs === undefined || idleMs < WORKTREE_JANITOR_CONSTANTS.RECLAIM_MIN_IDLE_MS)
|
|
850
|
-
return null;
|
|
851
|
-
try {
|
|
852
|
-
const r = await reclaimRegenerableDirs(worktreePath, this.opts.sizeOf ?? diskUsageBytes, dryRun);
|
|
853
|
-
return r.dirs.length > 0 ? r : null;
|
|
854
|
-
}
|
|
855
|
-
catch (err) {
|
|
856
|
-
this.logger.warn('Reclaiming regenerable dirs failed (non-fatal)', { path: worktreePath, error: err instanceof Error ? err.message : String(err) });
|
|
857
|
-
return null;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
1208
|
/** `origin/<default>` after one quiet fetch per repo per pass; null when there is no origin. */
|
|
861
1209
|
async defaultRef(repo) {
|
|
862
1210
|
if (repo.defaultRef !== undefined)
|
|
@@ -996,6 +1344,97 @@ export async function defaultListRepoPaths() {
|
|
|
996
1344
|
}
|
|
997
1345
|
return out;
|
|
998
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Default WorkItem -> worktree lookup for the task-end hook: the worktree the
|
|
1349
|
+
* per-WorkItem worktree feature recorded on the item (`metadata.worktree.path`).
|
|
1350
|
+
*
|
|
1351
|
+
* @param workItemId - WorkItem id
|
|
1352
|
+
* @returns Worktree path, or null when the item has none
|
|
1353
|
+
*/
|
|
1354
|
+
export async function defaultFindWorkItemWorktree(workItemId) {
|
|
1355
|
+
try {
|
|
1356
|
+
const { TaskPoolService } = await import('../task-pool/task-pool.service.js');
|
|
1357
|
+
const item = (await TaskPoolService.getInstance().getAllItems()).find((i) => i.id === workItemId);
|
|
1358
|
+
const wt = item?.metadata?.worktree;
|
|
1359
|
+
return typeof wt?.path === 'string' ? wt.path : null;
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Default transcript sources: Crewly's records under CREWLY_HOME
|
|
1367
|
+
* (session-state, last-conversations, handover files), the live session
|
|
1368
|
+
* backend (names) and persistence (names + current conversation ids, kept),
|
|
1369
|
+
* and every team member's session name.
|
|
1370
|
+
*
|
|
1371
|
+
* @param now - Current time (ms)
|
|
1372
|
+
* @returns Known session names, recorded ids, ids to keep
|
|
1373
|
+
*/
|
|
1374
|
+
export async function defaultTranscriptSources(now) {
|
|
1375
|
+
const sets = await readCrewlyRecords(getCrewlyHomePath(), now);
|
|
1376
|
+
try {
|
|
1377
|
+
const { getSessionBackendSync } = await import('../session/session-backend.factory.js');
|
|
1378
|
+
for (const name of getSessionBackendSync()?.listSessions() ?? [])
|
|
1379
|
+
sets.knownSessionNames.add(name);
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
// no session backend
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
const { getSessionStatePersistence } = await import('../session/session-state-persistence.js');
|
|
1386
|
+
const persistence = getSessionStatePersistence();
|
|
1387
|
+
for (const name of persistence.getRegisteredSessions()) {
|
|
1388
|
+
sets.knownSessionNames.add(name);
|
|
1389
|
+
const id = persistence.getSessionId(name);
|
|
1390
|
+
if (id) {
|
|
1391
|
+
sets.keepIds.add(id.toLowerCase());
|
|
1392
|
+
sets.recordedIds.add(id.toLowerCase());
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
catch {
|
|
1397
|
+
// no persistence
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
const { StorageService } = await import('../core/storage.service.js');
|
|
1401
|
+
for (const team of await StorageService.getInstance().getTeams()) {
|
|
1402
|
+
for (const m of team.members ?? [])
|
|
1403
|
+
if (m.sessionName)
|
|
1404
|
+
sets.knownSessionNames.add(m.sessionName);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
catch {
|
|
1408
|
+
// no teams store
|
|
1409
|
+
}
|
|
1410
|
+
return sets;
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Default live-session names (their logs are never deleted by the cap): the
|
|
1414
|
+
* session backend's sessions plus every persisted session.
|
|
1415
|
+
*
|
|
1416
|
+
* @returns Session names
|
|
1417
|
+
*/
|
|
1418
|
+
export async function defaultLiveSessionNames() {
|
|
1419
|
+
const out = new Set();
|
|
1420
|
+
try {
|
|
1421
|
+
const { getSessionBackendSync } = await import('../session/session-backend.factory.js');
|
|
1422
|
+
for (const name of getSessionBackendSync()?.listSessions() ?? [])
|
|
1423
|
+
out.add(name);
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
// no session backend
|
|
1427
|
+
}
|
|
1428
|
+
try {
|
|
1429
|
+
const { getSessionStatePersistence } = await import('../session/session-state-persistence.js');
|
|
1430
|
+
for (const name of getSessionStatePersistence().getRegisteredSessions())
|
|
1431
|
+
out.add(name);
|
|
1432
|
+
}
|
|
1433
|
+
catch {
|
|
1434
|
+
// no persistence
|
|
1435
|
+
}
|
|
1436
|
+
return [...out];
|
|
1437
|
+
}
|
|
999
1438
|
/**
|
|
1000
1439
|
* Default busy-cwd probe: the start directory of every live Crewly session,
|
|
1001
1440
|
* plus the cwd of every process of this user (which covers the PTY shells,
|