pi-background-run 0.3.0 → 0.4.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 +61 -22
- package/extension/index.test.ts +521 -48
- package/extension/index.ts +466 -217
- package/package.json +1 -1
- package/skill/run-bg/SKILL.md +7 -4
package/extension/index.test.ts
CHANGED
|
@@ -35,6 +35,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
35
35
|
wakes: CapturedWake[];
|
|
36
36
|
entries: any[];
|
|
37
37
|
tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
38
|
+
commands: Map<
|
|
39
|
+
string,
|
|
40
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
41
|
+
>;
|
|
38
42
|
ctx: any;
|
|
39
43
|
handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
|
|
40
44
|
fireSessionStart: () => Promise<void>;
|
|
@@ -45,6 +49,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
45
49
|
string,
|
|
46
50
|
{ execute: (...args: any[]) => Promise<any> }
|
|
47
51
|
>();
|
|
52
|
+
const commands = new Map<
|
|
53
|
+
string,
|
|
54
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
55
|
+
>();
|
|
48
56
|
const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
|
|
49
57
|
const idle = opts.idle ?? true;
|
|
50
58
|
const ctx = {
|
|
@@ -64,6 +72,9 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
64
72
|
registerTool(def: any) {
|
|
65
73
|
tools.set(def.name, def);
|
|
66
74
|
},
|
|
75
|
+
registerCommand(name: string, def: any) {
|
|
76
|
+
commands.set(name, def);
|
|
77
|
+
},
|
|
67
78
|
on(event: string, handler: (...args: any[]) => Promise<any>) {
|
|
68
79
|
const list = handlers.get(event) ?? [];
|
|
69
80
|
list.push(handler);
|
|
@@ -75,7 +86,16 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
75
86
|
await h({ reason: "startup" }, ctx);
|
|
76
87
|
}
|
|
77
88
|
};
|
|
78
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
pi,
|
|
91
|
+
wakes,
|
|
92
|
+
entries,
|
|
93
|
+
tools,
|
|
94
|
+
commands,
|
|
95
|
+
ctx,
|
|
96
|
+
handlers,
|
|
97
|
+
fireSessionStart,
|
|
98
|
+
};
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
async function loadExtension(
|
|
@@ -268,11 +288,19 @@ test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lin
|
|
|
268
288
|
const text = tail.content[0].text as string;
|
|
269
289
|
assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
|
|
270
290
|
assert.ok(text.includes("OK green"), "text after stripping survives");
|
|
271
|
-
assert.match(
|
|
291
|
+
assert.match(
|
|
292
|
+
text,
|
|
293
|
+
/wait {2}\[x5\]/,
|
|
294
|
+
"5 identical lines collapsed to one with count",
|
|
295
|
+
);
|
|
272
296
|
assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
|
|
273
297
|
assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
|
|
274
298
|
assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
|
|
275
|
-
assert.match(
|
|
299
|
+
assert.match(
|
|
300
|
+
text,
|
|
301
|
+
/1 repeated-line run collapsed/,
|
|
302
|
+
"notes mention run collapse",
|
|
303
|
+
);
|
|
276
304
|
assert.ok((tail.details as any).condensed === true);
|
|
277
305
|
} finally {
|
|
278
306
|
delete process.env.PI_BGRUN_DIR;
|
|
@@ -309,7 +337,10 @@ test("bgtail: raw=true skips condensing", async () => {
|
|
|
309
337
|
);
|
|
310
338
|
const text = tail.content[0].text as string;
|
|
311
339
|
assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
|
|
312
|
-
assert.ok(
|
|
340
|
+
assert.ok(
|
|
341
|
+
text.includes("wait\nwait\nwait"),
|
|
342
|
+
"raw keeps repeated lines uncollapsed",
|
|
343
|
+
);
|
|
313
344
|
assert.ok((tail.details as any).condensed === false);
|
|
314
345
|
} finally {
|
|
315
346
|
delete process.env.PI_BGRUN_DIR;
|
|
@@ -667,7 +698,7 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
|
|
|
667
698
|
);
|
|
668
699
|
const text = res.content[0].text as string;
|
|
669
700
|
// No 'name:' line in the response.
|
|
670
|
-
assert.ok(!/^
|
|
701
|
+
assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
|
|
671
702
|
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
672
703
|
assert.ok(
|
|
673
704
|
id.startsWith("echo-unnamed-job-"),
|
|
@@ -702,7 +733,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
702
733
|
ctx,
|
|
703
734
|
);
|
|
704
735
|
assert.ok(
|
|
705
|
-
!/^
|
|
736
|
+
!/^ {2}name:/m.test(res1.content[0].text as string),
|
|
706
737
|
"blank name ignored",
|
|
707
738
|
);
|
|
708
739
|
|
|
@@ -716,7 +747,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
716
747
|
ctx,
|
|
717
748
|
);
|
|
718
749
|
const text2 = res2.content[0].text as string;
|
|
719
|
-
const nameLine = (text2.match(/^
|
|
750
|
+
const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
|
|
720
751
|
assert.equal(nameLine.length, 80, "name truncated to 80 chars");
|
|
721
752
|
|
|
722
753
|
await waitForWakes(wakes, 2);
|
|
@@ -1068,14 +1099,15 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
|
|
|
1068
1099
|
}
|
|
1069
1100
|
});
|
|
1070
1101
|
|
|
1071
|
-
test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1102
|
+
test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1072
1103
|
// Regression: exit marker must win over pid liveness. Old code checked
|
|
1073
1104
|
// pid first, so any log whose id-pid happened to be a live process (e.g.
|
|
1074
1105
|
// pi's own pid from the old id bug, or pid reuse) was kept forever.
|
|
1075
1106
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1076
1107
|
process.env.PI_BGRUN_DIR = dir;
|
|
1077
1108
|
try {
|
|
1078
|
-
// Old finished log whose id-pid is THIS process (alive!) — must
|
|
1109
|
+
// Old finished foreign log whose id-pid is THIS process (alive!) — must
|
|
1110
|
+
// still be removed by an explicit global sweep.
|
|
1079
1111
|
const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
|
|
1080
1112
|
writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
|
|
1081
1113
|
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
@@ -1086,14 +1118,31 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
|
|
|
1086
1118
|
await loadExtension(pi);
|
|
1087
1119
|
const bgclean = tools.get("bgclean")!;
|
|
1088
1120
|
|
|
1121
|
+
// Default scope: this session only — the foreign log is untouched.
|
|
1122
|
+
const scoped = await bgclean.execute(
|
|
1123
|
+
"call-stale-scoped",
|
|
1124
|
+
{ days: 7 },
|
|
1125
|
+
undefined,
|
|
1126
|
+
undefined,
|
|
1127
|
+
ctx,
|
|
1128
|
+
);
|
|
1129
|
+
assert.match(scoped.content[0].text as string, /removed 0/);
|
|
1130
|
+
assert.ok(
|
|
1131
|
+
existsSync(oldPath),
|
|
1132
|
+
"foreign log untouched by session-scoped bgclean",
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1089
1135
|
const result = await bgclean.execute(
|
|
1090
1136
|
"call-stale",
|
|
1091
|
-
{ days: 7 },
|
|
1137
|
+
{ days: 7, all: true },
|
|
1092
1138
|
undefined,
|
|
1093
1139
|
undefined,
|
|
1094
1140
|
ctx,
|
|
1095
1141
|
);
|
|
1096
|
-
assert.match(
|
|
1142
|
+
assert.match(
|
|
1143
|
+
result.content[0].text as string,
|
|
1144
|
+
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1145
|
+
);
|
|
1097
1146
|
assert.ok(
|
|
1098
1147
|
!existsSync(oldPath),
|
|
1099
1148
|
"finished job's log removed despite live id-pid",
|
|
@@ -1134,10 +1183,134 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
|
|
|
1134
1183
|
}
|
|
1135
1184
|
});
|
|
1136
1185
|
|
|
1137
|
-
test("
|
|
1186
|
+
test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
|
|
1138
1187
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1139
1188
|
process.env.PI_BGRUN_DIR = dir;
|
|
1140
1189
|
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1190
|
+
try {
|
|
1191
|
+
// A job from 5 days ago whose transcript entry never got a done entry
|
|
1192
|
+
// (pi wasn't running when it exited), whose log is long gone and whose
|
|
1193
|
+
// pid is definitely dead.
|
|
1194
|
+
const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
|
|
1195
|
+
const logPath = join(dir, `${zombieId}.log`); // never created
|
|
1196
|
+
const priorEntries = [
|
|
1197
|
+
{
|
|
1198
|
+
type: "custom",
|
|
1199
|
+
customType: "bgrun-job",
|
|
1200
|
+
data: {
|
|
1201
|
+
id: zombieId,
|
|
1202
|
+
pid: 99999999,
|
|
1203
|
+
cmd: "cd /old/project && make test",
|
|
1204
|
+
name: undefined,
|
|
1205
|
+
started: Date.now() - 5 * 24 * 60 * 60 * 1000,
|
|
1206
|
+
logPath,
|
|
1207
|
+
state: "running",
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
];
|
|
1211
|
+
const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
|
|
1212
|
+
priorEntries,
|
|
1213
|
+
});
|
|
1214
|
+
ctx.hasUI = true;
|
|
1215
|
+
const widgetCalls: (string[] | undefined)[] = [];
|
|
1216
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1217
|
+
widgetCalls.push(lines);
|
|
1218
|
+
|
|
1219
|
+
await loadExtension(pi);
|
|
1220
|
+
await fireSessionStart();
|
|
1221
|
+
|
|
1222
|
+
// Revalidation runs before the widget ever renders — the zombie is
|
|
1223
|
+
// cleared immediately instead of showing as "running" forever.
|
|
1224
|
+
assert.ok(
|
|
1225
|
+
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1226
|
+
"reconstructed zombie never shown in the widget",
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
// A done entry is appended so future resumes reconstruct it as done.
|
|
1230
|
+
const doneEntry = entries.find(
|
|
1231
|
+
(e) =>
|
|
1232
|
+
e.customType === "bgrun-job" &&
|
|
1233
|
+
e.data?.id === zombieId &&
|
|
1234
|
+
e.data?.state === "done",
|
|
1235
|
+
);
|
|
1236
|
+
assert.ok(doneEntry, "done entry appended for the recovered job");
|
|
1237
|
+
|
|
1238
|
+
// Single-id lookup reports done, not running.
|
|
1239
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
1240
|
+
const res = await bgstatus.execute(
|
|
1241
|
+
"call-z1",
|
|
1242
|
+
{ id: zombieId },
|
|
1243
|
+
undefined,
|
|
1244
|
+
undefined,
|
|
1245
|
+
ctx,
|
|
1246
|
+
);
|
|
1247
|
+
assert.match(res.content[0].text as string, /: done/);
|
|
1248
|
+
} finally {
|
|
1249
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
|
|
1255
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1256
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1257
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1258
|
+
try {
|
|
1259
|
+
// Jobs killed by a signal persist state:"done" with exitCode: undefined —
|
|
1260
|
+
// reconstruction must honor the state field, not just the exit code.
|
|
1261
|
+
const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
|
|
1262
|
+
const logPath = join(dir, `${killedId}.log`);
|
|
1263
|
+
writeFileSync(logPath, "partial output\n"); // no marker — killed before it
|
|
1264
|
+
const priorEntries = [
|
|
1265
|
+
{
|
|
1266
|
+
type: "custom",
|
|
1267
|
+
customType: "bgrun-job",
|
|
1268
|
+
data: {
|
|
1269
|
+
id: killedId,
|
|
1270
|
+
pid: process.pid, // alive — liveness alone must not resurrect it as running
|
|
1271
|
+
cmd: "npm run watch",
|
|
1272
|
+
name: "nightly-watch",
|
|
1273
|
+
started: Date.now() - 60_000,
|
|
1274
|
+
logPath,
|
|
1275
|
+
state: "done",
|
|
1276
|
+
exitCode: undefined,
|
|
1277
|
+
exitedAt: Date.now() - 30_000,
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
];
|
|
1281
|
+
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1282
|
+
ctx.hasUI = true;
|
|
1283
|
+
const widgetCalls: (string[] | undefined)[] = [];
|
|
1284
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1285
|
+
widgetCalls.push(lines);
|
|
1286
|
+
|
|
1287
|
+
await loadExtension(pi);
|
|
1288
|
+
await fireSessionStart();
|
|
1289
|
+
|
|
1290
|
+
assert.ok(
|
|
1291
|
+
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1292
|
+
"signal-killed job with a done entry is not resurrected as running",
|
|
1293
|
+
);
|
|
1294
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
1295
|
+
const res = await bgstatus.execute(
|
|
1296
|
+
"call-z2",
|
|
1297
|
+
{ id: killedId },
|
|
1298
|
+
undefined,
|
|
1299
|
+
undefined,
|
|
1300
|
+
ctx,
|
|
1301
|
+
);
|
|
1302
|
+
assert.match(res.content[0].text as string, /: done/);
|
|
1303
|
+
} finally {
|
|
1304
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1305
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
|
|
1310
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1311
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1312
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1313
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1141
1314
|
try {
|
|
1142
1315
|
const fs = await import("node:fs");
|
|
1143
1316
|
const backdate = (path: string) => {
|
|
@@ -1145,92 +1318,283 @@ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs",
|
|
|
1145
1318
|
fs.utimesSync(path, oldTime, oldTime);
|
|
1146
1319
|
};
|
|
1147
1320
|
|
|
1148
|
-
//
|
|
1321
|
+
// This session's old done job (from the transcript) with a backdated log.
|
|
1322
|
+
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1323
|
+
const myLog = join(dir, `${mineId}.log`);
|
|
1324
|
+
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1325
|
+
backdate(myLog);
|
|
1326
|
+
|
|
1327
|
+
// A foreign session's week-old FINISHED log — an orphan; swept by default.
|
|
1328
|
+
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1329
|
+
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1330
|
+
backdate(orphanLog);
|
|
1331
|
+
|
|
1332
|
+
// A foreign session's RECENT finished log — within retention, kept.
|
|
1333
|
+
const recentForeignLog = join(
|
|
1334
|
+
dir,
|
|
1335
|
+
`foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
|
|
1336
|
+
);
|
|
1337
|
+
fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
|
|
1338
|
+
|
|
1339
|
+
// A foreign session's week-old RUNNING log (no marker, live pid) — running
|
|
1340
|
+
// jobs are pid-protected even when old.
|
|
1341
|
+
const runningForeignLog = join(
|
|
1342
|
+
dir,
|
|
1343
|
+
`foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
|
|
1344
|
+
);
|
|
1345
|
+
fs.writeFileSync(runningForeignLog, "still going\n");
|
|
1346
|
+
backdate(runningForeignLog);
|
|
1347
|
+
|
|
1348
|
+
const priorEntries = [
|
|
1349
|
+
{
|
|
1350
|
+
type: "custom",
|
|
1351
|
+
customType: "bgrun-job",
|
|
1352
|
+
data: {
|
|
1353
|
+
id: mineId,
|
|
1354
|
+
pid: 99999999,
|
|
1355
|
+
cmd: "echo mine",
|
|
1356
|
+
name: undefined,
|
|
1357
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1358
|
+
logPath: myLog,
|
|
1359
|
+
state: "done",
|
|
1360
|
+
exitCode: 0,
|
|
1361
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
];
|
|
1365
|
+
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1366
|
+
await loadExtension(pi);
|
|
1367
|
+
await fireSessionStart();
|
|
1368
|
+
|
|
1369
|
+
assert.ok(!fs.existsSync(myLog), "this session's old log swept");
|
|
1370
|
+
assert.ok(
|
|
1371
|
+
!fs.existsSync(orphanLog),
|
|
1372
|
+
"week-old finished foreign orphan swept by default",
|
|
1373
|
+
);
|
|
1374
|
+
assert.ok(
|
|
1375
|
+
fs.existsSync(recentForeignLog),
|
|
1376
|
+
"recent foreign log kept (within retention)",
|
|
1377
|
+
);
|
|
1378
|
+
assert.ok(
|
|
1379
|
+
fs.existsSync(runningForeignLog),
|
|
1380
|
+
"old but RUNNING foreign log kept (pid-protected)",
|
|
1381
|
+
);
|
|
1382
|
+
} finally {
|
|
1383
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1384
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
|
|
1389
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1390
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1391
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1392
|
+
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1393
|
+
try {
|
|
1394
|
+
const fs = await import("node:fs");
|
|
1395
|
+
const backdate = (path: string) => {
|
|
1396
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1397
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1401
|
+
const myLog = join(dir, `${mineId}.log`);
|
|
1402
|
+
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1403
|
+
backdate(myLog);
|
|
1404
|
+
|
|
1405
|
+
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1406
|
+
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1407
|
+
backdate(orphanLog);
|
|
1408
|
+
|
|
1409
|
+
const priorEntries = [
|
|
1410
|
+
{
|
|
1411
|
+
type: "custom",
|
|
1412
|
+
customType: "bgrun-job",
|
|
1413
|
+
data: {
|
|
1414
|
+
id: mineId,
|
|
1415
|
+
pid: 99999999,
|
|
1416
|
+
cmd: "echo mine",
|
|
1417
|
+
name: undefined,
|
|
1418
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1419
|
+
logPath: myLog,
|
|
1420
|
+
state: "done",
|
|
1421
|
+
exitCode: 0,
|
|
1422
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
];
|
|
1426
|
+
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1427
|
+
await loadExtension(pi);
|
|
1428
|
+
await fireSessionStart();
|
|
1429
|
+
|
|
1430
|
+
assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
|
|
1431
|
+
assert.ok(
|
|
1432
|
+
fs.existsSync(orphanLog),
|
|
1433
|
+
"foreign orphan untouched when globalAutoClean is off",
|
|
1434
|
+
);
|
|
1435
|
+
} finally {
|
|
1436
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1437
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1438
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
|
|
1443
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1444
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1445
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1446
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
|
|
1447
|
+
try {
|
|
1448
|
+
const fs = await import("node:fs");
|
|
1449
|
+
const backdate = (path: string) => {
|
|
1450
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1451
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
// Old foreign log A + first session_start (no marker yet) → global sweep
|
|
1455
|
+
// runs, A removed.
|
|
1149
1456
|
const logA = join(dir, "old-a-1000000000-99999.log");
|
|
1150
1457
|
fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
|
|
1151
1458
|
backdate(logA);
|
|
1152
1459
|
{
|
|
1153
|
-
const { pi,
|
|
1460
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1154
1461
|
await loadExtension(pi);
|
|
1155
1462
|
await fireSessionStart();
|
|
1156
1463
|
}
|
|
1157
|
-
assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
|
|
1464
|
+
assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
|
|
1158
1465
|
assert.ok(
|
|
1159
1466
|
fs.existsSync(join(dir, ".last-clean")),
|
|
1160
1467
|
"throttle marker written",
|
|
1161
1468
|
);
|
|
1162
1469
|
|
|
1163
|
-
// Old log B + second session_start while marker is fresh →
|
|
1470
|
+
// Old foreign log B + second session_start while marker is fresh →
|
|
1471
|
+
// throttled, B kept.
|
|
1164
1472
|
const logB = join(dir, "old-b-1000000000-99998.log");
|
|
1165
1473
|
fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
|
|
1166
1474
|
backdate(logB);
|
|
1167
1475
|
{
|
|
1168
|
-
const { pi,
|
|
1476
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1169
1477
|
await loadExtension(pi);
|
|
1170
1478
|
await fireSessionStart();
|
|
1171
1479
|
}
|
|
1172
|
-
assert.ok(
|
|
1480
|
+
assert.ok(
|
|
1481
|
+
fs.existsSync(logB),
|
|
1482
|
+
"second global sweep throttled — old log B kept",
|
|
1483
|
+
);
|
|
1173
1484
|
|
|
1174
|
-
// Manual bgclean ignores the throttle and removes B.
|
|
1485
|
+
// Manual `bgclean all` ignores the throttle and removes B.
|
|
1175
1486
|
const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
|
|
1176
1487
|
await loadExtension(pi3);
|
|
1177
1488
|
const bgclean = tools3.get("bgclean")!;
|
|
1178
1489
|
const result = await bgclean.execute(
|
|
1179
1490
|
"call-t1",
|
|
1180
|
-
{},
|
|
1491
|
+
{ all: true },
|
|
1181
1492
|
undefined,
|
|
1182
1493
|
undefined,
|
|
1183
1494
|
ctx3,
|
|
1184
1495
|
);
|
|
1185
|
-
assert.match(
|
|
1186
|
-
|
|
1496
|
+
assert.match(
|
|
1497
|
+
result.content[0].text as string,
|
|
1498
|
+
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1499
|
+
);
|
|
1500
|
+
assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
|
|
1187
1501
|
} finally {
|
|
1188
1502
|
delete process.env.PI_BGRUN_DIR;
|
|
1503
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1189
1504
|
rmSync(dir, { recursive: true, force: true });
|
|
1190
1505
|
}
|
|
1191
1506
|
});
|
|
1192
1507
|
|
|
1193
|
-
test("bgclean:
|
|
1508
|
+
test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
|
|
1194
1509
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1195
1510
|
process.env.PI_BGRUN_DIR = dir;
|
|
1511
|
+
// Isolate bgclean's scoping from the global orphan auto-sweep (default on)
|
|
1512
|
+
// so the foreign log survives session_start for bgclean to (not) act on.
|
|
1513
|
+
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1196
1514
|
try {
|
|
1197
|
-
const
|
|
1515
|
+
const fs = await import("node:fs");
|
|
1516
|
+
const backdate = (path: string) => {
|
|
1517
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1518
|
+
fs.utimesSync(path, oldTime, oldTime);
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
const mkEntry = (
|
|
1522
|
+
id: string,
|
|
1523
|
+
logPath: string,
|
|
1524
|
+
extra: Record<string, unknown> = {},
|
|
1525
|
+
) => ({
|
|
1526
|
+
type: "custom",
|
|
1527
|
+
customType: "bgrun-job",
|
|
1528
|
+
data: {
|
|
1529
|
+
id,
|
|
1530
|
+
pid: 99999999,
|
|
1531
|
+
cmd: `echo ${id}`,
|
|
1532
|
+
name: undefined,
|
|
1533
|
+
started: Date.now() - 60_000,
|
|
1534
|
+
logPath,
|
|
1535
|
+
state: "done",
|
|
1536
|
+
exitCode: 0,
|
|
1537
|
+
exitedAt: Date.now() - 30_000,
|
|
1538
|
+
...extra,
|
|
1539
|
+
},
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// This session's recent done job (fresh log — kept).
|
|
1543
|
+
const recentId = `recent-job-${Date.now()}-99999998`;
|
|
1544
|
+
const recentLog = join(dir, `${recentId}.log`);
|
|
1545
|
+
fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
|
|
1546
|
+
|
|
1547
|
+
// This session's old done job (backdated log — removed by default scope).
|
|
1548
|
+
const oldId = `old-session-job-${Date.now()}-99999997`;
|
|
1549
|
+
const oldLog = join(dir, `${oldId}.log`);
|
|
1550
|
+
fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
|
|
1551
|
+
backdate(oldLog);
|
|
1552
|
+
|
|
1553
|
+
// A foreign session's old log — untouched by default, removed with all.
|
|
1554
|
+
const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
|
|
1555
|
+
fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1556
|
+
backdate(foreignLog);
|
|
1557
|
+
|
|
1558
|
+
const priorEntries = [
|
|
1559
|
+
mkEntry(recentId, recentLog),
|
|
1560
|
+
mkEntry(oldId, oldLog, {
|
|
1561
|
+
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1562
|
+
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1563
|
+
}),
|
|
1564
|
+
];
|
|
1565
|
+
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1198
1566
|
await loadExtension(pi);
|
|
1199
|
-
|
|
1567
|
+
await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
|
|
1568
|
+
|
|
1200
1569
|
const bgclean = tools.get("bgclean")!;
|
|
1201
1570
|
|
|
1202
|
-
//
|
|
1203
|
-
await
|
|
1204
|
-
"call-
|
|
1205
|
-
{
|
|
1571
|
+
// Default: this session only.
|
|
1572
|
+
const scoped = await bgclean.execute(
|
|
1573
|
+
"call-c2",
|
|
1574
|
+
{ days: 7 },
|
|
1206
1575
|
undefined,
|
|
1207
1576
|
undefined,
|
|
1208
1577
|
ctx,
|
|
1209
1578
|
);
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
1579
|
+
assert.match(scoped.content[0].text as string, /\(this session\)/);
|
|
1580
|
+
assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
|
|
1581
|
+
assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
|
|
1582
|
+
assert.ok(
|
|
1583
|
+
fs.existsSync(foreignLog),
|
|
1584
|
+
"foreign log untouched by session-scoped bgclean",
|
|
1585
|
+
);
|
|
1218
1586
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1587
|
+
// all: true sweeps the shared dir.
|
|
1588
|
+
const global = await bgclean.execute(
|
|
1589
|
+
"call-c3",
|
|
1590
|
+
{ days: 7, all: true },
|
|
1222
1591
|
undefined,
|
|
1223
1592
|
undefined,
|
|
1224
1593
|
ctx,
|
|
1225
1594
|
);
|
|
1226
|
-
|
|
1227
|
-
assert.
|
|
1228
|
-
assert.ok(
|
|
1229
|
-
// The recent log should still exist.
|
|
1230
|
-
const remaining = fs
|
|
1231
|
-
.readdirSync(dir)
|
|
1232
|
-
.filter((f: string) => f.endsWith(".log"));
|
|
1233
|
-
assert.equal(remaining.length, 1, "recent log kept");
|
|
1595
|
+
assert.match(global.content[0].text as string, /\(all sessions\)/);
|
|
1596
|
+
assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
|
|
1597
|
+
assert.ok(fs.existsSync(recentLog), "recent log still kept");
|
|
1234
1598
|
} finally {
|
|
1235
1599
|
delete process.env.PI_BGRUN_DIR;
|
|
1236
1600
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -1292,8 +1656,117 @@ test("bgclean: does not remove a running job's log", async () => {
|
|
|
1292
1656
|
} catch {
|
|
1293
1657
|
// already gone — fine
|
|
1294
1658
|
}
|
|
1659
|
+
} finally {
|
|
1660
|
+
delete process.env.PI_BGRUN_DIR;
|
|
1661
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1662
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
|
|
1667
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1668
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
1669
|
+
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1670
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1671
|
+
try {
|
|
1672
|
+
const { pi, wakes, tools, commands, ctx } = makeFakePi();
|
|
1673
|
+
ctx.hasUI = true;
|
|
1674
|
+
const notes: { text: string; kind: string }[] = [];
|
|
1675
|
+
ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
|
|
1676
|
+
|
|
1677
|
+
await loadExtension(pi);
|
|
1678
|
+
|
|
1679
|
+
// All three human-facing commands are registered (/bgrun is agent-only).
|
|
1680
|
+
assert.ok(commands.has("bgstatus"), "/bgstatus registered");
|
|
1681
|
+
assert.ok(commands.has("bgtail"), "/bgtail registered");
|
|
1682
|
+
assert.ok(commands.has("bgclean"), "/bgclean registered");
|
|
1683
|
+
assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
|
|
1684
|
+
|
|
1685
|
+
// Run a real job to completion so there's something to inspect.
|
|
1686
|
+
const bgrun = tools.get("bgrun")!;
|
|
1687
|
+
const res = await bgrun.execute(
|
|
1688
|
+
"call-cmd1",
|
|
1689
|
+
{ command: "echo cmd-mirror", name: "mirror-job" },
|
|
1690
|
+
undefined,
|
|
1691
|
+
undefined,
|
|
1692
|
+
ctx,
|
|
1693
|
+
);
|
|
1694
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
1695
|
+
await waitForWakes(wakes, 1);
|
|
1696
|
+
|
|
1697
|
+
// /bgstatus <id> → single-job status via notify.
|
|
1698
|
+
await commands.get("bgstatus")!.handler(id, ctx);
|
|
1699
|
+
assert.ok(
|
|
1700
|
+
notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
|
|
1701
|
+
"/bgstatus <id> notifies job status",
|
|
1702
|
+
);
|
|
1703
|
+
|
|
1704
|
+
// /bgstatus done → listing includes the finished job.
|
|
1705
|
+
await commands.get("bgstatus")!.handler("done", ctx);
|
|
1706
|
+
assert.ok(
|
|
1707
|
+
notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
|
|
1708
|
+
"/bgstatus done lists finished jobs",
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
// /bgtail <id> <lines> → condensed tail via notify.
|
|
1712
|
+
await commands.get("bgtail")!.handler(`${id} 5`, ctx);
|
|
1713
|
+
assert.ok(
|
|
1714
|
+
notes.some((n) => n.text.includes("cmd-mirror")),
|
|
1715
|
+
"/bgtail notifies the log tail",
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
// /bgtail with no args → usage error.
|
|
1719
|
+
await commands.get("bgtail")!.handler("", ctx);
|
|
1720
|
+
assert.ok(
|
|
1721
|
+
notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
|
|
1722
|
+
"/bgtail without id shows usage",
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1725
|
+
// /bgclean (no args) → session-scoped summary via notify.
|
|
1726
|
+
await commands.get("bgclean")!.handler("", ctx);
|
|
1727
|
+
assert.ok(
|
|
1728
|
+
notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
|
|
1729
|
+
"/bgclean notifies the session-scoped summary",
|
|
1730
|
+
);
|
|
1731
|
+
|
|
1732
|
+
// /bgclean 7 all → global scope.
|
|
1733
|
+
await commands.get("bgclean")!.handler("7 all", ctx);
|
|
1734
|
+
assert.ok(
|
|
1735
|
+
notes.some((n) => /\(all sessions\)/.test(n.text)),
|
|
1736
|
+
"/bgclean all notifies the global summary",
|
|
1737
|
+
);
|
|
1295
1738
|
} finally {
|
|
1296
1739
|
delete process.env.PI_BGRUN_DIR;
|
|
1297
1740
|
rmSync(dir, { recursive: true, force: true });
|
|
1298
1741
|
}
|
|
1299
1742
|
});
|
|
1743
|
+
|
|
1744
|
+
test("formatSince: same-day shows time only; older days include the date", async () => {
|
|
1745
|
+
const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
|
|
1746
|
+
const mod: any = await import(url);
|
|
1747
|
+
assert.equal(typeof mod.formatSince, "function");
|
|
1748
|
+
|
|
1749
|
+
const now = new Date("2026-09-09T10:00:00").getTime();
|
|
1750
|
+
const sameDay = new Date("2026-09-09T06:30:12").getTime();
|
|
1751
|
+
const prevDay = new Date("2026-09-04T15:05:40").getTime();
|
|
1752
|
+
const prevMonth = new Date("2026-08-12T23:59:59").getTime();
|
|
1753
|
+
const prevYear = new Date("2025-12-30T08:00:00").getTime();
|
|
1754
|
+
|
|
1755
|
+
// Same calendar day → time only (unchanged display).
|
|
1756
|
+
assert.equal(mod.formatSince(sameDay, now), "06:30:12");
|
|
1757
|
+
|
|
1758
|
+
// Different day, same year → date + time.
|
|
1759
|
+
const prevDayStr = mod.formatSince(prevDay, now);
|
|
1760
|
+
assert.match(prevDayStr, /Sep 4/);
|
|
1761
|
+
assert.match(prevDayStr, /15:05:40/);
|
|
1762
|
+
|
|
1763
|
+
const prevMonthStr = mod.formatSince(prevMonth, now);
|
|
1764
|
+
assert.match(prevMonthStr, /Aug 12/);
|
|
1765
|
+
assert.match(prevMonthStr, /23:59:59/);
|
|
1766
|
+
|
|
1767
|
+
// Different year → date includes the year.
|
|
1768
|
+
const prevYearStr = mod.formatSince(prevYear, now);
|
|
1769
|
+
assert.match(prevYearStr, /2025/);
|
|
1770
|
+
assert.match(prevYearStr, /Dec 30/);
|
|
1771
|
+
assert.match(prevYearStr, /08:00:00/);
|
|
1772
|
+
});
|