@vibelord/lanes 1.0.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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
"""lanes tmp scan|plan|apply|reap — what fills a container's RAM-backed /tmp, and removing the DEAD part of it.
|
|
2
|
+
|
|
3
|
+
lanes tmp plan [container…] [--budget S] [--fresh] [--all] table (largest first) + $STATE/tmpscan/<c>.plan.tsv
|
|
4
|
+
lanes tmp scan … the same table, no plan file
|
|
5
|
+
lanes tmp apply [container…] --approve delete the plan's STALE units (without: list them)
|
|
6
|
+
lanes tmp reap [container…] [--budget S] hourly timer: idle > 3 days, /tmp AND <home>/.tmp;
|
|
7
|
+
DRY-RUN until $STATE/flags/tmpreap.armed exists
|
|
8
|
+
|
|
9
|
+
Runs on the HOST, through /proc/<container-init-pid>/root/tmp (the path the viewers already use): no docker exec, no
|
|
10
|
+
du, no subprocess at all. tmpfs metadata is never swapped out, so an lstat walk stays cheap on a thrashing host; it
|
|
11
|
+
still runs at nice 19 under a wall-clock budget and RESUMES from $STATE/tmpscan/<container>.json on the next run.
|
|
12
|
+
|
|
13
|
+
UNITS are what gets judged and removed as a whole: every top-level entry of /tmp, except that claude-<uid>/ is split
|
|
14
|
+
into claude-<uid>/<project>/<session> (that one directory held 53 of the 95 GiB). A unit is KEEP when ANY of these
|
|
15
|
+
holds, and STALE only when none does:
|
|
16
|
+
* its name is sacred (tmux-*, .lz, X11/ICE, systemd-private-*, snap-private-tmp) — these are not even walked;
|
|
17
|
+
* a process of the container uses it: cwd/root/exe/any fd (by path AND by st_dev+st_ino, so another path to the
|
|
18
|
+
same file counts) or a mapped file;
|
|
19
|
+
* it contains a socket, fifo or device;
|
|
20
|
+
* anything in it belongs to another uid than the container user;
|
|
21
|
+
* its newest mtime/ctime is younger than 24 h (reap: 3 days) — ctime too: a just-unpacked tarball has old mtimes;
|
|
22
|
+
* it is a Claude session whose transcript ~/.claude/projects/*/<session>.jsonl was written in the last 48 h, or
|
|
23
|
+
whose transcripts cannot be looked up at all;
|
|
24
|
+
* it is a Claude session that a LIVE process may own: a process that is not a lane fixture (shell, lane-run, tmux)
|
|
25
|
+
has the cwd <project> is named after, and the session was made or written to after that process started. An
|
|
26
|
+
idle agent holds no cwd and no fd under its session; this is the one rule that sees it. Not seen: a session
|
|
27
|
+
resumed under its old id by an agent that has not been given a prompt since;
|
|
28
|
+
* the scan of it was cut short, failed, or met a mount point.
|
|
29
|
+
|
|
30
|
+
Deleting (apply, armed reap) re-judges every unit IMMEDIATELY before removing it — fresh walk, process references at
|
|
31
|
+
most 5 s old — and removes it through directory fds only (openat O_NOFOLLOW, unlinkat): never by path, never
|
|
32
|
+
through a symlink, never across a mount, never a special file. Every outcome is appended to
|
|
33
|
+
$STATE/tmpscan/deleted.log. apply refuses a plan older than 6 h or made for another container init pid.
|
|
34
|
+
Known blind spot, reported on every run: fds of processes of ANOTHER uid cannot be read without privilege.
|
|
35
|
+
"""
|
|
36
|
+
import collections
|
|
37
|
+
import fnmatch
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import signal
|
|
42
|
+
import stat
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import guard as G
|
|
48
|
+
import lanes_lib as L
|
|
49
|
+
|
|
50
|
+
BUDGET = 120 # s of wall clock per run and container
|
|
51
|
+
PLAN_MAX_AGE = 6 * 3600 # apply refuses an older plan; an unfinished scan older than this starts over
|
|
52
|
+
FRESH = 24 * 3600 # plan/apply: changed more recently than this => KEEP
|
|
53
|
+
REAP_IDLE = 3 * 86400 # reap: the same rule, stricter
|
|
54
|
+
TRANSCRIPT_FRESH = 48 * 3600
|
|
55
|
+
# Directories that DECLARE themselves regenerable (bford.info/cachedir: cargo `target`, …). Measured 2026-09-19: agents
|
|
56
|
+
# build inside their Claude scratchpads, and 26 of the 95 GiB of RAM-backed /tmp were idle cargo targets inside LIVE
|
|
57
|
+
# sessions — units the unit rules rightly KEEP. A cache nobody wrote to or holds open for this long may go anyway.
|
|
58
|
+
CACHE_TAG = b'Signature: 8a477f597d28d172789f06886806bc55'
|
|
59
|
+
CACHE_IDLE = 6 * 3600
|
|
60
|
+
REFS_MAX_AGE = 5 # s a process-reference pass may be reused for while deleting
|
|
61
|
+
MAX_DEPTH = 100 # one directory fd per level; deeper trees are KEEP, not an fd exhaustion
|
|
62
|
+
SACRED = ('tmux-*', '.lz', '.X11-unix', '.ICE-unix', 'systemd-private-*', 'snap-private-tmp')
|
|
63
|
+
CLAUDE_DIR = re.compile(r'^claude-\d+$')
|
|
64
|
+
KEEP, STALE = 'KEEP', 'STALE'
|
|
65
|
+
TICKS = os.sysconf('SC_CLK_TCK')
|
|
66
|
+
START_SLACK = 300 # s: btime moves with clock steps, and a session directory is made AFTER its agent started
|
|
67
|
+
# What sits in a project directory for the life of a LANE, not of an agent. Counting these would date every session
|
|
68
|
+
# since the lane was made as "may be theirs". An exclusion list on purpose: a comm it does not know (the agent's is
|
|
69
|
+
# `claude`, `node` or a version number) KEEPs more, a comm missing from an inclusion list would delete more.
|
|
70
|
+
LANE_FIXTURES = frozenset(['bash', 'zsh', 'sh', 'dash', 'fish', 'lane-run', 'tmux', 'tmux: server', 'zellij'])
|
|
71
|
+
|
|
72
|
+
# key: names the root in file names and logs · host: where THIS process opens it · view: the same directory as the
|
|
73
|
+
# container's processes spell it (what /proc/<pid>/cwd and the fd links contain).
|
|
74
|
+
Root = collections.namedtuple('Root', 'key host view')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ names
|
|
78
|
+
|
|
79
|
+
def _sacred(unit):
|
|
80
|
+
return any(fnmatch.fnmatchcase(unit.split('/')[0], pattern) for pattern in SACRED)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _safe_name(unit):
|
|
84
|
+
"""A unit we are willing to write into a line-oriented plan and to open again from it."""
|
|
85
|
+
parts = unit.split('/')
|
|
86
|
+
# isprintable: no tab/newline (the plan and the log are line-oriented) and no undecodable byte (surrogate).
|
|
87
|
+
return (all(part not in ('', '.', '..') for part in parts) and unit.isprintable()
|
|
88
|
+
and (len(parts) == 1 or (len(parts) <= 3 and bool(CLAUDE_DIR.match(parts[0])))))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _unit_of(rel):
|
|
92
|
+
"""The unit a path below the root belongs to (or, for claude-<uid>[/<project>], the prefix of many units)."""
|
|
93
|
+
parts = rel.split('/')
|
|
94
|
+
return '/'.join(parts[:3]) if CLAUDE_DIR.match(parts[0]) else parts[0]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _holds_units(unit):
|
|
98
|
+
"""claude-<uid> and claude-<uid>/<project> are not units but HOLD them. list_units never yields one as a
|
|
99
|
+
directory — a hand-edited plan could, and removing it would skip every per-session check below it."""
|
|
100
|
+
parts = unit.split('/')
|
|
101
|
+
return len(parts) < 3 and bool(CLAUDE_DIR.match(parts[0]))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _session(unit):
|
|
105
|
+
parts = unit.split('/')
|
|
106
|
+
return parts[2] if len(parts) == 3 and CLAUDE_DIR.match(parts[0]) else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _project_name(cwd):
|
|
110
|
+
"""claude-<uid>/<project>, like ~/.claude/projects/<project>, is the agent's cwd with every character that is
|
|
111
|
+
not a letter or a digit turned into '-' (checked: '/', '.' and '_' all are)."""
|
|
112
|
+
return re.sub(r'[^A-Za-z0-9]', '-', cwd)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ directory fds
|
|
116
|
+
|
|
117
|
+
def _open_dir(name, dir_fd=None):
|
|
118
|
+
"""A directory, and only a real one: O_NOFOLLOW turns a symlink planted in its place into an error."""
|
|
119
|
+
return os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0), dir_fd=dir_fd)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _open_parent(root_fd, unit):
|
|
123
|
+
"""fd of the directory that holds the unit's last component; the caller closes it."""
|
|
124
|
+
fd = os.dup(root_fd)
|
|
125
|
+
for part in unit.split('/')[:-1]:
|
|
126
|
+
try:
|
|
127
|
+
below = _open_dir(part, fd)
|
|
128
|
+
finally:
|
|
129
|
+
os.close(fd)
|
|
130
|
+
fd = below
|
|
131
|
+
return fd
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _names(fd):
|
|
135
|
+
"""[(name, is_dir)], lstat semantics: a symlink to a directory is NOT a directory."""
|
|
136
|
+
with os.scandir(fd) as entries:
|
|
137
|
+
return sorted((entry.name, entry.is_dir(follow_symlinks=False)) for entry in entries)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_units(root_fd):
|
|
141
|
+
units = []
|
|
142
|
+
|
|
143
|
+
def split(parent_fd, prefix, name, levels):
|
|
144
|
+
try:
|
|
145
|
+
fd = _open_dir(name, parent_fd)
|
|
146
|
+
except OSError:
|
|
147
|
+
units.append(prefix + name) # unreadable: one unit, which the scan fails on => KEEP
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
for child, is_dir in _names(fd):
|
|
151
|
+
if is_dir and levels > 1:
|
|
152
|
+
split(fd, f'{prefix}{name}/', child, levels - 1)
|
|
153
|
+
else:
|
|
154
|
+
units.append(f'{prefix}{name}/{child}')
|
|
155
|
+
finally:
|
|
156
|
+
os.close(fd)
|
|
157
|
+
|
|
158
|
+
for name, is_dir in _names(root_fd):
|
|
159
|
+
if is_dir and CLAUDE_DIR.match(name):
|
|
160
|
+
split(root_fd, '', name, 2)
|
|
161
|
+
else:
|
|
162
|
+
units.append(name)
|
|
163
|
+
return units
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ scan
|
|
167
|
+
|
|
168
|
+
class _Cut(Exception):
|
|
169
|
+
"""The wall-clock budget ran out in the middle of a unit."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Acc:
|
|
173
|
+
def __init__(self, dev, inodes, deadline, clock):
|
|
174
|
+
self.dev, self.inodes, self.deadline, self.clock = dev, inodes or {}, deadline, clock
|
|
175
|
+
self.bytes = self.files = self.special = self.seen = 0
|
|
176
|
+
self.newest = 0.0
|
|
177
|
+
self.uids = set()
|
|
178
|
+
self.live = None
|
|
179
|
+
|
|
180
|
+
def tick(self):
|
|
181
|
+
if self.deadline is not None and self.clock() > self.deadline:
|
|
182
|
+
raise _Cut()
|
|
183
|
+
|
|
184
|
+
def add(self, st):
|
|
185
|
+
self.seen += 1
|
|
186
|
+
if not self.seen % 2048:
|
|
187
|
+
self.tick()
|
|
188
|
+
self.bytes += st.st_blocks * 512 # allocated, not apparent: what the tmpfs really holds
|
|
189
|
+
self.files += not stat.S_ISDIR(st.st_mode)
|
|
190
|
+
self.newest = max(self.newest, st.st_mtime, st.st_ctime)
|
|
191
|
+
self.uids.add(st.st_uid)
|
|
192
|
+
if not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)):
|
|
193
|
+
self.special += 1
|
|
194
|
+
if self.live is None and st.st_ino in self.inodes:
|
|
195
|
+
self.live = self.inodes[st.st_ino]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _walk(parent_fd, name, acc, depth):
|
|
199
|
+
fd = _open_dir(name, parent_fd)
|
|
200
|
+
try:
|
|
201
|
+
if os.fstat(fd).st_dev != acc.dev:
|
|
202
|
+
raise L.LanesError('mount point inside')
|
|
203
|
+
acc.tick()
|
|
204
|
+
below = []
|
|
205
|
+
with os.scandir(fd) as entries: # entry.stat() is fstatat(fd, name, NOFOLLOW): fd must stay open
|
|
206
|
+
for entry in entries:
|
|
207
|
+
acc.add(entry.stat(follow_symlinks=False))
|
|
208
|
+
if entry.is_dir(follow_symlinks=False):
|
|
209
|
+
below.append(entry.name)
|
|
210
|
+
# Recurse only after the iterator is closed: one fd per level, not two.
|
|
211
|
+
if below and depth >= MAX_DEPTH:
|
|
212
|
+
raise L.LanesError(f'deeper than {MAX_DEPTH} directories')
|
|
213
|
+
for child in below:
|
|
214
|
+
_walk(fd, child, acc, depth + 1)
|
|
215
|
+
finally:
|
|
216
|
+
os.close(fd)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def scan_unit(root_fd, unit, dev, inodes=None, deadline=None, clock=time.monotonic):
|
|
220
|
+
"""Walk one unit. Never raises: what could not be established becomes rec['error'] / rec['partial'] (=> KEEP)."""
|
|
221
|
+
acc = _Acc(dev, inodes, deadline, clock)
|
|
222
|
+
rec = {}
|
|
223
|
+
try:
|
|
224
|
+
parent = _open_parent(root_fd, unit)
|
|
225
|
+
try:
|
|
226
|
+
leaf = unit.split('/')[-1]
|
|
227
|
+
st = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
|
|
228
|
+
rec['ino'], rec['dir'] = st.st_ino, stat.S_ISDIR(st.st_mode)
|
|
229
|
+
acc.add(st)
|
|
230
|
+
if st.st_dev != dev:
|
|
231
|
+
raise L.LanesError('mount point')
|
|
232
|
+
if stat.S_ISDIR(st.st_mode):
|
|
233
|
+
_walk(parent, leaf, acc, 1)
|
|
234
|
+
finally:
|
|
235
|
+
os.close(parent)
|
|
236
|
+
except _Cut:
|
|
237
|
+
rec['partial'] = True
|
|
238
|
+
except L.LanesError as error:
|
|
239
|
+
rec['error'] = str(error)
|
|
240
|
+
except OSError as error:
|
|
241
|
+
rec['error'] = error.strerror or str(error)
|
|
242
|
+
rec.update(bytes=acc.bytes, files=acc.files, newest=acc.newest, special=acc.special, uids=sorted(acc.uids))
|
|
243
|
+
if acc.live:
|
|
244
|
+
rec['live'] = acc.live
|
|
245
|
+
return rec
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------ who uses what
|
|
249
|
+
|
|
250
|
+
def _boot_time(env):
|
|
251
|
+
try:
|
|
252
|
+
with open(f'{env.proc}/stat') as handle:
|
|
253
|
+
return next((int(line.split()[1]) for line in handle if line.startswith('btime ')), 0)
|
|
254
|
+
except (OSError, ValueError, IndexError):
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _started(env, pid, btime):
|
|
259
|
+
"""(comm, wall-clock start) from /proc/<pid>/stat: field 22 is the start in clock ticks since boot. What cannot
|
|
260
|
+
be read is start 0.0 — "older than every session" — so that the doubt KEEPs more, never less."""
|
|
261
|
+
try:
|
|
262
|
+
with open(f'{env.proc}/{pid}/stat') as handle:
|
|
263
|
+
text = handle.read()
|
|
264
|
+
comm, fields = text[text.index('(') + 1:text.rindex(')')], text[text.rindex(')') + 1:].split()
|
|
265
|
+
return comm, (btime + int(fields[19]) / TICKS) if btime else 0.0
|
|
266
|
+
except (OSError, ValueError, IndexError):
|
|
267
|
+
return '', 0.0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def live_refs(env, pids, view, dev):
|
|
271
|
+
"""One pass over the container's processes. paths: {unit or unit prefix: why}; inodes: {st_ino on `dev`: why};
|
|
272
|
+
projects: {a cwd as Claude spells a project: (earliest start of a process working there, why)}."""
|
|
273
|
+
paths, inodes, projects, unreadable = {}, {}, {}, 0
|
|
274
|
+
prefix = view.rstrip('/') + '/'
|
|
275
|
+
btime = _boot_time(env)
|
|
276
|
+
|
|
277
|
+
def by_path(text, why):
|
|
278
|
+
if text.endswith(' (deleted)'):
|
|
279
|
+
text = text[:-10]
|
|
280
|
+
if text.startswith(prefix) and text[len(prefix):].strip('/'):
|
|
281
|
+
paths.setdefault(_unit_of(text[len(prefix):].strip('/')), why)
|
|
282
|
+
|
|
283
|
+
def link(path, why):
|
|
284
|
+
try:
|
|
285
|
+
text = os.readlink(path)
|
|
286
|
+
except OSError:
|
|
287
|
+
return None
|
|
288
|
+
if not text.startswith('/'):
|
|
289
|
+
return None # socket:[…], pipe:[…], anon_inode:…
|
|
290
|
+
by_path(text, why)
|
|
291
|
+
if text.startswith(('/dev/', '/proc/', '/sys/')):
|
|
292
|
+
return text
|
|
293
|
+
try:
|
|
294
|
+
st = os.stat(path) # through the magic link: the file itself, whatever its path
|
|
295
|
+
except OSError:
|
|
296
|
+
return text
|
|
297
|
+
if st.st_dev == dev:
|
|
298
|
+
inodes.setdefault(st.st_ino, why)
|
|
299
|
+
return text
|
|
300
|
+
|
|
301
|
+
for pid in pids:
|
|
302
|
+
base = f'{env.proc}/{pid}'
|
|
303
|
+
for kind in ('cwd', 'root', 'exe'):
|
|
304
|
+
text = link(f'{base}/{kind}', f'pid {pid} {kind}')
|
|
305
|
+
if kind == 'cwd' and text:
|
|
306
|
+
# An agent holds NOTHING under its session directory while it idles — no cwd, no fd (checked) —
|
|
307
|
+
# but it keeps the cwd its project is named after.
|
|
308
|
+
comm, start = _started(env, pid, btime)
|
|
309
|
+
project = _project_name(text[:-10] if text.endswith(' (deleted)') else text)
|
|
310
|
+
if comm not in LANE_FIXTURES and (project not in projects or start < projects[project][0]):
|
|
311
|
+
projects[project] = (start, f'pid {pid} ({comm or "?"})')
|
|
312
|
+
try:
|
|
313
|
+
for fd in os.listdir(f'{base}/fd'):
|
|
314
|
+
link(f'{base}/fd/{fd}', f'pid {pid} fd {fd}')
|
|
315
|
+
except PermissionError:
|
|
316
|
+
unreadable += 1 # another uid: invisible without privilege — reported, not hidden
|
|
317
|
+
except OSError:
|
|
318
|
+
continue # it exited
|
|
319
|
+
try:
|
|
320
|
+
with open(f'{base}/maps') as handle:
|
|
321
|
+
for line in handle:
|
|
322
|
+
fields = line.rstrip('\n').split(None, 5)
|
|
323
|
+
if len(fields) == 6 and fields[5].startswith('/'):
|
|
324
|
+
by_path(fields[5], f'pid {pid} map')
|
|
325
|
+
except OSError:
|
|
326
|
+
pass
|
|
327
|
+
return {'paths': paths, 'inodes': inodes, 'projects': projects, 'pids': len(pids), 'unreadable': unreadable,
|
|
328
|
+
'at': env.clock()}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def transcripts(home, sessions):
|
|
332
|
+
"""{session: [transcript paths on the home volume]}; None = could not look (=> Claude sessions are KEEP). A home
|
|
333
|
+
without .claude/projects is "could not look" too: sessions exist, so their transcripts are kept somewhere else
|
|
334
|
+
(CLAUDE_CONFIG_DIR) and the 48 h rule would silently never apply."""
|
|
335
|
+
if not home or not os.path.isdir(home): # unknown, or a volume path that is gone: NOT "no transcripts"
|
|
336
|
+
return None
|
|
337
|
+
found = {}
|
|
338
|
+
try:
|
|
339
|
+
with os.scandir(os.path.join(home, '.claude', 'projects')) as projects:
|
|
340
|
+
for project in projects:
|
|
341
|
+
if not project.is_dir(follow_symlinks=False):
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
with os.scandir(project.path) as files:
|
|
345
|
+
for entry in files:
|
|
346
|
+
if entry.name.endswith('.jsonl') and entry.name[:-6] in sessions:
|
|
347
|
+
found.setdefault(entry.name[:-6], []).append(entry.path)
|
|
348
|
+
except OSError:
|
|
349
|
+
return None
|
|
350
|
+
except OSError:
|
|
351
|
+
return None
|
|
352
|
+
return found
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _ago(seconds):
|
|
356
|
+
seconds = max(0, int(seconds))
|
|
357
|
+
return f'{seconds // 86400}d' if seconds >= 2 * 86400 else f'{seconds // 3600}h' if seconds >= 3600 \
|
|
358
|
+
else f'{seconds // 60}m'
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def classify(unit, rec, live, scripts, now, idle=FRESH, uid=L.CONTAINER_UID):
|
|
362
|
+
"""(KEEP|STALE, reason). Every doubt is a KEEP; STALE is what is left when nothing speaks for the unit."""
|
|
363
|
+
if _sacred(unit):
|
|
364
|
+
return KEEP, 'sacred name'
|
|
365
|
+
if not _safe_name(unit):
|
|
366
|
+
return KEEP, 'unsafe name'
|
|
367
|
+
if rec.get('error'):
|
|
368
|
+
return KEEP, f"scan failed: {rec['error']}"
|
|
369
|
+
if rec.get('partial'):
|
|
370
|
+
return KEEP, 'scan cut by the budget (plan again with --fresh and a larger --budget)'
|
|
371
|
+
if rec.get('dir') and _holds_units(unit):
|
|
372
|
+
return KEEP, 'holds units, is not one'
|
|
373
|
+
parts = unit.split('/')
|
|
374
|
+
for depth in range(1, len(parts) + 1):
|
|
375
|
+
why = live['paths'].get('/'.join(parts[:depth]))
|
|
376
|
+
if why:
|
|
377
|
+
return KEEP, f'in use: {why}'
|
|
378
|
+
why = rec.get('live') or live['inodes'].get(rec.get('ino'))
|
|
379
|
+
if why:
|
|
380
|
+
return KEEP, f'in use: {why}'
|
|
381
|
+
if rec.get('special'):
|
|
382
|
+
return KEEP, f"holds {rec['special']} socket/fifo/device"
|
|
383
|
+
foreign = [owner for owner in rec.get('uids', []) if owner != uid]
|
|
384
|
+
if foreign:
|
|
385
|
+
return KEEP, 'owned by uid ' + ','.join(map(str, foreign))
|
|
386
|
+
if now - rec.get('newest', now) < idle:
|
|
387
|
+
return KEEP, f"changed {_ago(now - rec.get('newest', now))} ago"
|
|
388
|
+
session = _session(unit)
|
|
389
|
+
if session:
|
|
390
|
+
if scripts is None:
|
|
391
|
+
return KEEP, 'Claude session, transcripts unreadable'
|
|
392
|
+
written = 0
|
|
393
|
+
for path in scripts.get(session, []):
|
|
394
|
+
try:
|
|
395
|
+
written = max(written, os.lstat(path).st_mtime)
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
if now - written < TRANSCRIPT_FRESH:
|
|
399
|
+
return KEEP, f'Claude transcript written {_ago(now - written)} ago'
|
|
400
|
+
# An agent that idles over a weekend fails every rule above while it is alive. Its session can only be one
|
|
401
|
+
# that was made or written to AFTER it started: that bound is what keeps a lane that has lived for months
|
|
402
|
+
# from protecting every session its project ever had.
|
|
403
|
+
start, why = live.get('projects', {}).get(parts[1], (None, None))
|
|
404
|
+
if why and max(rec.get('newest', 0), written) >= start - START_SLACK:
|
|
405
|
+
return KEEP, f'may belong to {why}, alive in this project' + (f' for {_ago(now - start)}' if start else '')
|
|
406
|
+
if not written:
|
|
407
|
+
return STALE, f"idle {_ago(now - rec['newest'])}, no transcript found"
|
|
408
|
+
return STALE, f"idle {_ago(now - rec['newest'])}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------ delete
|
|
412
|
+
|
|
413
|
+
def _remove(parent_fd, name, dev, depth, holds_units=False):
|
|
414
|
+
st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
415
|
+
if holds_units and stat.S_ISDIR(st.st_mode):
|
|
416
|
+
raise L.LanesError(f'{name} holds units, it is not one')
|
|
417
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
418
|
+
if not (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)):
|
|
419
|
+
raise L.LanesError(f'special file appeared: {name}')
|
|
420
|
+
os.unlink(name, dir_fd=parent_fd) # unlinkat: a symlink goes, its target is never looked at
|
|
421
|
+
return
|
|
422
|
+
if depth > MAX_DEPTH:
|
|
423
|
+
raise L.LanesError(f'deeper than {MAX_DEPTH} directories')
|
|
424
|
+
fd = _open_dir(name, parent_fd)
|
|
425
|
+
try:
|
|
426
|
+
opened = os.fstat(fd)
|
|
427
|
+
if opened.st_dev != dev or opened.st_ino != st.st_ino:
|
|
428
|
+
raise L.LanesError(f'not the directory that was judged (mount or swap): {name}')
|
|
429
|
+
with os.scandir(fd) as entries:
|
|
430
|
+
children = [entry.name for entry in entries]
|
|
431
|
+
for child in children:
|
|
432
|
+
_remove(fd, child, dev, depth + 1)
|
|
433
|
+
finally:
|
|
434
|
+
os.close(fd)
|
|
435
|
+
os.rmdir(name, dir_fd=parent_fd)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def delete_unit(root_fd, unit, dev):
|
|
439
|
+
"""Remove one unit bottom-up through directory fds. The last line of defence repeats the name rules."""
|
|
440
|
+
if _sacred(unit) or not _safe_name(unit):
|
|
441
|
+
raise L.LanesError(f'refusing to delete {unit!r}')
|
|
442
|
+
parent = _open_parent(root_fd, unit)
|
|
443
|
+
try:
|
|
444
|
+
_remove(parent, unit.split('/')[-1], dev, 1, _holds_units(unit))
|
|
445
|
+
finally:
|
|
446
|
+
os.close(parent)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _log(env, container, root, outcome, unit, rec, reason):
|
|
450
|
+
path = env.state / 'tmpscan' / 'deleted.log'
|
|
451
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
stamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(env.now()))
|
|
453
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
454
|
+
with os.fdopen(fd, 'a') as handle:
|
|
455
|
+
handle.write(f"{stamp} {container} {root.key} {outcome} bytes={rec.get('bytes', 0)} files={rec.get('files', 0)}"
|
|
456
|
+
f' unit={unit} :: {reason}\n')
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _sweep(env, container, pid, root, root_fd, dev, units, idle, home, delete, deadline=None):
|
|
460
|
+
"""Judge each unit AGAIN, right now, and remove it only if it is still STALE."""
|
|
461
|
+
scripts = transcripts(home, {_session(unit) for unit in units} - {None})
|
|
462
|
+
refs, done = None, {'deleted': 0, 'bytes': 0, 'kept': 0, 'failed': 0}
|
|
463
|
+
for unit in units:
|
|
464
|
+
if deadline is not None and env.clock() > deadline:
|
|
465
|
+
print(f" budget used up: {len(units) - done['deleted'] - done['kept'] - done['failed']} units left for the next run")
|
|
466
|
+
break
|
|
467
|
+
if refs is None or env.clock() - refs['at'] > REFS_MAX_AGE:
|
|
468
|
+
# cgroup_pids also re-proves that `pid` is still that docker container: a restart aborts the sweep.
|
|
469
|
+
refs = live_refs(env, G.cgroup_pids(env, pid), root.view, dev)
|
|
470
|
+
rec = scan_unit(root_fd, unit, dev, refs['inodes'])
|
|
471
|
+
verdict, reason = classify(unit, rec, refs, scripts, env.now(), idle, env.uid)
|
|
472
|
+
if verdict != STALE:
|
|
473
|
+
done['kept'] += 1
|
|
474
|
+
print(f' kept {unit}: {reason}')
|
|
475
|
+
if delete:
|
|
476
|
+
_log(env, container, root, 'kept', unit, rec, reason)
|
|
477
|
+
continue
|
|
478
|
+
if not delete:
|
|
479
|
+
done['deleted'] += 1
|
|
480
|
+
done['bytes'] += rec['bytes']
|
|
481
|
+
print(f" would delete {_size(rec['bytes']):>10} {unit} ({reason})")
|
|
482
|
+
continue
|
|
483
|
+
try:
|
|
484
|
+
delete_unit(root_fd, unit, dev)
|
|
485
|
+
except (OSError, L.LanesError) as error:
|
|
486
|
+
done['failed'] += 1
|
|
487
|
+
print(f' FAILED {unit}: {error}')
|
|
488
|
+
_log(env, container, root, 'failed', unit, rec, str(error))
|
|
489
|
+
continue
|
|
490
|
+
done['deleted'] += 1
|
|
491
|
+
done['bytes'] += rec['bytes']
|
|
492
|
+
print(f" deleted {_size(rec['bytes']):>10} {unit}")
|
|
493
|
+
_log(env, container, root, 'deleted', unit, rec, reason)
|
|
494
|
+
return done
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ------------------------------------------------------------------ resumable scan
|
|
498
|
+
|
|
499
|
+
def _state_file(env, container, suffix):
|
|
500
|
+
return env.state / 'tmpscan' / f'{container}.{suffix}'
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def scan(env, pid, root, root_fd, dev, path, deadline, inodes, fresh=False):
|
|
504
|
+
"""Scan the units the manifest at `path` does not hold yet, until the deadline. Returns the manifest."""
|
|
505
|
+
manifest = L.read_json(path) or {}
|
|
506
|
+
if (fresh or manifest.get('version') != 1 or not isinstance(manifest.get('units'), dict)
|
|
507
|
+
or manifest.get('pid') != pid or manifest.get('dev') != dev
|
|
508
|
+
or manifest.get('root') != root.key or manifest.get('complete')
|
|
509
|
+
or env.now() - manifest.get('started_at', 0) > PLAN_MAX_AGE):
|
|
510
|
+
manifest = {'version': 1, 'pid': pid, 'dev': dev, 'root': root.key, 'started_at': env.now(), 'units': {}}
|
|
511
|
+
units = list_units(root_fd)
|
|
512
|
+
present = set(units)
|
|
513
|
+
manifest['units'] = {unit: rec for unit, rec in manifest['units'].items() if unit in present}
|
|
514
|
+
manifest['resumed'] = len(manifest['units'])
|
|
515
|
+
scanned = 0
|
|
516
|
+
try:
|
|
517
|
+
for unit in units:
|
|
518
|
+
if unit in manifest['units']:
|
|
519
|
+
continue
|
|
520
|
+
if _sacred(unit):
|
|
521
|
+
manifest['units'][unit] = {'bytes': 0, 'files': 0, 'newest': 0, 'special': 0, 'uids': []}
|
|
522
|
+
continue
|
|
523
|
+
rec = scan_unit(root_fd, unit, dev, inodes, deadline, env.clock)
|
|
524
|
+
# The unit the deadline fell into is retried by the next run — unless it was this run's first: then it
|
|
525
|
+
# alone is larger than a whole budget, and recording it as cut (=> KEEP) is what lets the scan move on.
|
|
526
|
+
if rec.get('partial') and scanned:
|
|
527
|
+
break
|
|
528
|
+
manifest['units'][unit] = rec
|
|
529
|
+
scanned += 1
|
|
530
|
+
if rec.get('partial'):
|
|
531
|
+
break
|
|
532
|
+
finally:
|
|
533
|
+
manifest['complete'] = len(manifest['units']) == len(units)
|
|
534
|
+
manifest['left'] = len(units) - len(manifest['units'])
|
|
535
|
+
L.atomic_write(path, json.dumps(manifest) + '\n')
|
|
536
|
+
return manifest
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ------------------------------------------------------------------ plan file
|
|
540
|
+
|
|
541
|
+
def write_plan(path, container, pid, root, now, complete, rows):
|
|
542
|
+
lines = [f'# lanes tmp plan v1\tcontainer={container}\tpid={pid}\troot={root.key}\tat={int(now)}\tcomplete={int(complete)}',
|
|
543
|
+
'# verdict\tbytes\tfiles\tnewest\tunit\treason (apply deletes STALE rows only; delete a row to spare it)']
|
|
544
|
+
for unit, rec, verdict, reason in rows:
|
|
545
|
+
shown = unit if _safe_name(unit) else repr(unit) # never STALE, see classify: only ever read by a human
|
|
546
|
+
lines.append(f"{verdict}\t{rec['bytes']}\t{rec['files']}\t{int(rec['newest'])}\t{shown}\t{reason}")
|
|
547
|
+
L.atomic_write(path, '\n'.join(lines) + '\n')
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def read_plan(path):
|
|
551
|
+
try:
|
|
552
|
+
lines = Path(path).read_text().split('\n')
|
|
553
|
+
except OSError:
|
|
554
|
+
raise L.LanesError(f'no plan at {path}: run `lanes tmp plan` first')
|
|
555
|
+
if not lines[0].startswith('# lanes tmp plan v1\t'):
|
|
556
|
+
raise L.LanesError(f'{path} is not a lanes tmp plan')
|
|
557
|
+
header = dict(field.split('=', 1) for field in lines[0].split('\t')[1:] if '=' in field)
|
|
558
|
+
units = [fields[4] for fields in (line.split('\t') for line in lines[1:]) if len(fields) >= 5 and fields[0] == STALE]
|
|
559
|
+
return header, units
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# ------------------------------------------------------------------ commands
|
|
563
|
+
|
|
564
|
+
def _size(count):
|
|
565
|
+
for unit in ('B', 'KiB', 'MiB', 'GiB'):
|
|
566
|
+
if count < 1024:
|
|
567
|
+
return f'{count:.0f} {unit}' if unit == 'B' else f'{count:.1f} {unit}'
|
|
568
|
+
count /= 1024
|
|
569
|
+
return f'{count:.1f} TiB'
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _home(env, container):
|
|
573
|
+
"""Host path of the container's /home/coder volume, as sync recorded it."""
|
|
574
|
+
for source in (L.read_json(env.state / 'containers.json', {}) or {},
|
|
575
|
+
(L.read_json(env.state / 'registry.json', {}) or {}).get('containers') or {}):
|
|
576
|
+
if (source.get(container) or {}).get('home'):
|
|
577
|
+
return source[container]['home']
|
|
578
|
+
return ''
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _used(root_fd):
|
|
582
|
+
vfs = os.fstatvfs(root_fd)
|
|
583
|
+
return (vfs.f_blocks - vfs.f_bfree) * vfs.f_frsize
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _judge(env, manifest, live, home, idle):
|
|
587
|
+
"""[(unit, rec, verdict, reason)], largest first."""
|
|
588
|
+
scripts = transcripts(home, {_session(unit) for unit in manifest['units']} - {None})
|
|
589
|
+
rows = [(unit, rec) + classify(unit, rec, live, scripts, env.now(), idle, env.uid) for unit, rec in manifest['units'].items()]
|
|
590
|
+
return sorted(rows, key=lambda row: (-row[1]['bytes'], row[0]))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _refs_note(live):
|
|
594
|
+
blind = f", {live['unreadable']} of another uid could not be read" if live['unreadable'] else ''
|
|
595
|
+
return f"{live['pids']} processes checked{blind}"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def cmd_plan(env, container, opts, write=True):
|
|
599
|
+
pid = G.container_pid(env, container)
|
|
600
|
+
pids = G.cgroup_pids(env, pid) # first: this is also the proof that `pid` still is that docker container
|
|
601
|
+
root = Root('tmp', f'{env.proc}/{pid}/root/tmp', '/tmp')
|
|
602
|
+
deadline = env.clock() + opts['budget']
|
|
603
|
+
root_fd = _open_dir(root.host)
|
|
604
|
+
try:
|
|
605
|
+
dev = os.fstat(root_fd).st_dev
|
|
606
|
+
live = live_refs(env, pids, root.view, dev)
|
|
607
|
+
manifest = scan(env, pid, root, root_fd, dev, _state_file(env, container, 'json'), deadline, live['inodes'],
|
|
608
|
+
opts['fresh'])
|
|
609
|
+
rows = _judge(env, manifest, live, _home(env, container), FRESH)
|
|
610
|
+
used = _used(root_fd)
|
|
611
|
+
finally:
|
|
612
|
+
os.close(root_fd)
|
|
613
|
+
print(f"== {container}: /tmp holds {_size(used)} · {len(rows)} units ({manifest['resumed']} from the previous run)"
|
|
614
|
+
f' · {_refs_note(live)}')
|
|
615
|
+
print(f"{'':7}{'SIZE':>10} {'FILES':>8} {'IDLE':>5} UNIT")
|
|
616
|
+
limit = len(rows) if opts['all'] else 40
|
|
617
|
+
for unit, rec, verdict, reason in rows[:limit]:
|
|
618
|
+
idle = _ago(env.now() - rec['newest']) if rec['newest'] else '-'
|
|
619
|
+
print(f"{verdict:7}{_size(rec['bytes']):>10} {rec['files']:>8} {idle:>5} {unit if _safe_name(unit) else repr(unit)}"
|
|
620
|
+
f' — {reason}')
|
|
621
|
+
if len(rows) > limit:
|
|
622
|
+
print(f'… {len(rows) - limit} smaller units (--all shows them; the plan file has every one)')
|
|
623
|
+
for verdict in (STALE, KEEP):
|
|
624
|
+
chosen = [rec for _unit, rec, which, _reason in rows if which == verdict]
|
|
625
|
+
print(f"{verdict:7}{_size(sum(rec['bytes'] for rec in chosen)):>10} in {len(chosen)} units")
|
|
626
|
+
if not manifest['complete']:
|
|
627
|
+
print(f"scan NOT complete: {manifest['left']} units left — run the same command again to continue")
|
|
628
|
+
if write:
|
|
629
|
+
path = _state_file(env, container, 'plan.tsv')
|
|
630
|
+
write_plan(path, container, pid, root, env.now(), manifest['complete'], rows)
|
|
631
|
+
print(f'plan: {path}\nreview it, then: lanes tmp apply {container} --approve')
|
|
632
|
+
return 0
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cmd_apply(env, container, opts):
|
|
636
|
+
header, units = read_plan(_state_file(env, container, 'plan.tsv'))
|
|
637
|
+
pid = G.container_pid(env, container)
|
|
638
|
+
if header.get('container') != container or header.get('root') != 'tmp':
|
|
639
|
+
raise L.LanesError('the plan was made for another container or root')
|
|
640
|
+
if header.get('pid') != str(pid):
|
|
641
|
+
raise L.LanesError(f"the container's init pid changed since the plan ({header.get('pid')} -> {pid}): plan again")
|
|
642
|
+
if not header.get('at', '').isdigit() or env.now() - int(header['at']) > PLAN_MAX_AGE:
|
|
643
|
+
raise L.LanesError(f'the plan is older than {PLAN_MAX_AGE // 3600} h: plan again')
|
|
644
|
+
root = Root('tmp', f'{env.proc}/{pid}/root/tmp', '/tmp')
|
|
645
|
+
root_fd = _open_dir(root.host)
|
|
646
|
+
try:
|
|
647
|
+
dev, before = os.fstat(root_fd).st_dev, _used(root_fd)
|
|
648
|
+
print(f"== {container}: {len(units)} STALE units in the plan"
|
|
649
|
+
+ ('' if opts['approve'] else ' — DRY-RUN (add --approve to delete)'))
|
|
650
|
+
done = _sweep(env, container, pid, root, root_fd, dev, units, FRESH, _home(env, container), opts['approve'])
|
|
651
|
+
after = _used(root_fd)
|
|
652
|
+
finally:
|
|
653
|
+
os.close(root_fd)
|
|
654
|
+
print(f"{'deleted' if opts['approve'] else 'would delete'} {done['deleted']} units, {_size(done['bytes'])}"
|
|
655
|
+
f" · kept {done['kept']} that are no longer stale · failed {done['failed']}"
|
|
656
|
+
f' · /tmp used {_size(before)} -> {_size(after)}')
|
|
657
|
+
return 1 if done['failed'] else 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _is_cache(fd):
|
|
661
|
+
try: # A virtualenv tags itself as a cache too (so that backups skip it), but it is an ENVIRONMENT: removing it
|
|
662
|
+
# breaks the checkout until someone recreates it (2026-09-20: two idle .venv went with the cargo targets).
|
|
663
|
+
os.stat('pyvenv.cfg', dir_fd=fd, follow_symlinks=False)
|
|
664
|
+
return False
|
|
665
|
+
except OSError:
|
|
666
|
+
pass
|
|
667
|
+
try:
|
|
668
|
+
tag = os.open('CACHEDIR.TAG', os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0), dir_fd=fd)
|
|
669
|
+
except OSError:
|
|
670
|
+
return False
|
|
671
|
+
try:
|
|
672
|
+
return os.read(tag, len(CACHE_TAG)) == CACHE_TAG
|
|
673
|
+
finally:
|
|
674
|
+
os.close(tag)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# The home volume is millions of directories: never walk into dependency trees, VCS data or tool homes, and stop at a
|
|
678
|
+
# depth where build outputs of a worktree (…/<worktrees>/<name>/<crate>/target) are still reached.
|
|
679
|
+
HOME_PRUNE = frozenset(['node_modules', '.git', '.cargo', '.rustup', '.npm', '.cache', '.local', '.claude', '.codex',
|
|
680
|
+
'.lz', '.tmp', '.pnpm-store', 'site-packages'])
|
|
681
|
+
HOME_DEPTH = 8
|
|
682
|
+
HOME_BUDGET = 1200 # s: even pruned, the home volume is a long walk (measured: minutes at nice 19)
|
|
683
|
+
HOME_EVERY = 20 * 3600 # the hourly reaper walks it once a day
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def find_caches(root_fd, dev, deadline=None, clock=time.monotonic, prune=frozenset(), max_depth=MAX_DEPTH):
|
|
687
|
+
"""Relative paths of the self-declared cache directories below the root. Directory fds only, no symlink is
|
|
688
|
+
followed, the device is never left, a cache is never entered, sacred top-level names are never opened."""
|
|
689
|
+
found = []
|
|
690
|
+
|
|
691
|
+
def below(fd, rel, depth):
|
|
692
|
+
if deadline is not None and clock() > deadline:
|
|
693
|
+
raise _Cut()
|
|
694
|
+
if rel and _is_cache(fd):
|
|
695
|
+
found.append(rel)
|
|
696
|
+
return
|
|
697
|
+
if depth >= max_depth:
|
|
698
|
+
return
|
|
699
|
+
for name, is_dir in _names(fd):
|
|
700
|
+
if not is_dir or name in prune or (not rel and _sacred(name)):
|
|
701
|
+
continue
|
|
702
|
+
try:
|
|
703
|
+
child = _open_dir(name, fd)
|
|
704
|
+
except OSError:
|
|
705
|
+
continue
|
|
706
|
+
try:
|
|
707
|
+
if os.fstat(child).st_dev == dev:
|
|
708
|
+
below(child, f'{rel}/{name}' if rel else name, depth + 1)
|
|
709
|
+
finally:
|
|
710
|
+
os.close(child)
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
below(root_fd, '', 0)
|
|
714
|
+
except _Cut:
|
|
715
|
+
pass # what was found so far is still true
|
|
716
|
+
return found
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def judge_cache(env, root_fd, rel, dev, inodes, idle=CACHE_IDLE):
|
|
720
|
+
"""-> (record, reason to KEEP or None). Same evidence as a unit: open files / cwds by inode, owner, specials, age."""
|
|
721
|
+
rec = scan_unit(root_fd, rel, dev, inodes)
|
|
722
|
+
if rec.get('error') or rec.get('partial'):
|
|
723
|
+
return rec, rec.get('error') or 'could not be walked completely'
|
|
724
|
+
if rec.get('live'):
|
|
725
|
+
return rec, f"in use: {rec['live']}"
|
|
726
|
+
if rec['special']:
|
|
727
|
+
return rec, 'holds a socket, fifo or device'
|
|
728
|
+
if set(rec['uids']) - {env.uid}:
|
|
729
|
+
return rec, 'not owned by the container user alone'
|
|
730
|
+
if env.now() - rec['newest'] < idle:
|
|
731
|
+
return rec, f"written {_ago(env.now() - rec['newest'])} ago"
|
|
732
|
+
return rec, None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _delete_cache(root_fd, rel, dev):
|
|
736
|
+
# A cache sits at any depth (…/<session>/scratchpad/target), so _safe_name's unit shape does not apply; its other
|
|
737
|
+
# rules do: no empty / '.' / '..' component, printable, nothing under a sacred top-level name, never the root.
|
|
738
|
+
parts = rel.split('/')
|
|
739
|
+
if not rel or any(part in ('', '.', '..') for part in parts) or not rel.isprintable() or _sacred(parts[0]):
|
|
740
|
+
raise L.LanesError(f'refusing to delete {rel!r}')
|
|
741
|
+
parent = _open_parent(root_fd, rel)
|
|
742
|
+
try:
|
|
743
|
+
leaf_fd = _open_dir(rel.split('/')[-1], parent)
|
|
744
|
+
try:
|
|
745
|
+
if not _is_cache(leaf_fd): # the tag went away since it was judged: not a cache any more
|
|
746
|
+
raise L.LanesError('no valid CACHEDIR.TAG any more')
|
|
747
|
+
finally:
|
|
748
|
+
os.close(leaf_fd)
|
|
749
|
+
_remove(parent, rel.split('/')[-1], dev, 1)
|
|
750
|
+
finally:
|
|
751
|
+
os.close(parent)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def sweep_caches(env, container, pid, root, root_fd, dev, delete, deadline, idle=CACHE_IDLE, prune=frozenset(),
|
|
755
|
+
max_depth=MAX_DEPTH):
|
|
756
|
+
"""List (and with delete=True remove) idle build caches. Every cache is judged with references taken just before."""
|
|
757
|
+
done = {'deleted': 0, 'bytes': 0, 'kept': 0, 'failed': 0}
|
|
758
|
+
refs, taken = None, 0.0
|
|
759
|
+
for rel in find_caches(root_fd, dev, deadline, env.clock, prune, max_depth):
|
|
760
|
+
if deadline is not None and env.clock() > deadline:
|
|
761
|
+
print(' budget used up: the remaining caches wait for the next run')
|
|
762
|
+
break
|
|
763
|
+
if refs is None or env.clock() - taken > REFS_MAX_AGE:
|
|
764
|
+
refs, taken = live_refs(env, G.cgroup_pids(env, pid), root.view, dev), env.clock()
|
|
765
|
+
rec, keep = judge_cache(env, root_fd, rel, dev, refs['inodes'], idle)
|
|
766
|
+
if keep:
|
|
767
|
+
done['kept'] += 1
|
|
768
|
+
print(f" KEEP {_size(rec.get('bytes', 0)):>9} {root.view}/{rel} ({keep})")
|
|
769
|
+
continue
|
|
770
|
+
if delete:
|
|
771
|
+
try:
|
|
772
|
+
_delete_cache(root_fd, rel, dev)
|
|
773
|
+
except (L.LanesError, OSError) as error:
|
|
774
|
+
done['failed'] += 1
|
|
775
|
+
_log(env, container, root, 'FAILED-cache', rel, rec, str(error))
|
|
776
|
+
print(f' FAILED {root.view}/{rel}: {error}')
|
|
777
|
+
continue
|
|
778
|
+
_log(env, container, root, 'deleted-cache', rel, rec, f'idle build cache (CACHEDIR.TAG), {_ago(env.now() - rec["newest"])}')
|
|
779
|
+
done['deleted'] += 1
|
|
780
|
+
done['bytes'] += rec['bytes']
|
|
781
|
+
print(f" {'DELETE' if delete else 'IDLE '} {_size(rec['bytes']):>9} {root.view}/{rel} (idle {_ago(env.now() - rec['newest'])})")
|
|
782
|
+
return done
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _home_root(env, container):
|
|
786
|
+
home = _home(env, container)
|
|
787
|
+
return Root('home', home, L.CONTAINER_HOME) if home else None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def cmd_caches(env, container, opts):
|
|
791
|
+
"""`lanes tmp caches [--home] [--approve]`: idle, unreferenced, self-declared build caches in the container's /tmp,
|
|
792
|
+
or with --home on its home volume (cargo `target` dirs of idle worktrees: 938 GiB on 2026-09-20), idle > 3 days."""
|
|
793
|
+
pid = G.container_pid(env, container)
|
|
794
|
+
G.cgroup_dir(env, pid)
|
|
795
|
+
if opts.get('home'):
|
|
796
|
+
root, idle, prune, depth = _home_root(env, container), REAP_IDLE, HOME_PRUNE, HOME_DEPTH
|
|
797
|
+
if root is None:
|
|
798
|
+
raise L.LanesError(f'{container}: home volume unknown — run `lanes sync`')
|
|
799
|
+
else:
|
|
800
|
+
root, idle, prune, depth = Root('tmp', f'{env.proc}/{pid}/root/tmp', '/tmp'), CACHE_IDLE, frozenset(), MAX_DEPTH
|
|
801
|
+
root_fd = _open_dir(root.host)
|
|
802
|
+
try:
|
|
803
|
+
dev = os.fstat(root_fd).st_dev
|
|
804
|
+
budget = max(opts['budget'], HOME_BUDGET) if opts.get('home') else opts['budget']
|
|
805
|
+
done = sweep_caches(env, container, pid, root, root_fd, dev, opts['approve'], env.clock() + budget,
|
|
806
|
+
idle, prune, depth)
|
|
807
|
+
finally:
|
|
808
|
+
os.close(root_fd)
|
|
809
|
+
verb = 'deleted' if opts['approve'] else 'idle, deletable with --approve:'
|
|
810
|
+
print(f"== {container} {root.view}: {verb} {done['deleted']} caches, {_size(done['bytes'])}; kept {done['kept']}")
|
|
811
|
+
return 1 if done['failed'] else 0
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def cmd_reap(env, container, opts):
|
|
815
|
+
pid = G.container_pid(env, container)
|
|
816
|
+
G.cgroup_dir(env, pid)
|
|
817
|
+
armed = (env.state / 'flags' / 'tmpreap.armed').exists()
|
|
818
|
+
home = _home(env, container)
|
|
819
|
+
deadline = env.clock() + opts['budget']
|
|
820
|
+
roots = [Root('tmp', f'{env.proc}/{pid}/root/tmp', '/tmp')]
|
|
821
|
+
if home: # the disk temp dir container/env.sh points TMPDIR at: it has no reboot to empty it
|
|
822
|
+
roots.append(Root('home-tmp', os.path.join(home, '.tmp'), L.CONTAINER_HOME + '/.tmp'))
|
|
823
|
+
failed = 0
|
|
824
|
+
for root in roots:
|
|
825
|
+
try:
|
|
826
|
+
root_fd = _open_dir(root.host)
|
|
827
|
+
except FileNotFoundError:
|
|
828
|
+
continue
|
|
829
|
+
try:
|
|
830
|
+
dev = os.fstat(root_fd).st_dev
|
|
831
|
+
live = live_refs(env, G.cgroup_pids(env, pid), root.view, dev)
|
|
832
|
+
manifest = scan(env, pid, root, root_fd, dev, _state_file(env, container, f'reap-{root.key}.json'),
|
|
833
|
+
deadline, live['inodes'])
|
|
834
|
+
stale = [unit for unit, _rec, verdict, _reason in _judge(env, manifest, live, home, REAP_IDLE)
|
|
835
|
+
if verdict == STALE]
|
|
836
|
+
print(f"== {container} {root.view}: {len(manifest['units'])} units scanned, {manifest['left']} left,"
|
|
837
|
+
f" {len(stale)} idle > {REAP_IDLE // 86400} d · {_refs_note(live)}"
|
|
838
|
+
+ ('' if armed else ' — DRY-RUN (touch flags/tmpreap.armed to arm)'))
|
|
839
|
+
done = _sweep(env, container, pid, root, root_fd, dev, stale, REAP_IDLE, home, armed, deadline)
|
|
840
|
+
failed += done['failed']
|
|
841
|
+
print(f" {'deleted' if armed else 'would delete'} {done['deleted']} units, {_size(done['bytes'])}")
|
|
842
|
+
cached = sweep_caches(env, container, pid, root, root_fd, dev, armed, deadline)
|
|
843
|
+
failed += cached['failed']
|
|
844
|
+
print(f" {'deleted' if armed else 'would delete'} {cached['deleted']} idle build caches, {_size(cached['bytes'])}")
|
|
845
|
+
finally:
|
|
846
|
+
os.close(root_fd)
|
|
847
|
+
disk = _home_root(env, container)
|
|
848
|
+
stamp = _state_file(env, container, 'home-sweep')
|
|
849
|
+
try:
|
|
850
|
+
due = opts['fresh'] or env.now() - os.stat(stamp).st_mtime > HOME_EVERY
|
|
851
|
+
except OSError:
|
|
852
|
+
due = True
|
|
853
|
+
if disk and due: # the DISK fills the same way the RAM did: every idle worktree keeps gigabytes of cargo `target`
|
|
854
|
+
try:
|
|
855
|
+
root_fd = _open_dir(disk.host)
|
|
856
|
+
except OSError:
|
|
857
|
+
root_fd = None
|
|
858
|
+
if root_fd is not None:
|
|
859
|
+
try:
|
|
860
|
+
cached = sweep_caches(env, container, pid, disk, root_fd, os.fstat(root_fd).st_dev, armed,
|
|
861
|
+
env.clock() + max(opts['budget'], HOME_BUDGET), REAP_IDLE, HOME_PRUNE, HOME_DEPTH)
|
|
862
|
+
L.atomic_write(stamp, f'{env.now()}\n')
|
|
863
|
+
failed += cached['failed']
|
|
864
|
+
print(f"== {container} {disk.view}: {'deleted' if armed else 'would delete'} {cached['deleted']} build caches"
|
|
865
|
+
f" idle > {REAP_IDLE // 86400} d, {_size(cached['bytes'])}")
|
|
866
|
+
finally:
|
|
867
|
+
os.close(root_fd)
|
|
868
|
+
return 1 if failed else 0
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def main(args, env):
|
|
872
|
+
usage = 'usage: lanes tmp scan|plan|apply|reap|caches [container…] [--budget S] [--fresh] [--all] [--home] [--approve]'
|
|
873
|
+
opts = {'budget': BUDGET, 'fresh': False, 'all': False, 'approve': False, 'home': False}
|
|
874
|
+
words, rest = [], list(args)
|
|
875
|
+
while rest:
|
|
876
|
+
arg = rest.pop(0)
|
|
877
|
+
if arg in ('--fresh', '--all', '--approve', '--home'):
|
|
878
|
+
opts[arg[2:]] = True
|
|
879
|
+
elif arg == '--budget' or arg.startswith('--budget='):
|
|
880
|
+
value = arg.partition('=')[2] or (rest.pop(0) if rest else '')
|
|
881
|
+
if not value.isdigit():
|
|
882
|
+
raise L.LanesError(usage)
|
|
883
|
+
opts['budget'] = int(value)
|
|
884
|
+
elif arg.startswith('-'):
|
|
885
|
+
raise L.LanesError(usage)
|
|
886
|
+
else:
|
|
887
|
+
words.append(arg)
|
|
888
|
+
if not words or words[0] not in ('scan', 'plan', 'apply', 'reap', 'caches'):
|
|
889
|
+
raise L.LanesError(usage)
|
|
890
|
+
code = 0
|
|
891
|
+
for container in words[1:] or G.container_names(env):
|
|
892
|
+
try:
|
|
893
|
+
if words[0] == 'apply':
|
|
894
|
+
code |= cmd_apply(env, container, opts)
|
|
895
|
+
elif words[0] == 'reap':
|
|
896
|
+
code |= cmd_reap(env, container, opts)
|
|
897
|
+
elif words[0] == 'caches':
|
|
898
|
+
code |= cmd_caches(env, container, opts)
|
|
899
|
+
else:
|
|
900
|
+
code |= cmd_plan(env, container, opts, write=words[0] == 'plan')
|
|
901
|
+
except (L.LanesError, OSError) as error:
|
|
902
|
+
# One container that is down (or a /tmp we may not open) must not hide the others.
|
|
903
|
+
print(f'lanes tmp {words[0]}: {container}: {error}')
|
|
904
|
+
code = 1
|
|
905
|
+
return code
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def run(args):
|
|
909
|
+
try:
|
|
910
|
+
os.nice(19)
|
|
911
|
+
except OSError:
|
|
912
|
+
pass
|
|
913
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) # so that the manifest's `finally` still saves
|
|
914
|
+
return main(args, G.Env())
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
if __name__ == '__main__':
|
|
918
|
+
try:
|
|
919
|
+
sys.exit(run(sys.argv[1:]))
|
|
920
|
+
except L.LanesError as error:
|
|
921
|
+
sys.exit(f'lanes tmp: {error}')
|