pi-background 0.2.1 → 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) => {
@@ -182,11 +220,14 @@ export default function (pi: ExtensionAPI) {
182
220
  "Keep using bash for quick commands whose output you need right away, such as git, ls, cat, grep, find, file inspection and short checks.",
183
221
  "To react when a bg_run job prints something specific while it keeps running (a server is ready, an epoch finished, an error appeared), pass wake_on with a regex instead of polling.",
184
222
  "Don't redirect a bg_run command's output to a file (> file, 2>&1, nohup): bg_run already saves all of it to the job's log, and redirecting hides the job's progress from the user and from wake_on. If the output should be kept, tell the user where the job's log is.",
223
+ "bg_run starts only after the other tool calls in the same reply have finished, so editing a file and starting a job that uses it in one reply is safe.",
185
224
  "Use bg_status to check progress of a bg_run job and bg_kill to stop one.",
186
225
  `bash commands time out after ${BASH_DEFAULT_TIMEOUT / 60} minutes unless you pass timeout (at most ${BASH_MAX_TIMEOUT / 60} minutes); run anything that may take longer with bg_run instead.`,
187
226
  "Unless the user explicitly asks for that bash command, never wait, poll or sleep in bash (while/until loops with sleep, watch, repeated status checks), not even as part of a larger command; start the wait with bg_run and pass wake_on to be woken when the awaited output appears.",
188
227
  "Unless the user explicitly asks otherwise, start long-running processes (training or evaluation runs, servers, watchers) with bg_run, not with & or nohup in bash followed by sleep; pass wake_on matching the lines you want to see, such as results or Traceback.",
189
228
  ],
229
+ // Runs after the edits in the same reply, so the job starts from the code pi just wrote
230
+ executionMode: "sequential",
190
231
  renderShell: "self",
191
232
  renderCall: (args: any, theme, context) => row(theme, `Background $ ${String(args?.command ?? "").split("\n")[0]}`, context),
192
233
  renderResult: (result, { expanded }, theme) => note(theme, result, expanded),
@@ -206,7 +247,7 @@ export default function (pi: ExtensionAPI) {
206
247
  mkdirSync(dir, { recursive: true });
207
248
  const log = join(dir, `${process.pid}-${id}.log`);
208
249
  const out = createWriteStream(log);
209
- 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) };
210
251
  jobs.set(id, job);
211
252
 
212
253
  const watch = pattern ? watcher(job, pattern) : undefined;
@@ -234,7 +275,7 @@ export default function (pi: ExtensionAPI) {
234
275
  out.end(() => {
235
276
  // A kill from session shutdown has no session left to notify
236
277
  if (job.status === "killed" && !jobs.has(id)) return;
237
- 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)}`, {
238
279
  headline: `${id} ${job.status === "exited" ? `exited ${job.exitCode}` : job.status} · ${elapsed(job)}`,
239
280
  last: tail(log, 1),
240
281
  error: job.status !== "exited" || job.exitCode !== 0,
@@ -286,6 +327,8 @@ export default function (pi: ExtensionAPI) {
286
327
  });
287
328
 
288
329
  pi.on("session_shutdown", async () => {
330
+ for (const timer of holds.values()) clearTimeout(timer);
331
+ holds.clear();
289
332
  for (const job of jobs.values()) {
290
333
  if (job.status === "running") job.controller.abort();
291
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background",
3
- "version": "0.2.1",
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",