pi-roundtable 0.7.17 → 0.7.19
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 +19 -0
- package/docs/plugins.md +1 -0
- package/package.json +1 -1
- package/src/core/attachments/attachment-fetcher.ts +35 -3
- package/src/core/builtin/stores.ts +29 -14
- package/src/core/modules/memory/owner-memory.ts +5 -3
- package/src/core/modules/schedules/schedule-prechecks.ts +166 -0
- package/src/core/modules/schedules/scheduler.ts +18 -136
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.19] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Behavior change: in a conversation outside the agent server, such as a plugin's persona conversation run through `context.turns`, a member or admin speaker now reads and changes their own memory. Before, every such turn carried the owner's core facts and upcoming events in its system prompt, whoever spoke, and `memory_add`, `memory_search`, and `memory_remove` (member-tier tools, when a turn's selection offered them) read and changed the owner's memory. The prompt section of such a turn is headed `Memory of <name>` and lists only that speaker's facts. A speaker of the owner tier, the owner or remote MCP's speaker, keeps the owner's memory, and the owner's own conversations, agent conversations, and every tool description read as before. No data moves: facts a member stored before this release sit in the owner's memory, and the owner may remove them with `memory_remove`.
|
|
13
|
+
|
|
14
|
+
## [0.7.18] - 2026-10-05
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Stop reading attachments as soon as they exceed the 25 MiB limit and cancel HTTP error bodies instead of leaving them open.
|
|
19
|
+
- Stop claiming or starting schedules when shutdown occurs during a database query or claim; already running turns still finish normally.
|
|
20
|
+
- Isolate keyless Jev tests from the developer's home, XDG configuration and environment.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Extract schedule precheck resolution, execution and cleanup tracking from the scheduler without changing its public contract.
|
|
25
|
+
- Release all five official packages in lockstep; MCP and sandbox include lifecycle fixes described in their changelogs.
|
|
26
|
+
|
|
8
27
|
## [0.7.17] - 2026-10-04
|
|
9
28
|
|
|
10
29
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -1658,6 +1658,7 @@ The runtime reads `prompt()` when it creates the conversation's session, so mess
|
|
|
1658
1658
|
The host merges every plugin's list and keeps each name once.
|
|
1659
1659
|
- A turn whose kind has no persona is refused with an error naming `personas`.
|
|
1660
1660
|
The Pi runtime refuses when it makes the session; a runtime of your own does the same, as the example's does.
|
|
1661
|
+
- With the memory addon on, a turn's `speaker` decides whose memory the system prompt carries and the memory tools change: a member's or an admin's own, and the owner's for a speaker of the owner tier.
|
|
1661
1662
|
|
|
1662
1663
|
Call `context.turns.run(input)` inside your claim's queue task to run a turn in a conversation it owns.
|
|
1663
1664
|
It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
|
package/package.json
CHANGED
|
@@ -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 =
|
|
60
|
-
if (data
|
|
61
|
-
fail(`larger than 25 MB (${data
|
|
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
|
+
}
|
|
@@ -4,7 +4,8 @@ import { PgMemoryStore } from "../modules/memory/owner-memory-store.ts";
|
|
|
4
4
|
import { memoryPrecheckRegistry } from "../modules/schedules/prechecks.ts";
|
|
5
5
|
import { PgScheduleStore } from "../modules/schedules/schedule-store.ts";
|
|
6
6
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
7
|
-
import { MEMORY, PRECHECKS, SCHEDULES } from "../services.ts";
|
|
7
|
+
import { MEMORY, type MemoryStore, PRECHECKS, SCHEDULES } from "../services.ts";
|
|
8
|
+
import type { SessionTool } from "../sessions.ts";
|
|
8
9
|
import { THE_SPEAKER, type Tier } from "../speakers.ts";
|
|
9
10
|
import { fixed } from "./session-tool.ts";
|
|
10
11
|
|
|
@@ -14,12 +15,37 @@ export interface MemoryOptions {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Each speaker keeps a memory of their own, so every tier may use the tools; the owner's reaches only the owner's turns. */
|
|
17
|
-
const MEMORY_TIERS: Readonly<Record<string, Tier>> = {
|
|
18
|
+
export const MEMORY_TIERS: Readonly<Record<string, Tier>> = {
|
|
18
19
|
memory_add: "member",
|
|
19
20
|
memory_search: "member",
|
|
20
21
|
memory_remove: "member",
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
/** The memory tools and prompt block of every conversation, over the given store. */
|
|
25
|
+
export function memorySessionTool(
|
|
26
|
+
memory: MemoryStore,
|
|
27
|
+
owner: MemoryOptions["owner"],
|
|
28
|
+
): SessionTool {
|
|
29
|
+
return fixed("owner-memory", (session) => {
|
|
30
|
+
// An agent session serves every speaker, so its tools name none.
|
|
31
|
+
if (session.agent)
|
|
32
|
+
return ownerMemoryExtension(
|
|
33
|
+
memory,
|
|
34
|
+
owner.id,
|
|
35
|
+
THE_SPEAKER,
|
|
36
|
+
session.speaker,
|
|
37
|
+
);
|
|
38
|
+
// Any other conversation may admit a member or an admin, who reads and changes their own
|
|
39
|
+
// memory; an owner-tier speaker, such as remote MCP, speaks as the owner.
|
|
40
|
+
const speaker = () => {
|
|
41
|
+
const current = session.speaker();
|
|
42
|
+
return current?.tier === "owner" ? undefined : current;
|
|
43
|
+
};
|
|
44
|
+
// Its tools keep the owner's words, so the owner's own chats read as before.
|
|
45
|
+
return ownerMemoryExtension(memory, owner.id, owner, speaker, false);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/**
|
|
24
50
|
* The remembered facts of each speaker: the table, the store over it provided as `MEMORY`, and
|
|
25
51
|
* the memory tools and prompt block of every conversation. An addon; `memory: false` leaves it
|
|
@@ -35,18 +61,7 @@ export function memoryPlugin(options: MemoryOptions): RoundtablePlugin {
|
|
|
35
61
|
const memory = PgMemoryStore.attach(database(), owner.id);
|
|
36
62
|
services.provide(MEMORY, memory);
|
|
37
63
|
return {
|
|
38
|
-
sessionTools: [
|
|
39
|
-
fixed("owner-memory", (session) =>
|
|
40
|
-
ownerMemoryExtension(
|
|
41
|
-
memory,
|
|
42
|
-
owner.id,
|
|
43
|
-
// An agent session serves every speaker, so its tools name none.
|
|
44
|
-
session.agent ? THE_SPEAKER : owner,
|
|
45
|
-
// The owner's own chats have one speaker; the agent server's have several.
|
|
46
|
-
session.agent ? session.speaker : undefined,
|
|
47
|
-
),
|
|
48
|
-
),
|
|
49
|
-
],
|
|
64
|
+
sessionTools: [memorySessionTool(memory, owner)],
|
|
50
65
|
toolTiers: MEMORY_TIERS,
|
|
51
66
|
};
|
|
52
67
|
},
|
|
@@ -47,10 +47,12 @@ export function ownerMemoryExtension(
|
|
|
47
47
|
ownerId: string,
|
|
48
48
|
owner: OwnerIdentity,
|
|
49
49
|
/**
|
|
50
|
-
* The person the running turn is for,
|
|
51
|
-
* the owner's, is what the turn reads and changes; without it every turn is the owner's.
|
|
50
|
+
* The person the running turn is for, when it may be someone other than the owner. Their
|
|
51
|
+
* memory, not the owner's, is what the turn reads and changes; without it every turn is the owner's.
|
|
52
52
|
*/
|
|
53
53
|
speaker?: () => Speaker | undefined,
|
|
54
|
+
/** Whether the tools tell the model that each speaker has a memory of their own. */
|
|
55
|
+
describesSpeakers = speaker !== undefined,
|
|
54
56
|
): ExtensionFactory {
|
|
55
57
|
const o = ownerWords(owner);
|
|
56
58
|
/** The running turn's other speaker; undefined when the owner's own memory applies. */
|
|
@@ -59,7 +61,7 @@ export function ownerMemoryExtension(
|
|
|
59
61
|
return current && current.id !== ownerId ? current : undefined;
|
|
60
62
|
};
|
|
61
63
|
const storeOf = () => memories.forSpeaker(other()?.id ?? ownerId);
|
|
62
|
-
const shared =
|
|
64
|
+
const shared = describesSpeakers
|
|
63
65
|
? " Whoever is speaking has a memory of their own, shared by every agent; this reads and changes theirs, not someone else's."
|
|
64
66
|
: "";
|
|
65
67
|
return (pi) => {
|
|
@@ -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
|
-
|
|
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.#
|
|
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;
|