pi-roundtable 0.7.17 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.18] - 2026-10-05
9
+
10
+ ### Fixed
11
+
12
+ - Stop reading attachments as soon as they exceed the 25 MiB limit and cancel HTTP error bodies instead of leaving them open.
13
+ - Stop claiming or starting schedules when shutdown occurs during a database query or claim; already running turns still finish normally.
14
+ - Isolate keyless Jev tests from the developer's home, XDG configuration and environment.
15
+
16
+ ### Changed
17
+
18
+ - Extract schedule precheck resolution, execution and cleanup tracking from the scheduler without changing its public contract.
19
+ - Release all five official packages in lockstep; MCP and sandbox include lifecycle fixes described in their changelogs.
20
+
8
21
  ## [0.7.17] - 2026-10-04
9
22
 
10
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,12 +53,13 @@ export async function fetchAttachments(
53
53
  try {
54
54
  const response = await fetchImpl(ref.url);
55
55
  if (!response.ok) {
56
+ await response.body?.cancel();
56
57
  fail(`download failed with HTTP ${response.status}`);
57
58
  continue;
58
59
  }
59
- const data = new Uint8Array(await response.arrayBuffer());
60
- if (data.byteLength > MAX_ATTACHMENT_BYTES) {
61
- fail(`larger than 25 MB (${data.byteLength} bytes)`);
60
+ const data = await readAttachmentBody(response);
61
+ if (typeof data === "number") {
62
+ fail(`larger than 25 MB (${data} bytes)`);
62
63
  continue;
63
64
  }
64
65
  const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
@@ -81,3 +82,34 @@ export async function fetchAttachments(
81
82
  }
82
83
  return { files, failures };
83
84
  }
85
+
86
+ /** Returns the bytes, or the observed size once the stream exceeds the limit. */
87
+ async function readAttachmentBody(
88
+ response: Response,
89
+ ): Promise<Uint8Array | number> {
90
+ if (!response.body) return new Uint8Array();
91
+ const reader = response.body.getReader();
92
+ const chunks: Uint8Array[] = [];
93
+ let size = 0;
94
+ try {
95
+ for (;;) {
96
+ const { done, value } = await reader.read();
97
+ if (done) break;
98
+ size += value.byteLength;
99
+ if (size > MAX_ATTACHMENT_BYTES) {
100
+ await reader.cancel();
101
+ return size;
102
+ }
103
+ chunks.push(value);
104
+ }
105
+ } finally {
106
+ reader.releaseLock();
107
+ }
108
+ const data = new Uint8Array(size);
109
+ let offset = 0;
110
+ for (const chunk of chunks) {
111
+ data.set(chunk, offset);
112
+ offset += chunk.byteLength;
113
+ }
114
+ return data;
115
+ }
@@ -0,0 +1,166 @@
1
+ import type { HoldCheck } from "../../holds.ts";
2
+ import type { Logger } from "../../log.ts";
3
+ import { timeZone, zonedStamp } from "../../time.ts";
4
+ import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
5
+ import {
6
+ type Precheck,
7
+ type PrecheckOutcome,
8
+ type PrecheckRegistry,
9
+ runPrecheck,
10
+ SCRIPT_PRECHECK,
11
+ } from "./prechecks.ts";
12
+ import type { Schedule } from "./schedule-store.ts";
13
+
14
+ interface SchedulePrechecksOptions {
15
+ /** The host's prechecks and script runner; without them, a schedule that has one runs with that as its error. */
16
+ prechecks?: Pick<PrecheckRegistry, "get"> &
17
+ Partial<Pick<PrecheckRegistry, "scriptRunner">>;
18
+ /**
19
+ * The host's hold rules, read when a script saved before its tools were recorded runs: it runs
20
+ * only if it calls no held tool. Without them, such a script does not run.
21
+ */
22
+ holds?: () => HoldCheck;
23
+ logger: Logger;
24
+ signal: AbortSignal;
25
+ }
26
+
27
+ /** Runs schedule prechecks and tracks underlying runs that outlive a timeout. */
28
+ export class SchedulePrechecks {
29
+ readonly #options: SchedulePrechecksOptions;
30
+ readonly #pending = new Set<Promise<unknown>>();
31
+
32
+ constructor(options: SchedulePrechecksOptions) {
33
+ this.#options = options;
34
+ }
35
+
36
+ async run(
37
+ schedule: Schedule,
38
+ name: string,
39
+ firedAt: Date,
40
+ ): Promise<PrecheckOutcome> {
41
+ const { logger } = this.#options;
42
+ let decision: PrecheckOutcome;
43
+ try {
44
+ const precheck = this.#resolve(schedule, firedAt);
45
+ decision =
46
+ typeof precheck === "string"
47
+ ? { kind: "failed", error: precheck }
48
+ : await runPrecheck(
49
+ precheck,
50
+ { schedule, firedAt },
51
+ {
52
+ signal: this.#options.signal,
53
+ settled: (run) => {
54
+ const tracked = run
55
+ .catch(() => undefined)
56
+ .finally(() => this.#pending.delete(tracked));
57
+ this.#pending.add(tracked);
58
+ },
59
+ },
60
+ );
61
+ } catch (error) {
62
+ decision = {
63
+ kind: "failed",
64
+ error: `the precheck could not be looked up: ${error instanceof Error ? error.message : String(error)}`,
65
+ };
66
+ }
67
+ // A warning, not an error: the woken turn carries the error already, and an error line would
68
+ // wake the ops agent's report turn for the same failure.
69
+ if (decision.kind === "failed")
70
+ logger.warn(
71
+ { schedule: schedule.id, precheck: name, error: decision.error },
72
+ "precheck failed; the scheduled turn runs with the error",
73
+ );
74
+ else
75
+ logger.info(
76
+ {
77
+ schedule: schedule.id,
78
+ precheck: name,
79
+ wake: decision.kind === "wake",
80
+ },
81
+ "precheck decided",
82
+ );
83
+ return decision;
84
+ }
85
+
86
+ async drain(limitMs: number): Promise<void> {
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const limit = new Promise<"late">((resolve) => {
89
+ timer = setTimeout(() => resolve("late"), limitMs);
90
+ });
91
+ const settled = Promise.allSettled([...this.#pending]);
92
+ try {
93
+ // pi-lens-ignore: no-single-promise-in-promise-methods — the race includes both all pending cleanup and the stop deadline
94
+ if ((await Promise.race([settled, limit])) === "late")
95
+ this.#options.logger.warn(
96
+ { prechecks: this.#pending.size },
97
+ "prechecks still cleaning up when the scheduler stopped",
98
+ );
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The schedule's precheck: a registered one by name, or its script through the host's runner.
106
+ * A script never runs in this process; without a runner it fails, and the turn still runs.
107
+ */
108
+ #resolve(schedule: Schedule, firedAt: Date): Precheck | string {
109
+ const { prechecks } = this.#options;
110
+ if (schedule.precheck)
111
+ return (
112
+ prechecks?.get(schedule.precheck) ??
113
+ "no precheck of that name is registered"
114
+ );
115
+ const script = schedule.precheckScript ?? "";
116
+ const runner = prechecks?.scriptRunner?.();
117
+ if (!runner)
118
+ return "this host has no precheck script runner, so the script did not run";
119
+ const tools = this.#tools(schedule, script, runner.toolName.bind(runner));
120
+ if (typeof tools === "string") return tools;
121
+ const zone = timeZone();
122
+ return {
123
+ name: SCRIPT_PRECHECK,
124
+ description: "the schedule's own precheck script",
125
+ ...(runner.timeoutMs === undefined
126
+ ? {}
127
+ : { timeoutMs: runner.timeoutMs }),
128
+ run: (context) =>
129
+ runner.run(script, {
130
+ ...context,
131
+ timeZone: zone,
132
+ today: zonedStamp(firedAt).slice(0, 10),
133
+ tools: tools.map(({ server, tool }) => ({ server, tool })),
134
+ }),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The tools a script may call: those approved when it was saved, or, for a script saved before
140
+ * they were recorded, those it calls when none of them is held. Otherwise why it cannot run.
141
+ */
142
+ #tools(
143
+ schedule: Schedule,
144
+ script: string,
145
+ toolName: (server: string, tool: string) => string,
146
+ ): PrecheckTool[] | string {
147
+ if (schedule.precheckTools) return schedule.precheckTools;
148
+ const save =
149
+ "save it again with schedule_update, so the owner can approve the tools it calls";
150
+ const holds = this.#options.holds?.();
151
+ if (!holds)
152
+ return `this script was saved before its tools were recorded, and this host cannot check them; ${save}`;
153
+ let tools: PrecheckTool[];
154
+ try {
155
+ tools = precheckScriptTools(script, { toolName, holds });
156
+ } catch (error) {
157
+ return `this script was saved before its tools were recorded and cannot be checked now (${error instanceof Error ? error.message : String(error)}); fix it and ${save}`;
158
+ }
159
+ const held = tools.flatMap((t) =>
160
+ t.held ? [`${t.server}/${t.tool}`] : [],
161
+ );
162
+ if (held.length > 0)
163
+ return `this script was saved before its tools were recorded and calls tools that need the owner's approval (${held.join(", ")}), so it did not run; ${save}`;
164
+ return tools;
165
+ }
166
+ }
@@ -2,17 +2,13 @@ import type { ScheduledOutcome } from "../../contract/channels.ts";
2
2
  import type { HoldCheck } from "../../holds.ts";
3
3
  import type { Logger } from "../../log.ts";
4
4
  import type { ScheduleStore } from "../../services.ts";
5
- import { timeZone, zonedStamp } from "../../time.ts";
6
- import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
7
5
  import {
8
- type Precheck,
9
6
  type PrecheckFinding,
10
- type PrecheckOutcome,
11
7
  type PrecheckRegistry,
12
- runPrecheck,
13
8
  SCRIPT_PRECHECK,
14
9
  } from "./prechecks.ts";
15
10
  import { nextRun } from "./recurrence.ts";
11
+ import { SchedulePrechecks } from "./schedule-prechecks.ts";
16
12
  import type { Schedule } from "./schedule-store.ts";
17
13
 
18
14
  export type { ScheduledOutcome };
@@ -81,14 +77,19 @@ function precheckPrefix(finding: PrecheckFinding | undefined): string {
81
77
  export class Scheduler {
82
78
  readonly #options: SchedulerOptions;
83
79
  readonly #running = new Set<Promise<void>>();
84
- /** Prechecks in flight, including their cleanup after a timeout, which stop waits for. */
85
- readonly #prechecks = new Set<Promise<unknown>>();
86
80
  readonly #stopping = new AbortController();
81
+ readonly #prechecks: SchedulePrechecks;
87
82
  #timer: ReturnType<typeof setInterval> | undefined;
88
83
  #ticking = false;
89
84
 
90
85
  constructor(options: SchedulerOptions) {
91
86
  this.#options = options;
87
+ this.#prechecks = new SchedulePrechecks({
88
+ prechecks: options.prechecks,
89
+ holds: options.holds,
90
+ logger: options.logger,
91
+ signal: this.#stopping.signal,
92
+ });
92
93
  }
93
94
 
94
95
  start(): void {
@@ -107,20 +108,7 @@ export class Scheduler {
107
108
  async stop(): Promise<void> {
108
109
  clearInterval(this.#timer);
109
110
  this.#stopping.abort();
110
- let timer: ReturnType<typeof setTimeout> | undefined;
111
- const limit = new Promise<"late">((resolve) => {
112
- timer = setTimeout(() => resolve("late"), PRECHECK_STOP_MS);
113
- });
114
- const settled = Promise.allSettled([...this.#prechecks]);
115
- try {
116
- if ((await Promise.race([settled, limit])) === "late")
117
- this.#options.logger.warn(
118
- { prechecks: this.#prechecks.size },
119
- "prechecks still cleaning up when the scheduler stopped",
120
- );
121
- } finally {
122
- clearTimeout(timer);
123
- }
111
+ await this.#prechecks.drain(PRECHECK_STOP_MS);
124
112
  }
125
113
 
126
114
  /** Claims every due schedule and starts its run; runs continue after tick resolves. */
@@ -131,12 +119,20 @@ export class Scheduler {
131
119
  try {
132
120
  const now = this.#options.now?.() ?? new Date();
133
121
  for (const schedule of await store.due(now)) {
122
+ if (this.#stopping.signal.aborted) break;
134
123
  const claimed = await store.claim(
135
124
  schedule,
136
125
  nextRun(schedule.recurrence, now),
137
126
  now,
138
127
  );
139
128
  if (!claimed) continue;
129
+ if (this.#stopping.signal.aborted) {
130
+ await this.#record(
131
+ schedule,
132
+ "skipped: the host stopped before its run",
133
+ );
134
+ break;
135
+ }
140
136
  if (now.getTime() - schedule.nextRun.getTime() > LATE_LIMIT_MS) {
141
137
  logger.warn(
142
138
  { schedule: schedule.id, due: schedule.nextRun },
@@ -173,7 +169,7 @@ export class Scheduler {
173
169
  schedule.precheck ??
174
170
  (schedule.precheckScript ? SCRIPT_PRECHECK : undefined);
175
171
  if (name) {
176
- const decision = await this.#precheck(schedule, name, firedAt);
172
+ const decision = await this.#prechecks.run(schedule, name, firedAt);
177
173
  if (this.#stopping.signal.aborted) {
178
174
  await this.#record(
179
175
  schedule,
@@ -206,120 +202,6 @@ export class Scheduler {
206
202
  );
207
203
  }
208
204
 
209
- /**
210
- * The schedule's precheck: a registered one by name, or its script through the host's runner.
211
- * A script never runs in this process; without a runner it fails, and the turn still runs.
212
- */
213
- #resolve(schedule: Schedule, firedAt: Date): Precheck | string {
214
- const { prechecks } = this.#options;
215
- if (schedule.precheck)
216
- return (
217
- prechecks?.get(schedule.precheck) ??
218
- "no precheck of that name is registered"
219
- );
220
- const script = schedule.precheckScript ?? "";
221
- const runner = prechecks?.scriptRunner?.();
222
- if (!runner)
223
- return "this host has no precheck script runner, so the script did not run";
224
- const tools = this.#tools(schedule, script, runner.toolName.bind(runner));
225
- if (typeof tools === "string") return tools;
226
- const zone = timeZone();
227
- return {
228
- name: SCRIPT_PRECHECK,
229
- description: "the schedule's own precheck script",
230
- ...(runner.timeoutMs === undefined
231
- ? {}
232
- : { timeoutMs: runner.timeoutMs }),
233
- run: (context) =>
234
- runner.run(script, {
235
- ...context,
236
- timeZone: zone,
237
- today: zonedStamp(firedAt).slice(0, 10),
238
- tools: tools.map(({ server, tool }) => ({ server, tool })),
239
- }),
240
- };
241
- }
242
-
243
- /**
244
- * The tools a script may call: those approved when it was saved, or, for a script saved before
245
- * they were recorded, those it calls when none of them is held. Otherwise why it cannot run.
246
- */
247
- #tools(
248
- schedule: Schedule,
249
- script: string,
250
- toolName: (server: string, tool: string) => string,
251
- ): PrecheckTool[] | string {
252
- if (schedule.precheckTools) return schedule.precheckTools;
253
- const save =
254
- "save it again with schedule_update, so the owner can approve the tools it calls";
255
- const holds = this.#options.holds?.();
256
- if (!holds)
257
- return `this script was saved before its tools were recorded, and this host cannot check them; ${save}`;
258
- let tools: PrecheckTool[];
259
- try {
260
- tools = precheckScriptTools(script, { toolName, holds });
261
- } catch (error) {
262
- return `this script was saved before its tools were recorded and cannot be checked now (${error instanceof Error ? error.message : String(error)}); fix it and ${save}`;
263
- }
264
- const held = tools.flatMap((t) =>
265
- t.held ? [`${t.server}/${t.tool}`] : [],
266
- );
267
- if (held.length > 0)
268
- return `this script was saved before its tools were recorded and calls tools that need the owner's approval (${held.join(", ")}), so it did not run; ${save}`;
269
- return tools;
270
- }
271
-
272
- /** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
273
- async #precheck(
274
- schedule: Schedule,
275
- name: string,
276
- firedAt: Date,
277
- ): Promise<PrecheckOutcome> {
278
- const { logger } = this.#options;
279
- let decision: PrecheckOutcome;
280
- try {
281
- const precheck = this.#resolve(schedule, firedAt);
282
- decision =
283
- typeof precheck === "string"
284
- ? { kind: "failed", error: precheck }
285
- : await runPrecheck(
286
- precheck,
287
- { schedule, firedAt },
288
- {
289
- signal: this.#stopping.signal,
290
- settled: (run) => {
291
- const tracked = run
292
- .catch(() => undefined)
293
- .finally(() => this.#prechecks.delete(tracked));
294
- this.#prechecks.add(tracked);
295
- },
296
- },
297
- );
298
- } catch (error) {
299
- decision = {
300
- kind: "failed",
301
- error: `the precheck could not be looked up: ${error instanceof Error ? error.message : String(error)}`,
302
- };
303
- }
304
- // A warning, not an error: the woken turn carries the error already, and an error line would
305
- // wake the ops agent's report turn for the same failure.
306
- if (decision.kind === "failed")
307
- logger.warn(
308
- { schedule: schedule.id, precheck: name, error: decision.error },
309
- "precheck failed; the scheduled turn runs with the error",
310
- );
311
- else
312
- logger.info(
313
- {
314
- schedule: schedule.id,
315
- precheck: name,
316
- wake: decision.kind === "wake",
317
- },
318
- "precheck decided",
319
- );
320
- return decision;
321
- }
322
-
323
205
  /** Records a run the precheck skipped and posts its note; a note that cannot be posted is logged. */
324
206
  async #skip(schedule: Schedule, note: string | undefined): Promise<void> {
325
207
  const { notify, logger } = this.#options;