pi-orchestrator-config 4.24.2 → 4.25.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/CHANGELOG.md +22 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/tmp-cleanup.ts +428 -0
- package/extensions/orchestrator/utils.ts +23 -3
- package/extensions/pidiff/pidiff.ts +60 -35
- package/extensions/pidiff/spawn-lock.ts +34 -0
- package/extensions/pitasks/index.ts +23 -16
- package/extensions/pitasks/reminders.ts +70 -0
- package/extensions/reload-flag/index.ts +99 -29
- package/extensions/shared/daemon-manager.ts +44 -23
- package/package.json +1 -1
- package/prompts/review-handler.md +12 -0
- package/scripts/pidiff-server.ts +26 -7
- package/settings-keys.json +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.25.0] - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- `.pi/tmp` is cleaned at session start: above `project_tmp_max_size` (default `2G`), entries older than
|
|
6
|
+
`project_tmp_retention_days` (default 7 days) are deleted oldest-first until the tree fits, and `0`
|
|
7
|
+
disables it entirely. The sweep lock lives in `.pi/run` rather than in the directory being swept, a run
|
|
8
|
+
that cannot take the lock deletes nothing, an entry replaced mid-scan is skipped, and oneshot sessions
|
|
9
|
+
skip the whole thing. A bad size value deletes nothing rather than falling back to a deleting default.
|
|
10
|
+
- Daemon control files moved to `.pi/run` — `pidiff.pid`, `pidiff.port`, and the `reload.flag` /
|
|
11
|
+
`reload.done` / `reload.pending` handshake. Readers fall back to `.pi/tmp` and `reload.done` is mirrored
|
|
12
|
+
back, so a daemon started before the upgrade is still found instead of a second one spawning on its
|
|
13
|
+
port. **The migration release needs exactly one manual `/reload`**: a session whose watcher was loaded
|
|
14
|
+
before the move cannot observe the new flag path. The old temp-dir reload files stay protected until no
|
|
15
|
+
pre-upgrade daemon remains.
|
|
16
|
+
- The pending-task reminder keys on how long a task has actually been pending rather than on the first
|
|
17
|
+
60-second tick, so it no longer fires about a minute after creation. It measures the oldest active task,
|
|
18
|
+
treats an undated task as old so a fresh sibling cannot mask it, and covers `agentBusy` in tests.
|
|
19
|
+
- `reviews fetch` approves on a clean findings catalog at the PR head instead of requiring a `completed`
|
|
20
|
+
Qodo session, so a legitimately skipped auto-run can approve. A run that is still in progress never does.
|
|
21
|
+
`reason` reports `no_findings`, `open_findings`, `not_at_head`, or `all_resolved` instead of always
|
|
22
|
+
claiming `all_resolved`, and the review-loop exit names which reviewer actually approved — a CodeRabbit
|
|
23
|
+
approval is no longer reported as Qodo's.
|
|
24
|
+
|
|
3
25
|
## [4.24.2] - 2026-11-04
|
|
4
26
|
|
|
5
27
|
- Async work is tracked even when an agent starts it: the task trigger now covers work set in
|
|
@@ -22,6 +22,7 @@ import { registerEnforcement } from "./enforcement.js";
|
|
|
22
22
|
import { registerProjectSettings } from "./project-settings.js";
|
|
23
23
|
import { registerRules } from "./rules.js";
|
|
24
24
|
import { registerSessionValidation } from "./session-validation.js";
|
|
25
|
+
import { registerTmpCleanup } from "./tmp-cleanup.js";
|
|
25
26
|
import { registerStatusLine } from "./status-line.js";
|
|
26
27
|
import { registerSubagentTool } from "./subagent-tool.js";
|
|
27
28
|
import { registerGithubAutocomplete } from "./github-autocomplete.js";
|
|
@@ -144,6 +145,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
145
|
registerPreferenceExtractor(pi);
|
|
145
146
|
registerMemoryTools(pi);
|
|
146
147
|
registerReviewLearning(pi);
|
|
148
|
+
registerTmpCleanup(pi);
|
|
147
149
|
registerSessionSearch(pi);
|
|
148
150
|
registerSettingsTui(pi);
|
|
149
151
|
registerMarkdownTransformer(pi);
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start sweep of `<cwd>/.pi/tmp`.
|
|
3
|
+
*
|
|
4
|
+
* Size is the only trigger, age is the safety floor: above `project_tmp_max_size`
|
|
5
|
+
* the oldest entries are deleted (only ones older than `project_tmp_retention_days`)
|
|
6
|
+
* until the directory is back under the cap. A project under the cap is never
|
|
7
|
+
* touched, no matter how old its entries are. Creator-side cleanup cannot be the
|
|
8
|
+
* mechanism — whatever crashes before its own cleanup lives forever.
|
|
9
|
+
*
|
|
10
|
+
* Every fs call in the traversal and the deletions is async: the sweep runs on Pi's
|
|
11
|
+
* event loop, so a multi-GB tree must yield between entries instead of freezing input.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import { promises as fsp } from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
|
+
import { isPiOneshotInvocation } from "../shared/oneshot.js";
|
|
20
|
+
import { getGlobalSettingsPath, getSettingDynamic, getSettingsPath, SETTINGS_KEYS } from "./project-settings.js";
|
|
21
|
+
import { readSettingsObject } from "./settings-source.js";
|
|
22
|
+
import { getProjectRunDir, getProjectTmpDir } from "./utils.js";
|
|
23
|
+
|
|
24
|
+
const log = createLogger("tmp_cleanup");
|
|
25
|
+
|
|
26
|
+
const MB = 1024 * 1024;
|
|
27
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** Defaults live in `settings-keys.json` only — never restated here. */
|
|
30
|
+
export const DEFAULT_MAX_SIZE = String(SETTINGS_KEYS.project_tmp_max_size.default);
|
|
31
|
+
export const DEFAULT_RETENTION_DAYS = Number(SETTINGS_KEYS.project_tmp_retention_days.default);
|
|
32
|
+
/**
|
|
33
|
+
* Transitional debris from pre-#1020 daemons: `.pi/tmp` was the control-file home
|
|
34
|
+
* before `.pi/run`, so a daemon started before the upgrade still holds its pid/port and
|
|
35
|
+
* reload handshake here, and the read-both fallback (#1025) needs these files to find it.
|
|
36
|
+
* Sweeping them would make the next connect() miss the live daemon and spawn a duplicate
|
|
37
|
+
* on the same port. Never deleted, whatever their age; the set disappears once no
|
|
38
|
+
* pre-upgrade daemon remains.
|
|
39
|
+
*/
|
|
40
|
+
export const TMP_PROTECTED_NAMES = new Set([
|
|
41
|
+
"pidiff.pid",
|
|
42
|
+
"pidiff.port",
|
|
43
|
+
"reload.flag",
|
|
44
|
+
"reload.done",
|
|
45
|
+
"reload.pending",
|
|
46
|
+
]);
|
|
47
|
+
/** Lives in `.pi/run`, never in the swept dir. */
|
|
48
|
+
export const TMP_SWEEP_LOCK = ".tmp-sweep.lock";
|
|
49
|
+
/** A crashed sweep would otherwise block every later session forever. */
|
|
50
|
+
const LOCK_STALE_MS = 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/** Accepted suffixes. A bare number is MB (`2048` = 2G). */
|
|
53
|
+
const UNITS: Record<string, number> = {
|
|
54
|
+
"": MB,
|
|
55
|
+
b: 1,
|
|
56
|
+
k: 1024,
|
|
57
|
+
kb: 1024,
|
|
58
|
+
kib: 1024,
|
|
59
|
+
m: MB,
|
|
60
|
+
mb: MB,
|
|
61
|
+
mib: MB,
|
|
62
|
+
g: 1024 * MB,
|
|
63
|
+
gb: 1024 * MB,
|
|
64
|
+
gib: 1024 * MB,
|
|
65
|
+
t: 1024 * 1024 * MB,
|
|
66
|
+
tb: 1024 * 1024 * MB,
|
|
67
|
+
tib: 1024 * 1024 * MB,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a size setting (`2G`, `2GiB`, `2048M`, `1.5G`, bare `2048` = MB, `0` = off).
|
|
72
|
+
* Returns undefined for anything else — callers must then delete nothing: a bad
|
|
73
|
+
* value may only ever clean less, never more.
|
|
74
|
+
*/
|
|
75
|
+
export function parseSize(raw: string | number | null | undefined): number | undefined {
|
|
76
|
+
const match = /^(\d+(?:\.\d+)?)([a-z]*)$/.exec(String(raw ?? "").trim().toLowerCase());
|
|
77
|
+
if (!match) return undefined;
|
|
78
|
+
const unit = UNITS[match[2]];
|
|
79
|
+
if (unit === undefined) return undefined;
|
|
80
|
+
const value = Number(match[1]);
|
|
81
|
+
return Number.isFinite(value) ? Math.round(value * unit) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TmpSweepResult {
|
|
85
|
+
beforeMb: number;
|
|
86
|
+
afterMb: number;
|
|
87
|
+
deleted: number;
|
|
88
|
+
skippedYoung: number;
|
|
89
|
+
elapsedMs: number;
|
|
90
|
+
/** Set when the run did nothing: cap disabled, unparsable value, another process holds the lock (or the run dir is unusable), or the tmp dir resolves outside the checkout. */
|
|
91
|
+
skipped?: "disabled" | "invalid_max_size" | "locked" | "outside_checkout";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TmpSweepOptions {
|
|
95
|
+
tmpDir: string;
|
|
96
|
+
maxBytes: number;
|
|
97
|
+
retentionDays: number;
|
|
98
|
+
now?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toMb(bytes: number): number {
|
|
102
|
+
return Math.round((bytes / MB) * 10) / 10;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Byte size and newest mtime of a path, never following a symlink (the link's own
|
|
107
|
+
* size stands in). The newest nested mtime is what decides the age floor — a
|
|
108
|
+
* top-level directory's own mtime does not move when a nested file is rewritten.
|
|
109
|
+
*/
|
|
110
|
+
async function entryStats(target: string): Promise<{ bytes: number; newestMs: number }> {
|
|
111
|
+
let stat: fs.Stats;
|
|
112
|
+
try {
|
|
113
|
+
stat = await fsp.lstat(target);
|
|
114
|
+
} catch {
|
|
115
|
+
return { bytes: 0, newestMs: 0 };
|
|
116
|
+
}
|
|
117
|
+
if (!stat.isDirectory()) return { bytes: stat.size, newestMs: stat.mtimeMs };
|
|
118
|
+
let total = 0;
|
|
119
|
+
let newestMs = stat.mtimeMs;
|
|
120
|
+
let names: string[];
|
|
121
|
+
try {
|
|
122
|
+
names = await fsp.readdir(target);
|
|
123
|
+
} catch {
|
|
124
|
+
return { bytes: total, newestMs };
|
|
125
|
+
}
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const child = await entryStats(path.join(target, name));
|
|
128
|
+
total += child.bytes;
|
|
129
|
+
newestMs = Math.max(newestMs, child.newestMs);
|
|
130
|
+
}
|
|
131
|
+
return { bytes: total, newestMs };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when `target` resolves strictly inside `base` — refuses any path escape. */
|
|
135
|
+
function isInside(base: string, target: string): boolean {
|
|
136
|
+
return target !== base && target.startsWith(base + path.sep);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True while `target` still resolves to itself. `target` is always a realpath here, so a
|
|
141
|
+
* false answer means it was swapped for a symlink (or is gone) after it was validated.
|
|
142
|
+
*/
|
|
143
|
+
async function isUnswapped(target: string): Promise<boolean> {
|
|
144
|
+
try {
|
|
145
|
+
return (await fsp.realpath(target)) === target;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Delete oldest-first from `.pi/tmp` until it fits under `maxBytes`, only touching
|
|
153
|
+
* entries older than `retentionDays`. Deletion units are the top-level entries, so
|
|
154
|
+
* a cloned repo or task dir goes as a whole instead of file by file.
|
|
155
|
+
*/
|
|
156
|
+
export async function sweepProjectTmp(opts: TmpSweepOptions): Promise<TmpSweepResult> {
|
|
157
|
+
const started = Date.now();
|
|
158
|
+
const now = opts.now ?? started;
|
|
159
|
+
const result: TmpSweepResult = { beforeMb: 0, afterMb: 0, deleted: 0, skippedYoung: 0, elapsedMs: 0 };
|
|
160
|
+
const base = path.resolve(opts.tmpDir);
|
|
161
|
+
|
|
162
|
+
let names: string[];
|
|
163
|
+
try {
|
|
164
|
+
names = await fsp.readdir(base);
|
|
165
|
+
} catch (error: any) {
|
|
166
|
+
if (error?.code !== "ENOENT") log.warn("tmp_sweep_scan_failed", { tmpDir: base, error: error?.message });
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rows: Array<{ target: string; bytes: number; newestMs: number; symlink: boolean; dev: number; ino: number }> = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
const target = path.join(base, name);
|
|
174
|
+
if (!isInside(base, target) || TMP_PROTECTED_NAMES.has(name)) continue;
|
|
175
|
+
let stat: fs.Stats;
|
|
176
|
+
try {
|
|
177
|
+
stat = await fsp.lstat(target);
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const { bytes, newestMs } = stat.isDirectory()
|
|
182
|
+
? await entryStats(target)
|
|
183
|
+
: { bytes: stat.size, newestMs: stat.mtimeMs };
|
|
184
|
+
total += bytes;
|
|
185
|
+
rows.push({ target, bytes, newestMs, symlink: stat.isSymbolicLink(), dev: stat.dev, ino: stat.ino });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
result.beforeMb = result.afterMb = toMb(total);
|
|
189
|
+
if (total <= opts.maxBytes) {
|
|
190
|
+
result.elapsedMs = Date.now() - started;
|
|
191
|
+
log.debug("tmp_sweep_under_cap", { tmpDir: base, before_mb: result.beforeMb, cap_bytes: opts.maxBytes });
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The scan is async: `.pi/tmp` itself may have been swapped for an outside-pointing
|
|
196
|
+
// symlink since it was validated. Destructive phase, so resolve it again — a swapped
|
|
197
|
+
// base does not resolve to itself.
|
|
198
|
+
if (!(await isUnswapped(base))) {
|
|
199
|
+
log.warn("tmp_sweep_outside_checkout", { tmpDir: base, phase: "delete" });
|
|
200
|
+
result.elapsedMs = Date.now() - started;
|
|
201
|
+
return { ...result, skipped: "outside_checkout" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const cutoff = now - opts.retentionDays * DAY_MS;
|
|
205
|
+
let remaining = total;
|
|
206
|
+
for (const row of rows.sort((left, right) => left.newestMs - right.newestMs)) {
|
|
207
|
+
if (remaining <= opts.maxBytes) break;
|
|
208
|
+
if (row.newestMs >= cutoff) {
|
|
209
|
+
result.skippedYoung++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// A task may have replaced this entry since we sized it; delete only the inode the
|
|
213
|
+
// age and size decisions were made for, never whatever now sits at the pathname.
|
|
214
|
+
let live: fs.Stats;
|
|
215
|
+
try {
|
|
216
|
+
live = await fsp.lstat(row.target);
|
|
217
|
+
} catch {
|
|
218
|
+
continue; // already gone
|
|
219
|
+
}
|
|
220
|
+
if (live.dev !== row.dev || live.ino !== row.ino) {
|
|
221
|
+
log.warn("tmp_sweep_entry_replaced", { target: row.target });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (row.symlink) await fsp.unlink(row.target);
|
|
226
|
+
else await fsp.rm(row.target, { recursive: true, force: true });
|
|
227
|
+
} catch (error: any) {
|
|
228
|
+
log.warn("tmp_sweep_delete_failed", { target: row.target, error: error?.message });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
remaining -= row.bytes;
|
|
232
|
+
result.deleted++;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
result.afterMb = toMb(remaining);
|
|
236
|
+
result.elapsedMs = Date.now() - started;
|
|
237
|
+
log.info("tmp_sweep", {
|
|
238
|
+
before_mb: result.beforeMb,
|
|
239
|
+
after_mb: result.afterMb,
|
|
240
|
+
deleted: result.deleted,
|
|
241
|
+
skipped_young: result.skippedYoung,
|
|
242
|
+
elapsed_ms: result.elapsedMs,
|
|
243
|
+
});
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** O_EXCL lock; returns the release callback, or null when another session holds it. */
|
|
248
|
+
function acquireLock(lockPath: string): (() => void) | null {
|
|
249
|
+
const pid = String(process.pid);
|
|
250
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
251
|
+
try {
|
|
252
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
253
|
+
fs.writeSync(fd, pid);
|
|
254
|
+
fs.closeSync(fd);
|
|
255
|
+
return () => {
|
|
256
|
+
try {
|
|
257
|
+
// Unlink only our own lock: a stale reclaim may have handed it to another session.
|
|
258
|
+
if (fs.readFileSync(lockPath, "utf8") !== pid) return;
|
|
259
|
+
fs.unlinkSync(lockPath);
|
|
260
|
+
} catch {
|
|
261
|
+
/* already gone */
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
} catch (error: any) {
|
|
265
|
+
// The run dir vanished after it was created: sweeping without the lock is the one
|
|
266
|
+
// thing this function must never do, so treat it as contended.
|
|
267
|
+
if (error?.code === "ENOENT") {
|
|
268
|
+
log.warn("tmp_sweep_lock_failed", { lockPath, error: error?.message });
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (error?.code !== "EEXIST") {
|
|
272
|
+
log.warn("tmp_sweep_lock_failed", { lockPath, error: error?.message });
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
let held: fs.Stats;
|
|
276
|
+
try {
|
|
277
|
+
held = fs.statSync(lockPath);
|
|
278
|
+
} catch {
|
|
279
|
+
continue; // holder released — retry the create
|
|
280
|
+
}
|
|
281
|
+
if (Date.now() - held.mtimeMs <= LOCK_STALE_MS) return null;
|
|
282
|
+
// Reclaim by rename: exactly one of N racing sessions wins, and the inode check
|
|
283
|
+
// rejects a lock another session created between our stat and the rename.
|
|
284
|
+
const reclaimPath = `${lockPath}.${process.pid}.reclaim`;
|
|
285
|
+
try {
|
|
286
|
+
fs.renameSync(lockPath, reclaimPath);
|
|
287
|
+
} catch {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
let moved: fs.Stats | undefined;
|
|
291
|
+
try {
|
|
292
|
+
moved = fs.statSync(reclaimPath);
|
|
293
|
+
} catch {
|
|
294
|
+
moved = undefined;
|
|
295
|
+
}
|
|
296
|
+
if (!moved || moved.dev !== held.dev || moved.ino !== held.ino) {
|
|
297
|
+
// We moved a lock that is not the stale one we stat'ed — it belongs to a session
|
|
298
|
+
// that took the pathname in between. Restore it without ever overwriting a third
|
|
299
|
+
// session's lock (`link` refuses an existing path), then treat the lock as held.
|
|
300
|
+
try {
|
|
301
|
+
fs.linkSync(reclaimPath, lockPath);
|
|
302
|
+
} catch {
|
|
303
|
+
/* a third session holds the pathname; its lock wins */
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
fs.unlinkSync(reclaimPath);
|
|
307
|
+
} catch {
|
|
308
|
+
/* already gone */
|
|
309
|
+
}
|
|
310
|
+
log.warn("tmp_sweep_lock_reclaim_raced", { lockPath });
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
fs.unlinkSync(reclaimPath);
|
|
315
|
+
} catch {
|
|
316
|
+
/* already gone */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The settings getter skips a wrong-typed value and returns the default, which here
|
|
325
|
+
* would re-enable deletion — so read the raw file value too. A numeric `0` meant as
|
|
326
|
+
* "off" then means off, and any other type is unparsable (deletes nothing).
|
|
327
|
+
*/
|
|
328
|
+
function rawSizeSetting(cwd: string): unknown {
|
|
329
|
+
for (const file of [getSettingsPath(cwd), getGlobalSettingsPath()]) {
|
|
330
|
+
const value = readSettingsObject(file, "project_tmp_max_size").project_tmp_max_size;
|
|
331
|
+
if (value !== undefined) return value;
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Resolve the settings, take the lock, sweep. Never throws. */
|
|
337
|
+
export async function runProjectTmpSweep(
|
|
338
|
+
cwd: string,
|
|
339
|
+
opts: { tmpDir?: string; now?: number } = {},
|
|
340
|
+
): Promise<TmpSweepResult> {
|
|
341
|
+
const empty: TmpSweepResult = { beforeMb: 0, afterMb: 0, deleted: 0, skippedYoung: 0, elapsedMs: 0 };
|
|
342
|
+
const configured = rawSizeSetting(cwd) ?? getSettingDynamic(cwd, "project_tmp_max_size");
|
|
343
|
+
const raw = String(configured ?? DEFAULT_MAX_SIZE);
|
|
344
|
+
const maxBytes = parseSize(configured as string | number | null | undefined);
|
|
345
|
+
if (maxBytes === undefined) {
|
|
346
|
+
// Spec says "fall back to the default AND clean nothing" — the two cannot both
|
|
347
|
+
// hold in one run, and "a bad value must never delete more, only less" decides it.
|
|
348
|
+
log.warn("tmp_sweep_invalid_max_size", {
|
|
349
|
+
value: raw,
|
|
350
|
+
default_max_size: DEFAULT_MAX_SIZE,
|
|
351
|
+
default_used: false,
|
|
352
|
+
deleted: 0,
|
|
353
|
+
});
|
|
354
|
+
return { ...empty, skipped: "invalid_max_size" };
|
|
355
|
+
}
|
|
356
|
+
if (maxBytes === 0) {
|
|
357
|
+
log.debug("tmp_sweep_disabled", {});
|
|
358
|
+
return { ...empty, skipped: "disabled" };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const retention = Number(getSettingDynamic(cwd, "project_tmp_retention_days"));
|
|
362
|
+
const retentionDays = Number.isFinite(retention) && retention >= 1 ? Math.min(Math.floor(retention), 365) : DEFAULT_RETENTION_DAYS;
|
|
363
|
+
|
|
364
|
+
const tmpDir = path.resolve(opts.tmpDir ?? getProjectTmpDir(cwd));
|
|
365
|
+
let checkout: string;
|
|
366
|
+
let base: string;
|
|
367
|
+
try {
|
|
368
|
+
[checkout, base] = await Promise.all([fsp.realpath(cwd), fsp.realpath(tmpDir)]);
|
|
369
|
+
} catch {
|
|
370
|
+
return empty; // no `.pi/tmp` yet
|
|
371
|
+
}
|
|
372
|
+
// A symlinked `.pi` would otherwise let a sweep in this checkout delete old files
|
|
373
|
+
// in whatever directory the link points at.
|
|
374
|
+
if (!isInside(checkout, base)) {
|
|
375
|
+
log.warn("tmp_sweep_outside_checkout", { tmpDir: base, cwd: checkout });
|
|
376
|
+
return { ...empty, skipped: "outside_checkout" };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// The lock lives in `.pi/run`, outside the directory being swept: a lock file inside
|
|
380
|
+
// the swept dir is itself sweepable data. No run dir, no lock, no sweep — an unwritable
|
|
381
|
+
// or full disk must never degrade into an unlocked sweep.
|
|
382
|
+
let lockPath: string;
|
|
383
|
+
try {
|
|
384
|
+
lockPath = path.join(getProjectRunDir(cwd), TMP_SWEEP_LOCK);
|
|
385
|
+
} catch (error: any) {
|
|
386
|
+
log.warn("tmp_sweep_run_dir_failed", { cwd, error: error?.message });
|
|
387
|
+
return { ...empty, skipped: "locked" };
|
|
388
|
+
}
|
|
389
|
+
// Nothing slow between the checks above and this write, and the same check runs again
|
|
390
|
+
// before the deletions — every use of the pathname is validated at the point of use.
|
|
391
|
+
if (!(await isUnswapped(base))) {
|
|
392
|
+
log.warn("tmp_sweep_outside_checkout", { tmpDir: base, phase: "lock" });
|
|
393
|
+
return { ...empty, skipped: "outside_checkout" };
|
|
394
|
+
}
|
|
395
|
+
const release = acquireLock(lockPath);
|
|
396
|
+
if (!release) {
|
|
397
|
+
log.info("tmp_sweep_skipped_locked", { tmpDir: base, lockPath });
|
|
398
|
+
return { ...empty, skipped: "locked" };
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
return await sweepProjectTmp({ tmpDir: base, maxBytes, retentionDays, now: opts.now });
|
|
402
|
+
} finally {
|
|
403
|
+
release();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Wire the sweep to session start: deferred past startup, and every fs call inside it
|
|
409
|
+
* is async, so a multi-GB unlink cannot delay or freeze the first prompt. Never a
|
|
410
|
+
* timer — only the cwd string is captured, so no stale `ctx` is touched after
|
|
411
|
+
* `/reload`. Oneshot invocations (`pi -p`, `--mode json`, piped print sessions) skip
|
|
412
|
+
* it: they must exit without extra work.
|
|
413
|
+
*/
|
|
414
|
+
export function registerTmpCleanup(pi: ExtensionAPI): void {
|
|
415
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
416
|
+
// argv alone cannot see `echo | pi` / `pi | cat`, so the session mode decides too.
|
|
417
|
+
if (ctx?.mode === "print" || ctx?.mode === "json" || isPiOneshotInvocation()) return;
|
|
418
|
+
const cwd = ctx?.cwd;
|
|
419
|
+
if (typeof cwd !== "string" || !cwd) return;
|
|
420
|
+
setImmediate(async () => {
|
|
421
|
+
try {
|
|
422
|
+
await runProjectTmpSweep(cwd);
|
|
423
|
+
} catch (error: any) {
|
|
424
|
+
log.warn("tmp_sweep_failed", { error: error?.message });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
@@ -207,15 +207,35 @@ export function resolveWorktreeRoot(cwd: string): string {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/** Get project-scoped temp dir under <cwd>/.pi/tmp/
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
* Pure throwaway — everything in it is deletable by construction. Runtime
|
|
211
|
+
* state that a live daemon owns belongs in getProjectRunDir (issue #1020).
|
|
212
|
+
* Deliberately NOT worktree-aware: pidiff runs one server per cwd — sharing
|
|
213
|
+
* that across worktrees would collide (issue #918: tmp stays per-cwd). */
|
|
213
214
|
export function getProjectTmpDir(cwd: string): string {
|
|
214
215
|
const dir = path.join(cwd, ".pi", "tmp");
|
|
215
216
|
fs.mkdirSync(dir, { recursive: true });
|
|
216
217
|
return dir;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/** Get project-scoped runtime-state dir under <cwd>/.pi/run/ — pids, ports,
|
|
221
|
+
* spawn locks and the reload handshake live here because they outlive the
|
|
222
|
+
* process that wrote them and must never be swept with .pi/tmp (#1020).
|
|
223
|
+
* Per-cwd for the same reason as getProjectTmpDir (issue #918). */
|
|
224
|
+
export function getProjectRunDir(cwd: string): string {
|
|
225
|
+
const dir = path.join(cwd, ".pi", "run");
|
|
226
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
return dir;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Pre-`.pi/run` location of the same control files — the read-only fallback
|
|
231
|
+
* for a daemon or session that started before the upgrade (#1020). New code
|
|
232
|
+
* writes here only for the two migration shims: the spawn lock a pre-#1020
|
|
233
|
+
* session is serialised on, and the `reload.done` mirror a pre-#1020
|
|
234
|
+
* requester polls. */
|
|
235
|
+
export function getProjectLegacyRunDir(cwd: string): string {
|
|
236
|
+
return path.join(cwd, ".pi", "tmp");
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
/** Get the shared project data dir under <main-checkout>/.pi/data/.
|
|
220
240
|
* Resolves through the main checkout (like Python get_project_root()) so the
|
|
221
241
|
* TS and Python sides agree on one .pi/data/reviews.db from worktrees too. */
|