crewly 1.20.296 → 1.20.298
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 +1 -1
- package/config/skills/agent/core/app-data/SKILL.md +13 -1
- package/config/skills/agent/core/app-data/execute.sh +60 -6
- package/config/skills/agent/core/app-data/execute.test.sh +42 -0
- package/config/skills/agent/core/docs-comment/SKILL.md +49 -4
- package/config/skills/agent/core/docs-comment/execute.sh +38 -2
- package/config/skills/agent/core/docs-comment/execute.test.sh +28 -0
- package/dist/backend/backend/src/constants.d.ts +48 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +48 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +2 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.controller.js +2 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +1 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.js +3 -1
- package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts +20 -0
- package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/browser/browser.controller.js +52 -9
- package/dist/backend/backend/src/controllers/browser/browser.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.controller.d.ts +31 -0
- package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.controller.js +198 -5
- package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.routes.d.ts +4 -0
- package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.routes.js +9 -1
- package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +11 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +33 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/app-wake-message.js +48 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.d.ts +23 -0
- package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/apps/apps.service.js +57 -6
- package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
- package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts +166 -0
- package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts.map +1 -0
- package/dist/backend/backend/src/services/google/docs-comment-gate.js +176 -0
- package/dist/backend/backend/src/services/google/docs-comment-gate.js.map +1 -0
- package/dist/backend/backend/src/services/google/docs-comments.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/docs-comments.service.js +2 -2
- package/dist/backend/backend/src/services/google/docs-comments.service.js.map +1 -1
- package/dist/backend/backend/src/services/google/google-api.client.d.ts +15 -0
- package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/google-api.client.js +16 -2
- package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
- package/dist/backend/backend/src/services/google/google-reauth.wiring.js +1 -1
- package/dist/backend/backend/src/services/google/google-reauth.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +12 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js +18 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts +131 -0
- package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js +251 -0
- package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js.map +1 -0
- package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts +61 -0
- package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/worktree-dirty.js +121 -0
- package/dist/backend/backend/src/services/worktree/worktree-dirty.js.map +1 -0
- package/dist/backend/backend/src/services/worktree/worktree-janitor.git.d.ts +2 -0
- package/dist/backend/backend/src/services/worktree/worktree-janitor.git.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.git.js +6 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.git.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +79 -5
- 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 +198 -15
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-landed.d.ts +76 -0
- package/dist/backend/backend/src/services/worktree/worktree-landed.d.ts.map +1 -0
- package/dist/backend/backend/src/services/worktree/worktree-landed.js +132 -0
- package/dist/backend/backend/src/services/worktree/worktree-landed.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +48 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +48 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/package.json +1 -1
|
@@ -33,15 +33,23 @@
|
|
|
33
33
|
* dir's index / HEAD / logs/HEAD) more than 2 hours ago in a known agent
|
|
34
34
|
* location (`<main>/.claude/worktrees/`, `<tmp>/crewly-worktrees/`, or a
|
|
35
35
|
* `worktree-agent-*` branch), more than 24 hours ago anywhere else.
|
|
36
|
-
* 6.
|
|
37
|
-
* change and no untracked file
|
|
38
|
-
*
|
|
36
|
+
* 6. Nothing but regenerable output is uncommitted: `git status --porcelain`
|
|
37
|
+
* shows no tracked change and no untracked file, ignoring untracked
|
|
38
|
+
* `node_modules` / `.next` / `dist` / `build` / `.turbo` / `coverage` /
|
|
39
|
+
* `.cache` / `logs` directories and `*.log` files (nested packages rarely
|
|
40
|
+
* ignore their own). A failing status keeps it.
|
|
39
41
|
* 7. Its work has landed: HEAD is an ancestor of `origin/<default branch>`
|
|
40
42
|
* after a quiet `git fetch` (fetch errors ignored), OR — when `gh` is
|
|
41
43
|
* installed and authenticated — a MERGED PR has this branch as head AND
|
|
42
44
|
* its head commit equals the worktree HEAD (squash merges; commits made
|
|
43
45
|
* after the merge keep the worktree). A repo without an origin (not on
|
|
44
|
-
* GitHub) only has the ancestor check, so its worktrees are kept
|
|
46
|
+
* GitHub) only has the ancestor check, so its worktrees are kept — OR, for
|
|
47
|
+
* any git host (Bitbucket, GitLab, squash merges), its CONTENT is already in
|
|
48
|
+
* `origin/<default>`: merging HEAD into it changes nothing
|
|
49
|
+
* (`git merge-tree --write-tree`, falling back to `git cherry`), or the
|
|
50
|
+
* branch's combined diff is patch-equivalent to one commit the default
|
|
51
|
+
* branch gained since (a squash commit). See worktree-landed.ts. Work in
|
|
52
|
+
* none of these is never removed. `gh` is not asked about non-GitHub remotes.
|
|
45
53
|
*
|
|
46
54
|
* ## Removal
|
|
47
55
|
* `git worktree remove <path>` without `--force`. If git refuses although
|
|
@@ -49,6 +57,17 @@
|
|
|
49
57
|
* no submodules, it is retried once with `--force`. Then the local branch is
|
|
50
58
|
* deleted (never a protected name), and `git worktree prune` runs per repo.
|
|
51
59
|
*
|
|
60
|
+
* ## Low disk: kept worktrees
|
|
61
|
+
* Under low disk, a worktree kept as `dirty` or `not-merged` and idle for
|
|
62
|
+
* 24 h+ still loses its `node_modules` / `.next` directories (reversible:
|
|
63
|
+
* reinstall / rebuild); the verdict carries `reclaimed` and the summary
|
|
64
|
+
* `reclaimedBytes`.
|
|
65
|
+
*
|
|
66
|
+
* ## Work completion
|
|
67
|
+
* A WorkItem reaching done / verified calls `requestTidy()`: one debounced pass
|
|
68
|
+
* (2 min) with a 10 min idle threshold, so a finished worktree is removed as
|
|
69
|
+
* soon as its change has landed, not at the next 30-minute tick.
|
|
70
|
+
*
|
|
52
71
|
* ## Scratch sweep
|
|
53
72
|
* After the worktrees, stale Claude Code session dirs
|
|
54
73
|
* (`<tmp>/claude-<uid>/<slug>/<uuid>/`) are deleted when idle for 3 days,
|
|
@@ -64,6 +83,15 @@
|
|
|
64
83
|
* the janitor left and why; below 5 GB the notice is urgent and repeats at
|
|
65
84
|
* most every 6 h.
|
|
66
85
|
*
|
|
86
|
+
* ## Runaway files
|
|
87
|
+
* Once a minute a watcher scans agent scratch dirs and worktrees for a single
|
|
88
|
+
* file that grows without bound (a 40 GB `all.md` filled a disk, crewly#1078):
|
|
89
|
+
* a file over 5 GB that is still growing, or any file growing over 1 GB/min,
|
|
90
|
+
* has its writer stopped, is cut to its first 1 MB, the agent working there is
|
|
91
|
+
* told and the owner is alerted once through the low-disk notifier. A big file
|
|
92
|
+
* that is not growing is only reported. See
|
|
93
|
+
* {@link module:services/worktree/runaway-file-watcher}.
|
|
94
|
+
*
|
|
67
95
|
* Runs every 30 minutes (first run 10 minutes after boot). Kill switch:
|
|
68
96
|
* `CREWLY_WORKTREE_JANITOR=0` (also stops the low-disk guard). Never throws;
|
|
69
97
|
* logs one summary line per sweep.
|
|
@@ -73,12 +101,13 @@
|
|
|
73
101
|
import { type StatFsFn } from './low-disk-guard.js';
|
|
74
102
|
import { type ScratchSweepSummary } from './scratch-janitor.js';
|
|
75
103
|
import { type DockerReclaimResult } from './docker-reclaim.js';
|
|
104
|
+
import { type RunawayOutcome } from './runaway-file-watcher.js';
|
|
76
105
|
import { type TraceDiskResult } from '../trace/trace-store.js';
|
|
77
106
|
/**
|
|
78
107
|
* Why a worktree is kept, or why it may go. (`not-agent-worktree` is retired:
|
|
79
108
|
* location no longer decides eligibility, only the idle threshold.)
|
|
80
109
|
*/
|
|
81
|
-
export type WorktreeReason = 'main-worktree' | 'bare' | 'prunable' | 'locked' | 'managed-by-workitem-worktrees' | 'agent-session-inside' | 'process-inside' | 'cwd-probe-failed' | 'recent' | 'dirty' | 'status-failed' | 'not-merged' | 'merged' | 'pr-merged';
|
|
110
|
+
export type WorktreeReason = 'main-worktree' | 'bare' | 'prunable' | 'locked' | 'managed-by-workitem-worktrees' | 'agent-session-inside' | 'process-inside' | 'cwd-probe-failed' | 'recent' | 'dirty' | 'status-failed' | 'not-merged' | 'merged' | 'pr-merged' | 'content-landed';
|
|
82
111
|
/** Verdict for one worktree (the dry-run output). */
|
|
83
112
|
export interface WorktreeVerdict {
|
|
84
113
|
/** Main worktree of the repo this worktree belongs to */
|
|
@@ -97,6 +126,11 @@ export interface WorktreeVerdict {
|
|
|
97
126
|
detail?: string;
|
|
98
127
|
/** Ms since last touched, when computed */
|
|
99
128
|
idleMs?: number;
|
|
129
|
+
/** Low disk only: regenerable dirs (node_modules, .next) of a kept worktree that were (or, in a dry run, would be) deleted */
|
|
130
|
+
reclaimed?: {
|
|
131
|
+
dirs: string[];
|
|
132
|
+
freedBytes: number;
|
|
133
|
+
};
|
|
100
134
|
}
|
|
101
135
|
/** Outcome of acting on one verdict. */
|
|
102
136
|
export interface WorktreeOutcome extends WorktreeVerdict {
|
|
@@ -127,6 +161,8 @@ export interface JanitorRunSummary {
|
|
|
127
161
|
wouldRemove: number;
|
|
128
162
|
/** Worktrees kept */
|
|
129
163
|
kept: number;
|
|
164
|
+
/** Bytes freed by deleting regenerable dirs (node_modules, .next) from kept worktrees under low disk */
|
|
165
|
+
reclaimedBytes: number;
|
|
130
166
|
/** Kept worktrees by reason (removal failures under `remove-failed`) */
|
|
131
167
|
keptReasons: Record<string, number>;
|
|
132
168
|
/** Per-worktree results */
|
|
@@ -203,6 +239,8 @@ export interface WorktreeJanitorOptions {
|
|
|
203
239
|
statePath?: string | null;
|
|
204
240
|
/** Docker reclaim on low disk (default: the real one, except under jest); null = never */
|
|
205
241
|
reclaimDocker?: (() => Promise<DockerReclaimResult>) | null;
|
|
242
|
+
/** Debounce before a completion-triggered pass (default COMPLETION_TIDY_DELAY_MS) */
|
|
243
|
+
tidyDelayMs?: number;
|
|
206
244
|
/** Environment for the kill switch (default process.env) */
|
|
207
245
|
env?: NodeJS.ProcessEnv;
|
|
208
246
|
/** Logger (default: component logger `WorktreeJanitor`) */
|
|
@@ -234,6 +272,11 @@ export declare class WorktreeJanitorService {
|
|
|
234
272
|
private lastPassAt;
|
|
235
273
|
private memoryNoticeState;
|
|
236
274
|
private notifier;
|
|
275
|
+
private agentNotifier;
|
|
276
|
+
private runawayTimer;
|
|
277
|
+
private tidyTimer;
|
|
278
|
+
private runawayWatcher;
|
|
279
|
+
private runawayInFlight;
|
|
237
280
|
/**
|
|
238
281
|
* @param options - Injectable dependencies; defaults use the live backend
|
|
239
282
|
*/
|
|
@@ -245,6 +288,22 @@ export declare class WorktreeJanitorService {
|
|
|
245
288
|
* @param notifier - Sender, or null to disable notices
|
|
246
289
|
*/
|
|
247
290
|
setLowDiskNotifier(notifier: LowDiskNotifier | null): void;
|
|
291
|
+
/**
|
|
292
|
+
* Set how an agent is told its working dir had a runaway file (the server
|
|
293
|
+
* wires the normal agent message delivery here).
|
|
294
|
+
*
|
|
295
|
+
* @param notifier - Sender (true when delivered), or null to skip agent notices
|
|
296
|
+
*/
|
|
297
|
+
setAgentNotifier(notifier: ((sessionName: string, message: string) => Promise<boolean>) | null): void;
|
|
298
|
+
/**
|
|
299
|
+
* One runaway-file scan now (also what the minute timer runs). Never
|
|
300
|
+
* throws; overlapping scans are skipped. Honours the kill switch.
|
|
301
|
+
*
|
|
302
|
+
* @returns What was flagged and done (empty when nothing is wrong)
|
|
303
|
+
*/
|
|
304
|
+
checkRunawayFiles(): Promise<RunawayOutcome[]>;
|
|
305
|
+
/** Sessions whose cwd holds `file`, or sits in the same scratch session dir; told via the agent notifier. */
|
|
306
|
+
private notifyAgentsNear;
|
|
248
307
|
/**
|
|
249
308
|
* Process-wide instance used by the server and the HTTP routes.
|
|
250
309
|
*
|
|
@@ -301,6 +360,15 @@ export declare class WorktreeJanitorService {
|
|
|
301
360
|
* @returns Summary with per-worktree verdicts
|
|
302
361
|
*/
|
|
303
362
|
plan(): Promise<JanitorRunSummary>;
|
|
363
|
+
/**
|
|
364
|
+
* Work just finished (a WorkItem / ticket reached done or verified): run one
|
|
365
|
+
* pass soon, so a finished worktree goes away when its PR is merged instead
|
|
366
|
+
* of waiting for the next 30-minute tick. Bursts are debounced into one pass
|
|
367
|
+
* after COMPLETION_TIDY_DELAY_MS; the idle threshold is COMPLETION_MIN_IDLE_MS.
|
|
368
|
+
* Only worktrees that pass every safety rule are removed (landed, clean, nobody
|
|
369
|
+
* inside) — unmerged work stays for the next pass. No-op when disabled. Never throws.
|
|
370
|
+
*/
|
|
371
|
+
requestTidy(): void;
|
|
304
372
|
/**
|
|
305
373
|
* One real pass. Concurrent calls share the pass in flight. Never throws.
|
|
306
374
|
* Honours the kill switch.
|
|
@@ -334,6 +402,12 @@ export declare class WorktreeJanitorService {
|
|
|
334
402
|
* Apply the removal rules to one worktree (read-only apart from fetch).
|
|
335
403
|
*/
|
|
336
404
|
private evaluate;
|
|
405
|
+
/**
|
|
406
|
+
* Low disk: delete the regenerable dirs (node_modules, .next) of a kept
|
|
407
|
+
* worktree that has been idle for a day. Reversible; never touches
|
|
408
|
+
* tracked or source files. Never throws.
|
|
409
|
+
*/
|
|
410
|
+
private reclaimKept;
|
|
337
411
|
/** `origin/<default>` after one quiet fetch per repo per pass; null when there is no origin. */
|
|
338
412
|
private defaultRef;
|
|
339
413
|
/** Number of a MERGED PR whose head is `branch` at exactly `head`, or null. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktree-janitor.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/worktree/worktree-janitor.service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"worktree-janitor.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/worktree/worktree-janitor.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -33,15 +33,23 @@
|
|
|
33
33
|
* dir's index / HEAD / logs/HEAD) more than 2 hours ago in a known agent
|
|
34
34
|
* location (`<main>/.claude/worktrees/`, `<tmp>/crewly-worktrees/`, or a
|
|
35
35
|
* `worktree-agent-*` branch), more than 24 hours ago anywhere else.
|
|
36
|
-
* 6.
|
|
37
|
-
* change and no untracked file
|
|
38
|
-
*
|
|
36
|
+
* 6. Nothing but regenerable output is uncommitted: `git status --porcelain`
|
|
37
|
+
* shows no tracked change and no untracked file, ignoring untracked
|
|
38
|
+
* `node_modules` / `.next` / `dist` / `build` / `.turbo` / `coverage` /
|
|
39
|
+
* `.cache` / `logs` directories and `*.log` files (nested packages rarely
|
|
40
|
+
* ignore their own). A failing status keeps it.
|
|
39
41
|
* 7. Its work has landed: HEAD is an ancestor of `origin/<default branch>`
|
|
40
42
|
* after a quiet `git fetch` (fetch errors ignored), OR — when `gh` is
|
|
41
43
|
* installed and authenticated — a MERGED PR has this branch as head AND
|
|
42
44
|
* its head commit equals the worktree HEAD (squash merges; commits made
|
|
43
45
|
* after the merge keep the worktree). A repo without an origin (not on
|
|
44
|
-
* GitHub) only has the ancestor check, so its worktrees are kept
|
|
46
|
+
* GitHub) only has the ancestor check, so its worktrees are kept — OR, for
|
|
47
|
+
* any git host (Bitbucket, GitLab, squash merges), its CONTENT is already in
|
|
48
|
+
* `origin/<default>`: merging HEAD into it changes nothing
|
|
49
|
+
* (`git merge-tree --write-tree`, falling back to `git cherry`), or the
|
|
50
|
+
* branch's combined diff is patch-equivalent to one commit the default
|
|
51
|
+
* branch gained since (a squash commit). See worktree-landed.ts. Work in
|
|
52
|
+
* none of these is never removed. `gh` is not asked about non-GitHub remotes.
|
|
45
53
|
*
|
|
46
54
|
* ## Removal
|
|
47
55
|
* `git worktree remove <path>` without `--force`. If git refuses although
|
|
@@ -49,6 +57,17 @@
|
|
|
49
57
|
* no submodules, it is retried once with `--force`. Then the local branch is
|
|
50
58
|
* deleted (never a protected name), and `git worktree prune` runs per repo.
|
|
51
59
|
*
|
|
60
|
+
* ## Low disk: kept worktrees
|
|
61
|
+
* Under low disk, a worktree kept as `dirty` or `not-merged` and idle for
|
|
62
|
+
* 24 h+ still loses its `node_modules` / `.next` directories (reversible:
|
|
63
|
+
* reinstall / rebuild); the verdict carries `reclaimed` and the summary
|
|
64
|
+
* `reclaimedBytes`.
|
|
65
|
+
*
|
|
66
|
+
* ## Work completion
|
|
67
|
+
* A WorkItem reaching done / verified calls `requestTidy()`: one debounced pass
|
|
68
|
+
* (2 min) with a 10 min idle threshold, so a finished worktree is removed as
|
|
69
|
+
* soon as its change has landed, not at the next 30-minute tick.
|
|
70
|
+
*
|
|
52
71
|
* ## Scratch sweep
|
|
53
72
|
* After the worktrees, stale Claude Code session dirs
|
|
54
73
|
* (`<tmp>/claude-<uid>/<slug>/<uuid>/`) are deleted when idle for 3 days,
|
|
@@ -64,6 +83,15 @@
|
|
|
64
83
|
* the janitor left and why; below 5 GB the notice is urgent and repeats at
|
|
65
84
|
* most every 6 h.
|
|
66
85
|
*
|
|
86
|
+
* ## Runaway files
|
|
87
|
+
* Once a minute a watcher scans agent scratch dirs and worktrees for a single
|
|
88
|
+
* file that grows without bound (a 40 GB `all.md` filled a disk, crewly#1078):
|
|
89
|
+
* a file over 5 GB that is still growing, or any file growing over 1 GB/min,
|
|
90
|
+
* has its writer stopped, is cut to its first 1 MB, the agent working there is
|
|
91
|
+
* told and the owner is alerted once through the low-disk notifier. A big file
|
|
92
|
+
* that is not growing is only reported. See
|
|
93
|
+
* {@link module:services/worktree/runaway-file-watcher}.
|
|
94
|
+
*
|
|
67
95
|
* Runs every 30 minutes (first run 10 minutes after boot). Kill switch:
|
|
68
96
|
* `CREWLY_WORKTREE_JANITOR=0` (also stops the low-disk guard). Never throws;
|
|
69
97
|
* logs one summary line per sweep.
|
|
@@ -80,8 +108,11 @@ import { safeReadJson, modifyJsonFile } from '../../utils/file-io.utils.js';
|
|
|
80
108
|
import { buildLowDiskNotice, diskLevel, idleThreshold, noticeToSend, readFreeBytes, recordNotice, } from './low-disk-guard.js';
|
|
81
109
|
import { defaultScratchRoots, sweepScratch } from './scratch-janitor.js';
|
|
82
110
|
import { reclaimDocker } from './docker-reclaim.js';
|
|
111
|
+
import { RunawayFileWatcher, runawayWatchRoots } from './runaway-file-watcher.js';
|
|
83
112
|
import { getTraceStore } from '../trace/trace-store.js';
|
|
84
113
|
import { canonicalPath, diskUsageBytes, discoverLinkedWorktreeRepos, formatBytes, isJanitorDisabled, isPathInside, latestWorktreeMtimeMs, listProcessCwds, parsePorcelainWorktrees, runCommand, } from './worktree-janitor.git.js';
|
|
114
|
+
import { contentLandedSignal, isNonGithubRemote } from './worktree-landed.js';
|
|
115
|
+
import { dirtyStatusLines, reclaimRegenerableDirs } from './worktree-dirty.js';
|
|
85
116
|
/** Milliseconds per minute (for the `recent` detail text). */
|
|
86
117
|
const MS_PER_MINUTE = 60 * 1000;
|
|
87
118
|
/**
|
|
@@ -110,6 +141,11 @@ export class WorktreeJanitorService {
|
|
|
110
141
|
lastPassAt = null;
|
|
111
142
|
memoryNoticeState = {};
|
|
112
143
|
notifier;
|
|
144
|
+
agentNotifier = null;
|
|
145
|
+
runawayTimer = null;
|
|
146
|
+
tidyTimer = null;
|
|
147
|
+
runawayWatcher = null;
|
|
148
|
+
runawayInFlight = false;
|
|
113
149
|
/**
|
|
114
150
|
* @param options - Injectable dependencies; defaults use the live backend
|
|
115
151
|
*/
|
|
@@ -126,6 +162,69 @@ export class WorktreeJanitorService {
|
|
|
126
162
|
setLowDiskNotifier(notifier) {
|
|
127
163
|
this.notifier = notifier;
|
|
128
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Set how an agent is told its working dir had a runaway file (the server
|
|
167
|
+
* wires the normal agent message delivery here).
|
|
168
|
+
*
|
|
169
|
+
* @param notifier - Sender (true when delivered), or null to skip agent notices
|
|
170
|
+
*/
|
|
171
|
+
setAgentNotifier(notifier) {
|
|
172
|
+
this.agentNotifier = notifier;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One runaway-file scan now (also what the minute timer runs). Never
|
|
176
|
+
* throws; overlapping scans are skipped. Honours the kill switch.
|
|
177
|
+
*
|
|
178
|
+
* @returns What was flagged and done (empty when nothing is wrong)
|
|
179
|
+
*/
|
|
180
|
+
async checkRunawayFiles() {
|
|
181
|
+
if (this.isDisabled() || this.runawayInFlight)
|
|
182
|
+
return [];
|
|
183
|
+
this.runawayInFlight = true;
|
|
184
|
+
try {
|
|
185
|
+
if (!this.runawayWatcher) {
|
|
186
|
+
this.runawayWatcher = new RunawayFileWatcher({
|
|
187
|
+
listRoots: async () => {
|
|
188
|
+
const repos = await (this.opts.listRepoPaths ?? defaultListRepoPaths)().catch(() => []);
|
|
189
|
+
return runawayWatchRoots(this.scratchRoots(), this.tmpRoots(), repos);
|
|
190
|
+
},
|
|
191
|
+
notifyOwner: async (notice) => (this.notifier ? this.notifier(notice) : false),
|
|
192
|
+
notifyAgents: (file, message) => this.notifyAgentsNear(file, message),
|
|
193
|
+
now: () => this.now(),
|
|
194
|
+
logger: this.logger,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return await this.runawayWatcher.scan();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
this.runawayInFlight = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Sessions whose cwd holds `file`, or sits in the same scratch session dir; told via the agent notifier. */
|
|
204
|
+
async notifyAgentsNear(file, message) {
|
|
205
|
+
const notifier = this.agentNotifier;
|
|
206
|
+
if (!notifier)
|
|
207
|
+
return [];
|
|
208
|
+
const told = [];
|
|
209
|
+
try {
|
|
210
|
+
const { getSessionBackendSync } = await import('../session/session-backend.factory.js');
|
|
211
|
+
const backend = getSessionBackendSync();
|
|
212
|
+
const unit = /^(.*[\\/]claude-\d+[\\/][^\\/]+[\\/][0-9a-f-]{36})(?:[\\/]|$)/i.exec(file)?.[1];
|
|
213
|
+
for (const name of backend?.listSessions() ?? []) {
|
|
214
|
+
const cwd = backend?.getSession(name)?.cwd;
|
|
215
|
+
if (!cwd)
|
|
216
|
+
continue;
|
|
217
|
+
if (isPathInside(file, cwd) || (unit && isPathInside(cwd, unit))) {
|
|
218
|
+
if (await notifier(name, message).catch(() => false))
|
|
219
|
+
told.push(name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// no session backend — the owner alert still goes out
|
|
225
|
+
}
|
|
226
|
+
return told;
|
|
227
|
+
}
|
|
129
228
|
/**
|
|
130
229
|
* Process-wide instance used by the server and the HTTP routes.
|
|
131
230
|
*
|
|
@@ -168,6 +267,8 @@ export class WorktreeJanitorService {
|
|
|
168
267
|
this.firstRunTimer.unref?.();
|
|
169
268
|
this.diskTimer = setInterval(() => void this.checkDisk(), WORKTREE_JANITOR_CONSTANTS.LOW_DISK_CHECK_INTERVAL_MS);
|
|
170
269
|
this.diskTimer.unref?.();
|
|
270
|
+
this.runawayTimer = setInterval(() => void this.checkRunawayFiles(), WORKTREE_JANITOR_CONSTANTS.RUNAWAY_CHECK_INTERVAL_MS);
|
|
271
|
+
this.runawayTimer.unref?.();
|
|
171
272
|
return true;
|
|
172
273
|
}
|
|
173
274
|
/** Cancel scheduled passes. */
|
|
@@ -178,6 +279,12 @@ export class WorktreeJanitorService {
|
|
|
178
279
|
clearInterval(this.intervalTimer);
|
|
179
280
|
if (this.diskTimer)
|
|
180
281
|
clearInterval(this.diskTimer);
|
|
282
|
+
if (this.runawayTimer)
|
|
283
|
+
clearInterval(this.runawayTimer);
|
|
284
|
+
if (this.tidyTimer)
|
|
285
|
+
clearTimeout(this.tidyTimer);
|
|
286
|
+
this.tidyTimer = null;
|
|
287
|
+
this.runawayTimer = null;
|
|
181
288
|
this.firstRunTimer = null;
|
|
182
289
|
this.intervalTimer = null;
|
|
183
290
|
this.diskTimer = null;
|
|
@@ -225,6 +332,41 @@ export class WorktreeJanitorService {
|
|
|
225
332
|
async plan() {
|
|
226
333
|
return this.pass(true);
|
|
227
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Work just finished (a WorkItem / ticket reached done or verified): run one
|
|
337
|
+
* pass soon, so a finished worktree goes away when its PR is merged instead
|
|
338
|
+
* of waiting for the next 30-minute tick. Bursts are debounced into one pass
|
|
339
|
+
* after COMPLETION_TIDY_DELAY_MS; the idle threshold is COMPLETION_MIN_IDLE_MS.
|
|
340
|
+
* Only worktrees that pass every safety rule are removed (landed, clean, nobody
|
|
341
|
+
* inside) — unmerged work stays for the next pass. No-op when disabled. Never throws.
|
|
342
|
+
*/
|
|
343
|
+
requestTidy() {
|
|
344
|
+
if (this.isDisabled() || this.tidyTimer)
|
|
345
|
+
return;
|
|
346
|
+
this.tidyTimer = setTimeout(() => {
|
|
347
|
+
this.tidyTimer = null;
|
|
348
|
+
void (async () => {
|
|
349
|
+
try {
|
|
350
|
+
if (this.inFlight)
|
|
351
|
+
await this.inFlight.catch(() => undefined);
|
|
352
|
+
if (this.isDisabled())
|
|
353
|
+
return;
|
|
354
|
+
this.inFlight = this.pass(false, WORKTREE_JANITOR_CONSTANTS.COMPLETION_MIN_IDLE_MS).finally(() => {
|
|
355
|
+
this.inFlight = null;
|
|
356
|
+
});
|
|
357
|
+
const summary = await this.inFlight;
|
|
358
|
+
if (!summary.disabled) {
|
|
359
|
+
this.lastSummary = summary;
|
|
360
|
+
this.lastPassAt = this.now();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
this.logger.warn('Completion tidy failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
}, this.opts.tidyDelayMs ?? WORKTREE_JANITOR_CONSTANTS.COMPLETION_TIDY_DELAY_MS);
|
|
368
|
+
this.tidyTimer.unref?.();
|
|
369
|
+
}
|
|
228
370
|
/**
|
|
229
371
|
* One real pass. Concurrent calls share the pass in flight. Never throws.
|
|
230
372
|
* Honours the kill switch.
|
|
@@ -379,7 +521,7 @@ export class WorktreeJanitorService {
|
|
|
379
521
|
git(args, options = {}) {
|
|
380
522
|
return runCommand(this.opts.gitBin ?? 'git', args, options);
|
|
381
523
|
}
|
|
382
|
-
async pass(dryRun) {
|
|
524
|
+
async pass(dryRun, idleOverrideMs) {
|
|
383
525
|
const started = Date.now();
|
|
384
526
|
const summary = {
|
|
385
527
|
startedAt: new Date(started).toISOString(),
|
|
@@ -390,6 +532,7 @@ export class WorktreeJanitorService {
|
|
|
390
532
|
removed: 0,
|
|
391
533
|
wouldRemove: 0,
|
|
392
534
|
kept: 0,
|
|
535
|
+
reclaimedBytes: 0,
|
|
393
536
|
keptReasons: {},
|
|
394
537
|
worktrees: [],
|
|
395
538
|
lowDisk: false,
|
|
@@ -411,7 +554,14 @@ export class WorktreeJanitorService {
|
|
|
411
554
|
for (const repo of repos) {
|
|
412
555
|
let removedHere = 0;
|
|
413
556
|
for (const entry of repo.entries) {
|
|
414
|
-
const verdict = await this.evaluate(repo, entry, busy, summary.lowDisk);
|
|
557
|
+
const verdict = await this.evaluate(repo, entry, busy, summary.lowDisk, idleOverrideMs);
|
|
558
|
+
if (summary.lowDisk && verdict.decision === 'keep' && (verdict.reason === 'dirty' || verdict.reason === 'not-merged')) {
|
|
559
|
+
const rec = await this.reclaimKept(entry.path, verdict.idleMs, dryRun);
|
|
560
|
+
if (rec) {
|
|
561
|
+
verdict.reclaimed = rec;
|
|
562
|
+
summary.reclaimedBytes += rec.freedBytes;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
415
565
|
const outcome = { ...verdict, removed: false, forced: false, branchDeleted: false };
|
|
416
566
|
if (verdict.decision === 'remove' && !dryRun) {
|
|
417
567
|
await this.remove(repo, outcome);
|
|
@@ -456,7 +606,7 @@ export class WorktreeJanitorService {
|
|
|
456
606
|
.sort((a, b) => b[1] - a[1])
|
|
457
607
|
.map(([k, v]) => `${k}: ${v}`)
|
|
458
608
|
.join(', ');
|
|
459
|
-
this.logger.info(`Worktree janitor: removed ${summary.removed}, kept ${summary.kept}${reasons ? ` (${reasons})` : ''} across ${summary.repos.length} repo(s) in ${summary.durationMs}ms`, { removed: summary.worktrees.filter((w) => w.removed).map((w) => w.path), lowDisk: summary.lowDisk });
|
|
609
|
+
this.logger.info(`Worktree janitor: removed ${summary.removed}, kept ${summary.kept}${reasons ? ` (${reasons})` : ''}${summary.reclaimedBytes > 0 ? `, reclaimed ${formatBytes(summary.reclaimedBytes)} of node_modules/.next from kept worktrees` : ''} across ${summary.repos.length} repo(s) in ${summary.durationMs}ms`, { removed: summary.worktrees.filter((w) => w.removed).map((w) => w.path), lowDisk: summary.lowDisk });
|
|
460
610
|
const sc = summary.scratch;
|
|
461
611
|
if (sc) {
|
|
462
612
|
const scReasons = Object.entries(sc.keptReasons)
|
|
@@ -496,7 +646,7 @@ export class WorktreeJanitorService {
|
|
|
496
646
|
const key = canonicalPath(main.path);
|
|
497
647
|
if (seen.has(key))
|
|
498
648
|
continue;
|
|
499
|
-
seen.set(key, { main: main.path, entries, fetched: false, defaultRef: undefined });
|
|
649
|
+
seen.set(key, { main: main.path, entries, fetched: false, defaultRef: undefined, landed: {} });
|
|
500
650
|
}
|
|
501
651
|
return [...seen.values()];
|
|
502
652
|
}
|
|
@@ -522,7 +672,7 @@ export class WorktreeJanitorService {
|
|
|
522
672
|
/**
|
|
523
673
|
* Apply the removal rules to one worktree (read-only apart from fetch).
|
|
524
674
|
*/
|
|
525
|
-
async evaluate(repo, entry, busy, lowDisk) {
|
|
675
|
+
async evaluate(repo, entry, busy, lowDisk, idleOverrideMs) {
|
|
526
676
|
const base = { repo: repo.main, path: entry.path, branch: entry.branch, head: entry.head };
|
|
527
677
|
const keep = (reason, detail, idleMs) => ({
|
|
528
678
|
...base,
|
|
@@ -555,14 +705,16 @@ export class WorktreeJanitorService {
|
|
|
555
705
|
const normalIdle = agentLocation
|
|
556
706
|
? (this.opts.minIdleMs ?? WORKTREE_JANITOR_CONSTANTS.MIN_IDLE_MS)
|
|
557
707
|
: (this.opts.minIdleOtherMs ?? WORKTREE_JANITOR_CONSTANTS.MIN_IDLE_OTHER_MS);
|
|
558
|
-
const minIdle = idleThreshold(normalIdle, lowDisk);
|
|
708
|
+
const minIdle = idleOverrideMs !== undefined ? Math.min(idleOverrideMs, idleThreshold(normalIdle, lowDisk)) : idleThreshold(normalIdle, lowDisk);
|
|
559
709
|
if (idleMs === undefined || idleMs < minIdle) {
|
|
560
710
|
return keep('recent', `needs ${Math.round(minIdle / MS_PER_MINUTE)} min idle (${agentLocation ? 'agent location' : 'other location'})`, idleMs);
|
|
561
711
|
}
|
|
562
|
-
|
|
712
|
+
// `normal` keeps untracked directories collapsed (one line per
|
|
713
|
+
// `node_modules/`), so a huge dependency tree cannot flood the output.
|
|
714
|
+
const status = await this.git(['-C', entry.path, 'status', '--porcelain', '--untracked-files=normal']);
|
|
563
715
|
if (status.code !== 0)
|
|
564
716
|
return keep('status-failed', status.stderr.trim().slice(0, 200), idleMs);
|
|
565
|
-
const dirtyLines = status.stdout
|
|
717
|
+
const dirtyLines = dirtyStatusLines(status.stdout);
|
|
566
718
|
if (dirtyLines.length > 0)
|
|
567
719
|
return keep('dirty', `${dirtyLines.length} changed/untracked path(s)`, idleMs);
|
|
568
720
|
if (!entry.head)
|
|
@@ -578,7 +730,31 @@ export class WorktreeJanitorService {
|
|
|
578
730
|
if (pr !== null)
|
|
579
731
|
return { ...base, decision: 'remove', reason: 'pr-merged', detail: `PR #${pr} merged`, idleMs };
|
|
580
732
|
}
|
|
581
|
-
|
|
733
|
+
if (defaultRef) {
|
|
734
|
+
// Host-agnostic: squash merges on Bitbucket / GitLab / anywhere leave no
|
|
735
|
+
// ancestry and no GitHub PR, but the CONTENT is in the default branch.
|
|
736
|
+
const signal = await contentLandedSignal((a, o) => this.git(a, o), repo.main, defaultRef, entry.head, repo.landed);
|
|
737
|
+
if (signal)
|
|
738
|
+
return { ...base, decision: 'remove', reason: 'content-landed', detail: `${signal} in ${defaultRef}`, idleMs };
|
|
739
|
+
}
|
|
740
|
+
return keep('not-merged', defaultRef ? `not in ${defaultRef} (history or content), no merged PR` : 'no origin default branch, no merged PR', idleMs);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Low disk: delete the regenerable dirs (node_modules, .next) of a kept
|
|
744
|
+
* worktree that has been idle for a day. Reversible; never touches
|
|
745
|
+
* tracked or source files. Never throws.
|
|
746
|
+
*/
|
|
747
|
+
async reclaimKept(worktreePath, idleMs, dryRun) {
|
|
748
|
+
if (idleMs === undefined || idleMs < WORKTREE_JANITOR_CONSTANTS.RECLAIM_MIN_IDLE_MS)
|
|
749
|
+
return null;
|
|
750
|
+
try {
|
|
751
|
+
const r = await reclaimRegenerableDirs(worktreePath, this.opts.sizeOf ?? diskUsageBytes, dryRun);
|
|
752
|
+
return r.dirs.length > 0 ? r : null;
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
this.logger.warn('Reclaiming regenerable dirs failed (non-fatal)', { path: worktreePath, error: err instanceof Error ? err.message : String(err) });
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
582
758
|
}
|
|
583
759
|
/** `origin/<default>` after one quiet fetch per repo per pass; null when there is no origin. */
|
|
584
760
|
async defaultRef(repo) {
|
|
@@ -628,6 +804,13 @@ export class WorktreeJanitorService {
|
|
|
628
804
|
const gh = this.opts.ghBin === undefined ? 'gh' : this.opts.ghBin;
|
|
629
805
|
if (!gh)
|
|
630
806
|
return null;
|
|
807
|
+
if (repo.nonGithub === undefined) {
|
|
808
|
+
const url = await this.git(['-C', repo.main, 'config', '--get', 'remote.origin.url']);
|
|
809
|
+
repo.nonGithub = url.code === 0 && isNonGithubRemote(url.stdout);
|
|
810
|
+
}
|
|
811
|
+
// Bitbucket / GitLab remotes: gh cannot see their PRs, and asking costs a call per worktree.
|
|
812
|
+
if (repo.nonGithub)
|
|
813
|
+
return null;
|
|
631
814
|
if (this.ghUsable === undefined) {
|
|
632
815
|
const auth = await runCommand(gh, ['auth', 'status'], { cwd: repo.main, timeoutMs: WORKTREE_JANITOR_CONSTANTS.GH_TIMEOUT_MS });
|
|
633
816
|
this.ghUsable = auth.code === 0;
|
|
@@ -654,8 +837,8 @@ export class WorktreeJanitorService {
|
|
|
654
837
|
if (r.code !== 0) {
|
|
655
838
|
// Force only when git's refusal can only be ignored output: status is
|
|
656
839
|
// still clean (untracked included) and there are no submodules.
|
|
657
|
-
const status = await this.git(['-C', outcome.path, 'status', '--porcelain', '--untracked-files=
|
|
658
|
-
const clean = status.code === 0 && status.stdout.
|
|
840
|
+
const status = await this.git(['-C', outcome.path, 'status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none']);
|
|
841
|
+
const clean = status.code === 0 && dirtyStatusLines(status.stdout).length === 0;
|
|
659
842
|
const hasSubmodules = fs.existsSync(path.join(outcome.path, '.gitmodules'));
|
|
660
843
|
if (clean && !hasSubmodules) {
|
|
661
844
|
r = await this.git(['-C', repo.main, 'worktree', 'remove', '--force', outcome.path]);
|