pi-background-run 0.4.0 → 0.6.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/README.md +480 -41
- package/extension/digestPresets.ts +316 -0
- package/extension/index.ts +2560 -360
- package/package.json +10 -3
- package/skill/digest-config/SKILL.md +117 -0
- package/skill/run-bg/SKILL.md +98 -22
- package/extension/index.test.ts +0 -1772
package/extension/index.ts
CHANGED
|
@@ -34,26 +34,607 @@ import { Type } from "typebox";
|
|
|
34
34
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
35
35
|
import { spawn } from "node:child_process";
|
|
36
36
|
import {
|
|
37
|
-
|
|
37
|
+
appendFileSync,
|
|
38
38
|
closeSync,
|
|
39
|
-
|
|
39
|
+
existsSync,
|
|
40
|
+
fstatSync,
|
|
41
|
+
readSync,
|
|
40
42
|
mkdirSync,
|
|
43
|
+
openSync,
|
|
44
|
+
readFileSync,
|
|
41
45
|
readdirSync,
|
|
46
|
+
realpathSync,
|
|
42
47
|
renameSync,
|
|
43
|
-
unlinkSync,
|
|
44
48
|
statSync,
|
|
49
|
+
unlinkSync,
|
|
45
50
|
writeFileSync,
|
|
46
51
|
} from "node:fs";
|
|
47
|
-
import { join } from "node:path";
|
|
52
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
48
53
|
import { homedir } from "node:os";
|
|
54
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
55
|
+
import {
|
|
56
|
+
DIGEST_PRESET_IDS,
|
|
57
|
+
digestNoMatchWarning,
|
|
58
|
+
selectDigestEntry,
|
|
59
|
+
type DigestEntry,
|
|
60
|
+
type DigestJobTarget,
|
|
61
|
+
type DigestMatch,
|
|
62
|
+
} from "./digestPresets.ts";
|
|
49
63
|
|
|
50
64
|
// Exit marker appended to every log so the file is self-describing: the exit
|
|
51
65
|
// code survives pi restarting. `;` (not `&&`) ensures the printf runs even when
|
|
52
66
|
// the command fails. Never use `set -e` in the wrapper.
|
|
53
67
|
const EXIT_MARKER = "__BGRUN_EXIT__=";
|
|
68
|
+
const JOBS_DIR_MARKER = ".bgrun-jobs";
|
|
69
|
+
// Tail-read caps — avoid whole-file readFileSync on runaway logs.
|
|
70
|
+
const LOG_TAIL_BYTES = 256 * 1024; // exit marker + last line
|
|
71
|
+
const LOG_READ_BYTES = 2 * 1024 * 1024; // bgtail / bggrep default window
|
|
72
|
+
const BGGREP_LINE_CAP = 10_000; // per-line match length cap
|
|
73
|
+
|
|
74
|
+
// Cap on the bytes a job may write to its log (stdout+stderr). Enforced inside
|
|
75
|
+
// the detached process tree, so it holds after pi exits. 0 = unlimited.
|
|
76
|
+
const DEFAULT_MAX_LOG_BYTES = 64 * 1024 * 1024;
|
|
77
|
+
// Above 2^53-1 a Number stringifies in exponential notation ("1e+21"), and the
|
|
78
|
+
// wrapper bakes the ceiling into the shell as a literal — `head -c 1e+21` fails
|
|
79
|
+
// and every byte of job output is discarded. A ceiling that large means
|
|
80
|
+
// "effectively unlimited", so it is clamped to the largest integral literal the
|
|
81
|
+
// shell can still parse.
|
|
82
|
+
const MAX_MAX_LOG_BYTES = Number.MAX_SAFE_INTEGER;
|
|
83
|
+
// Slack added when deriving read/count bounds from a ceiling: the wrapper writes
|
|
84
|
+
// its notice and exit marker PAST the capped bytes, so a capped log is slightly
|
|
85
|
+
// larger than the cap. A bound equal to the cap leaves the first bytes of every
|
|
86
|
+
// capped log unreadable and drops its line count.
|
|
87
|
+
const WRAPPER_OVERHEAD_BYTES = 4096;
|
|
88
|
+
// Machine-readable flags the wrapper appends to its exit marker. The marker is
|
|
89
|
+
// the one line a command cannot forge (only the LAST marker counts, so printing
|
|
90
|
+
// one is not evidence of completion) — carrying truncation there makes "the log
|
|
91
|
+
// was capped" unforgeable, unlike a printable notice line that job output can
|
|
92
|
+
// imitate.
|
|
93
|
+
const EXIT_MARKER_TRUNC_FLAG = " truncated=";
|
|
94
|
+
const EXIT_MARKER_NOCAP_FLAG = " nocap=1";
|
|
95
|
+
// Human-readable wrapper notices, in the reserved `__BGRUN_` namespace so they
|
|
96
|
+
// cannot collide with a command's own output. Readers filter them exactly like
|
|
97
|
+
// EXIT_MARKER: wrapper bookkeeping, not job output, so they are never counted as
|
|
98
|
+
// content lines or reported as the job's last line.
|
|
99
|
+
const TRUNC_NOTICE_PREFIX = "__BGRUN_TRUNC__ output truncated";
|
|
100
|
+
const CAPFAIL_NOTICE_PREFIX = "__BGRUN_NOCAP__ log ceiling unavailable";
|
|
101
|
+
// Line bound for a log scan. A byte window alone is not enough: a capped log of
|
|
102
|
+
// very short lines (the classic `yes ''` runaway) holds millions of lines in a
|
|
103
|
+
// few MiB, and materializing them as JS strings costs ~100 bytes each — measured
|
|
104
|
+
// at >3 GB of RSS for a 64 MiB window, i.e. an OOM on exactly the log class the
|
|
105
|
+
// ceiling exists for. 500k lines is ~40 MB of JS strings — a bounded cost that
|
|
106
|
+
// still dwarfs anything a real job prints into a window.
|
|
107
|
+
// ceiling exists for. Past this many lines only the tail is scanned, and the
|
|
108
|
+
// caveat says so.
|
|
109
|
+
const LOG_SCAN_LINES_MAX = 500_000;
|
|
54
110
|
|
|
55
111
|
const DEFAULT_CLEANUP_DAYS = 7;
|
|
56
112
|
const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle
|
|
113
|
+
/** Default jobs dir inside a recognizable project root (`.git` or `.pi`). */
|
|
114
|
+
const PROJECT_LOCAL_JOBS_REL = ".pi-bgrun/jobs";
|
|
115
|
+
|
|
116
|
+
// Files a spawn stages in the jobs dir under one shared
|
|
117
|
+
// `.tmp-<slug>-<ts>-<hex>` stem: the log itself (renamed to `<id>.log` once the
|
|
118
|
+
// child pid is known) plus the wrapper's exit-code, fifo, liveness and
|
|
119
|
+
// truncation-flag files. Only these exact suffixes are ours — an unrelated
|
|
120
|
+
// `.tmp-*` is not.
|
|
121
|
+
const STAGING_SUFFIXES = [".log", ".ec", ".fifo", ".pid", ".trunc"];
|
|
122
|
+
// A staging file is only reclaimable once it is clearly nobody's business: the
|
|
123
|
+
// owner's liveness file says the wrapper is gone AND the file is older than this
|
|
124
|
+
// floor. Without the floor, an aggressive cleanup cutoff (a `bgclean` "clean
|
|
125
|
+
// everything" using a tiny positive `days`) can unlink the scratch files of a
|
|
126
|
+
// job that started milliseconds ago, before its liveness file exists.
|
|
127
|
+
const STAGING_MIN_AGE_MS = 60_000;
|
|
128
|
+
|
|
129
|
+
// Machine-global jobs dir. Resolved per call (not a module constant) so
|
|
130
|
+
// PI_BGRUN_GLOBAL_DIR can redirect it — used by tests to stay off the real
|
|
131
|
+
// ~/.pi-bgrun, and available for setups with a custom home or shared scratch.
|
|
132
|
+
function globalJobsDir(): string {
|
|
133
|
+
return expandTilde(
|
|
134
|
+
process.env.PI_BGRUN_GLOBAL_DIR || join(homeDir(), ".pi-bgrun", "jobs"),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// bggrep runs caller-supplied regexes. A pathological pattern (e.g. /^(a+)+$/)
|
|
139
|
+
// can backtrack catastrophically, and V8 has no regex step limit and cannot
|
|
140
|
+
// interrupt a regex running on the main thread — so the match loop runs in a
|
|
141
|
+
// worker with a wall-clock budget. On expiry the worker is terminated and a
|
|
142
|
+
// bounded error is returned instead of hanging the session. Bun's engine is
|
|
143
|
+
// more backtracking-resistant, but Node is the common case.
|
|
144
|
+
const BGGREP_DEFAULT_TIMEOUT_MS = 2_000;
|
|
145
|
+
|
|
146
|
+
// Executed inside the worker (eval'd). Uses require(): available in an eval
|
|
147
|
+
// worker on both Node and Bun, unlike a static import (the eval body is CJS).
|
|
148
|
+
const BGGREP_WORKER_SOURCE = `
|
|
149
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
150
|
+
try {
|
|
151
|
+
const re = new RegExp(workerData.source);
|
|
152
|
+
const lines = workerData.lines;
|
|
153
|
+
const cap = workerData.cap;
|
|
154
|
+
const out = [];
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
let line = lines[i];
|
|
157
|
+
if (line.length > cap) line = line.slice(0, cap);
|
|
158
|
+
if (re.test(line)) out.push(i);
|
|
159
|
+
}
|
|
160
|
+
parentPort.postMessage({ ok: true, matches: out });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
parentPort.postMessage({ ok: false, message: String((err && err.message) || err) });
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
// Normalize a configured byte ceiling. 0 stays "unlimited"; a positive fraction
|
|
167
|
+
// (0.5) becomes 1 rather than flooring to 0, which would silently mean
|
|
168
|
+
// "unlimited"; anything above MAX_MAX_LOG_BYTES is clamped so the value always
|
|
169
|
+
// renders as a plain integer in the wrapper.
|
|
170
|
+
export function normalizeMaxLogBytes(value: unknown): number | undefined {
|
|
171
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
if (value === 0) return 0;
|
|
175
|
+
const floored = Math.floor(value);
|
|
176
|
+
if (floored < 1) return 1;
|
|
177
|
+
return Math.min(floored, MAX_MAX_LOG_BYTES);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Upper bound for an explicit search window (bgtail/bggrep `bytes`): the ceiling
|
|
181
|
+
// in force plus the wrapper's overhead, so the widest window a caller can ask
|
|
182
|
+
// for can actually cover a log the cap produced. An equal-to-cap bound (the
|
|
183
|
+
// original constant) left the first bytes of every capped log unreadable while
|
|
184
|
+
// the caveat advertised itself as the remedy.
|
|
185
|
+
export function readWindowMax(): number {
|
|
186
|
+
let cap = DEFAULT_MAX_LOG_BYTES;
|
|
187
|
+
try {
|
|
188
|
+
const configured = resolveConfig().maxLogBytes;
|
|
189
|
+
if (configured > 0) cap = configured;
|
|
190
|
+
} catch {
|
|
191
|
+
// No resolvable config → the default ceiling.
|
|
192
|
+
}
|
|
193
|
+
return cap + WRAPPER_OVERHEAD_BYTES;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Clamp a caller-supplied log search window: absent/garbage/non-positive →
|
|
197
|
+
// the default, anything wider than the bound above → the bound (searching past
|
|
198
|
+
// what a job could have written is pure cost, and the window is materialized).
|
|
199
|
+
export function clampReadWindow(bytes: unknown, max = readWindowMax()): number {
|
|
200
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
201
|
+
return LOG_READ_BYTES;
|
|
202
|
+
}
|
|
203
|
+
return Math.min(Math.floor(bytes), max);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Trim a scan window to its last LOG_SCAN_LINES_MAX lines without splitting the
|
|
207
|
+
// whole window first: walking the newline positions backwards costs one pass
|
|
208
|
+
// over the window and never materializes millions of short strings. Returns the
|
|
209
|
+
// text to scan plus whether the line bound (rather than the byte window) decided
|
|
210
|
+
// the view, so callers can say so instead of implying the whole window was read.
|
|
211
|
+
export function boundScanLines(content: string): {
|
|
212
|
+
content: string;
|
|
213
|
+
lineBoundHit: boolean;
|
|
214
|
+
} {
|
|
215
|
+
let end = content.length;
|
|
216
|
+
let seen = 0;
|
|
217
|
+
while (seen < LOG_SCAN_LINES_MAX) {
|
|
218
|
+
const nl = content.lastIndexOf("\n", end - 1);
|
|
219
|
+
if (nl === -1) break;
|
|
220
|
+
end = nl;
|
|
221
|
+
seen++;
|
|
222
|
+
}
|
|
223
|
+
// "Hit" only when the limit is what stopped the walk AND bytes were actually
|
|
224
|
+
// trimmed off the front — running out of newlines means the whole window fits.
|
|
225
|
+
const hit = seen === LOG_SCAN_LINES_MAX && end > 0;
|
|
226
|
+
return hit
|
|
227
|
+
? { content: content.slice(end + 1), lineBoundHit: true }
|
|
228
|
+
: { content, lineBoundHit: false };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Read at call time so tests (and users) can lower the budget; a non-positive
|
|
232
|
+
// or non-numeric value falls back to the default.
|
|
233
|
+
export function bggrepTimeoutMs(): number {
|
|
234
|
+
const raw = Number(process.env.PI_BGRUN_GREP_TIMEOUT_MS);
|
|
235
|
+
return Number.isFinite(raw) && raw > 0 ? raw : BGGREP_DEFAULT_TIMEOUT_MS;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type GrepMatchOutcome =
|
|
239
|
+
| { kind: "ok"; matchIdx: number[] }
|
|
240
|
+
| { kind: "timeout" }
|
|
241
|
+
| { kind: "invalid"; message: string };
|
|
242
|
+
|
|
243
|
+
// Bounded between lines only — a single pathological line can still stall.
|
|
244
|
+
// Used solely when worker_threads is unavailable (never on Node or Bun).
|
|
245
|
+
export function matchLinesSyncBounded(
|
|
246
|
+
source: string,
|
|
247
|
+
lines: string[],
|
|
248
|
+
cap: number,
|
|
249
|
+
budgetMs: number,
|
|
250
|
+
): GrepMatchOutcome {
|
|
251
|
+
let re: RegExp;
|
|
252
|
+
try {
|
|
253
|
+
re = new RegExp(source);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return { kind: "invalid", message: (err as Error).message };
|
|
256
|
+
}
|
|
257
|
+
const out: number[] = [];
|
|
258
|
+
const start = Date.now();
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
if ((i & 0x3ff) === 0 && Date.now() - start > budgetMs) {
|
|
261
|
+
return { kind: "timeout" };
|
|
262
|
+
}
|
|
263
|
+
const line = lines[i].length > cap ? lines[i].slice(0, cap) : lines[i];
|
|
264
|
+
if (re.test(line)) out.push(i);
|
|
265
|
+
}
|
|
266
|
+
return { kind: "ok", matchIdx: out };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Exported, and workerSource-injectable, so a test can prove the ABORT path on
|
|
270
|
+
// any engine: pass a worker body that never returns and the budget must still
|
|
271
|
+
// yield `{kind: "timeout"}`. Input-driven catastrophic patterns cannot test it
|
|
272
|
+
// — engines differ (V8 backtracks exponentially where JSC does not), so the
|
|
273
|
+
// only portable assertion is that termination works.
|
|
274
|
+
export async function matchLinesWithBudget(
|
|
275
|
+
source: string,
|
|
276
|
+
lines: string[],
|
|
277
|
+
cap: number,
|
|
278
|
+
budgetMs: number,
|
|
279
|
+
workerSource: string = BGGREP_WORKER_SOURCE,
|
|
280
|
+
): Promise<GrepMatchOutcome> {
|
|
281
|
+
let WorkerCtor: typeof import("node:worker_threads").Worker;
|
|
282
|
+
try {
|
|
283
|
+
({ Worker: WorkerCtor } = await import("node:worker_threads"));
|
|
284
|
+
} catch {
|
|
285
|
+
return matchLinesSyncBounded(source, lines, cap, budgetMs);
|
|
286
|
+
}
|
|
287
|
+
let worker: import("node:worker_threads").Worker;
|
|
288
|
+
try {
|
|
289
|
+
worker = new WorkerCtor(workerSource, {
|
|
290
|
+
eval: true,
|
|
291
|
+
workerData: { source, lines, cap },
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
return matchLinesSyncBounded(source, lines, cap, budgetMs);
|
|
295
|
+
}
|
|
296
|
+
return new Promise<GrepMatchOutcome>((resolve) => {
|
|
297
|
+
let settled = false;
|
|
298
|
+
const finish = (outcome: GrepMatchOutcome) => {
|
|
299
|
+
if (settled) return;
|
|
300
|
+
settled = true;
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
worker.removeAllListeners();
|
|
303
|
+
// Swallow a late 'error' emitted after listeners are dropped, or it
|
|
304
|
+
// becomes an unhandled emitter throw on the way to terminate().
|
|
305
|
+
worker.on("error", () => {});
|
|
306
|
+
void worker.terminate();
|
|
307
|
+
resolve(outcome);
|
|
308
|
+
};
|
|
309
|
+
const timer = setTimeout(() => finish({ kind: "timeout" }), budgetMs);
|
|
310
|
+
worker.on(
|
|
311
|
+
"message",
|
|
312
|
+
(msg: { ok: boolean; matches?: number[]; message?: string }) => {
|
|
313
|
+
finish(
|
|
314
|
+
msg.ok
|
|
315
|
+
? { kind: "ok", matchIdx: msg.matches ?? [] }
|
|
316
|
+
: { kind: "invalid", message: msg.message ?? "invalid pattern" },
|
|
317
|
+
);
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
worker.on("error", (err) =>
|
|
321
|
+
finish({ kind: "invalid", message: err.message }),
|
|
322
|
+
);
|
|
323
|
+
worker.on("exit", (code) => {
|
|
324
|
+
// Any exit before a message is a failure — including exit 0, which would
|
|
325
|
+
// otherwise linger until the budget and be misreported as a timeout.
|
|
326
|
+
if (!settled) {
|
|
327
|
+
finish({
|
|
328
|
+
kind: "invalid",
|
|
329
|
+
message: `grep worker exited with code ${code} before a result`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Default regex for bggrep when the caller passes no pattern: common failure
|
|
337
|
+
// signatures across test runners and build tools. ONLY a convenience default —
|
|
338
|
+
// bggrep's contract is that the caller's own pattern always wins, because a
|
|
339
|
+
// generic default on arbitrary tools/languages misses more than it catches.
|
|
340
|
+
export const DEFAULT_GREP_PATTERN =
|
|
341
|
+
"--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖";
|
|
342
|
+
|
|
343
|
+
function readLogSlice(
|
|
344
|
+
logPath: string,
|
|
345
|
+
maxBytes: number,
|
|
346
|
+
): { content: string; truncated: boolean; size: number } | null {
|
|
347
|
+
try {
|
|
348
|
+
const st = statSync(logPath);
|
|
349
|
+
const size = st.size;
|
|
350
|
+
if (size === 0) return { content: "", truncated: false, size: 0 };
|
|
351
|
+
const readLen = Math.min(size, maxBytes);
|
|
352
|
+
const fd = openSync(logPath, "r");
|
|
353
|
+
try {
|
|
354
|
+
const buf = Buffer.alloc(readLen);
|
|
355
|
+
// Honor the byte count: a short read (file rotated/truncated between stat
|
|
356
|
+
// and read) would otherwise leave the buffer's tail zero-filled and leak
|
|
357
|
+
// NUL bytes into bgtail/bggrep output.
|
|
358
|
+
const n = readSync(fd, buf, 0, readLen, size - readLen);
|
|
359
|
+
return {
|
|
360
|
+
content: (n < readLen ? buf.subarray(0, n) : buf).toString("utf8"),
|
|
361
|
+
truncated: readLen < size,
|
|
362
|
+
size,
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
closeSync(fd);
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// The wrapper writes __BGRUN_EXIT__=N as the FINAL line of the log. A marker
|
|
373
|
+
// that is NOT the last non-empty line is just job output that happened to
|
|
374
|
+
// contain the string (e.g. a command that greps a bgrun log) and is NOT
|
|
375
|
+
// evidence of completion. Position matters both ways: trusting any marker would
|
|
376
|
+
// let cleanup delete a running job's log; trusting none would let a finished
|
|
377
|
+
// log whose pid was later reused live forever.
|
|
378
|
+
function parseExitFromContent(content: string): number | null {
|
|
379
|
+
const lines = content.split("\n");
|
|
380
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
381
|
+
if (lines[i].trim().length === 0) continue;
|
|
382
|
+
const match = lines[i].match(/^__BGRUN_EXIT__=(-?\d+)/);
|
|
383
|
+
return match ? parseInt(match[1], 10) : null;
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function parseExitFromLogPath(logPath: string): number | null {
|
|
389
|
+
const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
|
|
390
|
+
if (!slice) return null;
|
|
391
|
+
return parseExitFromContent(slice.content);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Wrapper bookkeeping lines — never job output. Every reader filters them, so a
|
|
395
|
+
// capped log's last line, line count, tail window and grep results still
|
|
396
|
+
// describe the COMMAND's output rather than the wrapper's own bookkeeping.
|
|
397
|
+
// The whole `__BGRUN_` namespace is reserved (exit marker + notices), which is
|
|
398
|
+
// also what makes a command printing those lines a deliberate forgery rather
|
|
399
|
+
// than an accident.
|
|
400
|
+
export function isWrapperLine(line: string): boolean {
|
|
401
|
+
return line.startsWith("__BGRUN_");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// What the wrapper recorded about the ceiling, read from the exit marker — the
|
|
405
|
+
// last non-empty line, and the only line a command cannot forge: only the LAST
|
|
406
|
+
// marker counts, so printing one is not evidence of completion. The flag rides
|
|
407
|
+
// on that marker ("__BGRUN_EXIT__=0 truncated=1000"), which is why a command's
|
|
408
|
+
// own output can no longer make a healthy log look capped (it used to be read
|
|
409
|
+
// from the notice line, whose position and text a command controls).
|
|
410
|
+
export type CapStatus =
|
|
411
|
+
| { kind: "truncated"; bytes: number }
|
|
412
|
+
| { kind: "ceiling-failed" }
|
|
413
|
+
| null;
|
|
414
|
+
|
|
415
|
+
export function parseCapStatusFromContent(content: string): CapStatus {
|
|
416
|
+
const lines = content.split("\n");
|
|
417
|
+
let i = lines.length - 1;
|
|
418
|
+
while (i >= 0 && lines[i].trim().length === 0) i--;
|
|
419
|
+
if (i < 0) return null;
|
|
420
|
+
const marker = lines[i];
|
|
421
|
+
if (!marker.startsWith(EXIT_MARKER)) return null;
|
|
422
|
+
const truncated = marker.match(/ truncated=(\d+)/);
|
|
423
|
+
if (truncated) return { kind: "truncated", bytes: parseInt(truncated[1], 10) };
|
|
424
|
+
if (marker.includes(EXIT_MARKER_NOCAP_FLAG)) return { kind: "ceiling-failed" };
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The byte ceiling a log hit, or null when it was not capped. Thin accessor over
|
|
429
|
+
// the marker parse, kept because most callers only care about the number.
|
|
430
|
+
export function parseTruncationFromContent(content: string): number | null {
|
|
431
|
+
const status = parseCapStatusFromContent(content);
|
|
432
|
+
return status?.kind === "truncated" ? status.bytes : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The marker is written within the last few hundred bytes of the log, so the
|
|
436
|
+
// standard tail slice decides this — no full read, even at the ceiling.
|
|
437
|
+
function readCapStatus(logPath: string): CapStatus {
|
|
438
|
+
const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
|
|
439
|
+
if (!slice) return null;
|
|
440
|
+
return parseCapStatusFromContent(slice.content);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Compact byte size for the wake and reader notes ("64 MiB", "1.5 KiB",
|
|
444
|
+
// "900 bytes"). One decimal is enough: this labels a ceiling, not a quantity.
|
|
445
|
+
export function formatBytes(n: number): string {
|
|
446
|
+
if (n >= 1024 * 1024) return `${Math.round((n / (1024 * 1024)) * 10) / 10} MiB`;
|
|
447
|
+
if (n >= 1024) return `${Math.round((n / 1024) * 10) / 10} KiB`;
|
|
448
|
+
return `${n} bytes`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function readLastLogLine(logPath: string, maxLen = 200): string | null {
|
|
452
|
+
const slice = readLogSlice(logPath, LOG_TAIL_BYTES);
|
|
453
|
+
if (!slice) return null;
|
|
454
|
+
return readLastLineFromContent(slice.content, maxLen);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function readLastLineFromContent(content: string, maxLen = 200): string | null {
|
|
458
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
459
|
+
if (lines.length === 0) return null;
|
|
460
|
+
const real = lines.filter((l) => !isWrapperLine(l));
|
|
461
|
+
// No content lines (a marker-only log) → nothing to show. Never fall back to
|
|
462
|
+
// the exit-marker line — that leaks "__BGRUN_EXIT__=N" into the wake.
|
|
463
|
+
if (real.length === 0) return null;
|
|
464
|
+
const last = real[real.length - 1];
|
|
465
|
+
return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function validateJobId(id: string, tool: string): void {
|
|
469
|
+
if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) {
|
|
470
|
+
throw new Error(`${tool}: invalid job id ${JSON.stringify(id)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isRunningPid(pid: number): boolean {
|
|
475
|
+
if (pid <= 0) return false;
|
|
476
|
+
try {
|
|
477
|
+
process.kill(pid, 0);
|
|
478
|
+
return true;
|
|
479
|
+
} catch (err) {
|
|
480
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
481
|
+
// EPERM means the process exists but we can't signal it — treat as alive.
|
|
482
|
+
return code === "EPERM";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function pidFromId(id: string): number | null {
|
|
487
|
+
const parts = id.split("-");
|
|
488
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
489
|
+
return Number.isFinite(pid) ? pid : null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// One entry per *.log in a jobs dir, with the derived state every caller needs
|
|
493
|
+
// (finish marker, owning pid + liveness, timestamps). This is the single scan
|
|
494
|
+
// used by cleanup, foreign-job adoption, and bgstatus — they used to each
|
|
495
|
+
// re-implement the readdir/filter/parse/pid dance and drifted apart.
|
|
496
|
+
interface ScannedLogFile {
|
|
497
|
+
id: string;
|
|
498
|
+
logPath: string;
|
|
499
|
+
pid: number | null; // pid encoded in the id's last segment
|
|
500
|
+
alive: boolean; // pid > 0 and signalable (or EPERM)
|
|
501
|
+
mtimeMs: number;
|
|
502
|
+
birthtimeMs: number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
interface ScannedLog extends ScannedLogFile {
|
|
506
|
+
exit: number | null; // parsed __BGRUN_EXIT__ marker, null while running
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The scan WITHOUT the exit-marker read. Exit parsing needs a tail read of the
|
|
510
|
+
// file, so callers that can filter by mtime first (cleanup) use this and pay
|
|
511
|
+
// for the read only on files they may actually act on.
|
|
512
|
+
function scanLogFiles(jobsDir: string): ScannedLogFile[] {
|
|
513
|
+
let names: string[];
|
|
514
|
+
try {
|
|
515
|
+
names = readdirSync(jobsDir);
|
|
516
|
+
} catch {
|
|
517
|
+
return []; // jobs dir doesn't exist — nothing to scan
|
|
518
|
+
}
|
|
519
|
+
const out: ScannedLogFile[] = [];
|
|
520
|
+
for (const name of names) {
|
|
521
|
+
if (!name.endsWith(".log")) continue;
|
|
522
|
+
// .tmp-*.log is the pre-rename staging file (see the spawn path). It is
|
|
523
|
+
// never a job — a crashed spawn can leave one behind; sweepStaleMarkers
|
|
524
|
+
// reclaims it.
|
|
525
|
+
if (name.startsWith(".tmp-")) continue;
|
|
526
|
+
const logPath = join(jobsDir, name);
|
|
527
|
+
let st: ReturnType<typeof statSync>;
|
|
528
|
+
try {
|
|
529
|
+
st = statSync(logPath);
|
|
530
|
+
} catch {
|
|
531
|
+
continue; // vanished between readdir and stat
|
|
532
|
+
}
|
|
533
|
+
const pid = pidFromId(name.slice(0, -".log".length));
|
|
534
|
+
out.push({
|
|
535
|
+
id: name.slice(0, -".log".length),
|
|
536
|
+
logPath,
|
|
537
|
+
pid,
|
|
538
|
+
alive: pid !== null && pid > 0 && isRunningPid(pid),
|
|
539
|
+
mtimeMs: st.mtimeMs,
|
|
540
|
+
birthtimeMs: st.birthtimeMs,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The full scan (exit marker resolved) for callers that need finished/running
|
|
547
|
+
// state for every entry.
|
|
548
|
+
function scanJobsDir(jobsDir: string): ScannedLog[] {
|
|
549
|
+
return scanLogFiles(jobsDir).map((e) => ({
|
|
550
|
+
...e,
|
|
551
|
+
exit: parseExitFromLogPath(e.logPath),
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Redact obvious credential values before they reach a filename, widget, or
|
|
556
|
+
// status line. The raw command still appears in the wake message (needed for
|
|
557
|
+
// context), but the persisted job id / slug is a much longer-lived leak
|
|
558
|
+
// channel (it survives in filenames and `bgstatus` output for cleanupDays).
|
|
559
|
+
// ── Secret redaction for slugs ─────────────────────────────────────────────
|
|
560
|
+
// A job id becomes a filename, and filenames get listed, shared, and scraped.
|
|
561
|
+
// Commands routinely embed credentials, so redact values BEFORE they reach a
|
|
562
|
+
// slug. This deliberately errs toward over-redaction: a mangled slug is
|
|
563
|
+
// cosmetic, a leaked token is not.
|
|
564
|
+
|
|
565
|
+
// Secret-ish key words, matched as a substring of a longer key (GH_TOKEN,
|
|
566
|
+
// AWS_SECRET_ACCESS_KEY, DB_PASSWORD) with a trailing non-letter guard so
|
|
567
|
+
// "author"/"designer" are not mistaken for "auth"/"sig".
|
|
568
|
+
const SECRET_KEY_WORDS =
|
|
569
|
+
"authorization|pass(?:word|wd|phrase)?|passw(?:or)?d|secret|token|" +
|
|
570
|
+
"api[-_]?key|apikey|access[-_]?key|private[-_]?key|client[-_]?secret|" +
|
|
571
|
+
"credential(?:s)?|session[-_]?id|signature|pwd|bearer|auth";
|
|
572
|
+
// A key: optional surrounding word chars/dots/dashes, then a secret word.
|
|
573
|
+
const SECRET_KEY = String.raw`[A-Za-z0-9_.-]*(?:${SECRET_KEY_WORDS})(?![A-Za-z])`;
|
|
574
|
+
// A value: a quoted string, a `scheme credential` pair ("Bearer abc"), or a
|
|
575
|
+
// bare token. The scheme form is tried first so the credential after it is
|
|
576
|
+
// consumed too — otherwise "Authorization: Bearer abc" redacts only "Bearer".
|
|
577
|
+
const SECRET_VALUE = String.raw`(?:'[^']*'|"[^"]*"|(?:bearer|basic|token|digest)\s+\S+|\S+)`;
|
|
578
|
+
const SECRET_ASSIGN_RE = new RegExp(
|
|
579
|
+
String.raw`(${SECRET_KEY})["']?\s*[:=]\s*["']?${SECRET_VALUE}`,
|
|
580
|
+
"gi",
|
|
581
|
+
);
|
|
582
|
+
const SECRET_FLAG_RE = new RegExp(
|
|
583
|
+
String.raw`(^|\s)(-{1,2}${SECRET_KEY})(\s*[:=]\s*|\s+)["']?${SECRET_VALUE}`,
|
|
584
|
+
"gi",
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
export function redactForSlug(command: string): string {
|
|
588
|
+
return (
|
|
589
|
+
command
|
|
590
|
+
// Header arguments: -H stays CASE-SENSITIVE (so a lower-case `-h`/help
|
|
591
|
+
// flag is never mangled); --header is case-insensitive. The whole
|
|
592
|
+
// argument is consumed — any header can carry a token.
|
|
593
|
+
.replace(/(^|\s)-H(=|\s+)('[^']*'|"[^"]*"|\S+)/g, "$1-H$2-REDACTED")
|
|
594
|
+
.replace(
|
|
595
|
+
/(^|\s)--header(=|\s+)('[^']*'|"[^"]*"|\S+)/gi,
|
|
596
|
+
"$1--header$2-REDACTED",
|
|
597
|
+
)
|
|
598
|
+
// curl -u user:pass / --user user:pass (only when it looks like a pair,
|
|
599
|
+
// so unrelated flags like `sort -u` are left alone).
|
|
600
|
+
.replace(/(^|\s)-u(\s+)([^\s:]+:[^\s]+)/g, "$1-u$2-REDACTED")
|
|
601
|
+
.replace(/(^|\s)--user(\s+)([^\s:]+:[^\s]+)/g, "$1--user$2-REDACTED")
|
|
602
|
+
// URL userinfo: scheme://user:pass@host.
|
|
603
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, "$1-REDACTED@")
|
|
604
|
+
// KEY=value / KEY: value, including quoted JSON ("password":"x").
|
|
605
|
+
.replace(SECRET_ASSIGN_RE, "$1-REDACTED")
|
|
606
|
+
// --flag value / --flag=value / --flag: value.
|
|
607
|
+
.replace(SECRET_FLAG_RE, "$1$2-REDACTED")
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveGitCommonDir(gitDir: string): string {
|
|
612
|
+
const commonFile = join(gitDir, "commondir");
|
|
613
|
+
if (!existsSync(commonFile)) return gitDir;
|
|
614
|
+
try {
|
|
615
|
+
const rel = readFileSync(commonFile, "utf8").trim();
|
|
616
|
+
return isAbsolute(rel) ? rel : join(gitDir, rel);
|
|
617
|
+
} catch {
|
|
618
|
+
return gitDir;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function ensureJobsDirMarker(jobsDir: string): void {
|
|
623
|
+
try {
|
|
624
|
+
mkdirSync(jobsDir, { recursive: true });
|
|
625
|
+
const marker = join(jobsDir, JOBS_DIR_MARKER);
|
|
626
|
+
if (!existsSync(marker)) writeFileSync(marker, "");
|
|
627
|
+
} catch {
|
|
628
|
+
// best-effort
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function logReadError(id: string, logPath: string): string {
|
|
633
|
+
if (existsSync(logPath)) {
|
|
634
|
+
return `Log for job ${id} at ${logPath} exists but could not be read (file may be too large or unreadable)`;
|
|
635
|
+
}
|
|
636
|
+
return `No log found for job ${id} at ${logPath}`;
|
|
637
|
+
}
|
|
57
638
|
|
|
58
639
|
// ── Configuration ───────────────────────────────────────────────────────────
|
|
59
640
|
//
|
|
@@ -66,6 +647,11 @@ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child h
|
|
|
66
647
|
|
|
67
648
|
interface BgrunConfig {
|
|
68
649
|
jobsDir: string;
|
|
650
|
+
// True when jobsDir resolves inside the project root (the default in a
|
|
651
|
+
// recognizable project, or an explicit RELATIVE path). Only then does bgrun
|
|
652
|
+
// auto-ignore the dir in .git/info/exclude — an absolute dir is the user's
|
|
653
|
+
// explicit choice.
|
|
654
|
+
jobsDirProjectLocal: boolean;
|
|
69
655
|
// Adopt other sessions' running jobs (found in the shared jobs dir) into
|
|
70
656
|
// this session's widget and job list. Default false — most sessions don't
|
|
71
657
|
// want unrelated jobs from other projects cluttering the widget.
|
|
@@ -75,14 +661,32 @@ interface BgrunConfig {
|
|
|
75
661
|
showCompletedJobs: boolean;
|
|
76
662
|
// Log retention for cleanup (auto-sweeps and the bgclean default).
|
|
77
663
|
cleanupDays: number;
|
|
78
|
-
//
|
|
664
|
+
// Byte ceiling for a job's log (stdout+stderr). A runaway job (`yes`, a spew
|
|
665
|
+
// loop) would otherwise fill the disk. The cap keeps the FIRST maxLogBytes
|
|
666
|
+
// bytes and appends a truncation notice; the job itself runs to completion
|
|
667
|
+
// with its real exit code. 0 = unlimited. Read per job at spawn time.
|
|
668
|
+
maxLogBytes: number;
|
|
669
|
+
// Auto-sweep the shared jobs dirs at session boundaries for orphans —
|
|
79
670
|
// finished (exit marker or dead pid) logs older than cleanupDays from
|
|
80
|
-
// sessions that crashed or are never resumed again.
|
|
671
|
+
// sessions that crashed or are never resumed again. Both the machine-global
|
|
672
|
+
// dir and the current project's dir are swept (see sharedJobsDirs), so
|
|
673
|
+
// pre-project-local logs are still reclaimed. Running jobs are always
|
|
81
674
|
// pid-protected. Throttled to once per cleanupDays via a .last-clean marker.
|
|
82
675
|
// Default true — without it, orphaned logs accumulate forever. Set false to
|
|
83
676
|
// keep every sweep session-scoped (then only `bgclean all` touches foreign
|
|
84
677
|
// logs).
|
|
85
678
|
globalAutoClean: boolean;
|
|
679
|
+
// Opt-in digest scorecards, normalized to an ordered list of entries (or
|
|
680
|
+
// undefined when unconfigured or fully invalid — an empty array is normalized
|
|
681
|
+
// to undefined so the session_start nudge still sees "not configured"). The
|
|
682
|
+
// object form is normalized to a single entry with no matchers. Each entry
|
|
683
|
+
// carries an optional `match` (globs against the job name / command line),
|
|
684
|
+
// an optional wake `label`, and a `preset` or custom `command`. At wake time
|
|
685
|
+
// the FIRST matching entry wins. Presets are shipped sh commands (see
|
|
686
|
+
// digestPresets.ts); command receives the job's log path as $1. Resolved from
|
|
687
|
+
// trusted project config only — never runs pattern matching unless the
|
|
688
|
+
// project opted in.
|
|
689
|
+
digest?: DigestEntry[];
|
|
86
690
|
}
|
|
87
691
|
|
|
88
692
|
interface BgrunConfigFile {
|
|
@@ -90,7 +694,9 @@ interface BgrunConfigFile {
|
|
|
90
694
|
adoptForeignJobs?: unknown;
|
|
91
695
|
showCompletedJobs?: unknown;
|
|
92
696
|
cleanupDays?: unknown;
|
|
697
|
+
maxLogBytes?: unknown;
|
|
93
698
|
globalAutoClean?: unknown;
|
|
699
|
+
digest?: unknown;
|
|
94
700
|
}
|
|
95
701
|
|
|
96
702
|
function parseBoolEnv(v: string | undefined): boolean | undefined {
|
|
@@ -102,28 +708,533 @@ function parseBoolEnv(v: string | undefined): boolean | undefined {
|
|
|
102
708
|
}
|
|
103
709
|
|
|
104
710
|
function readConfigFile(path: string): BgrunConfigFile {
|
|
711
|
+
let text: string;
|
|
712
|
+
try {
|
|
713
|
+
text = readFileSync(path, "utf8");
|
|
714
|
+
} catch {
|
|
715
|
+
return {}; // missing — normal, not an error
|
|
716
|
+
}
|
|
105
717
|
try {
|
|
106
|
-
const raw = JSON.parse(
|
|
718
|
+
const raw = JSON.parse(text);
|
|
107
719
|
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
108
720
|
return raw as BgrunConfigFile;
|
|
109
|
-
|
|
110
|
-
|
|
721
|
+
console.error(
|
|
722
|
+
`[pi-bgrun] config ${path} is not a JSON object — ignoring its contents`,
|
|
723
|
+
);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
console.error(
|
|
726
|
+
`[pi-bgrun] config ${path} is malformed JSON (${(err as Error).message}) — ignoring its contents`,
|
|
727
|
+
);
|
|
111
728
|
}
|
|
112
729
|
return {};
|
|
113
730
|
}
|
|
114
731
|
|
|
732
|
+
// A byte-budget copier that writes as it reads: keep the first $cap bytes of
|
|
733
|
+
// stdin on stdout, flag (O_EXCL, 0600) if anything was left over, and drain the
|
|
734
|
+
// rest so the producer never gets SIGPIPE. Used as the drain when perl is
|
|
735
|
+
// available: `dd` and `head` are the portable choices, but both buffer their
|
|
736
|
+
// output — measured: nothing on disk until 4-8 KiB accumulated, so a running
|
|
737
|
+
// capped job's log looks stalled for a slow producer, breaking the live-tail
|
|
738
|
+
// workflow bgtail documents. perl's sysread/syswrite has no stdio buffering, so
|
|
739
|
+
// the first byte lands immediately. The program avoids quotes so it can be
|
|
740
|
+
// single-quoted in the wrapper.
|
|
741
|
+
const PERL_CAP_COPIER = [
|
|
742
|
+
`use Fcntl;`,
|
|
743
|
+
`my $cap = $ARGV[0]; my $flag = $ARGV[1]; my $left = $cap; my $over = 0; my $flagged = 0; my $buf;`,
|
|
744
|
+
`while (1) {`,
|
|
745
|
+
` my $n = sysread(STDIN, $buf, 65536);`,
|
|
746
|
+
` last if !defined($n) || $n == 0;`,
|
|
747
|
+
` if ($left > 0) {`,
|
|
748
|
+
` my $take = $n < $left ? $n : $left;`,
|
|
749
|
+
` my $off = 0;`,
|
|
750
|
+
` while ($off < $take) { my $w = syswrite(STDOUT, $buf, $take - $off, $off); last if !defined($w) || $w <= 0; $off += $w; }`,
|
|
751
|
+
` $left -= $off;`,
|
|
752
|
+
` $over = 1 if $n > $take;`,
|
|
753
|
+
` } else { $over = 1; }`,
|
|
754
|
+
` if ($over && !$flagged) { my $fh; if (sysopen($fh, $flag, O_WRONLY | O_CREAT | O_EXCL, 0600)) { close($fh); } $flagged = 1; }`,
|
|
755
|
+
`}`,
|
|
756
|
+
].join("\n");
|
|
757
|
+
|
|
758
|
+
// ── Job wrapper ─────────────────────────────────────────────────────────────
|
|
759
|
+
//
|
|
760
|
+
// Every job runs inside a detached `sh -c` tree, so the log ceiling has to live
|
|
761
|
+
// there too — it must hold after pi exits. The command is passed as argv ($1),
|
|
762
|
+
// never interpolated, or `#`, quotes and heredocs would break.
|
|
763
|
+
//
|
|
764
|
+
// Capped shape: the command runs as its OWN background job writing into a fifo;
|
|
765
|
+
// a drain copies at most `cap` bytes of that into the log and then reports
|
|
766
|
+
// whether anything was left over. Two properties drive that structure:
|
|
767
|
+
//
|
|
768
|
+
// - Completion must follow the COMMAND, not the data flow. As a pipeline stage,
|
|
769
|
+
// `wait` would return only when every holder of the pipe's write end closes
|
|
770
|
+
// it — and a child the command backgrounded (`server &`, a watcher, a
|
|
771
|
+
// daemonized tool) inherited that fd, so the job would never wake while the
|
|
772
|
+
// child lived. `wait "$prod"` returns when `sh -c` is reaped; the strays keep
|
|
773
|
+
// running, they just stop being logged (which is the point of a ceiling).
|
|
774
|
+
// - The drain may outlive the command, so truncation is reported through a flag
|
|
775
|
+
// FILE, and the wrapper's exit marker carries the machine-readable flag. A
|
|
776
|
+
// notice line in the log is not evidence: a command can print the same text,
|
|
777
|
+
// and only the LAST marker counts, so the marker is the one line a command
|
|
778
|
+
// cannot forge.
|
|
779
|
+
//
|
|
780
|
+
// The drain's byte budget is exact only with `dd iflag=fullblock` (each block is
|
|
781
|
+
// filled before it counts): plain `dd` counts READS, so a slow writer would
|
|
782
|
+
// exhaust the budget without filling the cap. Without `iflag` (most non-GNU
|
|
783
|
+
// systems) `head -c` is used instead — exact, but block-buffered, so a running
|
|
784
|
+
// job's log lags by up to 8 KiB until the job exits.
|
|
785
|
+
//
|
|
786
|
+
// If `mkfifo` fails, fall back to the uncapped path: losing output is worse than
|
|
787
|
+
// losing the ceiling — but say so, in the log and in the marker.
|
|
788
|
+
//
|
|
789
|
+
// argv: $1 command, $2 ecfile, $3 fifo, $4 pidfile, $5 truncation flag.
|
|
790
|
+
export function cappedWrapper(maxBytes: number): string {
|
|
791
|
+
const cap = String(maxBytes);
|
|
792
|
+
return [
|
|
793
|
+
`flag=`,
|
|
794
|
+
// Staging names are ours: clear any leftover or planted entry first — rm
|
|
795
|
+
// unlinks the name and never follows a link — and every write below runs
|
|
796
|
+
// under `set -C` (noclobber) so a path that reappears is refused rather than
|
|
797
|
+
// written through. umask is scoped to those writes: the command must keep
|
|
798
|
+
// its own.
|
|
799
|
+
`rm -f "$2" "$3" "$4" "$5" 2>/dev/null`,
|
|
800
|
+
`if mkfifo -m 600 "$3" 2>/dev/null && [ -p "$3" ]; then`,
|
|
801
|
+
` ( umask 077; set -C; printf '%d' "$$" >"$4" ) 2>/dev/null || :`,
|
|
802
|
+
` { sh -c "$1" 2>&1; ec=$?; ( umask 077; set -C; printf '%d' "$ec" >"$2" ) 2>/dev/null; } >"$3" &`,
|
|
803
|
+
` prod=$!`,
|
|
804
|
+
` { if command -v perl >/dev/null 2>&1; then`,
|
|
805
|
+
// One process: cap + flag + drain, no stdio buffering (see PERL_CAP_COPIER).
|
|
806
|
+
` perl -e '${PERL_CAP_COPIER}' ${cap} "$5"`,
|
|
807
|
+
` elif dd iflag=fullblock bs=1 count=0 </dev/null >/dev/null 2>&1; then`,
|
|
808
|
+
// Exact, but block-buffered: the log lags by up to one block while the job
|
|
809
|
+
// runs. (Plain `dd` is worse: it counts READS, so a slow writer exhausts the
|
|
810
|
+
// budget without filling the cap and later output is dropped.)
|
|
811
|
+
` { dd iflag=fullblock bs=4096 count=$(( ${cap} / 4096 )) 2>/dev/null; dd iflag=fullblock bs=1 count=$(( ${cap} % 4096 )) 2>/dev/null; }`,
|
|
812
|
+
` if [ "$(dd bs=1 count=1 2>/dev/null | wc -c)" -gt 0 ]; then ( umask 077; set -C; : >"$5" ) 2>/dev/null || :; fi`,
|
|
813
|
+
` cat >/dev/null`,
|
|
814
|
+
` else`,
|
|
815
|
+
` head -c ${cap}`,
|
|
816
|
+
` if [ "$(dd bs=1 count=1 2>/dev/null | wc -c)" -gt 0 ]; then ( umask 077; set -C; : >"$5" ) 2>/dev/null || :; fi`,
|
|
817
|
+
` cat >/dev/null`,
|
|
818
|
+
` fi; } <"$3" &`,
|
|
819
|
+
` drain=$!`,
|
|
820
|
+
` wait "$prod"`,
|
|
821
|
+
// The command is done. The drain copies unbuffered, so there is nothing to
|
|
822
|
+
// flush — this short bounded wait only gives it the moment it needs to
|
|
823
|
+
// notice EOF. A child the command backgrounded and did not wait for can hold
|
|
824
|
+
// the fifo open indefinitely; the job must complete anyway (and that stray's
|
|
825
|
+
// output simply stops being logged, which is what a ceiling is for). Note
|
|
826
|
+
// that after the byte budget is spent only the discard stage remains, so
|
|
827
|
+
// nothing can be written to the log after these notices.
|
|
828
|
+
` j=0`,
|
|
829
|
+
` while kill -0 "$drain" 2>/dev/null && [ "$j" -lt 5 ]; do sleep 0.02; j=$((j + 1)); done`,
|
|
830
|
+
` ec=$(if [ -f "$2" ]; then cat "$2" 2>/dev/null; fi)`,
|
|
831
|
+
` if [ -e "$5" ]; then`,
|
|
832
|
+
` printf '\\n${TRUNC_NOTICE_PREFIX}: kept the first %s bytes\\n' ${cap}`,
|
|
833
|
+
` flag="${EXIT_MARKER_TRUNC_FLAG}${cap}"`,
|
|
834
|
+
` fi`,
|
|
835
|
+
`else`,
|
|
836
|
+
` sh -c "$1" 2>&1`,
|
|
837
|
+
` ec=$?`,
|
|
838
|
+
` printf '\\n${CAPFAIL_NOTICE_PREFIX} (mkfifo failed, so this job ran uncapped)\\n'`,
|
|
839
|
+
` flag="${EXIT_MARKER_NOCAP_FLAG}"`,
|
|
840
|
+
`fi`,
|
|
841
|
+
`rm -f "$2" "$3" "$4" "$5" 2>/dev/null`,
|
|
842
|
+
`[ -n "$ec" ] || ec=-1`,
|
|
843
|
+
`printf '\\n%s%d%s\\n' "${EXIT_MARKER}" "$ec" "\${flag}"`,
|
|
844
|
+
`exit "$ec"`,
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── Project-local jobs dir ──────────────────────────────────────────────────
|
|
849
|
+
//
|
|
850
|
+
// By default, when the session cwd is inside a recognizable project root
|
|
851
|
+
// (`.git` or `.pi`), logs land at `<project>/.pi-bgrun/jobs`. The root is found
|
|
852
|
+
// by walking up from the cwd, so a session started in a subdirectory still
|
|
853
|
+
// resolves project-locally. With no project root the default falls back to the
|
|
854
|
+
// machine-global `~/.pi-bgrun/jobs`. An explicit RELATIVE `jobsDir` (from any
|
|
855
|
+
// config layer, or PI_BGRUN_DIR) resolves the same way; an absolute path is
|
|
856
|
+
// used as-is (migration-safe). Project-local logs stay inside the workspace
|
|
857
|
+
// sandbox so analysis tools confined to the project root (e.g. context-mode's
|
|
858
|
+
// ctx_execute_file/ctx_index) can process whole logs without flooding context.
|
|
859
|
+
|
|
860
|
+
function isProjectRootLike(dir: string): boolean {
|
|
861
|
+
// Cheap heuristic: a directory holding .git or pi's config dir is a project.
|
|
862
|
+
return (
|
|
863
|
+
existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME))
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// realpath that never throws: a non-existent or unreadable path falls back to
|
|
868
|
+
// the literal path so callers can compare paths without guarding every step.
|
|
869
|
+
function safeRealpath(p: string): string {
|
|
870
|
+
try {
|
|
871
|
+
return realpathSync(p);
|
|
872
|
+
} catch {
|
|
873
|
+
return p;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Nearest ancestor of `start` (inclusive) that looks like a project root.
|
|
878
|
+
// The user's home directory is never treated as a project root: pi's global
|
|
879
|
+
// agent dir (~/.pi/agent) would otherwise make every cwd under $HOME resolve
|
|
880
|
+
// to $HOME. Paths are canonicalized so a symlinked $HOME is still recognized.
|
|
881
|
+
function findProjectRoot(
|
|
882
|
+
start: string,
|
|
883
|
+
home: string = homeDir(),
|
|
884
|
+
): string | undefined {
|
|
885
|
+
const homeReal = safeRealpath(home);
|
|
886
|
+
let cur = start;
|
|
887
|
+
for (;;) {
|
|
888
|
+
if (safeRealpath(cur) !== homeReal && isProjectRootLike(cur)) return cur;
|
|
889
|
+
const parent = dirname(cur);
|
|
890
|
+
if (parent === cur || safeRealpath(cur) === homeReal) return undefined;
|
|
891
|
+
cur = parent;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// The user's home directory, HOME-first. Node's os.homedir() already resolves
|
|
896
|
+
// HOME before falling back to the passwd entry, but Bun's ignores HOME — so
|
|
897
|
+
// deriving it here keeps `~`, the machine-global jobs dir and the project-root
|
|
898
|
+
// exclusion identical under both runtimes (and lets tests pin HOME).
|
|
899
|
+
function homeDir(): string {
|
|
900
|
+
return process.env.HOME || homedir();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Expand a leading `~` (bare or `~/...`) to the user's home directory so a
|
|
904
|
+
// config/env path like `~/.pi-bgrun/jobs` is absolute rather than a relative
|
|
905
|
+
// path interpreted project-locally.
|
|
906
|
+
function expandTilde(p: string): string {
|
|
907
|
+
if (p === "~") return homeDir();
|
|
908
|
+
if (p.startsWith("~/")) return join(homeDir(), p.slice(2));
|
|
909
|
+
return p;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Project/worktree root for identity keys — the enclosing project root when
|
|
913
|
+
// there is one, else the directory itself (so a non-project cwd still gets a
|
|
914
|
+
// stable key). Matches the root resolveJobsDirPath uses for project-local
|
|
915
|
+
// logs, so two cwds in the same checkout share one digest-nudge key.
|
|
916
|
+
function projectRootFor(dir: string): string {
|
|
917
|
+
return findProjectRoot(dir) ?? dir;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function resolveJobsDirPath(
|
|
921
|
+
raw: string | undefined,
|
|
922
|
+
ctx?: { cwd?: string; home?: string },
|
|
923
|
+
): { dir: string; projectLocal: boolean } {
|
|
924
|
+
const p = raw ? expandTilde(raw) : raw;
|
|
925
|
+
// Absolute paths are the user's explicit choice: used as-is, never flagged
|
|
926
|
+
// project-local, and no ancestor walk needed.
|
|
927
|
+
if (p && isAbsolute(p)) return { dir: p, projectLocal: false };
|
|
928
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
929
|
+
const root = cwd ? findProjectRoot(cwd, ctx?.home) : undefined;
|
|
930
|
+
if (!p) {
|
|
931
|
+
return root
|
|
932
|
+
? { dir: join(root, PROJECT_LOCAL_JOBS_REL), projectLocal: true }
|
|
933
|
+
: { dir: globalJobsDir(), projectLocal: false };
|
|
934
|
+
}
|
|
935
|
+
if (!root) return { dir: globalJobsDir(), projectLocal: false };
|
|
936
|
+
// A relative path can escape the project root ("../outside"); only flag it
|
|
937
|
+
// project-local when the joined dir actually stays inside the root, so git
|
|
938
|
+
// exclusion and the untrusted-repo guard apply to the right thing.
|
|
939
|
+
const joined = join(root, p);
|
|
940
|
+
const rel = relative(root, joined);
|
|
941
|
+
// `..foo` is a sibling, not an escape — only `..` itself or `../` escapes.
|
|
942
|
+
const inside =
|
|
943
|
+
rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
|
|
944
|
+
return { dir: joined, projectLocal: inside };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Auto-ignore a project-local jobs dir in git so logs never pollute
|
|
948
|
+
// `git status`: appends the dir pattern to the enclosing repo's
|
|
949
|
+
// .git/info/exclude (local-only — the tracked .gitignore is never touched).
|
|
950
|
+
// Memoized only on SUCCESS — a transient failure (unwritable exclude file,
|
|
951
|
+
// .git appearing later) is retried on the next bgrun. Every step is
|
|
952
|
+
// best-effort and must never fail a bgrun.
|
|
953
|
+
const gitExcludedDirs = new Set<string>();
|
|
954
|
+
|
|
955
|
+
// Returns true when the dir is settled (pattern written, already present, or
|
|
956
|
+
// legitimately nothing to do — no repo above, dir is the repo root itself).
|
|
957
|
+
// False only on failure, so the caller retries next time.
|
|
958
|
+
export function ensureGitExcluded(jobsDir: string): boolean {
|
|
959
|
+
if (gitExcludedDirs.has(jobsDir)) return true;
|
|
960
|
+
if (tryEnsureGitExcluded(jobsDir)) {
|
|
961
|
+
gitExcludedDirs.add(jobsDir);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function tryEnsureGitExcluded(jobsDir: string): boolean {
|
|
968
|
+
try {
|
|
969
|
+
// Walk up from jobsDir to the enclosing work tree.
|
|
970
|
+
let cur = jobsDir;
|
|
971
|
+
for (;;) {
|
|
972
|
+
const dot = join(cur, ".git");
|
|
973
|
+
if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir);
|
|
974
|
+
const parent = dirname(cur);
|
|
975
|
+
if (parent === cur) return true; // filesystem root — no repo above; nothing to do
|
|
976
|
+
cur = parent;
|
|
977
|
+
}
|
|
978
|
+
} catch {
|
|
979
|
+
// best-effort — ignore hygiene must never break job creation
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function appendExcludePattern(
|
|
985
|
+
repoRoot: string,
|
|
986
|
+
dotGit: string,
|
|
987
|
+
jobsDir: string,
|
|
988
|
+
): boolean {
|
|
989
|
+
if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do
|
|
990
|
+
// `.git` is a directory in a normal checkout, or a file pointing at the
|
|
991
|
+
// real git dir in linked worktrees (git worktree add) and submodules.
|
|
992
|
+
let gitDir = dotGit;
|
|
993
|
+
if (statSync(dotGit).isFile()) {
|
|
994
|
+
const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
|
|
995
|
+
if (!m) return false; // unparseable .git file — retry later
|
|
996
|
+
gitDir = m[1].trim();
|
|
997
|
+
}
|
|
998
|
+
gitDir = resolveGitCommonDir(gitDir);
|
|
999
|
+
const rel = relative(repoRoot, jobsDir);
|
|
1000
|
+
// Defense-in-depth: the walk-up guarantees jobsDir sits under repoRoot, but
|
|
1001
|
+
// a future caller or symlinked path could break that — ../-prefixed
|
|
1002
|
+
// patterns are silently useless in gitignore semantics, so skip them.
|
|
1003
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
|
|
1004
|
+
return true;
|
|
1005
|
+
const pattern = rel.split(sep).join("/") + "/";
|
|
1006
|
+
const excludePath = join(gitDir, "info", "exclude");
|
|
1007
|
+
let existing = "";
|
|
1008
|
+
try {
|
|
1009
|
+
existing = readFileSync(excludePath, "utf8");
|
|
1010
|
+
} catch {
|
|
1011
|
+
// no exclude file yet — we'll create it
|
|
1012
|
+
}
|
|
1013
|
+
if (existing.split("\n").some((l) => l.trim() === pattern)) return true;
|
|
1014
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
1015
|
+
appendFileSync(
|
|
1016
|
+
excludePath,
|
|
1017
|
+
`\n# pi-bgrun job logs (auto-added)\n${pattern}\n`,
|
|
1018
|
+
);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Digest config validation: invalid values are dropped from the resolved
|
|
1023
|
+
// config (best-effort — a malformed digest section must never break a wake or
|
|
1024
|
+
// the whole config), but the human gets one console.error per distinct invalid
|
|
1025
|
+
// field so typos are discoverable without flooding the log. The field set is a
|
|
1026
|
+
// fixed, code-defined list (preset / command / match / type / ...), so the
|
|
1027
|
+
// dedupe set is naturally bounded.
|
|
1028
|
+
const digestWarned = new Set<string>();
|
|
1029
|
+
function warnDigestInvalid(field: string, value: unknown): void {
|
|
1030
|
+
if (digestWarned.has(field)) return;
|
|
1031
|
+
digestWarned.add(field);
|
|
1032
|
+
const hint =
|
|
1033
|
+
field === "preset"
|
|
1034
|
+
? ` — valid presets: ${DIGEST_PRESET_IDS.join(", ")}`
|
|
1035
|
+
: "";
|
|
1036
|
+
// Point at the array form too: the same `digest` key accepts an ordered
|
|
1037
|
+
// list of { type, match, label, preset, command } entries.
|
|
1038
|
+
const shapeHint =
|
|
1039
|
+
" — digest takes an object or an array of { type, match, label, preset, command } entries";
|
|
1040
|
+
// field "" means the whole `digest` section was unusable (wrong shape).
|
|
1041
|
+
const where = field ? `digest.${field}` : "digest";
|
|
1042
|
+
console.error(
|
|
1043
|
+
`[pi-bgrun] ignoring invalid ${where} in pi-bgrun.json: ${JSON.stringify(value)}${hint}${shapeHint}`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function projectHash(projectDir: string): string {
|
|
1048
|
+
return createHash("sha256").update(projectDir).digest("hex").slice(0, 16);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Per-project "this project has run a bgrun job" marker in the jobs dir,
|
|
1053
|
+
* keyed by the project/worktree root (the enclosing root found by walking up
|
|
1054
|
+
* from cwd, falling back to cwd itself). Written (best-effort) at spawn and
|
|
1055
|
+
* read at session_start by the digest nudge, so evidence of use stays
|
|
1056
|
+
* project-scoped even when the jobs dir is shared (an absolute/global
|
|
1057
|
+
* `jobsDir`); project-local dirs get the same per-project key harmlessly.
|
|
1058
|
+
*/
|
|
1059
|
+
function projectMarkerPath(
|
|
1060
|
+
jobsDir: string,
|
|
1061
|
+
projectDir: string,
|
|
1062
|
+
prefix: string,
|
|
1063
|
+
): string {
|
|
1064
|
+
return join(jobsDir, `${prefix}${projectHash(projectDir)}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export function jobUsageMarkerPath(
|
|
1068
|
+
jobsDir: string,
|
|
1069
|
+
projectDir: string,
|
|
1070
|
+
): string {
|
|
1071
|
+
return projectMarkerPath(jobsDir, projectDir, ".bgrun-used-");
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Per-project marker path for the one-shot digest nudge. When the jobs dir is
|
|
1076
|
+
* shared (an absolute/global `jobsDir`), a bare `.digest-nudge-done` marker
|
|
1077
|
+
* would silence the nudge for every other project after the first to earn it;
|
|
1078
|
+
* keying by the project/worktree root gives each project its own one-shot.
|
|
1079
|
+
* Under the project-local default the dir is already per-project, so the key
|
|
1080
|
+
* is redundant but harmless.
|
|
1081
|
+
*/
|
|
1082
|
+
export function digestNudgeMarkerPath(
|
|
1083
|
+
jobsDir: string,
|
|
1084
|
+
projectDir: string,
|
|
1085
|
+
): string {
|
|
1086
|
+
return projectMarkerPath(jobsDir, projectDir, ".digest-nudge-");
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* One-shot session_start toast for a trusted project with no digest
|
|
1091
|
+
* configured (see maybeNudgeDigest). Exported so tests assert the real string.
|
|
1092
|
+
*/
|
|
1093
|
+
export const DIGEST_NUDGE_TEXT =
|
|
1094
|
+
"pi-bgrun: no digest configured for this project — use the digest-config skill to set one up.";
|
|
1095
|
+
|
|
1096
|
+
// Job and config `type` values are short routing tokens. Both sides cap at the
|
|
1097
|
+
// same length; if only the job side truncated, a >MAX_TYPE_LEN config type
|
|
1098
|
+
// would silently never match the job's truncated type.
|
|
1099
|
+
const MAX_TYPE_LEN = 40;
|
|
1100
|
+
const MAX_LABEL_LEN = 60;
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Trim, lowercase, and cap a job or config `type`. Non-string or blank →
|
|
1104
|
+
* undefined. Both the bgrun param and the digest config go through here so
|
|
1105
|
+
* their truncation can never drift apart.
|
|
1106
|
+
*/
|
|
1107
|
+
function normalizeType(raw: unknown): string | undefined {
|
|
1108
|
+
if (typeof raw !== "string") return undefined;
|
|
1109
|
+
const trimmed = raw.trim().toLowerCase();
|
|
1110
|
+
if (!trimmed) return undefined;
|
|
1111
|
+
return trimmed.slice(0, MAX_TYPE_LEN);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Normalize one digest entry from the object-or-array config. Best-effort:
|
|
1115
|
+
// anything unusable is dropped (never throws). An entry without a usable
|
|
1116
|
+
// preset or command contributes nothing; a non-string `match` field drops the
|
|
1117
|
+
// whole entry (the human gets the one-time warning).
|
|
1118
|
+
function normalizeDigestEntry(raw: unknown): DigestEntry | undefined {
|
|
1119
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
1120
|
+
const entry = raw as {
|
|
1121
|
+
type?: unknown;
|
|
1122
|
+
match?: unknown;
|
|
1123
|
+
label?: unknown;
|
|
1124
|
+
preset?: unknown;
|
|
1125
|
+
command?: unknown;
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// `type` and `match` compose (AND): both are kept and both must match at
|
|
1129
|
+
// selection time. An invalid `type` (present but not a non-empty string)
|
|
1130
|
+
// drops the whole entry (same best-effort policy as an invalid `match`).
|
|
1131
|
+
// normalizeType() applies the same trim/lowercase/cap the job side uses, so
|
|
1132
|
+
// a long config type still matches the (truncated) long job type.
|
|
1133
|
+
let type: string | undefined;
|
|
1134
|
+
if (entry.type !== undefined) {
|
|
1135
|
+
type = normalizeType(entry.type);
|
|
1136
|
+
if (!type) {
|
|
1137
|
+
warnDigestInvalid("type", entry.type);
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
let match: DigestMatch | undefined;
|
|
1143
|
+
if (entry.match !== undefined) {
|
|
1144
|
+
if (
|
|
1145
|
+
!entry.match ||
|
|
1146
|
+
typeof entry.match !== "object" ||
|
|
1147
|
+
Array.isArray(entry.match)
|
|
1148
|
+
) {
|
|
1149
|
+
warnDigestInvalid("match", entry.match);
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
const rawMatch = entry.match as { name?: unknown; command?: unknown };
|
|
1153
|
+
const normalized: DigestMatch = {};
|
|
1154
|
+
// A glob pattern is any string; a blank one is treated as absent so it
|
|
1155
|
+
// doesn't constrain matching. A non-string is invalid → drop the entry.
|
|
1156
|
+
if (rawMatch.name !== undefined) {
|
|
1157
|
+
if (typeof rawMatch.name !== "string") {
|
|
1158
|
+
warnDigestInvalid("match.name", rawMatch.name);
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
if (rawMatch.name.trim()) normalized.name = rawMatch.name;
|
|
1162
|
+
}
|
|
1163
|
+
if (rawMatch.command !== undefined) {
|
|
1164
|
+
if (typeof rawMatch.command !== "string") {
|
|
1165
|
+
warnDigestInvalid("match.command", rawMatch.command);
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
if (rawMatch.command.trim()) normalized.command = rawMatch.command;
|
|
1169
|
+
}
|
|
1170
|
+
if (normalized.name !== undefined || normalized.command !== undefined) {
|
|
1171
|
+
match = normalized;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
let preset: string | undefined;
|
|
1176
|
+
if (entry.preset !== undefined) {
|
|
1177
|
+
if (
|
|
1178
|
+
typeof entry.preset === "string" &&
|
|
1179
|
+
DIGEST_PRESET_IDS.includes(entry.preset)
|
|
1180
|
+
) {
|
|
1181
|
+
preset = entry.preset;
|
|
1182
|
+
} else {
|
|
1183
|
+
warnDigestInvalid("preset", entry.preset);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
let command: string | undefined;
|
|
1188
|
+
if (entry.command !== undefined) {
|
|
1189
|
+
if (typeof entry.command === "string" && entry.command.trim()) {
|
|
1190
|
+
command = entry.command;
|
|
1191
|
+
} else {
|
|
1192
|
+
warnDigestInvalid("command", entry.command);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Neither preset nor command → nothing this entry can score. Drop it.
|
|
1197
|
+
if (!preset && !command) return undefined;
|
|
1198
|
+
|
|
1199
|
+
const out: DigestEntry = {};
|
|
1200
|
+
if (type) out.type = type;
|
|
1201
|
+
if (match) out.match = match;
|
|
1202
|
+
if (typeof entry.label === "string" && entry.label.trim()) {
|
|
1203
|
+
out.label = entry.label.trim().slice(0, MAX_LABEL_LEN);
|
|
1204
|
+
}
|
|
1205
|
+
if (preset) out.preset = preset;
|
|
1206
|
+
if (command) out.command = command;
|
|
1207
|
+
return out;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
115
1210
|
// Resolved per call (cheap: at most two small file reads) so env/config
|
|
116
1211
|
// changes are picked up without module reloads — and tests can isolate.
|
|
117
|
-
|
|
1212
|
+
// Exported for tests, like formatSince.
|
|
1213
|
+
export function resolveConfig(ctx?: {
|
|
118
1214
|
cwd?: string;
|
|
119
1215
|
isProjectTrusted?: () => boolean;
|
|
1216
|
+
// Test seam: an explicit path, immutable for the process. Tests may also
|
|
1217
|
+
// simply pin HOME — homeDir() honors it under every runtime, unlike Bun's
|
|
1218
|
+
// os.homedir().
|
|
1219
|
+
userConfigPath?: string;
|
|
120
1220
|
}): BgrunConfig {
|
|
121
|
-
|
|
1221
|
+
// User config: $HOME/.pi/agent/pi-bgrun.json. Overridable by an explicit
|
|
1222
|
+
// test seam (ctx.userConfigPath) and by PI_BGRUN_USER_CONFIG (mirrors the
|
|
1223
|
+
// PI_BGRUN_DIR escape hatch).
|
|
1224
|
+
const user = readConfigFile(
|
|
1225
|
+
ctx?.userConfigPath ??
|
|
1226
|
+
process.env.PI_BGRUN_USER_CONFIG ??
|
|
1227
|
+
join(homeDir(), ".pi", "agent", "pi-bgrun.json"),
|
|
1228
|
+
);
|
|
122
1229
|
let project: BgrunConfigFile = {};
|
|
123
1230
|
try {
|
|
124
1231
|
if (ctx?.isProjectTrusted?.()) {
|
|
1232
|
+
// Read the project config from the same root resolveJobsDirPath uses, so
|
|
1233
|
+
// a session started in a subdirectory still picks up <root>/.pi config.
|
|
1234
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1235
|
+
const projectRoot = projectRootFor(cwd);
|
|
125
1236
|
project = readConfigFile(
|
|
126
|
-
join(
|
|
1237
|
+
join(projectRoot, CONFIG_DIR_NAME, "pi-bgrun.json"),
|
|
127
1238
|
);
|
|
128
1239
|
}
|
|
129
1240
|
} catch {
|
|
@@ -154,11 +1265,59 @@ function resolveConfig(ctx?: {
|
|
|
154
1265
|
: undefined;
|
|
155
1266
|
const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS);
|
|
156
1267
|
const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined;
|
|
1268
|
+
// Byte ceiling: unlike cleanupDays, 0 is meaningful ("unlimited"), so it is
|
|
1269
|
+
// accepted — but a BLANK env var is not, or an empty
|
|
1270
|
+
// PI_BGRUN_MAX_LOG_BYTES= would silently disable the cap. Normalization also
|
|
1271
|
+
// keeps the value in a range the wrapper can express: a positive fraction
|
|
1272
|
+
// becomes 1 (flooring it to 0 would silently mean "unlimited"), and an
|
|
1273
|
+
// enormous value is clamped instead of stringifying to "1e+21", which the
|
|
1274
|
+
// shell's `head -c`/`dd` reject — discarding every byte of job output.
|
|
1275
|
+
const maxBytesFile = normalizeMaxLogBytes(merged.maxLogBytes);
|
|
1276
|
+
const maxBytesRaw = process.env.PI_BGRUN_MAX_LOG_BYTES;
|
|
1277
|
+
const maxBytesEnvValue =
|
|
1278
|
+
maxBytesRaw === undefined || maxBytesRaw.trim() === ""
|
|
1279
|
+
? NaN
|
|
1280
|
+
: Number(maxBytesRaw);
|
|
1281
|
+
const maxBytesEnv = normalizeMaxLogBytes(maxBytesEnvValue);
|
|
1282
|
+
const { dir: jobsDir, projectLocal: jobsDirProjectLocal } =
|
|
1283
|
+
resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx);
|
|
1284
|
+
// Digest section: accept either the legacy single-object form (normalized to
|
|
1285
|
+
// one entry with no matchers) or an ordered array of entries. Invalid inputs
|
|
1286
|
+
// are dropped best-effort (warnDigestInvalid logs once per distinct field) —
|
|
1287
|
+
// including a present-but-unusable section (a string/number, or a list that
|
|
1288
|
+
// empties out). When both preset and command are valid within an entry, both
|
|
1289
|
+
// are kept here — resolveDigest() gives the preset precedence. An empty or
|
|
1290
|
+
// all-invalid section normalizes to undefined so `cfg.digest` truthiness
|
|
1291
|
+
// still means "configured" (the session_start nudge relies on that).
|
|
1292
|
+
let digest: BgrunConfig["digest"];
|
|
1293
|
+
if (Array.isArray(merged.digest)) {
|
|
1294
|
+
const entries = merged.digest
|
|
1295
|
+
.map((raw) => normalizeDigestEntry(raw))
|
|
1296
|
+
.filter((e): e is DigestEntry => e !== undefined);
|
|
1297
|
+
if (entries.length) {
|
|
1298
|
+
digest = entries;
|
|
1299
|
+
} else if (merged.digest.length) {
|
|
1300
|
+
// A non-empty list that normalized to nothing: every entry was invalid.
|
|
1301
|
+
warnDigestInvalid("", merged.digest);
|
|
1302
|
+
}
|
|
1303
|
+
} else if (merged.digest !== undefined && merged.digest !== null) {
|
|
1304
|
+
// A present non-null value that isn't a list. `null` is treated as absent.
|
|
1305
|
+
if (typeof merged.digest === "object") {
|
|
1306
|
+
const entry = normalizeDigestEntry(merged.digest);
|
|
1307
|
+
if (entry) {
|
|
1308
|
+
digest = [entry];
|
|
1309
|
+
} else {
|
|
1310
|
+
warnDigestInvalid("", merged.digest);
|
|
1311
|
+
}
|
|
1312
|
+
} else {
|
|
1313
|
+
// Present but not an object/list — a likely mistake like
|
|
1314
|
+
// `"digest": "go-test"`, which would otherwise be silently unconfigured.
|
|
1315
|
+
warnDigestInvalid("", merged.digest);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
157
1318
|
return {
|
|
158
|
-
jobsDir
|
|
159
|
-
|
|
160
|
-
dirFile ||
|
|
161
|
-
join(homedir(), ".pi-bgrun", "jobs"),
|
|
1319
|
+
jobsDir,
|
|
1320
|
+
jobsDirProjectLocal,
|
|
162
1321
|
adoptForeignJobs:
|
|
163
1322
|
parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false,
|
|
164
1323
|
showCompletedJobs:
|
|
@@ -166,10 +1325,12 @@ function resolveConfig(ctx?: {
|
|
|
166
1325
|
completedFile ??
|
|
167
1326
|
false,
|
|
168
1327
|
cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS,
|
|
1328
|
+
maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES,
|
|
169
1329
|
globalAutoClean:
|
|
170
1330
|
parseBoolEnv(process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN) ??
|
|
171
1331
|
globalCleanFile ??
|
|
172
1332
|
true,
|
|
1333
|
+
digest,
|
|
173
1334
|
};
|
|
174
1335
|
}
|
|
175
1336
|
|
|
@@ -198,15 +1359,28 @@ export function formatSince(started: number, now: number = Date.now()): string {
|
|
|
198
1359
|
return `${ymd} ${time}`;
|
|
199
1360
|
}
|
|
200
1361
|
|
|
1362
|
+
// Universal-stats duration formatting for the wake message's Stats line: one
|
|
1363
|
+
// decimal in seconds under a minute ("42.3s"), m:ss above ("5:07").
|
|
1364
|
+
// Exported for tests, like formatSince.
|
|
1365
|
+
export function formatDuration(ms: number): string {
|
|
1366
|
+
const s = Math.max(0, ms) / 1000;
|
|
1367
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
1368
|
+
const m = Math.floor(s / 60) + (Math.round(s % 60) === 60 ? 1 : 0);
|
|
1369
|
+
const rem = Math.round(s % 60) % 60;
|
|
1370
|
+
return `${m}:${String(rem).padStart(2, "0")}`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
201
1373
|
interface JobRecord {
|
|
202
1374
|
id: string;
|
|
203
1375
|
pid: number;
|
|
204
1376
|
cmd: string;
|
|
205
1377
|
name?: string; // optional human-readable label
|
|
1378
|
+
type?: string; // optional job type used for digest scorecard selection
|
|
206
1379
|
started: number;
|
|
207
1380
|
logPath: string;
|
|
208
1381
|
exitedAt?: number;
|
|
209
1382
|
exitCode?: number;
|
|
1383
|
+
donePersisted?: boolean; // done entry already appended to the transcript
|
|
210
1384
|
child?: ReturnType<typeof spawn>; // absent for adopted (fs-discovered) jobs
|
|
211
1385
|
ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler
|
|
212
1386
|
adopted?: boolean; // true when discovered from the jobs dir (another session's job)
|
|
@@ -219,6 +1393,7 @@ interface BgrunJobEntryData {
|
|
|
219
1393
|
pid: number;
|
|
220
1394
|
cmd: string;
|
|
221
1395
|
name?: string;
|
|
1396
|
+
type?: string;
|
|
222
1397
|
started: number;
|
|
223
1398
|
logPath: string;
|
|
224
1399
|
state: "running" | "done";
|
|
@@ -232,21 +1407,30 @@ interface BgStatusDetails {
|
|
|
232
1407
|
exitCode?: number;
|
|
233
1408
|
cmd?: string;
|
|
234
1409
|
name?: string;
|
|
1410
|
+
type?: string;
|
|
235
1411
|
count?: number;
|
|
236
1412
|
recovered?: boolean;
|
|
237
1413
|
}
|
|
238
1414
|
|
|
239
|
-
function isRunningPid(pid: number): boolean {
|
|
240
|
-
try {
|
|
241
|
-
process.kill(pid, 0);
|
|
242
|
-
return true;
|
|
243
|
-
} catch {
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
1415
|
export default function (pi: ExtensionAPI) {
|
|
249
1416
|
const jobs = new Map<string, JobRecord>();
|
|
1417
|
+
// bgtail's delta-tailing bookmarks: one entry per job id ever tailed, holding
|
|
1418
|
+
// the high-water mark of what the caller has already had the opportunity to
|
|
1419
|
+
// see. Declared here, ahead of the cleanup helpers, so removing a log can
|
|
1420
|
+
// evict its bookmark. TAIL_BOOKMARK_CAP bounds the rest — cleanup only evicts
|
|
1421
|
+
// jobs whose log it removed, and a long session that tails many job ids
|
|
1422
|
+
// (foreign ones are never cleaned here) would otherwise grow it forever.
|
|
1423
|
+
const TAIL_BOOKMARK_CAP = 1_000;
|
|
1424
|
+
// A bookmark is the high-water mark of what the caller has seen PLUS the
|
|
1425
|
+
// search window it was seen through: the same log read through a wider window
|
|
1426
|
+
// is a different view, not newly appended output.
|
|
1427
|
+
type TailBookmark = {
|
|
1428
|
+
lines: number;
|
|
1429
|
+
bytes: number;
|
|
1430
|
+
first: string;
|
|
1431
|
+
window: number;
|
|
1432
|
+
};
|
|
1433
|
+
const tailBookmarks = new Map<string, TailBookmark>();
|
|
250
1434
|
// Poller for stale job records — anything running with no live ChildProcess
|
|
251
1435
|
// handle (adopted foreign jobs + jobs reconstructed from transcript entries
|
|
252
1436
|
// after a restart). No exit event exists for those, so their logs/pids are
|
|
@@ -256,7 +1440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
256
1440
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
257
1441
|
|
|
258
1442
|
function makeSlug(command: string): string {
|
|
259
|
-
const raw = command
|
|
1443
|
+
const raw = redactForSlug(command)
|
|
260
1444
|
.toLowerCase()
|
|
261
1445
|
.replace(/[/\\.-]+/g, " ")
|
|
262
1446
|
.trim();
|
|
@@ -267,52 +1451,117 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
1451
|
return slug || "job";
|
|
268
1452
|
}
|
|
269
1453
|
|
|
270
|
-
// Normalize an optional human-readable name:
|
|
1454
|
+
// Normalize an optional human-readable name: strip control characters
|
|
1455
|
+
// (newlines, tabs, escape/ANSI bytes) so a name can never forge extra lines
|
|
1456
|
+
// in the wake, widget, toast, or transcript; collapse whitespace; cap length.
|
|
271
1457
|
function sanitizeName(name: string | undefined): string | undefined {
|
|
272
|
-
const trimmed = (name ?? "")
|
|
1458
|
+
const trimmed = (name ?? "")
|
|
1459
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
1460
|
+
.replace(/\s+/g, " ")
|
|
1461
|
+
.trim();
|
|
273
1462
|
if (!trimmed) return undefined;
|
|
274
1463
|
return trimmed.slice(0, 80);
|
|
275
1464
|
}
|
|
276
1465
|
|
|
277
|
-
|
|
1466
|
+
// Normalize an optional job type via the shared normalizeType(), so the
|
|
1467
|
+
// bgrun param and the config `type` truncate identically (see MAX_TYPE_LEN).
|
|
1468
|
+
function sanitizeType(type: string | undefined): string | undefined {
|
|
1469
|
+
return normalizeType(type);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Count the log's total lines with a bounded-memory streaming scan (one
|
|
1473
|
+
// fixed-size buffer, no full-file read). Missing/unreadable file → null:
|
|
1474
|
+
// the Stats line then just omits the line count — best-effort, never
|
|
1475
|
+
// breaks a wake.
|
|
1476
|
+
function countLogLines(logPath: string): number | null {
|
|
1477
|
+
let fd: number;
|
|
278
1478
|
try {
|
|
279
|
-
|
|
280
|
-
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
281
|
-
if (lines.length === 0) return null;
|
|
282
|
-
const real = lines.filter((l) => !l.startsWith(EXIT_MARKER));
|
|
283
|
-
const last = real[real.length - 1] ?? lines[lines.length - 1];
|
|
284
|
-
return last.length > maxLen ? last.slice(0, maxLen) + "…" : last;
|
|
1479
|
+
fd = openSync(logPath, "r");
|
|
285
1480
|
} catch {
|
|
286
1481
|
return null;
|
|
287
1482
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function parseExitFromLog(logPath: string): number | null {
|
|
291
1483
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1484
|
+
// fstat, not the scan's own progress: the tail pread below is positioned
|
|
1485
|
+
// by the REAL file size, so bounding the scan can never misplace it.
|
|
1486
|
+
const size = fstatSync(fd).size;
|
|
1487
|
+
if (size === 0) return 0;
|
|
1488
|
+
// readWindowMax, not the cap: a capped log is cap + notice + marker, so a
|
|
1489
|
+
// bound EQUAL to the cap would omit the line count for every capped job —
|
|
1490
|
+
// exactly where magnitude matters most.
|
|
1491
|
+
if (size > readWindowMax()) return null;
|
|
1492
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
1493
|
+
let newlines = 0;
|
|
1494
|
+
let seen = 0;
|
|
1495
|
+
let bytesRead = 0;
|
|
1496
|
+
do {
|
|
1497
|
+
bytesRead = readSync(fd, buf, 0, buf.length, null);
|
|
1498
|
+
if (bytesRead <= 0) break;
|
|
1499
|
+
seen += bytesRead;
|
|
1500
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
1501
|
+
if (buf[i] === 0x0a) newlines++;
|
|
1502
|
+
}
|
|
1503
|
+
} while (bytesRead === buf.length);
|
|
1504
|
+
// A log that changed size mid-scan (rotated, or appended by a resumed
|
|
1505
|
+
// job) would produce a count that matches neither state.
|
|
1506
|
+
if (seen !== size) return null;
|
|
1507
|
+
// One bounded pread of the tail for the final-byte + exit-marker check.
|
|
1508
|
+
const tailLen = Math.min(size, 512);
|
|
1509
|
+
const tail = Buffer.alloc(tailLen);
|
|
1510
|
+
readSync(fd, tail, 0, tailLen, size - tailLen);
|
|
1511
|
+
const tailText = tail.toString("latin1");
|
|
1512
|
+
const endsWithNewline = tailText.charCodeAt(tailText.length - 1) === 0x0a;
|
|
1513
|
+
let count = newlines + (endsWithNewline ? 0 : 1);
|
|
1514
|
+
// The wrapper appends "\n<EXIT_MARKER><ec><flags>\n" — and, when it had to
|
|
1515
|
+
// drop output or could not install the ceiling, "\n<NOTICE>\n" before
|
|
1516
|
+
// that. Those newlines are not command output, so drop the whole trailing
|
|
1517
|
+
// wrapper block, including its leading separator when the output already
|
|
1518
|
+
// ended in a newline. WHICH notice precedes the marker is decided by the
|
|
1519
|
+
// marker's own flags, not by matching notice text: a command that prints
|
|
1520
|
+
// the phrase must not have its line discounted as wrapper bookkeeping.
|
|
1521
|
+
const markerAt = tailText.lastIndexOf("\n" + EXIT_MARKER);
|
|
1522
|
+
if (markerAt === 0) {
|
|
1523
|
+
// The file is only the wrapper's "\n<marker>\n" — no command output.
|
|
1524
|
+
return 0;
|
|
1525
|
+
}
|
|
1526
|
+
if (markerAt !== -1) {
|
|
1527
|
+
const afterMarker = tailText.slice(markerAt + 1);
|
|
1528
|
+
const markerEnd = afterMarker.indexOf("\n");
|
|
1529
|
+
const markerLine =
|
|
1530
|
+
markerEnd === -1 ? afterMarker : afterMarker.slice(0, markerEnd);
|
|
1531
|
+
const noticePrefix = markerLine.includes(EXIT_MARKER_NOCAP_FLAG)
|
|
1532
|
+
? CAPFAIL_NOTICE_PREFIX
|
|
1533
|
+
: markerLine.includes(EXIT_MARKER_TRUNC_FLAG)
|
|
1534
|
+
? TRUNC_NOTICE_PREFIX
|
|
1535
|
+
: null;
|
|
1536
|
+
const noticeAt = noticePrefix
|
|
1537
|
+
? tailText.lastIndexOf("\n" + noticePrefix)
|
|
1538
|
+
: -1;
|
|
1539
|
+
const blockStart =
|
|
1540
|
+
noticeAt !== -1 && noticeAt < markerAt ? noticeAt : markerAt;
|
|
1541
|
+
let extra = 0;
|
|
1542
|
+
for (let i = blockStart + 1; i < tailText.length; i++) {
|
|
1543
|
+
if (tailText.charCodeAt(i) === 0x0a) extra++;
|
|
1544
|
+
}
|
|
1545
|
+
if (blockStart > 0 && tailText.charCodeAt(blockStart - 1) === 0x0a)
|
|
1546
|
+
extra++;
|
|
1547
|
+
count = Math.max(0, count - extra);
|
|
1548
|
+
}
|
|
1549
|
+
return count;
|
|
299
1550
|
} catch {
|
|
300
1551
|
return null;
|
|
1552
|
+
} finally {
|
|
1553
|
+
closeSync(fd);
|
|
301
1554
|
}
|
|
302
1555
|
}
|
|
303
1556
|
|
|
304
|
-
function pidFromId(id: string): number | null {
|
|
305
|
-
// id format: <slug>-<ts>-<pid>
|
|
306
|
-
const parts = id.split("-");
|
|
307
|
-
const pid = parseInt(parts[parts.length - 1], 10);
|
|
308
|
-
return Number.isFinite(pid) ? pid : null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
1557
|
// ── Live status widget ────────────────────────────────────────────────────
|
|
312
1558
|
|
|
313
|
-
function updateWidget(
|
|
1559
|
+
function updateWidget(
|
|
1560
|
+
ctx: ExtensionContext,
|
|
1561
|
+
opts: { persistRevalidate?: boolean } = {},
|
|
1562
|
+
): void {
|
|
314
1563
|
if (!ctx.hasUI) return;
|
|
315
|
-
revalidateStaleJobs();
|
|
1564
|
+
revalidateStaleJobs({ persist: opts.persistRevalidate ?? true });
|
|
316
1565
|
const running: JobRecord[] = [];
|
|
317
1566
|
for (const rec of jobs.values()) {
|
|
318
1567
|
if (rec.exitCode === undefined) running.push(rec);
|
|
@@ -325,65 +1574,121 @@ export default function (pi: ExtensionAPI) {
|
|
|
325
1574
|
for (const rec of running) {
|
|
326
1575
|
const startedAt = formatSince(rec.started);
|
|
327
1576
|
const cmd = rec.cmd.length > 40 ? rec.cmd.slice(0, 37) + "…" : rec.cmd;
|
|
328
|
-
const label = rec.name ? `${rec.name} · ${cmd}` : cmd
|
|
1577
|
+
const label = rec.name ? `${rec.name} · ${cmd}` : cmd;
|
|
329
1578
|
const tag = rec.adopted ? " (adopted)" : "";
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
);
|
|
1579
|
+
// Full id (not truncated) so it can be copied straight into /bgtail <id>.
|
|
1580
|
+
lines.push(` ${rec.id} ${label} (since ${startedAt})${tag}`);
|
|
333
1581
|
}
|
|
334
1582
|
ctx.ui.setWidget("bgrun", lines);
|
|
335
1583
|
}
|
|
336
1584
|
|
|
337
1585
|
// ── Cleanup ───────────────────────────────────────────────────────────────
|
|
338
1586
|
|
|
1587
|
+
// Is the wrapper that owns this staging stem still running? It records its
|
|
1588
|
+
// own pid next to its scratch files, so liveness is exact for a job started by
|
|
1589
|
+
// ANY session sharing this jobs dir — unlike a log, whose protection needs the
|
|
1590
|
+
// pid in the file name.
|
|
1591
|
+
function stagingOwnerAlive(pidPath: string): boolean {
|
|
1592
|
+
try {
|
|
1593
|
+
const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
|
|
1594
|
+
return Number.isFinite(pid) && isRunningPid(pid);
|
|
1595
|
+
} catch {
|
|
1596
|
+
// No liveness record (or unreadable) → treat as an orphan's leftovers.
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Sweep stale per-project digest markers (.bgrun-used-*, .digest-nudge-*) and
|
|
1602
|
+
// orphaned staging files. Markers aren't session-scoped, so they'd otherwise
|
|
1603
|
+
// accumulate one per project forever; a project that runs bgrun again
|
|
1604
|
+
// re-writes its usage marker at spawn, so removing a stale one can at most
|
|
1605
|
+
// re-enable one future nudge.
|
|
1606
|
+
function sweepStaleMarkers(jobsDir: string, cutoff: number): void {
|
|
1607
|
+
let names: string[];
|
|
1608
|
+
try {
|
|
1609
|
+
names = readdirSync(jobsDir);
|
|
1610
|
+
} catch {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
// Staging files are only reclaimable when nobody owns them. Age alone used
|
|
1614
|
+
// to delete a RUNNING job's fifo/flag — the wrapper's scratch files live for
|
|
1615
|
+
// the whole job, so any aggressive cutoff killed them mid-run, silently
|
|
1616
|
+
// removing the truncation notice and injecting a shell error into the log.
|
|
1617
|
+
const stagingCutoff = Math.max(cutoff, Date.now() - STAGING_MIN_AGE_MS);
|
|
1618
|
+
for (const name of names) {
|
|
1619
|
+
const isStaging =
|
|
1620
|
+
name.startsWith(".tmp-") &&
|
|
1621
|
+
STAGING_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
1622
|
+
if (
|
|
1623
|
+
!isStaging &&
|
|
1624
|
+
!name.startsWith(".bgrun-used-") &&
|
|
1625
|
+
!name.startsWith(".digest-nudge-")
|
|
1626
|
+
)
|
|
1627
|
+
continue;
|
|
1628
|
+
try {
|
|
1629
|
+
const markerPath = join(jobsDir, name);
|
|
1630
|
+
const mtimeMs = statSync(markerPath).mtimeMs;
|
|
1631
|
+
if (isStaging) {
|
|
1632
|
+
if (mtimeMs > stagingCutoff) continue;
|
|
1633
|
+
const stem = name.replace(/\.[a-z]+$/, "");
|
|
1634
|
+
if (stagingOwnerAlive(join(jobsDir, `${stem}.pid`))) continue;
|
|
1635
|
+
} else if (mtimeMs > cutoff) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
unlinkSync(markerPath);
|
|
1639
|
+
} catch {
|
|
1640
|
+
// ignore
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
339
1645
|
function cleanOldJobs(
|
|
340
1646
|
days: number,
|
|
341
1647
|
jobsDir: string,
|
|
342
1648
|
ctx?: ExtensionContext,
|
|
1649
|
+
// The ownership marker protects the AUTOMATIC global sweep from deleting
|
|
1650
|
+
// logs in an unrelated dir (a stray PI_BGRUN_DIR). An explicit
|
|
1651
|
+
// `bgclean all` is the user's direct intent, so it bypasses the gate.
|
|
1652
|
+
opts: { requireOwnership?: boolean } = {},
|
|
343
1653
|
): { removed: number; kept: number; skippedRunning: number } {
|
|
344
1654
|
const result = { removed: 0, kept: 0, skippedRunning: 0 };
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
1655
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1656
|
+
if (
|
|
1657
|
+
opts.requireOwnership !== false &&
|
|
1658
|
+
!existsSync(join(jobsDir, JOBS_DIR_MARKER))
|
|
1659
|
+
) {
|
|
1660
|
+
// Not recognizably ours — touch NOTHING, marker files included. The gate
|
|
1661
|
+
// exists so a stray PI_BGRUN_DIR is never emptied.
|
|
349
1662
|
return result;
|
|
350
1663
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
st = statSync(logPath);
|
|
358
|
-
} catch {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
// mtime check
|
|
362
|
-
if (st.mtimeMs > cutoff) {
|
|
1664
|
+
// Past the gate: sweep our own stale per-project marker files.
|
|
1665
|
+
sweepStaleMarkers(jobsDir, cutoff);
|
|
1666
|
+
for (const entry of scanLogFiles(jobsDir)) {
|
|
1667
|
+
// mtime check FIRST — young files are never candidates, so skip early.
|
|
1668
|
+
if (entry.mtimeMs > cutoff) {
|
|
363
1669
|
result.kept++;
|
|
364
1670
|
continue;
|
|
365
1671
|
}
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
1672
|
+
// A TERMINAL marker means the wrapper finished writing — trust it even
|
|
1673
|
+
// when the pid looks alive (that is a reused pid; otherwise the log would
|
|
1674
|
+
// never be reclaimed). A non-terminal marker is not completion evidence,
|
|
1675
|
+
// so fall through to pid liveness, which protects a job that merely
|
|
1676
|
+
// printed the string.
|
|
1677
|
+
const finished = parseExitFromLogPath(entry.logPath) !== null;
|
|
371
1678
|
if (!finished) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (rec && rec.exitCode === undefined) {
|
|
375
|
-
result.skippedRunning++;
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
const pid = pidFromId(id);
|
|
379
|
-
if (pid !== null && pid > 0 && isRunningPid(pid)) {
|
|
1679
|
+
const rec = jobs.get(entry.id);
|
|
1680
|
+
if (entry.alive && rec?.exitCode === undefined) {
|
|
380
1681
|
result.skippedRunning++;
|
|
381
1682
|
continue;
|
|
382
1683
|
}
|
|
383
1684
|
}
|
|
1685
|
+
// finished, dead pid, or our record says done → safe to remove.
|
|
384
1686
|
try {
|
|
385
|
-
unlinkSync(logPath);
|
|
1687
|
+
unlinkSync(entry.logPath);
|
|
386
1688
|
result.removed++;
|
|
1689
|
+
// The log is gone: its delta bookmark would otherwise pin a stale
|
|
1690
|
+
// high-water mark (and a Map slot) for the life of the session.
|
|
1691
|
+
tailBookmarks.delete(entry.id);
|
|
387
1692
|
} catch {
|
|
388
1693
|
// ignore
|
|
389
1694
|
}
|
|
@@ -406,15 +1711,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
406
1711
|
const result = { removed: 0, kept: 0, skippedRunning: 0 };
|
|
407
1712
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
408
1713
|
for (const rec of jobs.values()) {
|
|
1714
|
+
// Adopted foreign jobs belong to another session — this session neither
|
|
1715
|
+
// owns nor reports on them (counting them as "skipped running" was
|
|
1716
|
+
// misleading).
|
|
1717
|
+
if (rec.adopted) continue;
|
|
409
1718
|
if (rec.exitCode === undefined) {
|
|
410
1719
|
result.skippedRunning++;
|
|
411
1720
|
continue;
|
|
412
1721
|
}
|
|
413
|
-
let st
|
|
1722
|
+
let st: ReturnType<typeof statSync>;
|
|
414
1723
|
try {
|
|
415
1724
|
st = statSync(rec.logPath);
|
|
416
1725
|
} catch {
|
|
417
|
-
|
|
1726
|
+
// Log already gone (cleaned by a global sweep). Drop the in-memory
|
|
1727
|
+
// record once it's past retention so finished jobs can't pin the Map
|
|
1728
|
+
// (and its ExtensionContext) for the life of the process.
|
|
1729
|
+
if (rec.exitedAt !== undefined && rec.exitedAt < cutoff) {
|
|
1730
|
+
jobs.delete(rec.id);
|
|
1731
|
+
tailBookmarks.delete(rec.id);
|
|
1732
|
+
}
|
|
1733
|
+
continue;
|
|
418
1734
|
}
|
|
419
1735
|
if (st.mtimeMs > cutoff) {
|
|
420
1736
|
result.kept++;
|
|
@@ -423,49 +1739,98 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
1739
|
try {
|
|
424
1740
|
unlinkSync(rec.logPath);
|
|
425
1741
|
result.removed++;
|
|
1742
|
+
jobs.delete(rec.id);
|
|
1743
|
+
tailBookmarks.delete(rec.id);
|
|
426
1744
|
} catch {
|
|
427
1745
|
// ignore
|
|
428
1746
|
}
|
|
429
1747
|
}
|
|
1748
|
+
// Markers aren't session data, so a session-scoped sweep may still drop
|
|
1749
|
+
// stale ones from this jobs dir — except in an untrusted project-local dir,
|
|
1750
|
+
// where deletion would mutate a repo the user has not trusted (the same
|
|
1751
|
+
// boundary autoCleanJobs enforces below).
|
|
1752
|
+
const cfg = resolveConfig(ctx);
|
|
1753
|
+
if (!(cfg.jobsDirProjectLocal && ctx?.isProjectTrusted?.() !== true)) {
|
|
1754
|
+
sweepStaleMarkers(cfg.jobsDir, cutoff);
|
|
1755
|
+
}
|
|
430
1756
|
if (result.removed > 0 && ctx?.hasUI) {
|
|
431
1757
|
ctx.ui.notify(`bgrun: cleaned ${result.removed} old job log(s)`, "info");
|
|
432
1758
|
}
|
|
433
1759
|
return result;
|
|
434
1760
|
}
|
|
435
1761
|
|
|
1762
|
+
// Every shared jobs dir the orphan sweep / `bgclean all` should touch. The
|
|
1763
|
+
// machine-global dir is included only for the project-local default (so
|
|
1764
|
+
// pre-project-local logs are still reclaimed); an explicit absolute jobsDir
|
|
1765
|
+
// is treated as fully isolated and swept alone.
|
|
1766
|
+
function sharedJobsDirs(projectDir: string, projectLocal: boolean): string[] {
|
|
1767
|
+
if (!projectLocal) return [projectDir];
|
|
1768
|
+
const global = globalJobsDir();
|
|
1769
|
+
// Dedup aliased paths (equal strings, or symlinks to the same dir) so the
|
|
1770
|
+
// sweep never counts/removes the same log twice.
|
|
1771
|
+
return safeRealpath(projectDir) === safeRealpath(global)
|
|
1772
|
+
? [global]
|
|
1773
|
+
: [global, projectDir];
|
|
1774
|
+
}
|
|
1775
|
+
|
|
436
1776
|
// Auto-clean at session boundaries. Two parts:
|
|
437
1777
|
// 1. Session-scoped sweep — this session's old logs only; cheap,
|
|
438
1778
|
// unthrottled.
|
|
439
|
-
// 2.
|
|
440
|
-
// PI_BGRUN_GLOBAL_AUTO_CLEAN=0) —
|
|
441
|
-
// FINISHED logs (exit marker, or dead pid)
|
|
442
|
-
// is what keeps orphans from crashed /
|
|
443
|
-
// accumulating: a week-old finished log is
|
|
444
|
-
// retention the owning session would apply
|
|
445
|
-
// always pid-protected. Throttled to one
|
|
446
|
-
// .last-clean marker
|
|
447
|
-
// launch.
|
|
1779
|
+
// 2. Orphan sweep (default on; disable via globalAutoClean: false /
|
|
1780
|
+
// PI_BGRUN_GLOBAL_AUTO_CLEAN=0) — every shared jobs dir (see
|
|
1781
|
+
// sharedJobsDirs), removing FINISHED logs (exit marker, or dead pid)
|
|
1782
|
+
// older than cleanupDays. This is what keeps orphans from crashed /
|
|
1783
|
+
// never-resumed sessions from accumulating: a week-old finished log is
|
|
1784
|
+
// garbage under the same retention the owning session would apply
|
|
1785
|
+
// itself, and running jobs are always pid-protected. Throttled to one
|
|
1786
|
+
// sweep per cleanupDays via a .last-clean marker in each dir so
|
|
1787
|
+
// restart-heavy workflows don't re-sweep on every launch.
|
|
448
1788
|
function autoCleanJobs(ctx: ExtensionContext): void {
|
|
449
1789
|
const cfg = resolveConfig(ctx);
|
|
1790
|
+
// Trust boundary: session start / shutdown must not write into a repo the
|
|
1791
|
+
// user has not trusted. For an untrusted project we skip both the
|
|
1792
|
+
// .git/info/exclude edit and the project-local dir sweep below (which would
|
|
1793
|
+
// create the dir for its .last-clean marker). The bgrun tool still ensures
|
|
1794
|
+
// exclusion at job-creation time — that is an explicit agent action, not an
|
|
1795
|
+
// incidental side effect of opening a session.
|
|
1796
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
1797
|
+
if (cfg.jobsDirProjectLocal && trusted) ensureGitExcluded(cfg.jobsDir);
|
|
450
1798
|
cleanSessionJobs(cfg.cleanupDays, ctx);
|
|
451
1799
|
if (!cfg.globalAutoClean) return;
|
|
452
|
-
const
|
|
453
|
-
try {
|
|
454
|
-
const last = Number(readFileSync(markerPath, "utf8").trim());
|
|
1800
|
+
for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) {
|
|
455
1801
|
if (
|
|
456
|
-
|
|
457
|
-
|
|
1802
|
+
cfg.jobsDirProjectLocal &&
|
|
1803
|
+
!trusted &&
|
|
1804
|
+
safeRealpath(dir) === safeRealpath(cfg.jobsDir)
|
|
458
1805
|
)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
1806
|
+
continue;
|
|
1807
|
+
const markerPath = join(dir, ".last-clean");
|
|
1808
|
+
try {
|
|
1809
|
+
const last = Number(readFileSync(markerPath, "utf8").trim());
|
|
1810
|
+
if (
|
|
1811
|
+
Number.isFinite(last) &&
|
|
1812
|
+
Date.now() - last < cfg.cleanupDays * 24 * 60 * 60 * 1000
|
|
1813
|
+
)
|
|
1814
|
+
continue;
|
|
1815
|
+
} catch {
|
|
1816
|
+
// no marker yet — run the sweep
|
|
1817
|
+
}
|
|
1818
|
+
// The known machine-global dir is ours even without a .bgrun-jobs marker
|
|
1819
|
+
// (the project-local default never writes one there), so bypass the
|
|
1820
|
+
// ownership gate for it only; the project-local dir stays gated.
|
|
1821
|
+
// The DEFAULT machine-global dir is ours even without a .bgrun-jobs
|
|
1822
|
+
// marker (the project-local default never writes one there). A custom
|
|
1823
|
+
// PI_BGRUN_GLOBAL_DIR is gated like any other dir, per the README.
|
|
1824
|
+
const isGlobal =
|
|
1825
|
+
!process.env.PI_BGRUN_GLOBAL_DIR &&
|
|
1826
|
+
safeRealpath(dir) === safeRealpath(globalJobsDir());
|
|
1827
|
+
cleanOldJobs(cfg.cleanupDays, dir, ctx, { requireOwnership: !isGlobal });
|
|
1828
|
+
try {
|
|
1829
|
+
mkdirSync(dir, { recursive: true });
|
|
1830
|
+
writeFileSync(markerPath, String(Date.now()));
|
|
1831
|
+
} catch {
|
|
1832
|
+
// best-effort
|
|
1833
|
+
}
|
|
469
1834
|
}
|
|
470
1835
|
}
|
|
471
1836
|
|
|
@@ -479,10 +1844,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
479
1844
|
// session's history; the log on disk still covers id lookup + cleanup).
|
|
480
1845
|
// - Reconstructed jobs ARE this session's history: mark them done and
|
|
481
1846
|
// append a done entry so future resumes reconstruct them as done too.
|
|
482
|
-
|
|
1847
|
+
// `persist: false` is for read-only callers (bgstatus): they still need an
|
|
1848
|
+
// accurate view, but asking for status must not append transcript cards.
|
|
1849
|
+
// The stale poller / session_start re-run with persistence and reconcile.
|
|
1850
|
+
function persistDoneEntry(rec: JobRecord, exit: number): void {
|
|
1851
|
+
rec.donePersisted = true;
|
|
1852
|
+
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
|
|
1853
|
+
id: rec.id,
|
|
1854
|
+
pid: rec.pid,
|
|
1855
|
+
cmd: rec.cmd,
|
|
1856
|
+
name: rec.name,
|
|
1857
|
+
type: rec.type,
|
|
1858
|
+
started: rec.started,
|
|
1859
|
+
logPath: rec.logPath,
|
|
1860
|
+
state: "done",
|
|
1861
|
+
exitCode: exit >= 0 ? exit : undefined,
|
|
1862
|
+
exitedAt: rec.exitedAt,
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function revalidateStaleJobs(opts: { persist?: boolean } = {}): void {
|
|
1867
|
+
const persist = opts.persist ?? true;
|
|
483
1868
|
for (const [id, rec] of jobs) {
|
|
484
|
-
if (rec.child
|
|
485
|
-
|
|
1869
|
+
if (rec.child) continue;
|
|
1870
|
+
if (rec.exitCode !== undefined) {
|
|
1871
|
+
// Already reconciled. A read-only pass (bgstatus, persist:false) sets
|
|
1872
|
+
// exitCode WITHOUT persisting, so a later persisting pass must still
|
|
1873
|
+
// write the done entry — otherwise the transcript card stays "running"
|
|
1874
|
+
// for the rest of the session.
|
|
1875
|
+
if (persist && !rec.donePersisted && !rec.adopted) {
|
|
1876
|
+
persistDoneEntry(rec, rec.exitCode);
|
|
1877
|
+
}
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
let exit = parseExitFromLogPath(rec.logPath);
|
|
1881
|
+
if (exit === null && rec.pid <= 0) {
|
|
1882
|
+
exit = -1;
|
|
1883
|
+
}
|
|
486
1884
|
if (exit === null && rec.pid > 0 && !isRunningPid(rec.pid)) {
|
|
487
1885
|
// pid gone with no marker — killed/crashed before the wrapper could write it,
|
|
488
1886
|
// or the log was already cleaned up
|
|
@@ -494,17 +1892,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
494
1892
|
} else {
|
|
495
1893
|
rec.exitCode = exit;
|
|
496
1894
|
rec.exitedAt = Date.now();
|
|
497
|
-
|
|
498
|
-
id: rec.id,
|
|
499
|
-
pid: rec.pid,
|
|
500
|
-
cmd: rec.cmd,
|
|
501
|
-
name: rec.name,
|
|
502
|
-
started: rec.started,
|
|
503
|
-
logPath: rec.logPath,
|
|
504
|
-
state: "done",
|
|
505
|
-
exitCode: exit >= 0 ? exit : undefined,
|
|
506
|
-
exitedAt: rec.exitedAt,
|
|
507
|
-
});
|
|
1895
|
+
if (persist) persistDoneEntry(rec, exit);
|
|
508
1896
|
}
|
|
509
1897
|
}
|
|
510
1898
|
}
|
|
@@ -618,10 +2006,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
618
2006
|
pid: d.pid,
|
|
619
2007
|
cmd: d.cmd,
|
|
620
2008
|
name: d.name,
|
|
2009
|
+
type: d.type,
|
|
621
2010
|
started: d.started,
|
|
622
2011
|
logPath: d.logPath,
|
|
623
2012
|
exitedAt: d.exitedAt,
|
|
624
2013
|
exitCode: isDone ? (d.exitCode ?? -1) : undefined,
|
|
2014
|
+
// Mark the done entry as already persisted, or revalidateStaleJobs
|
|
2015
|
+
// appends a duplicate done card on every resume.
|
|
2016
|
+
donePersisted: isDone,
|
|
625
2017
|
ctx,
|
|
626
2018
|
});
|
|
627
2019
|
}
|
|
@@ -641,37 +2033,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
641
2033
|
const cfg = resolveConfig(ctx);
|
|
642
2034
|
const jobsDir = cfg.jobsDir;
|
|
643
2035
|
if (cfg.adoptForeignJobs) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
} catch {
|
|
658
|
-
// keep fallback
|
|
659
|
-
}
|
|
660
|
-
jobs.set(id, {
|
|
661
|
-
id,
|
|
662
|
-
pid,
|
|
663
|
-
cmd: "(started by another session)",
|
|
664
|
-
started,
|
|
665
|
-
logPath,
|
|
666
|
-
ctx,
|
|
667
|
-
adopted: true,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
} catch {
|
|
671
|
-
// jobs dir doesn't exist — nothing to adopt.
|
|
2036
|
+
for (const entry of scanJobsDir(jobsDir)) {
|
|
2037
|
+
if (jobs.has(entry.id)) continue;
|
|
2038
|
+
if (entry.exit !== null) continue; // finished — nothing to show in the widget
|
|
2039
|
+
if (!entry.alive) continue; // dead pid, marker just not written yet
|
|
2040
|
+
jobs.set(entry.id, {
|
|
2041
|
+
id: entry.id,
|
|
2042
|
+
pid: entry.pid ?? -1,
|
|
2043
|
+
cmd: "(started by another session)",
|
|
2044
|
+
started: entry.birthtimeMs || Date.now(),
|
|
2045
|
+
logPath: entry.logPath,
|
|
2046
|
+
ctx,
|
|
2047
|
+
adopted: true,
|
|
2048
|
+
});
|
|
672
2049
|
}
|
|
673
2050
|
}
|
|
674
2051
|
|
|
2052
|
+
// One-shot digest nudge (toast only, never the LLM context). All of its
|
|
2053
|
+
// failure modes are swallowed inside — it must never break session_start.
|
|
2054
|
+
maybeNudgeDigest(ctx);
|
|
2055
|
+
|
|
675
2056
|
// Show the widget if anything is now running. revalidateStaleJobs()
|
|
676
2057
|
// inside clears zombies — reconstructed jobs that finished while pi was
|
|
677
2058
|
// down — before they ever render. Then start the stale poller for
|
|
@@ -704,14 +2085,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
704
2085
|
"Run a long shell command detached in the background. Returns 'started: <job-id>' immediately. " +
|
|
705
2086
|
"You will be woken automatically when the job finishes. Use this instead of bash for any command " +
|
|
706
2087
|
"expected to run >30s or emit >100 lines (tests, builds, linters). Optionally pass `name` for a " +
|
|
707
|
-
"short human-readable label used in the job id, status output, and wake messages
|
|
2088
|
+
"short human-readable label used in the job id, status output, and wake messages, and `type` to " +
|
|
2089
|
+
"select the project's digest scorecard.",
|
|
708
2090
|
promptSnippet:
|
|
709
2091
|
"Run a long command detached in the background; get woken on completion",
|
|
710
2092
|
promptGuidelines: [
|
|
711
2093
|
"Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.",
|
|
712
2094
|
"Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.",
|
|
2095
|
+
"When the project's digest config defines `type` entries, pass the matching `type` (e.g. type: 'test') so the wake selects the right scorecard; the vocabulary comes from the project's `.pi/pi-bgrun.json` digest entries.",
|
|
713
2096
|
"After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.",
|
|
714
|
-
"Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use ctx_execute_file on the log path
|
|
2097
|
+
"Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search or ctx_execute_file on the log path for whole-log analysis.",
|
|
715
2098
|
],
|
|
716
2099
|
parameters: Type.Object({
|
|
717
2100
|
command: Type.String({
|
|
@@ -725,159 +2108,364 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
2108
|
"Used in the job id, status output, the status widget, and wake messages.",
|
|
726
2109
|
}),
|
|
727
2110
|
),
|
|
2111
|
+
type: Type.Optional(
|
|
2112
|
+
Type.String({
|
|
2113
|
+
description:
|
|
2114
|
+
"Optional job type used to select the project's digest scorecard (e.g. 'test', 'build', 'lint'). " +
|
|
2115
|
+
"The vocabulary comes from the `type` fields in the project's `digest` config entries in " +
|
|
2116
|
+
"`.pi/pi-bgrun.json`; when the project's digest config defines types, prefer passing the matching one.",
|
|
2117
|
+
}),
|
|
2118
|
+
),
|
|
728
2119
|
}),
|
|
729
2120
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
730
|
-
const { command, name: rawName } = params;
|
|
2121
|
+
const { command, name: rawName, type: rawType } = params;
|
|
731
2122
|
if (!command || !command.trim()) {
|
|
732
2123
|
throw new Error("bgrun: command is required");
|
|
733
2124
|
}
|
|
734
2125
|
const name = sanitizeName(rawName);
|
|
2126
|
+
const type = sanitizeType(rawType);
|
|
735
2127
|
|
|
736
|
-
const
|
|
2128
|
+
const cfg = resolveConfig(ctx);
|
|
2129
|
+
// Project-local logs are auto-ignored in .git/info/exclude (best-effort)
|
|
2130
|
+
// so they never pollute `git status`. Absolute dirs are left untouched.
|
|
2131
|
+
if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir);
|
|
2132
|
+
const jobsDir = cfg.jobsDir;
|
|
737
2133
|
mkdirSync(jobsDir, { recursive: true });
|
|
2134
|
+
ensureJobsDirMarker(jobsDir);
|
|
2135
|
+
// Evidence-of-use marker (best-effort): lets the digest nudge tell that
|
|
2136
|
+
// THIS project has run bgrun, without scanning the shared jobs dir.
|
|
2137
|
+
try {
|
|
2138
|
+
writeFileSync(
|
|
2139
|
+
jobUsageMarkerPath(jobsDir, projectRootFor(ctx.cwd ?? process.cwd())),
|
|
2140
|
+
String(Date.now()),
|
|
2141
|
+
);
|
|
2142
|
+
} catch {
|
|
2143
|
+
// a marker write must never block a spawn
|
|
2144
|
+
}
|
|
738
2145
|
|
|
739
2146
|
const slug = makeSlug(name ?? command);
|
|
740
2147
|
const ts = Math.floor(Date.now() / 1000);
|
|
741
2148
|
// The id must carry the CHILD's pid (liveness checks depend on it), but the
|
|
742
2149
|
// log fd must exist before spawn. Create at a temp path, rename after spawn.
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
);
|
|
747
|
-
let logFd: number;
|
|
2150
|
+
// randomBytes (not Math.random) plus O_EXCL: the temp name is not
|
|
2151
|
+
// guessable and a pre-planted symlink cannot be truncated through.
|
|
2152
|
+
const stem = `.tmp-${slug}-${ts}-${randomBytes(4).toString("hex")}`;
|
|
2153
|
+
const tmpPath = join(jobsDir, `${stem}.log`);
|
|
2154
|
+
let logFd: number | undefined;
|
|
2155
|
+
let logPath = tmpPath;
|
|
748
2156
|
try {
|
|
749
|
-
|
|
2157
|
+
// 0600: job logs can contain secrets pulled from the environment.
|
|
2158
|
+
logFd = openSync(tmpPath, "wx", 0o600);
|
|
750
2159
|
} catch (err) {
|
|
751
2160
|
throw new Error(
|
|
752
2161
|
`bgrun: cannot create log file: ${(err as Error).message}`,
|
|
753
2162
|
);
|
|
754
2163
|
}
|
|
755
|
-
const wrapped = `${command}; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit $ec`;
|
|
756
|
-
|
|
757
|
-
const child = spawn("sh", ["-c", wrapped], {
|
|
758
|
-
stdio: ["ignore", logFd, logFd],
|
|
759
|
-
detached: true,
|
|
760
|
-
});
|
|
761
|
-
child.unref();
|
|
762
|
-
|
|
763
|
-
const childPid = child.pid ?? -1;
|
|
764
|
-
const id = `${slug}-${ts}-${childPid}`;
|
|
765
|
-
const logPath = join(jobsDir, `${id}.log`);
|
|
766
2164
|
try {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
2165
|
+
// Pass command as argv — interpolation breaks on #, quotes, heredocs.
|
|
2166
|
+
// maxLogBytes 0 means "unlimited": keep the pre-ceiling wrapper exactly
|
|
2167
|
+
// (a zero-byte ceiling is meaningless, so it cannot be routed through
|
|
2168
|
+
// the capped path).
|
|
2169
|
+
const capped = cfg.maxLogBytes > 0;
|
|
2170
|
+
const wrapper = capped
|
|
2171
|
+
? cappedWrapper(cfg.maxLogBytes)
|
|
2172
|
+
: `sh -c "$1"; ec=$?; printf '\\n${EXIT_MARKER}%d\\n' "$ec"; exit "$ec"`;
|
|
2173
|
+
const child = spawn(
|
|
2174
|
+
"sh",
|
|
2175
|
+
capped
|
|
2176
|
+
? [
|
|
2177
|
+
"-c",
|
|
2178
|
+
wrapper,
|
|
2179
|
+
"bgrun",
|
|
2180
|
+
command,
|
|
2181
|
+
join(jobsDir, `${stem}.ec`),
|
|
2182
|
+
join(jobsDir, `${stem}.fifo`),
|
|
2183
|
+
// Liveness, so a cleanup sweep can tell a running job's scratch
|
|
2184
|
+
// files from an orphan's instead of judging them by age alone.
|
|
2185
|
+
join(jobsDir, `${stem}.pid`),
|
|
2186
|
+
// Truncation flag: written by the drain when bytes were left
|
|
2187
|
+
// over. A file, not the drain's exit status, because the drain
|
|
2188
|
+
// may still be running when the wrapper prints.
|
|
2189
|
+
join(jobsDir, `${stem}.trunc`),
|
|
2190
|
+
]
|
|
2191
|
+
: ["-c", wrapper, "bgrun", command],
|
|
2192
|
+
{
|
|
2193
|
+
stdio: ["ignore", logFd, logFd],
|
|
2194
|
+
detached: true,
|
|
2195
|
+
},
|
|
772
2196
|
);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const record: JobRecord = {
|
|
776
|
-
id,
|
|
777
|
-
pid: childPid,
|
|
778
|
-
cmd: command,
|
|
779
|
-
name,
|
|
780
|
-
started: Date.now(),
|
|
781
|
-
logPath,
|
|
782
|
-
child,
|
|
783
|
-
ctx,
|
|
784
|
-
};
|
|
785
|
-
jobs.set(id, record);
|
|
786
|
-
|
|
787
|
-
// Persist a bgrun-job entry (running state) — transcript card + restart recovery.
|
|
788
|
-
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
|
|
789
|
-
id,
|
|
790
|
-
pid: childPid,
|
|
791
|
-
cmd: command,
|
|
792
|
-
name,
|
|
793
|
-
started: Date.now(),
|
|
794
|
-
logPath,
|
|
795
|
-
state: "running",
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
closeSync(logFd);
|
|
799
|
-
|
|
800
|
-
updateWidget(ctx);
|
|
2197
|
+
child.unref();
|
|
801
2198
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
2199
|
+
const childPid = child.pid ?? -1;
|
|
2200
|
+
const id = `${slug}-${ts}-${childPid}`;
|
|
2201
|
+
const finalLogPath = join(jobsDir, `${id}.log`);
|
|
2202
|
+
try {
|
|
2203
|
+
renameSync(tmpPath, finalLogPath);
|
|
2204
|
+
logPath = finalLogPath;
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
console.error(
|
|
2207
|
+
`[pi-bgrun] rename to final log path failed:`,
|
|
2208
|
+
(err as Error).message,
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
809
2211
|
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
2212
|
+
const record: JobRecord = {
|
|
2213
|
+
id,
|
|
2214
|
+
pid: childPid,
|
|
2215
|
+
cmd: command,
|
|
2216
|
+
name,
|
|
2217
|
+
type,
|
|
2218
|
+
started: Date.now(),
|
|
2219
|
+
logPath,
|
|
2220
|
+
child,
|
|
2221
|
+
ctx,
|
|
2222
|
+
};
|
|
2223
|
+
jobs.set(id, record);
|
|
815
2224
|
|
|
816
|
-
// Persist
|
|
2225
|
+
// Persist a bgrun-job entry (running state) — transcript card + restart recovery.
|
|
817
2226
|
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
|
|
818
2227
|
id,
|
|
819
|
-
pid:
|
|
820
|
-
cmd:
|
|
821
|
-
name
|
|
822
|
-
|
|
2228
|
+
pid: childPid,
|
|
2229
|
+
cmd: command,
|
|
2230
|
+
name,
|
|
2231
|
+
type,
|
|
2232
|
+
started: Date.now(),
|
|
823
2233
|
logPath,
|
|
824
|
-
state: "
|
|
825
|
-
exitCode: exitCode >= 0 ? exitCode : undefined,
|
|
826
|
-
exitedAt: rec.exitedAt,
|
|
2234
|
+
state: "running",
|
|
827
2235
|
});
|
|
828
2236
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
2237
|
+
updateWidget(ctx);
|
|
2238
|
+
|
|
2239
|
+
const finishSpawnFailure = (err: Error) => {
|
|
2240
|
+
const rec = jobs.get(id);
|
|
2241
|
+
if (!rec || rec.exitCode !== undefined) return;
|
|
2242
|
+
rec.exitedAt = Date.now();
|
|
2243
|
+
rec.exitCode = -1;
|
|
2244
|
+
rec.donePersisted = true;
|
|
2245
|
+
delete rec.child;
|
|
2246
|
+
try {
|
|
2247
|
+
appendFileSync(
|
|
2248
|
+
rec.logPath,
|
|
2249
|
+
`\n[pi-bgrun] spawn failed: ${err.message}\n${EXIT_MARKER}-1\n`,
|
|
2250
|
+
);
|
|
2251
|
+
} catch {
|
|
2252
|
+
// best-effort
|
|
840
2253
|
}
|
|
841
|
-
|
|
2254
|
+
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
|
|
2255
|
+
id,
|
|
2256
|
+
pid: rec.pid,
|
|
2257
|
+
cmd: rec.cmd,
|
|
2258
|
+
name: rec.name,
|
|
2259
|
+
started: rec.started,
|
|
2260
|
+
logPath: rec.logPath,
|
|
2261
|
+
state: "done",
|
|
2262
|
+
exitCode: -1,
|
|
2263
|
+
exitedAt: rec.exitedAt,
|
|
2264
|
+
});
|
|
2265
|
+
const namePrefix = rec.name ? `"${rec.name}" ` : "";
|
|
2266
|
+
const wake =
|
|
2267
|
+
`❌ Background job ${namePrefix}\`${id}\` failed to start: ${err.message}\n` +
|
|
2268
|
+
`Command: ${command}`;
|
|
2269
|
+
try {
|
|
2270
|
+
if (rec.ctx.isIdle()) pi.sendUserMessage(wake);
|
|
2271
|
+
else pi.sendUserMessage(wake, { deliverAs: "followUp" });
|
|
2272
|
+
} catch {
|
|
2273
|
+
try {
|
|
2274
|
+
pi.sendUserMessage(wake, { deliverAs: "followUp" });
|
|
2275
|
+
} catch (e2) {
|
|
2276
|
+
console.error(
|
|
2277
|
+
`[pi-bgrun] wake failed for job ${id}:`,
|
|
2278
|
+
(e2 as Error).message,
|
|
2279
|
+
);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if (rec.ctx.hasUI) {
|
|
2283
|
+
rec.ctx.ui.notify(
|
|
2284
|
+
`❌ ${(rec.name ?? command).slice(0, 50)} → spawn failed`,
|
|
2285
|
+
"error",
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
updateWidget(rec.ctx);
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
// ── exit handler: record exit, persist done entry, wake, notify, widget ─
|
|
2292
|
+
child.on("exit", async (code, signal) => {
|
|
2293
|
+
const rec = jobs.get(id);
|
|
2294
|
+
if (!rec) return;
|
|
2295
|
+
// A spawn that emitted 'error' first already finalized this job; a
|
|
2296
|
+
// follow-up 'exit' must not append a second done entry or wake.
|
|
2297
|
+
if (rec.exitCode !== undefined) return;
|
|
2298
|
+
rec.exitedAt = Date.now();
|
|
2299
|
+
rec.exitCode = code ?? -1;
|
|
2300
|
+
// Set BEFORE the digest await: without it a read-only bgstatus in
|
|
2301
|
+
// that window could append a second done entry.
|
|
2302
|
+
rec.donePersisted = true;
|
|
2303
|
+
delete rec.child; // release the handle reference
|
|
2304
|
+
|
|
2305
|
+
const exitCode = code ?? parseExitFromLogPath(logPath) ?? -1;
|
|
2306
|
+
const exitStr =
|
|
2307
|
+
exitCode >= 0 ? String(exitCode) : `signal ${signal ?? "?"}`;
|
|
2308
|
+
const exitEmoji = exitCode === 0 ? "✅" : "❌";
|
|
2309
|
+
const lastLine = readLastLogLine(logPath);
|
|
2310
|
+
|
|
2311
|
+
// Universal stats — duration + log line count. Non-heuristic, always
|
|
2312
|
+
// present, never pattern-based. A missing log contributes no line
|
|
2313
|
+
// count (duration is always known).
|
|
2314
|
+
const logLines = countLogLines(logPath);
|
|
2315
|
+
const statsParts = [formatDuration(rec.exitedAt - rec.started)];
|
|
2316
|
+
if (logLines !== null)
|
|
2317
|
+
statsParts.push(`${logLines.toLocaleString("en-US")} lines`);
|
|
2318
|
+
// The cap is the one fact that changes what the others MEAN: the line
|
|
2319
|
+
// count, the last line and any digest describe only the bytes that
|
|
2320
|
+
// were kept. Say so in the line the agent reads first. A ceiling that
|
|
2321
|
+
// could not be installed is the opposite case — the log is complete
|
|
2322
|
+
// but unbounded — and that must not be silent either.
|
|
2323
|
+
const capStatus = readCapStatus(logPath);
|
|
2324
|
+
const truncatedAt =
|
|
2325
|
+
capStatus?.kind === "truncated" ? capStatus.bytes : null;
|
|
2326
|
+
if (truncatedAt !== null)
|
|
2327
|
+
statsParts.push(`log truncated at ${formatBytes(truncatedAt)}`);
|
|
2328
|
+
else if (capStatus?.kind === "ceiling-failed")
|
|
2329
|
+
statsParts.push("no log ceiling (command ran uncapped)");
|
|
2330
|
+
|
|
2331
|
+
// Persist the done-state entry.
|
|
2332
|
+
pi.appendEntry<BgrunJobEntryData>("bgrun-job", {
|
|
2333
|
+
id,
|
|
2334
|
+
pid: rec.pid,
|
|
2335
|
+
cmd: rec.cmd,
|
|
2336
|
+
name: rec.name,
|
|
2337
|
+
type: rec.type,
|
|
2338
|
+
started: rec.started,
|
|
2339
|
+
logPath,
|
|
2340
|
+
state: "done",
|
|
2341
|
+
exitCode: exitCode >= 0 ? exitCode : undefined,
|
|
2342
|
+
exitedAt: rec.exitedAt,
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
// Opt-in project-config digest (best-effort, silent-fail). rec.ctx is
|
|
2346
|
+
// the ExtensionContext captured at tool-call time and retains
|
|
2347
|
+
// everything resolveConfig needs (cwd + isProjectTrusted), so the
|
|
2348
|
+
// digest config is resolved here at exit — config edits made while the
|
|
2349
|
+
// job ran are picked up, and trust is evaluated against the same
|
|
2350
|
+
// session that spawned the job. No spawn-time capture needed. When a
|
|
2351
|
+
// digest is configured, the wake is sent only after this bounded
|
|
2352
|
+
// attempt (≤ ~5.25s: 5s timeout + 250ms kill grace) completes; a digest
|
|
2353
|
+
// that fails, times out, or prints
|
|
2354
|
+
// nothing appends nothing, and the exit code / universal part above are
|
|
2355
|
+
// never affected.
|
|
2356
|
+
let digestBlock: { label: string; text: string } | undefined;
|
|
842
2357
|
try {
|
|
843
|
-
|
|
844
|
-
|
|
2358
|
+
// First matching entry wins, in config order. The label defaults to
|
|
2359
|
+
// the entry's label, the entry's type, a matched `match.name`, then
|
|
2360
|
+
// the entry's preset id (or "command").
|
|
2361
|
+
const digestEntries = resolveConfig(rec.ctx).digest;
|
|
2362
|
+
const digestTarget: DigestJobTarget = {
|
|
2363
|
+
name: rec.name,
|
|
2364
|
+
type: rec.type,
|
|
2365
|
+
command: rec.cmd,
|
|
2366
|
+
};
|
|
2367
|
+
const selected = selectDigestEntry(digestEntries, digestTarget);
|
|
2368
|
+
if (selected) {
|
|
2369
|
+
if (truncatedAt !== null) {
|
|
2370
|
+
// A scorecard reads the log's END (summary lines, failure
|
|
2371
|
+
// lists) — exactly what a head cap drops. Its numbers would be
|
|
2372
|
+
// confidently wrong, so report why it was skipped instead.
|
|
2373
|
+
digestBlock = {
|
|
2374
|
+
label: selected.label,
|
|
2375
|
+
text:
|
|
2376
|
+
`skipped — the log was truncated at ${formatBytes(truncatedAt)} ` +
|
|
2377
|
+
"and this scorecard reads the log's end, which the cap dropped",
|
|
2378
|
+
};
|
|
2379
|
+
} else {
|
|
2380
|
+
const raw = await runDigestCommand(selected.command, logPath);
|
|
2381
|
+
const text = raw === undefined ? undefined : capDigestOutput(raw);
|
|
2382
|
+
if (text) digestBlock = { label: selected.label, text };
|
|
2383
|
+
}
|
|
2384
|
+
} else if (digestEntries?.length) {
|
|
2385
|
+
// Configured but nothing selected — otherwise silent. Surface the
|
|
2386
|
+
// job's type/name plus the configured types, once per distinct
|
|
2387
|
+
// diagnostic (capped), so a type mismatch or dead glob is visible.
|
|
2388
|
+
const warning = digestNoMatchWarning(digestTarget, digestEntries);
|
|
2389
|
+
if (!digestNoMatchWarned.has(warning)) {
|
|
2390
|
+
if (digestNoMatchWarned.size < DIGEST_NO_MATCH_WARN_CAP) {
|
|
2391
|
+
digestNoMatchWarned.add(warning);
|
|
2392
|
+
console.error(warning);
|
|
2393
|
+
} else if (!digestNoMatchSuppressed) {
|
|
2394
|
+
// Don't silently drop further distinct mismatches.
|
|
2395
|
+
digestNoMatchSuppressed = true;
|
|
2396
|
+
console.error(
|
|
2397
|
+
`[pi-bgrun] further digest no-match diagnostics suppressed (cap ${DIGEST_NO_MATCH_WARN_CAP})`,
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
} catch (e) {
|
|
2403
|
+
// Silent-fail: a broken digest never breaks a wake (ground rule 3).
|
|
845
2404
|
console.error(
|
|
846
|
-
`[pi-bgrun]
|
|
847
|
-
(
|
|
2405
|
+
`[pi-bgrun] digest failed for job ${id}:`,
|
|
2406
|
+
(e as Error).message,
|
|
848
2407
|
);
|
|
849
2408
|
}
|
|
850
|
-
}
|
|
851
2409
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
)
|
|
859
|
-
|
|
2410
|
+
// Wake the agent.
|
|
2411
|
+
const namePrefix = rec.name ? `"${rec.name}" ` : "";
|
|
2412
|
+
let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`;
|
|
2413
|
+
wake += `Command: ${command}\n`;
|
|
2414
|
+
wake += `Stats: ${statsParts.join(", ")}\n`;
|
|
2415
|
+
if (lastLine) wake += `Last output: ${lastLine}\n`;
|
|
2416
|
+
if (digestBlock) {
|
|
2417
|
+
wake += `digest (${digestBlock.label}): ${digestBlock.text}\n`;
|
|
2418
|
+
}
|
|
2419
|
+
wake += `Review the result now: call \`bgtail\` with this job id to see the output, summarize pass/fail, and continue the task that depended on it.`;
|
|
2420
|
+
try {
|
|
2421
|
+
if (rec.ctx.isIdle()) {
|
|
2422
|
+
pi.sendUserMessage(wake);
|
|
2423
|
+
} else {
|
|
2424
|
+
pi.sendUserMessage(wake, { deliverAs: "followUp" });
|
|
2425
|
+
}
|
|
2426
|
+
} catch {
|
|
2427
|
+
try {
|
|
2428
|
+
pi.sendUserMessage(wake, { deliverAs: "followUp" });
|
|
2429
|
+
} catch (e2) {
|
|
2430
|
+
console.error(
|
|
2431
|
+
`[pi-bgrun] wake failed for job ${id}:`,
|
|
2432
|
+
(e2 as Error).message,
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
860
2436
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
2437
|
+
// Toast for the human.
|
|
2438
|
+
if (rec.ctx.hasUI) {
|
|
2439
|
+
const toastLabel = (rec.name ?? command).slice(0, 50);
|
|
2440
|
+
rec.ctx.ui.notify(
|
|
2441
|
+
`${exitEmoji} ${toastLabel} → exit ${exitStr}`,
|
|
2442
|
+
exitCode === 0 ? "info" : "error",
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
864
2445
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
updateWidget(ctx);
|
|
869
|
-
});
|
|
2446
|
+
// Update/clear the widget.
|
|
2447
|
+
updateWidget(rec.ctx);
|
|
2448
|
+
});
|
|
870
2449
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
2450
|
+
child.on("error", (err) => {
|
|
2451
|
+
console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message);
|
|
2452
|
+
finishSpawnFailure(err);
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
const startedLines = [`started: ${id}`];
|
|
2456
|
+
if (name) startedLines.push(` name: ${name}`);
|
|
2457
|
+
if (type) startedLines.push(` type: ${type}`);
|
|
2458
|
+
startedLines.push(
|
|
2459
|
+
` log: ${logPath}`,
|
|
2460
|
+
` You'll be woken automatically when it finishes.`,
|
|
2461
|
+
);
|
|
2462
|
+
return {
|
|
2463
|
+
content: [{ type: "text", text: startedLines.join("\n") }],
|
|
2464
|
+
details: { id, name, type, logPath, pid: childPid },
|
|
2465
|
+
};
|
|
2466
|
+
} finally {
|
|
2467
|
+
if (logFd !== undefined) closeSync(logFd);
|
|
2468
|
+
}
|
|
881
2469
|
},
|
|
882
2470
|
});
|
|
883
2471
|
|
|
@@ -890,6 +2478,162 @@ export default function (pi: ExtensionAPI) {
|
|
|
890
2478
|
const LINE_CAP = 2000; // chars per line after stripping
|
|
891
2479
|
const TOTAL_CAP = 8000; // chars for the whole bgtail result
|
|
892
2480
|
|
|
2481
|
+
// ── Digest: opt-in project-config scorecard appended to the wake ──────────
|
|
2482
|
+
// Runs only when a trusted project (or the user file) configures a `digest`
|
|
2483
|
+
// section. Best-effort, silent-fail: errors, timeouts, and empty output all
|
|
2484
|
+
// contribute nothing, and the digest never affects the exit code, ordering,
|
|
2485
|
+
// or the wake's universal part (ground rules 2-3).
|
|
2486
|
+
const DIGEST_TIMEOUT_MS = 5000; // hard bound on added wake latency
|
|
2487
|
+
const DIGEST_KILL_GRACE_MS = 250; // SIGTERM → SIGKILL grace
|
|
2488
|
+
const DIGEST_TOTAL_CAP = 500; // chars appended to the wake, first lines win
|
|
2489
|
+
const DIGEST_LINE_CAP = 200; // per-line cap, consistent with the condenser
|
|
2490
|
+
|
|
2491
|
+
// Run a digest command (log path arrives as $1) and collect stdout.
|
|
2492
|
+
// Resolves undefined on spawn error, non-timeout failure semantics are the
|
|
2493
|
+
// caller's concern (empty output is dropped when capping). A timed-out
|
|
2494
|
+
// command contributes NOTHING — after SIGKILL we resolve immediately with
|
|
2495
|
+
// undefined so the wake is never delayed past DIGEST_TIMEOUT_MS + grace.
|
|
2496
|
+
function runDigestCommand(
|
|
2497
|
+
cmd: string,
|
|
2498
|
+
logPath: string,
|
|
2499
|
+
): Promise<string | undefined> {
|
|
2500
|
+
return new Promise((resolve) => {
|
|
2501
|
+
let settled = false;
|
|
2502
|
+
let timedOut = false;
|
|
2503
|
+
const finish = (out: string | undefined) => {
|
|
2504
|
+
if (settled) return;
|
|
2505
|
+
settled = true;
|
|
2506
|
+
// Stop accepting output. A grandchild can keep the pipe's write end
|
|
2507
|
+
// open after `sh` exits (pipelines, `cmd &`), and a live `data`
|
|
2508
|
+
// listener would otherwise grow this buffer forever.
|
|
2509
|
+
child?.stdout?.removeAllListeners("data");
|
|
2510
|
+
resolve(out);
|
|
2511
|
+
};
|
|
2512
|
+
let child: ReturnType<typeof spawn>;
|
|
2513
|
+
try {
|
|
2514
|
+
child = spawn("sh", ["-c", cmd, "--", logPath], {
|
|
2515
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2516
|
+
// Own process group so a timeout can kill the whole pipeline (sh AND
|
|
2517
|
+
// its children), not just `sh`. Without this, grandchildren survive
|
|
2518
|
+
// and keep the pipe open.
|
|
2519
|
+
detached: true,
|
|
2520
|
+
});
|
|
2521
|
+
} catch {
|
|
2522
|
+
finish(undefined);
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
let stdout = "";
|
|
2526
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
2527
|
+
// Bounded collection: once past the wake cap, stop buffering but keep
|
|
2528
|
+
// the listener attached so the child is never blocked on a full pipe.
|
|
2529
|
+
// capDigestOutput() trims the overshoot to DIGEST_TOTAL_CAP.
|
|
2530
|
+
if (stdout.length > DIGEST_TOTAL_CAP) return;
|
|
2531
|
+
stdout += chunk.toString();
|
|
2532
|
+
});
|
|
2533
|
+
// Kill the whole process group (see `detached` above). Process groups
|
|
2534
|
+
// are POSIX-only; the `child.kill` fallback covers platforms where the
|
|
2535
|
+
// negative-pid kill fails. `childExited` guards against signalling a
|
|
2536
|
+
// group whose pid may already have been recycled after the child exits.
|
|
2537
|
+
let childExited = false;
|
|
2538
|
+
const killGroup = (signal: NodeJS.Signals) => {
|
|
2539
|
+
if (childExited) return;
|
|
2540
|
+
const pid = child.pid;
|
|
2541
|
+
try {
|
|
2542
|
+
if (pid === undefined) throw new Error("no pid");
|
|
2543
|
+
process.kill(-pid, signal);
|
|
2544
|
+
} catch {
|
|
2545
|
+
try {
|
|
2546
|
+
child.kill(signal);
|
|
2547
|
+
} catch {
|
|
2548
|
+
// already gone
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
};
|
|
2552
|
+
// Hard timeout: SIGTERM first, SIGKILL after a short grace.
|
|
2553
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2554
|
+
const killTimer = setTimeout(() => {
|
|
2555
|
+
timedOut = true;
|
|
2556
|
+
killGroup("SIGTERM");
|
|
2557
|
+
graceTimer = setTimeout(() => {
|
|
2558
|
+
killGroup("SIGKILL");
|
|
2559
|
+
finish(undefined);
|
|
2560
|
+
}, DIGEST_KILL_GRACE_MS);
|
|
2561
|
+
}, DIGEST_TIMEOUT_MS);
|
|
2562
|
+
const stopTimers = () => {
|
|
2563
|
+
clearTimeout(killTimer);
|
|
2564
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
2565
|
+
};
|
|
2566
|
+
child.on("error", () => {
|
|
2567
|
+
childExited = true;
|
|
2568
|
+
stopTimers();
|
|
2569
|
+
finish(undefined);
|
|
2570
|
+
});
|
|
2571
|
+
child.on("exit", (code) => {
|
|
2572
|
+
childExited = true;
|
|
2573
|
+
stopTimers();
|
|
2574
|
+
// Contract: a digest that ERRORS contributes nothing. Gate on the exit
|
|
2575
|
+
// code so partial output from a failed command never reaches the wake.
|
|
2576
|
+
// Shipped presets all end in `head`, which exits 0.
|
|
2577
|
+
finish(timedOut || code !== 0 ? undefined : stdout);
|
|
2578
|
+
});
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// Cap digest output for the wake: first lines win. ANSI stripped (reusing
|
|
2583
|
+
// the condenser's regex), per-line cap for consistency, blank lines
|
|
2584
|
+
// dropped, ~500 chars total. Nothing usable → undefined (nothing appended).
|
|
2585
|
+
function capDigestOutput(raw: string): string | undefined {
|
|
2586
|
+
const lines = raw
|
|
2587
|
+
.replace(ANSI_RE, "")
|
|
2588
|
+
.split("\n")
|
|
2589
|
+
.map((l) =>
|
|
2590
|
+
l.length > DIGEST_LINE_CAP ? l.slice(0, DIGEST_LINE_CAP) : l,
|
|
2591
|
+
)
|
|
2592
|
+
.filter((l) => l.trim().length > 0);
|
|
2593
|
+
if (lines.length === 0) return undefined;
|
|
2594
|
+
const joined = lines.join("\n");
|
|
2595
|
+
const capped =
|
|
2596
|
+
joined.length > DIGEST_TOTAL_CAP
|
|
2597
|
+
? joined.slice(0, DIGEST_TOTAL_CAP)
|
|
2598
|
+
: joined;
|
|
2599
|
+
return capped.trim() || undefined;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// No-match diagnostics seen this process (capped) — keyed by the full
|
|
2603
|
+
// warning string so a type mismatch and a dead regex each surface once.
|
|
2604
|
+
const digestNoMatchWarned = new Set<string>();
|
|
2605
|
+
let digestNoMatchSuppressed = false;
|
|
2606
|
+
const DIGEST_NO_MATCH_WARN_CAP = 3;
|
|
2607
|
+
|
|
2608
|
+
// ── Digest nudge: one-shot session_start toast for digest-less projects ────
|
|
2609
|
+
// When a trusted project has actually used bgrun (≥1 finished job log in the
|
|
2610
|
+
// jobs dir) but never configured a digest, point the human at the
|
|
2611
|
+
// digest-config skill once. Toast only — never sendUserMessage, so it costs
|
|
2612
|
+
// zero LLM context. Dismissal is a per-project marker file in the jobs dir;
|
|
2613
|
+
// the user's config files are never written.
|
|
2614
|
+
function maybeNudgeDigest(ctx: ExtensionContext): void {
|
|
2615
|
+
try {
|
|
2616
|
+
if (!ctx.isProjectTrusted?.()) return;
|
|
2617
|
+
const cfg = resolveConfig(ctx);
|
|
2618
|
+
if (cfg.digest) return; // already configured — nothing to nudge
|
|
2619
|
+
if (!ctx.hasUI) return; // toast-only feature; no UI → nothing to do
|
|
2620
|
+
const projectDir = projectRootFor(ctx.cwd ?? process.cwd());
|
|
2621
|
+
// Project-scoped evidence of use (written at spawn) — never the shared
|
|
2622
|
+
// jobs dir as a whole, which would toast every project on the machine.
|
|
2623
|
+
if (!existsSync(jobUsageMarkerPath(cfg.jobsDir, projectDir))) return;
|
|
2624
|
+
const markerPath = digestNudgeMarkerPath(cfg.jobsDir, projectDir);
|
|
2625
|
+
if (existsSync(markerPath)) return; // already nudged once — stay silent
|
|
2626
|
+
ctx.ui.notify(DIGEST_NUDGE_TEXT, "info");
|
|
2627
|
+
try {
|
|
2628
|
+
writeFileSync(markerPath, String(Date.now()));
|
|
2629
|
+
} catch {
|
|
2630
|
+
// best-effort — a marker write failure must never break session_start
|
|
2631
|
+
}
|
|
2632
|
+
} catch (err) {
|
|
2633
|
+
console.error("[pi-bgrun] digest nudge failed:", (err as Error).message);
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
|
|
893
2637
|
function condenseLogLines(
|
|
894
2638
|
lines: string[],
|
|
895
2639
|
opts: { raw?: boolean } = {},
|
|
@@ -948,63 +2692,237 @@ export default function (pi: ExtensionAPI) {
|
|
|
948
2692
|
return { text: out.join("\n"), truncated: notes };
|
|
949
2693
|
}
|
|
950
2694
|
|
|
951
|
-
// ── bgtail: read
|
|
2695
|
+
// ── bgtail: read the newest lines of a job's log, condensed for context ────
|
|
2696
|
+
//
|
|
2697
|
+
// Delta tailing: each read bookmarks the total raw line count at read time
|
|
2698
|
+
// (the high-water mark of what the caller has had the opportunity to see).
|
|
2699
|
+
// The FIRST read for a job returns the full last-N tail; repeat reads return
|
|
2700
|
+
// only lines appended since, so polling a running job never re-pays context
|
|
2701
|
+
// for lines already seen. Deliberately-skipped prefix lines are never
|
|
2702
|
+
// replayed as "new". raw: true keeps the verbatim last-N window (no delta
|
|
2703
|
+
// header) but still advances the bookmark. A shrunken log (rotated/replaced)
|
|
2704
|
+
// resets to a full tail. Bookmarks are in-memory only (see tailBookmarks
|
|
2705
|
+
// above) — a session restart starts fresh with a full tail.
|
|
2706
|
+
|
|
2707
|
+
// Record a bookmark, evicting the oldest entry once the map is full. Cleanup
|
|
2708
|
+
// drops bookmarks when it removes a log; this bounds the rest.
|
|
2709
|
+
function rememberTail(id: string, bookmark: TailBookmark): void {
|
|
2710
|
+
tailBookmarks.set(id, bookmark);
|
|
2711
|
+
if (tailBookmarks.size <= TAIL_BOOKMARK_CAP) return;
|
|
2712
|
+
const oldest = tailBookmarks.keys().next().value;
|
|
2713
|
+
if (oldest !== undefined && oldest !== id) tailBookmarks.delete(oldest);
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
// Resolve a job's log path and read its bounded slice, single-sourcing the
|
|
2717
|
+
// "in-memory record first, then the configured jobs dir" rule shared by
|
|
2718
|
+
// bgtail and bggrep. The record's logPath stays correct even if the config
|
|
2719
|
+
// (and thus the resolved jobs dir) changes mid-session. On failure the caller
|
|
2720
|
+
// renders tool-specific error details.
|
|
2721
|
+
function resolveLogForJob(
|
|
2722
|
+
id: string,
|
|
2723
|
+
tool: string,
|
|
2724
|
+
ctx: ExtensionContext | undefined,
|
|
2725
|
+
window: number,
|
|
2726
|
+
):
|
|
2727
|
+
| { logPath: string; content: string; size: number }
|
|
2728
|
+
| { logPath: string; errorText: string; notFound: boolean } {
|
|
2729
|
+
validateJobId(id, tool);
|
|
2730
|
+
const logPath =
|
|
2731
|
+
jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`);
|
|
2732
|
+
const slice = readLogSlice(logPath, window);
|
|
2733
|
+
if (!slice) {
|
|
2734
|
+
return {
|
|
2735
|
+
logPath,
|
|
2736
|
+
errorText: logReadError(id, logPath),
|
|
2737
|
+
notFound: !existsSync(logPath),
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
return { logPath, content: slice.content, size: slice.size };
|
|
2741
|
+
}
|
|
952
2742
|
|
|
953
2743
|
// Shared by the bgtail tool (agent-facing) and the /bgtail slash command
|
|
954
2744
|
// (human-facing).
|
|
955
2745
|
async function bgtailCore(
|
|
956
|
-
params: { id: string; lines?: number; raw?: boolean },
|
|
2746
|
+
params: { id: string; lines?: number; raw?: boolean; bytes?: number },
|
|
957
2747
|
ctx?: ExtensionContext,
|
|
958
2748
|
): Promise<{
|
|
959
2749
|
content: { type: "text"; text: string }[];
|
|
960
2750
|
details: Record<string, unknown>;
|
|
961
2751
|
isError?: boolean;
|
|
962
2752
|
}> {
|
|
963
|
-
const { id, lines = 40, raw = false } = params;
|
|
2753
|
+
const { id, lines: linesParam = 40, raw = false } = params;
|
|
2754
|
+
// Clamp defensively — direct callers (e.g. the slash command) bypass the
|
|
2755
|
+
// tool schema, and lines < 1 would corrupt slicing (slice(-0) = whole log).
|
|
2756
|
+
const lines = Math.max(1, Math.floor(linesParam));
|
|
964
2757
|
if (!id) throw new Error("bgtail: id is required");
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const all = content
|
|
969
|
-
.split("\n")
|
|
970
|
-
.filter((l) => !l.startsWith(EXIT_MARKER) && l.trim().length > 0);
|
|
971
|
-
const tail = all.slice(-lines);
|
|
972
|
-
const { text, truncated } = condenseLogLines(tail, { raw });
|
|
973
|
-
const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
|
|
2758
|
+
const readWindow = clampReadWindow(params.bytes);
|
|
2759
|
+
const resolved = resolveLogForJob(id, "bgtail", ctx, readWindow);
|
|
2760
|
+
if ("errorText" in resolved) {
|
|
974
2761
|
return {
|
|
975
|
-
content: [{ type: "text", text:
|
|
2762
|
+
content: [{ type: "text", text: resolved.errorText }],
|
|
976
2763
|
details: {
|
|
977
2764
|
id,
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
notFound: false,
|
|
981
|
-
condensed: !raw,
|
|
982
|
-
...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
|
|
2765
|
+
logPath: resolved.logPath,
|
|
2766
|
+
notFound: resolved.notFound,
|
|
983
2767
|
},
|
|
984
|
-
};
|
|
985
|
-
} catch {
|
|
986
|
-
return {
|
|
987
|
-
content: [
|
|
988
|
-
{ type: "text", text: `No log found for job ${id} at ${logPath}` },
|
|
989
|
-
],
|
|
990
|
-
details: { id, linesShown: 0, logPath, notFound: true },
|
|
991
2768
|
isError: true,
|
|
992
2769
|
};
|
|
993
2770
|
}
|
|
2771
|
+
const { logPath, content, size } = resolved;
|
|
2772
|
+
// The cap dropped bytes off the END, so every "last N lines" view below is
|
|
2773
|
+
// the end of what was KEPT. Flag it in the output, or a mid-run line reads
|
|
2774
|
+
// as the job's final word — and in the details, for callers that parse them.
|
|
2775
|
+
const truncatedAt = parseTruncationFromContent(content);
|
|
2776
|
+
const capNote =
|
|
2777
|
+
truncatedAt === null
|
|
2778
|
+
? ""
|
|
2779
|
+
: `\n\n(log truncated at ${formatBytes(truncatedAt)} — lines past the cap were never written, so this is not the run's real end)`;
|
|
2780
|
+
const truncDetails =
|
|
2781
|
+
truncatedAt === null ? {} : { truncatedAtBytes: truncatedAt };
|
|
2782
|
+
// Tail reads are bounded at LOG_READ_BYTES, so on a log past that window
|
|
2783
|
+
// every view above is the end of a slice — say so, or "not in the output"
|
|
2784
|
+
// reads as "not in the log". The advertised maximum is the ceiling actually
|
|
2785
|
+
// in force plus the wrapper's overhead, so it can cover a capped log
|
|
2786
|
+
// (a max equal to the cap left its first bytes permanently unreadable).
|
|
2787
|
+
const windowNote =
|
|
2788
|
+
size > readWindow
|
|
2789
|
+
? `\n\n(searched the last ${formatBytes(readWindow)} of ${formatBytes(size)} — the earlier bytes were not searched; pass a larger \`bytes\` (max ${formatBytes(readWindowMax())}) or use ctx_execute_file on the log path)`
|
|
2790
|
+
: "";
|
|
2791
|
+
// Content lines only: wrapper bookkeeping (exit marker, truncation notice)
|
|
2792
|
+
// and blanks are filtered BEFORE the window is sliced, so "last N lines"
|
|
2793
|
+
// means the last N content lines (matching pre-delta behavior) and
|
|
2794
|
+
// bookmarks count content lines.
|
|
2795
|
+
// boundScanLines first: a window of very short lines (the `yes ''` runaway
|
|
2796
|
+
// the ceiling exists for) is millions of lines in a few MiB, and splitting
|
|
2797
|
+
// it all costs gigabytes of RSS on the host's main thread.
|
|
2798
|
+
// /\r?\n/ keeps CRLF logs from leaving a stray \r on every line.
|
|
2799
|
+
const scan = boundScanLines(content);
|
|
2800
|
+
const lineNote = scan.lineBoundHit
|
|
2801
|
+
? `\n\n(and only the last ${LOG_SCAN_LINES_MAX.toLocaleString("en-US")} lines of that window were scanned)`
|
|
2802
|
+
: "";
|
|
2803
|
+
const rawLines = scan.content
|
|
2804
|
+
.split(/\r?\n/)
|
|
2805
|
+
.filter((l) => !isWrapperLine(l) && l.trim().length > 0);
|
|
2806
|
+
const total = rawLines.length;
|
|
2807
|
+
const first = rawLines[0]?.slice(0, 200) ?? "";
|
|
2808
|
+
const prev = tailBookmarks.get(id);
|
|
2809
|
+
// Same log, different window: a wider view would look like pages of "new"
|
|
2810
|
+
// lines that were only never looked at before, so reset the delta. It also
|
|
2811
|
+
// moves the window's first line, so it must be tested BEFORE the
|
|
2812
|
+
// replacement heuristic below — otherwise a widened read misreports the log
|
|
2813
|
+
// as replaced.
|
|
2814
|
+
const windowChanged = prev !== undefined && prev.window !== readWindow;
|
|
2815
|
+
// Append-only logs never mutate earlier lines, so a changed first
|
|
2816
|
+
// content line means the log was replaced or rotated — reset to a full
|
|
2817
|
+
// tail. Catches same-size replacements the shrink checks cannot see.
|
|
2818
|
+
// (A previously-empty log growing content is growth, not replacement.)
|
|
2819
|
+
const replaced =
|
|
2820
|
+
prev !== undefined &&
|
|
2821
|
+
!windowChanged &&
|
|
2822
|
+
prev.lines > 0 &&
|
|
2823
|
+
prev.first !== first;
|
|
2824
|
+
const shrank =
|
|
2825
|
+
prev !== undefined && (prev.lines > total || prev.bytes > size);
|
|
2826
|
+
let window: string[];
|
|
2827
|
+
let header: string | undefined;
|
|
2828
|
+
let newLines: number | undefined;
|
|
2829
|
+
if (raw || prev === undefined || shrank || replaced || windowChanged) {
|
|
2830
|
+
// Full tail: first read, raw mode, a shrunken/replaced log, or a changed
|
|
2831
|
+
// search window (all resets).
|
|
2832
|
+
window = rawLines.slice(-lines);
|
|
2833
|
+
if (!raw) {
|
|
2834
|
+
header = shrank
|
|
2835
|
+
? "log shrank since last read — showing full tail"
|
|
2836
|
+
: replaced
|
|
2837
|
+
? "log was replaced since last read — showing full tail"
|
|
2838
|
+
: windowChanged
|
|
2839
|
+
? "search window changed since last read — showing full tail"
|
|
2840
|
+
: undefined;
|
|
2841
|
+
}
|
|
2842
|
+
} else {
|
|
2843
|
+
const fresh = rawLines.slice(prev.lines);
|
|
2844
|
+
newLines = fresh.length;
|
|
2845
|
+
if (fresh.length === 0) {
|
|
2846
|
+
rememberTail(id, {
|
|
2847
|
+
lines: total,
|
|
2848
|
+
bytes: size,
|
|
2849
|
+
first,
|
|
2850
|
+
window: readWindow,
|
|
2851
|
+
});
|
|
2852
|
+
return {
|
|
2853
|
+
content: [
|
|
2854
|
+
{
|
|
2855
|
+
type: "text",
|
|
2856
|
+
text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})${capNote}${windowNote}${lineNote}`,
|
|
2857
|
+
},
|
|
2858
|
+
],
|
|
2859
|
+
details: {
|
|
2860
|
+
id,
|
|
2861
|
+
linesShown: 0,
|
|
2862
|
+
logPath,
|
|
2863
|
+
notFound: false,
|
|
2864
|
+
condensed: true,
|
|
2865
|
+
newLines: 0,
|
|
2866
|
+
totalLines: total,
|
|
2867
|
+
windowBytes: readWindow,
|
|
2868
|
+
...truncDetails,
|
|
2869
|
+
},
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
window = fresh.length > lines ? fresh.slice(-lines) : fresh;
|
|
2873
|
+
header =
|
|
2874
|
+
`+${fresh.length} new line${fresh.length === 1 ? "" : "s"} since last read — ` +
|
|
2875
|
+
`log at ${total} lines${fresh.length > lines ? ` (showing last ${lines})` : ""}`;
|
|
2876
|
+
}
|
|
2877
|
+
rememberTail(id, {
|
|
2878
|
+
lines: total,
|
|
2879
|
+
bytes: size,
|
|
2880
|
+
first,
|
|
2881
|
+
window: readWindow,
|
|
2882
|
+
});
|
|
2883
|
+
const shown = window;
|
|
2884
|
+
const { text, truncated } = condenseLogLines(shown, { raw });
|
|
2885
|
+
// Delta reads early-return above, so an empty window here can only be
|
|
2886
|
+
// a first read of an empty log (full-tail path).
|
|
2887
|
+
const body = shown.length === 0 ? "(empty log)" : text;
|
|
2888
|
+
const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
|
|
2889
|
+
const head = header ? `${header}\n` : "";
|
|
2890
|
+
return {
|
|
2891
|
+
content: [
|
|
2892
|
+
{
|
|
2893
|
+
type: "text",
|
|
2894
|
+
text: head + body + notes + capNote + windowNote + lineNote,
|
|
2895
|
+
},
|
|
2896
|
+
],
|
|
2897
|
+
details: {
|
|
2898
|
+
id,
|
|
2899
|
+
linesShown: shown.length,
|
|
2900
|
+
logPath,
|
|
2901
|
+
notFound: false,
|
|
2902
|
+
condensed: !raw,
|
|
2903
|
+
...(newLines === undefined ? {} : { newLines, totalLines: total }),
|
|
2904
|
+
...(truncated.length > 0 ? { condenserNotes: truncated } : {}),
|
|
2905
|
+
windowBytes: readWindow,
|
|
2906
|
+
...truncDetails,
|
|
2907
|
+
},
|
|
2908
|
+
};
|
|
994
2909
|
}
|
|
995
2910
|
|
|
996
2911
|
pi.registerTool({
|
|
997
2912
|
name: "bgtail",
|
|
998
2913
|
label: "Tail Background Log",
|
|
999
2914
|
description:
|
|
1000
|
-
"
|
|
2915
|
+
"Read the newest lines of a background job's log, condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. The first read returns the last N lines (default 40); REPEAT reads return only lines appended since your last read (delta tailing) — polling a running job never re-pays for the same lines. raw: true returns the unprocessed last-N window. A shrunken, replaced, or differently-windowed log resets to a full tail. Reads only the log's last 2 MiB by default (`bytes` widens it, max 64 MiB) and says so when the log is bigger. For pattern search use bggrep; for whole-log analysis, ctx_execute_file on the log path.",
|
|
1001
2916
|
promptSnippet: "Read the last N lines of a bgrun job's log",
|
|
1002
2917
|
parameters: Type.Object({
|
|
1003
2918
|
id: Type.String({
|
|
1004
2919
|
description: "Job id (from bgrun's 'started: <id>' response)",
|
|
1005
2920
|
}),
|
|
1006
2921
|
lines: Type.Optional(
|
|
1007
|
-
Type.Number({
|
|
2922
|
+
Type.Number({
|
|
2923
|
+
description: "Number of lines to show (default 40)",
|
|
2924
|
+
minimum: 1,
|
|
2925
|
+
}),
|
|
1008
2926
|
),
|
|
1009
2927
|
raw: Type.Optional(
|
|
1010
2928
|
Type.Boolean({
|
|
@@ -1012,12 +2930,249 @@ export default function (pi: ExtensionAPI) {
|
|
|
1012
2930
|
"Skip condensing (ANSI strip, collapse, caps) and return raw text",
|
|
1013
2931
|
}),
|
|
1014
2932
|
),
|
|
2933
|
+
bytes: Type.Optional(
|
|
2934
|
+
Type.Number({
|
|
2935
|
+
description:
|
|
2936
|
+
"Search window in bytes (default 2097152 = 2 MiB; capped at the configured log ceiling plus the wrapper's overhead — 67108864 = 64 MiB by default). Widening affects how much is SCANNED (and what the scan costs in CPU and memory) — the returned text stays capped.",
|
|
2937
|
+
minimum: 1,
|
|
2938
|
+
}),
|
|
2939
|
+
),
|
|
1015
2940
|
}),
|
|
1016
2941
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1017
2942
|
return bgtailCore(params, ctx);
|
|
1018
2943
|
},
|
|
1019
2944
|
});
|
|
1020
2945
|
|
|
2946
|
+
// ── bggrep: pattern search over a job's log, capped for context ───────────
|
|
2947
|
+
//
|
|
2948
|
+
// bggrep runs inside the extension, so it resolves the job id to the
|
|
2949
|
+
// configured jobs dir itself (no path to reconstruct) and needs no shell
|
|
2950
|
+
// quoting for the regex; ctx_execute_file can read the same file, but you
|
|
2951
|
+
// must hand it the absolute path. Matches are line-numbered (grep -n style),
|
|
2952
|
+
// optionally with context lines, capped at MAX_GREP_MATCHES, and run
|
|
2953
|
+
// through the same condenser as bgtail so a search can never flood context.
|
|
2954
|
+
|
|
2955
|
+
const MAX_GREP_MATCHES = 50;
|
|
2956
|
+
|
|
2957
|
+
async function bggrepCore(
|
|
2958
|
+
params: { id: string; pattern?: string; context?: number; bytes?: number },
|
|
2959
|
+
ctx?: ExtensionContext,
|
|
2960
|
+
|
|
2961
|
+
): Promise<{
|
|
2962
|
+
content: { type: "text"; text: string }[];
|
|
2963
|
+
details: Record<string, unknown>;
|
|
2964
|
+
isError?: boolean;
|
|
2965
|
+
}> {
|
|
2966
|
+
const { id, pattern, context: contextParam = 0 } = params;
|
|
2967
|
+
// Clamp defensively — negative context would exclude the match lines
|
|
2968
|
+
// themselves from the context windows (lo > hi no-ops the inner loop).
|
|
2969
|
+
const context = Math.max(0, Math.floor(contextParam));
|
|
2970
|
+
if (!id) throw new Error("bggrep: id is required");
|
|
2971
|
+
// resolveLogForJob() below validates the id; no need to double-check.
|
|
2972
|
+
const source = pattern ?? DEFAULT_GREP_PATTERN;
|
|
2973
|
+
try {
|
|
2974
|
+
// Validate up front so a bad pattern fails immediately, without a worker.
|
|
2975
|
+
void new RegExp(source);
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
throw new Error(
|
|
2978
|
+
`bggrep: invalid pattern ${JSON.stringify(source)}: ${(err as Error).message}`,
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2981
|
+
// Record-first, same as bgtail — correct across config changes.
|
|
2982
|
+
const readWindow = clampReadWindow(params.bytes);
|
|
2983
|
+
const resolved = resolveLogForJob(id, "bggrep", ctx, readWindow);
|
|
2984
|
+
if ("errorText" in resolved) {
|
|
2985
|
+
return {
|
|
2986
|
+
content: [{ type: "text", text: resolved.errorText }],
|
|
2987
|
+
details: {
|
|
2988
|
+
id,
|
|
2989
|
+
matches: 0,
|
|
2990
|
+
logPath: resolved.logPath,
|
|
2991
|
+
notFound: resolved.notFound,
|
|
2992
|
+
},
|
|
2993
|
+
isError: true,
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
const { logPath, content, size } = resolved;
|
|
2997
|
+
// A capped log is missing its END, and "no matches" is exactly what a
|
|
2998
|
+
// failure pattern looks like when the failures were past the cap — so the
|
|
2999
|
+
// note belongs next to the count, not just in the details.
|
|
3000
|
+
const truncatedAt = parseTruncationFromContent(content);
|
|
3001
|
+
const truncNote =
|
|
3002
|
+
truncatedAt === null
|
|
3003
|
+
? ""
|
|
3004
|
+
: `\n\n(log truncated at ${formatBytes(truncatedAt)} — output past the cap was never written and was not searched)`;
|
|
3005
|
+
const truncDetails =
|
|
3006
|
+
truncatedAt === null ? {} : { truncatedAtBytes: truncatedAt };
|
|
3007
|
+
// Same window caveat as bgtail: the search covers only the last `window`
|
|
3008
|
+
// bytes, so a miss on a bigger log means "not in the searched slice". The
|
|
3009
|
+
// advertised maximum is the ceiling in force plus the wrapper's overhead, so
|
|
3010
|
+
// it can cover a capped log.
|
|
3011
|
+
const windowNote =
|
|
3012
|
+
size > readWindow
|
|
3013
|
+
? `\n\n(searched the last ${formatBytes(readWindow)} of ${formatBytes(size)} — the earlier bytes were not searched; pass a larger \`bytes\` (max ${formatBytes(readWindowMax())}) or use ctx_execute_file on the log path)`
|
|
3014
|
+
: "";
|
|
3015
|
+
// Bound the LINE count before splitting: a window of very short lines is
|
|
3016
|
+
// millions of lines in a few MiB, and materializing them costs ~100 bytes
|
|
3017
|
+
// each (>3 GB measured for a 64 MiB window) — on the main thread, in the
|
|
3018
|
+
// very log class the ceiling exists for. The caveat says when it bit.
|
|
3019
|
+
const scan = boundScanLines(content);
|
|
3020
|
+
const lineNote = scan.lineBoundHit
|
|
3021
|
+
? `\n\n(and only the last ${LOG_SCAN_LINES_MAX.toLocaleString("en-US")} lines of that window were searched)`
|
|
3022
|
+
: "";
|
|
3023
|
+
// /\r?\n/ normalizes CRLF (a trailing \r would break $-anchored patterns
|
|
3024
|
+
// and leak into output); blank lines are KEPT so L<n> numbers match the
|
|
3025
|
+
// file. A trailing empty split element is dropped; "" yields zero lines.
|
|
3026
|
+
const split = scan.content === "" ? [] : scan.content.split(/\r?\n/);
|
|
3027
|
+
if (split.length > 0 && split[split.length - 1] === "") split.pop();
|
|
3028
|
+
const rawLines = split.filter((l) => !isWrapperLine(l));
|
|
3029
|
+
// Match under a wall-clock budget in a worker: a caller-supplied regex can
|
|
3030
|
+
// backtrack catastrophically and would otherwise hang the main thread with
|
|
3031
|
+
// no way to interrupt it.
|
|
3032
|
+
const budgetMs = bggrepTimeoutMs();
|
|
3033
|
+
const outcome = await matchLinesWithBudget(
|
|
3034
|
+
source,
|
|
3035
|
+
rawLines,
|
|
3036
|
+
BGGREP_LINE_CAP,
|
|
3037
|
+
budgetMs,
|
|
3038
|
+
);
|
|
3039
|
+
if (outcome.kind === "invalid") {
|
|
3040
|
+
throw new Error(
|
|
3041
|
+
`bggrep: invalid pattern ${JSON.stringify(source)}: ${outcome.message}`,
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
if (outcome.kind === "timeout") {
|
|
3045
|
+
return {
|
|
3046
|
+
content: [
|
|
3047
|
+
{
|
|
3048
|
+
type: "text",
|
|
3049
|
+
text:
|
|
3050
|
+
`bggrep: /${source}/ exceeded the ${budgetMs}ms match budget across ` +
|
|
3051
|
+
`${rawLines.length} line${rawLines.length === 1 ? "" : "s"} — likely ` +
|
|
3052
|
+
`catastrophic backtracking; no results computed.`,
|
|
3053
|
+
},
|
|
3054
|
+
],
|
|
3055
|
+
details: {
|
|
3056
|
+
id,
|
|
3057
|
+
matches: 0,
|
|
3058
|
+
linesSearched: rawLines.length,
|
|
3059
|
+
logPath,
|
|
3060
|
+
notFound: false,
|
|
3061
|
+
pattern: source,
|
|
3062
|
+
timedOut: true,
|
|
3063
|
+
windowBytes: readWindow,
|
|
3064
|
+
...truncDetails,
|
|
3065
|
+
},
|
|
3066
|
+
isError: true,
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
const matchIdx = outcome.matchIdx;
|
|
3070
|
+
const header =
|
|
3071
|
+
`${matchIdx.length} match${matchIdx.length === 1 ? "" : "es"} for /${source}/ ` +
|
|
3072
|
+
`in ${rawLines.length} line${rawLines.length === 1 ? "" : "s"}`;
|
|
3073
|
+
if (matchIdx.length === 0) {
|
|
3074
|
+
return {
|
|
3075
|
+
content: [
|
|
3076
|
+
{ type: "text", text: `${header} — none${truncNote}${windowNote}${lineNote}` },
|
|
3077
|
+
],
|
|
3078
|
+
details: {
|
|
3079
|
+
id,
|
|
3080
|
+
matches: 0,
|
|
3081
|
+
linesSearched: rawLines.length,
|
|
3082
|
+
logPath,
|
|
3083
|
+
notFound: false,
|
|
3084
|
+
windowBytes: readWindow,
|
|
3085
|
+
...truncDetails,
|
|
3086
|
+
},
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
const capped = matchIdx.length > MAX_GREP_MATCHES;
|
|
3090
|
+
const shownIdx = capped ? matchIdx.slice(0, MAX_GREP_MATCHES) : matchIdx;
|
|
3091
|
+
// Context windows, merged where they overlap or touch (grep -C style).
|
|
3092
|
+
const include = new Set<number>();
|
|
3093
|
+
for (const i of shownIdx) {
|
|
3094
|
+
const lo = Math.max(0, i - context);
|
|
3095
|
+
const hi = Math.min(rawLines.length - 1, i + context);
|
|
3096
|
+
for (let j = lo; j <= hi; j++) include.add(j);
|
|
3097
|
+
}
|
|
3098
|
+
const sorted = [...include].sort((a, b) => a - b);
|
|
3099
|
+
const out: string[] = [];
|
|
3100
|
+
let prev = -2;
|
|
3101
|
+
for (const i of sorted) {
|
|
3102
|
+
if (prev >= 0 && i > prev + 1) {
|
|
3103
|
+
const gap = i - prev - 1;
|
|
3104
|
+
out.push(`…[${gap} line${gap === 1 ? "" : "s"} skipped]…`);
|
|
3105
|
+
}
|
|
3106
|
+
out.push(`L${i + 1}: ${rawLines[i]}`);
|
|
3107
|
+
prev = i;
|
|
3108
|
+
}
|
|
3109
|
+
const { text, truncated } = condenseLogLines(out);
|
|
3110
|
+
const notes = truncated.length > 0 ? `\n\n(${truncated.join("; ")})` : "";
|
|
3111
|
+
const capNote = capped
|
|
3112
|
+
? ` — showing first ${MAX_GREP_MATCHES}; ${matchIdx.length - MAX_GREP_MATCHES} more not shown`
|
|
3113
|
+
: "";
|
|
3114
|
+
return {
|
|
3115
|
+
content: [
|
|
3116
|
+
{
|
|
3117
|
+
type: "text",
|
|
3118
|
+
text: `${header}${capNote}\n${text}${notes}${truncNote}${windowNote}${lineNote}`,
|
|
3119
|
+
},
|
|
3120
|
+
],
|
|
3121
|
+
details: {
|
|
3122
|
+
id,
|
|
3123
|
+
matches: matchIdx.length,
|
|
3124
|
+
linesSearched: rawLines.length,
|
|
3125
|
+
logPath,
|
|
3126
|
+
notFound: false,
|
|
3127
|
+
pattern: source,
|
|
3128
|
+
capped,
|
|
3129
|
+
windowBytes: readWindow,
|
|
3130
|
+
...truncDetails,
|
|
3131
|
+
},
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
pi.registerTool({
|
|
3136
|
+
name: "bggrep",
|
|
3137
|
+
label: "Grep Background Log",
|
|
3138
|
+
description:
|
|
3139
|
+
"Search the tail of a background job's log with a regex — the last 2 MiB by default, widen with `bytes` (each line is pre-truncated to 10k chars before matching); returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. Matching runs under a wall-clock budget (default 2s, PI_BGRUN_GREP_TIMEOUT_MS), so a runaway regex fails instead of hanging. Resolves the job id to the configured jobs dir itself, so there is no log path to reconstruct; ctx_execute_file can read the same file, but needs the absolute path. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).",
|
|
3140
|
+
promptSnippet: "Search a bgrun job's log for a pattern",
|
|
3141
|
+
promptGuidelines: [
|
|
3142
|
+
"Never search a bgrun log with the bash tool — uncapped output can flood context, and it needs manual log-path reconstruction and regex shell-quoting; bggrep is bounded by design.",
|
|
3143
|
+
"Prefer bggrep over bash grep or reading a bgrun log — matches are line-numbered, capped, and condensed.",
|
|
3144
|
+
"Pass an explicit pattern when you know the tool's output format; the default only catches common failure signatures.",
|
|
3145
|
+
],
|
|
3146
|
+
parameters: Type.Object({
|
|
3147
|
+
id: Type.String({
|
|
3148
|
+
description: "Job id (from bgrun's 'started: <id>' response)",
|
|
3149
|
+
}),
|
|
3150
|
+
pattern: Type.Optional(
|
|
3151
|
+
Type.String({
|
|
3152
|
+
description:
|
|
3153
|
+
"Regex to search for. Default: generic failure signatures — override when you know the format.",
|
|
3154
|
+
}),
|
|
3155
|
+
),
|
|
3156
|
+
context: Type.Optional(
|
|
3157
|
+
Type.Number({
|
|
3158
|
+
description:
|
|
3159
|
+
"Context lines around each match (default 0, grep -C style)",
|
|
3160
|
+
minimum: 0,
|
|
3161
|
+
}),
|
|
3162
|
+
),
|
|
3163
|
+
bytes: Type.Optional(
|
|
3164
|
+
Type.Number({
|
|
3165
|
+
description:
|
|
3166
|
+
"Search window in bytes (default 2097152 = 2 MiB; capped at the configured log ceiling plus the wrapper's overhead — 67108864 = 64 MiB by default). Widening affects how much is SCANNED (and what the scan costs in CPU and memory) — the returned matches stay capped (~50 matches, ~8KB).",
|
|
3167
|
+
minimum: 1,
|
|
3168
|
+
}),
|
|
3169
|
+
),
|
|
3170
|
+
}),
|
|
3171
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3172
|
+
return bggrepCore(params, ctx);
|
|
3173
|
+
},
|
|
3174
|
+
});
|
|
3175
|
+
|
|
1021
3176
|
// ── bgstatus: list jobs (in-memory while alive; dir scan after restart) ─────
|
|
1022
3177
|
|
|
1023
3178
|
// Shared by the bgstatus tool (agent-facing) and the /bgstatus slash command
|
|
@@ -1034,58 +3189,81 @@ export default function (pi: ExtensionAPI) {
|
|
|
1034
3189
|
const cfg = resolveConfig(ctx);
|
|
1035
3190
|
const jobsDir = cfg.jobsDir;
|
|
1036
3191
|
if (id) {
|
|
3192
|
+
validateJobId(id, "bgstatus");
|
|
1037
3193
|
const rec = jobs.get(id);
|
|
1038
3194
|
if (rec) {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
3195
|
+
// Read-only reconciliation: an in-memory record whose exit event never
|
|
3196
|
+
// fired (or one reconstructed on restart) can lag its log. Derive the
|
|
3197
|
+
// real state from the marker / pid liveness WITHOUT mutating or
|
|
3198
|
+
// persisting — the list path and the 30s poller own persistence. This
|
|
3199
|
+
// keeps by-id and list from disagreeing for up to a poll interval.
|
|
3200
|
+
let exit = rec.exitCode;
|
|
3201
|
+
if (exit === undefined && !rec.child) {
|
|
3202
|
+
// No live handle (reconstructed/adopted) — derive from the log or a
|
|
3203
|
+
// dead pid. A live child is authoritative: a running job whose own
|
|
3204
|
+
// output contains a spurious __BGRUN_EXIT__ line must not read done.
|
|
3205
|
+
const fromLog = parseExitFromLogPath(rec.logPath);
|
|
3206
|
+
if (fromLog !== null) exit = fromLog;
|
|
3207
|
+
else if (rec.pid <= 0 || !isRunningPid(rec.pid)) exit = -1;
|
|
3208
|
+
}
|
|
3209
|
+
const state = exit === undefined ? "running" : "done";
|
|
3210
|
+
const exitStr = exit === undefined ? "" : ` exit=${exit}`;
|
|
3211
|
+
const lines = [`${id}: ${state}${exitStr}`];
|
|
1042
3212
|
if (rec.name) lines.push(` name: ${rec.name}`);
|
|
3213
|
+
if (rec.type) lines.push(` type: ${rec.type}`);
|
|
1043
3214
|
lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`);
|
|
1044
3215
|
return {
|
|
1045
3216
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
1046
3217
|
details: {
|
|
1047
3218
|
id,
|
|
1048
3219
|
state,
|
|
1049
|
-
exitCode:
|
|
3220
|
+
exitCode: exit ?? undefined,
|
|
1050
3221
|
cmd: rec.cmd,
|
|
1051
3222
|
name: rec.name,
|
|
3223
|
+
type: rec.type,
|
|
1052
3224
|
recovered: false,
|
|
1053
3225
|
},
|
|
1054
3226
|
};
|
|
1055
3227
|
}
|
|
1056
3228
|
const logPath = join(jobsDir, `${id}.log`);
|
|
1057
|
-
|
|
1058
|
-
const exit = parseExitFromLog(logPath);
|
|
1059
|
-
const state = exit === null ? "running" : "done";
|
|
1060
|
-
return {
|
|
1061
|
-
content: [
|
|
1062
|
-
{
|
|
1063
|
-
type: "text",
|
|
1064
|
-
text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
|
|
1065
|
-
},
|
|
1066
|
-
],
|
|
1067
|
-
details: {
|
|
1068
|
-
id,
|
|
1069
|
-
state,
|
|
1070
|
-
exitCode: exit ?? undefined,
|
|
1071
|
-
recovered: true,
|
|
1072
|
-
},
|
|
1073
|
-
};
|
|
1074
|
-
} catch {
|
|
3229
|
+
if (!existsSync(logPath)) {
|
|
1075
3230
|
return {
|
|
1076
3231
|
content: [{ type: "text", text: `No job found with id ${id}` }],
|
|
1077
3232
|
details: { id, state: "unknown" },
|
|
1078
3233
|
isError: true,
|
|
1079
3234
|
};
|
|
1080
3235
|
}
|
|
3236
|
+
let exit = parseExitFromLogPath(logPath);
|
|
3237
|
+
if (exit === null) {
|
|
3238
|
+
const pid = pidFromId(id);
|
|
3239
|
+
if (pid !== null && (pid <= 0 || !isRunningPid(pid))) exit = -1;
|
|
3240
|
+
}
|
|
3241
|
+
const state = exit === null ? "running" : "done";
|
|
3242
|
+
return {
|
|
3243
|
+
content: [
|
|
3244
|
+
{
|
|
3245
|
+
type: "text",
|
|
3246
|
+
text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`,
|
|
3247
|
+
},
|
|
3248
|
+
],
|
|
3249
|
+
details: {
|
|
3250
|
+
id,
|
|
3251
|
+
state,
|
|
3252
|
+
exitCode: exit ?? undefined,
|
|
3253
|
+
recovered: true,
|
|
3254
|
+
},
|
|
3255
|
+
};
|
|
1081
3256
|
}
|
|
1082
3257
|
// List: this session's jobs (running by default; finished only when
|
|
1083
|
-
// includeDone / showCompletedJobs is set)
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
3258
|
+
// includeDone / showCompletedJobs is set). Other sessions' RUNNING jobs
|
|
3259
|
+
// appear only when adoptForeignJobs is opted in; finished foreign logs
|
|
3260
|
+
// from the shared dir can also appear when finished jobs are included.
|
|
3261
|
+
// Hidden disk logs get a one-line count instead of spamming the listing.
|
|
1086
3262
|
const showDone = params.includeDone ?? cfg.showCompletedJobs;
|
|
1087
|
-
|
|
1088
|
-
|
|
3263
|
+
// Read-only: asking for status must not append transcript cards. The stale
|
|
3264
|
+
// poller (when a job is unsupervised) persists independently.
|
|
3265
|
+
revalidateStaleJobs({ persist: false });
|
|
3266
|
+
updateWidget(ctx, { persistRevalidate: false });
|
|
1089
3267
|
const lines: string[] = [];
|
|
1090
3268
|
const seen = new Set<string>();
|
|
1091
3269
|
for (const [jid, rec] of jobs) {
|
|
@@ -1099,30 +3277,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
1099
3277
|
}
|
|
1100
3278
|
}
|
|
1101
3279
|
let hiddenOnDisk = 0;
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
3280
|
+
if (!showDone && !cfg.adoptForeignJobs) {
|
|
3281
|
+
// Default listing: every on-disk log is just a hidden count. Skip the
|
|
3282
|
+
// exit-marker parse (a 256 KB tail read per file) — the cheap scan's
|
|
3283
|
+
// names are all we need.
|
|
3284
|
+
for (const entry of scanLogFiles(jobsDir)) {
|
|
3285
|
+
if (!seen.has(entry.id)) hiddenOnDisk++;
|
|
3286
|
+
}
|
|
3287
|
+
} else {
|
|
3288
|
+
for (const entry of scanJobsDir(jobsDir)) {
|
|
3289
|
+
if (seen.has(entry.id)) continue;
|
|
3290
|
+
if (entry.exit !== null) {
|
|
1110
3291
|
// finished log on disk (other or older session)
|
|
1111
3292
|
if (showDone) {
|
|
1112
|
-
lines.push(` ${
|
|
3293
|
+
lines.push(` ${entry.id}: done exit=${entry.exit} (from log)`);
|
|
1113
3294
|
} else {
|
|
1114
3295
|
hiddenOnDisk++;
|
|
1115
3296
|
}
|
|
1116
|
-
} else if (cfg.adoptForeignJobs) {
|
|
1117
|
-
|
|
1118
|
-
lines.push(` ${jid}: running (from log)`);
|
|
3297
|
+
} else if (cfg.adoptForeignJobs && entry.alive) {
|
|
3298
|
+
lines.push(` ${entry.id}: running (from log)`);
|
|
1119
3299
|
} else {
|
|
1120
3300
|
hiddenOnDisk++;
|
|
1121
3301
|
}
|
|
1122
3302
|
}
|
|
1123
|
-
} catch {
|
|
1124
|
-
// jobs dir doesn't exist — nothing to scan.
|
|
1125
3303
|
}
|
|
3304
|
+
// Count jobs, not display lines: capture before appending the "(N more…)"
|
|
3305
|
+
// footer, which is a note rather than a job.
|
|
3306
|
+
const jobCount = lines.length;
|
|
1126
3307
|
if (hiddenOnDisk > 0) {
|
|
1127
3308
|
lines.push(
|
|
1128
3309
|
` (${hiddenOnDisk} more job log(s) on disk — pass includeDone to list, bgclean all to prune)`,
|
|
@@ -1136,7 +3317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1136
3317
|
}
|
|
1137
3318
|
return {
|
|
1138
3319
|
content: [{ type: "text", text: `bgrun jobs:\n${lines.join("\n")}` }],
|
|
1139
|
-
details: { count:
|
|
3320
|
+
details: { count: jobCount },
|
|
1140
3321
|
};
|
|
1141
3322
|
}
|
|
1142
3323
|
|
|
@@ -1146,7 +3327,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1146
3327
|
description:
|
|
1147
3328
|
"Show status of background jobs. With an id: one job's state + exit code. Without: list this session's " +
|
|
1148
3329
|
"running jobs (finished jobs are hidden by default — pass includeDone or set showCompletedJobs to list " +
|
|
1149
|
-
"them
|
|
3330
|
+
"them). Other sessions' running jobs are listed only when adoptForeignJobs is enabled; finished foreign " +
|
|
3331
|
+
"logs from the shared dir can also appear when finished jobs are included.",
|
|
1150
3332
|
promptSnippet: "Check status of bgrun jobs",
|
|
1151
3333
|
parameters: Type.Object({
|
|
1152
3334
|
id: Type.Optional(
|
|
@@ -1166,8 +3348,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1166
3348
|
|
|
1167
3349
|
// ── bgclean: remove old job logs ───────────────────────────────────────────
|
|
1168
3350
|
|
|
1169
|
-
// ── bgclean: remove old job logs ──────────────────────────────────────
|
|
1170
|
-
|
|
1171
3351
|
// Shared by the bgclean tool (agent-facing) and the /bgclean slash command
|
|
1172
3352
|
// (human-facing).
|
|
1173
3353
|
async function bgcleanCore(
|
|
@@ -1179,21 +3359,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
1179
3359
|
}> {
|
|
1180
3360
|
const cfg = resolveConfig(ctx);
|
|
1181
3361
|
const { days = cfg.cleanupDays, all = false } = params;
|
|
1182
|
-
if (typeof days !== "number" || days
|
|
1183
|
-
throw new Error(
|
|
1184
|
-
`bgclean: days must be a non-negative number, got ${days}`,
|
|
1185
|
-
);
|
|
3362
|
+
if (typeof days !== "number" || days <= 0 || !Number.isFinite(days)) {
|
|
3363
|
+
throw new Error(`bgclean: days must be a positive number, got ${days}`);
|
|
1186
3364
|
}
|
|
1187
3365
|
let result;
|
|
1188
3366
|
if (all) {
|
|
1189
|
-
result =
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
3367
|
+
result = { removed: 0, kept: 0, skippedRunning: 0 };
|
|
3368
|
+
// "all" spans every shared jobs dir — the current project's plus the
|
|
3369
|
+
// machine-global default (so pre-project-local logs are still reachable).
|
|
3370
|
+
for (const dir of sharedJobsDirs(cfg.jobsDir, cfg.jobsDirProjectLocal)) {
|
|
3371
|
+
// An explicit `bgclean all` is the user's direct intent — bypass the
|
|
3372
|
+
// ownership marker gate so it always works.
|
|
3373
|
+
const r = cleanOldJobs(days, dir, ctx, { requireOwnership: false });
|
|
3374
|
+
result.removed += r.removed;
|
|
3375
|
+
result.kept += r.kept;
|
|
3376
|
+
result.skippedRunning += r.skippedRunning;
|
|
3377
|
+
// Do not create a jobs dir just to stamp the throttle marker — that
|
|
3378
|
+
// would dirty git status in a repo with no jobs (and mutate an
|
|
3379
|
+
// untrusted repo). Only refresh the marker when the dir already exists.
|
|
3380
|
+
if (!existsSync(dir)) continue;
|
|
3381
|
+
// Only the project-local dir may be git-excluded. The shared global
|
|
3382
|
+
// dir must NOT be excluded in whatever repo happens to contain it
|
|
3383
|
+
// (e.g. $HOME being a dotfiles repo).
|
|
3384
|
+
if (
|
|
3385
|
+
cfg.jobsDirProjectLocal &&
|
|
3386
|
+
safeRealpath(dir) === safeRealpath(cfg.jobsDir)
|
|
3387
|
+
)
|
|
3388
|
+
ensureGitExcluded(dir);
|
|
3389
|
+
try {
|
|
3390
|
+
writeFileSync(join(dir, ".last-clean"), String(Date.now()));
|
|
3391
|
+
} catch {
|
|
3392
|
+
// best-effort
|
|
3393
|
+
}
|
|
1197
3394
|
}
|
|
1198
3395
|
} else {
|
|
1199
3396
|
// Session-scoped by default: bg* commands apply to the current
|
|
@@ -1213,8 +3410,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1213
3410
|
label: "Clean Old Background Jobs",
|
|
1214
3411
|
description:
|
|
1215
3412
|
"Remove old background job logs from disk. Default scope: THIS session's jobs only (other sessions' logs are " +
|
|
1216
|
-
"untouched)
|
|
1217
|
-
"
|
|
3413
|
+
"untouched); this also drops stale per-project digest markers in the session's jobs dir (they are not session " +
|
|
3414
|
+
"data). Pass all: true to sweep every shared jobs dir — under the project-local default that is the current " +
|
|
3415
|
+
"project's dir plus the machine-global one; an explicit absolute jobsDir is swept alone. Retention: " +
|
|
3416
|
+
"cleanupDays config (default 7 days). Never removes a running job's log. Prints a summary of what was removed " +
|
|
3417
|
+
"vs kept.",
|
|
1218
3418
|
promptSnippet:
|
|
1219
3419
|
"Remove old bgrun job logs (this session by default; all: true for every session's)",
|
|
1220
3420
|
parameters: Type.Object({
|
|
@@ -1226,7 +3426,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1226
3426
|
all: Type.Optional(
|
|
1227
3427
|
Type.Boolean({
|
|
1228
3428
|
description:
|
|
1229
|
-
"Sweep
|
|
3429
|
+
"Sweep every shared jobs dir (all sessions' logs) — plus the machine-global dir under the project-local default; an absolute jobsDir is swept alone (default false)",
|
|
1230
3430
|
}),
|
|
1231
3431
|
),
|
|
1232
3432
|
}),
|