pi-background 0.2.2 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-background
2
2
 
3
- Lets [pi](https://pi.dev) run long commands in the background instead of waiting on them. pi starts the job, carries on or hands back to you, and gets a message when the job finishes or prints something it's waiting for.
3
+ Lets [Pi](https://pi.dev) run long commands in the background instead of waiting on them. Pi starts the job, carries on or hands back to you, and gets a message when the job finishes or prints something it's waiting for.
4
4
 
5
5
  <picture>
6
6
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Pringled/pi-extensions/main/assets/images/pi-background-dark.gif">
@@ -13,12 +13,13 @@ pi install npm:pi-background
13
13
 
14
14
  ## Features
15
15
 
16
- - Long commands like downloads, builds, test suites and training runs go to the background, and pi hears back with the exit code and the end of the output when they finish.
16
+ - Long commands like downloads, builds, test suites and training runs go to the background, and Pi hears back with the exit code and the end of the output when they finish.
17
17
  - While a job runs, a line above the input shows it with its elapsed time and latest output, such as `⏵ bg1 python3 train.py · 4m12s · epoch 3/10 loss 0.41`.
18
- - pi can wait for a specific line, such as a dev server's `listening on` or a training run's `epoch 3`, without checking again and again.
19
- - Regular commands time out after 2 minutes, unless pi asks for longer (up to 10), so pi never hangs on a stuck command. Commands that start their own background process with `&` are left alone.
18
+ - Pi can wait for a specific line, such as a dev server's `listening on` or a training run's `epoch 3`, without checking again and again.
19
+ - Jobs started together are reported together: when Pi starts several jobs at once, such as benchmarks to compare, it's woken once when the last one finishes instead of after each. You can keep chatting meanwhile, and a job that runs much longer doesn't hold the others back for more than 2 minutes.
20
+ - Regular commands time out after 2 minutes, unless Pi asks for longer (up to 10), so Pi never hangs on a stuck command. Commands that start their own background process with `&` are left alone.
20
21
 
21
- You don't need to do anything differently: pi decides when a job belongs in the background. You can also ask for it, for example "run the tests in the background and tell me when they're done".
22
+ You don't need to do anything differently: Pi decides when a job belongs in the background. You can also ask for it, for example "run the tests in the background and tell me when they're done".
22
23
 
23
24
  ## Settings
24
25
 
@@ -32,7 +33,7 @@ The running jobs line has three levels. Set one in `~/.pi/agent/settings.json`,
32
33
 
33
34
  ## Tools
34
35
 
35
- pi gets three tools for this: `bg_run` starts a job, `bg_status` shows how it's doing and its latest output, and `bg_kill` stops it. Jobs run with the same environment and sandbox as pi's own shell commands, and stop when you close pi.
36
+ Pi gets three tools for this: `bg_run` starts a job, `bg_status` shows how it's doing and its latest output, and `bg_kill` stops it. Jobs run with the same environment and sandbox as Pi's own shell commands, and stop when you close Pi.
36
37
 
37
38
  ## License
38
39
 
@@ -12,6 +12,8 @@ const BASH_MAX_TIMEOUT = 600;
12
12
  const WAKE_DEBOUNCE_MS = 1000;
13
13
  const MAX_WAKES = 10;
14
14
  const MAX_SHOWN_JOBS = 3;
15
+ // How long finished results wait for jobs started alongside them before Pi is woken anyway
16
+ const GROUP_HOLD_MS = 2 * 60_000;
15
17
 
16
18
  interface Job {
17
19
  id: string;
@@ -24,6 +26,10 @@ interface Job {
24
26
  ended?: number;
25
27
  // The latest output line, shown while the job runs
26
28
  lastLine?: string;
29
+ // The turn that started the job; jobs from the same turn are reported together
30
+ group: number;
31
+ // Started with wake_on, so it may run indefinitely and doesn't hold back the others
32
+ watched: boolean;
27
33
  }
28
34
 
29
35
  function tail(path: string, lines: number): string {
@@ -79,8 +85,40 @@ export default function (pi: ExtensionAPI) {
79
85
  const ops = createLocalBashOperations();
80
86
  let next = 1;
81
87
 
82
- const wake = (content: string, details: { headline: string; last: string; error: boolean }) =>
83
- pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn: true, deliverAs: "followUp" });
88
+ const wake = (content: string, details: { headline: string; last: string; error: boolean }, triggerTurn = true) =>
89
+ pi.sendMessage({ customType: "background-job", content, display: true, details }, { triggerTurn, deliverAs: "followUp" });
90
+
91
+ // Jobs started in the same turn usually belong together, like several benchmarks to compare. While some of them
92
+ // still run, a successful one's result is added to the conversation without waking Pi, so it answers once at the
93
+ // end instead of after each job. A job that runs on too long doesn't hold the others back for more than GROUP_HOLD_MS.
94
+ let turn = 0;
95
+ pi.on("turn_start", async () => {
96
+ turn++;
97
+ });
98
+ const holds = new Map<number, NodeJS.Timeout>();
99
+ const runningInGroup = (job: Job) =>
100
+ [...jobs.values()].filter((other) => other !== job && other.group === job.group && other.status === "running" && !other.watched);
101
+ const report = (job: Job, content: string, details: { headline: string; last: string; error: boolean }) => {
102
+ const others = job.watched ? [] : runningInGroup(job);
103
+ // A failure wakes Pi straight away, so it can react before the rest of the group finishes
104
+ if (others.length === 0 || details.error) {
105
+ clearTimeout(holds.get(job.group));
106
+ holds.delete(job.group);
107
+ return wake(content, details);
108
+ }
109
+ wake(`${content}\n(Still running from the same turn: ${others.map((other) => other.id).join(", ")}; you'll be woken when they finish.)`, details, false);
110
+ if (holds.has(job.group)) return;
111
+ holds.set(job.group, setTimeout(() => {
112
+ holds.delete(job.group);
113
+ const still = runningInGroup(job);
114
+ if (still.length === 0) return;
115
+ wake(`Background jobs from the same turn are still running after ${GROUP_HOLD_MS / 60_000} minutes: ${still.map((other) => other.id).join(", ")}. The results of the ones that finished are above; you'll get a message when each of these finishes.`, {
116
+ headline: `${still.map((other) => other.id).join(", ")} still running`,
117
+ last: "",
118
+ error: false,
119
+ });
120
+ }, GROUP_HOLD_MS));
121
+ };
84
122
 
85
123
  // Wakes the agent with output lines matching pattern, batched per second and capped per job
86
124
  const watcher = (job: Job, pattern: RegExp) => {
@@ -209,7 +247,7 @@ export default function (pi: ExtensionAPI) {
209
247
  mkdirSync(dir, { recursive: true });
210
248
  const log = join(dir, `${process.pid}-${id}.log`);
211
249
  const out = createWriteStream(log);
212
- const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running" };
250
+ const job: Job = { id, command: params.command, log, started: Date.now(), controller: new AbortController(), status: "running", group: turn, watched: Boolean(pattern) };
213
251
  jobs.set(id, job);
214
252
 
215
253
  const watch = pattern ? watcher(job, pattern) : undefined;
@@ -237,7 +275,7 @@ export default function (pi: ExtensionAPI) {
237
275
  out.end(() => {
238
276
  // A kill from session shutdown has no session left to notify
239
277
  if (job.status === "killed" && !jobs.has(id)) return;
240
- wake(`Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
278
+ report(job, `Background job finished: ${summary(job)}\nLast lines:\n${tail(log, TAIL_LINES)}`, {
241
279
  headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
242
280
  last: tail(log, 1),
243
281
  error: job.status !== "exited" || job.exitCode !== 0,
@@ -289,6 +327,8 @@ export default function (pi: ExtensionAPI) {
289
327
  });
290
328
 
291
329
  pi.on("session_shutdown", async () => {
330
+ for (const timer of holds.values()) clearTimeout(timer);
331
+ holds.clear();
292
332
  for (const job of jobs.values()) {
293
333
  if (job.status === "running") job.controller.abort();
294
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Thomas van Dongen",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
- "description": "Lets pi run long commands in the background and wakes it when they finish, so it never blocks or hangs",
16
+ "description": "Lets Pi run long commands in the background and wakes it when they finish, so it never blocks or hangs",
17
17
  "keywords": [
18
18
  "pi-package",
19
19
  "pi",