pi-roundtable 0.9.0 → 0.9.1

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.9.1] - 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - A SIGTERM no longer lets the host keep taking work until it was idle: in 0.9.0 the shutdown drain only waited, so messages, error and webhook reports, schedules, and plugin turns that arrived meanwhile were served and waited for too, and a host that kept getting them never became idle until the one-hour limit (a deploy waited for systemd to kill it). The drain now starts by closing the channel queue: no new turn starts from any source. A message that arrives is not run and its channel gets one short restarting notice (not for a bot's or an integration's message, which is only logged), including a message that waited behind a running turn, and a message is no longer steered into a running turn; a background turn (a schedule's, a delegated task's report, an error report) is skipped; `context.turns.run`, a task for `context.queue.run` that has not started, and a group's next member's turn are refused (`HostStoppingError`, new in `pi-roundtable`), and so are new worker tasks; a remote MCP run fails instead of starting. The scheduler claims nothing during the drain, so a due schedule stays due and fires after the next start if it is less than twelve hours late. Turns already running finish.
13
+ - The drain is bounded by three minutes by default instead of an hour: `drainSeconds` in the config, or `drain.limitMs` in `RoundtableOptions`. At the limit the host stops each running turn as the owner's stop does, gives it `drain.abortGraceMs` (ten seconds by default) to end, hands what is left to `aborted`, and goes on to stop the services and exit. Give the service manager a stop timeout a minute longer than the drain, such as systemd's `TimeoutStopSec=5min`.
14
+ - A turn or worker task whose model ignores the abort (a claude-bridge subprocess that stays alive after "turn timed out; aborting") no longer holds its channel, and the drain, for ever: a run still going `turnAbortGraceMs` (15 seconds by default, `PiAgentRuntimeOptions`) after its abort has its session disposed of and ends as failed, and the conversation's next turn opens a new session from its files. An owner's stop of such a turn is guarded the same way.
15
+
16
+ ### Added
17
+
18
+ - `QueuePort.closed` (`context.queue.closed`): whether the host is shutting down, for a plugin that starts work from its own timer or route.
19
+ - The `drainSeconds` configuration key.
20
+
8
21
  ## [0.9.0] - 2026-10-09
9
22
 
10
23
  Principal and identity attribution replace implicit owner authority.
package/docs/plugins.md CHANGED
@@ -898,7 +898,7 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
898
898
 
899
899
  A service has a name and optional `start`, `stop`, and `busy`.
900
900
  Services start once everything is set up and stop in reverse order.
901
- `busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most an hour, so a deploy rarely cuts work short.
901
+ `busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most the drain limit (three minutes by default, `drainSeconds` in the config), so a deploy rarely cuts work short.
902
902
  The host adds its own channel queue to that wait, so a surface that queues its turns through `context.queue` needs no `busy` for them.
903
903
 
904
904
  A service may also have `startInBackground`.
@@ -2791,7 +2791,7 @@ The copied file begins with this warning.
2791
2791
  `release-notice` listens to two [events](#events-hear-what-the-core-does).
2792
2792
  When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
2793
2793
  It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
2794
- At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on after an hour, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
2794
+ At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on at its limit, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
2795
2795
  A plain restart of the same release with nothing cut short posts nothing.
2796
2796
 
2797
2797
  The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
@@ -2999,16 +2999,19 @@ If a startup step fails, the host stops its listeners, stops services in reverse
2999
2999
  The process exits non-zero.
3000
3000
  The same host may call `run()` again after that.
3001
3001
 
3002
- On `SIGTERM` or `SIGINT` the bot stops serving new work last:
3002
+ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs, and stops serving last:
3003
3003
 
3004
- 1. It keeps serving until the channel queue is empty and no service reports `busy()` work, for at most an hour; whatever is left is logged and given up on.
3005
- 2. Every plugin hears `shutdown(left)`, while every service is still running.
3006
- 3. The HTTP listener closes, so no request reaches a service that has stopped.
3007
- 4. Services stop in the reverse of the order they started.
3008
- 5. The database pool closes.
3004
+ 1. It stops starting work: the channel queue closes (`context.queue.closed` turns true), and from then on no new turn starts, whoever asks. A message that arrives is not run; its channel gets one short "restarting, try again shortly" reply, except a bot's or an integration's message, which is only logged. A background turn (a schedule's, a delegated task's report, an error report) is skipped, and so is a turn a plugin asks of `context.turns.run`, which rejects with `HostStoppingError`; a task for the queue that has not started, whether queued before or after, is refused with the same error. The scheduler claims nothing, so a due schedule stays due and fires after the next start if it is less than twelve hours late. A turn that is already running finishes. A plugin that starts turns from a timer or a route of its own should check `context.queue.closed` first.
3005
+ 2. It waits until the channel queue is empty and no service reports `busy()` work, for at most three minutes (`drainSeconds` in the config, `drain.limitMs` in `RoundtableOptions`). If work is left, it stops each running turn as the owner's stop does, gives them ten seconds (`drain.abortGraceMs`) to end, and goes on; whatever is left is logged and given up on.
3006
+ 3. Every plugin hears `shutdown(left)`, while every service is still running.
3007
+ 4. The HTTP listener closes, so no request reaches a service that has stopped.
3008
+ 5. Services stop in the reverse of the order they started.
3009
+ 6. The database pool closes.
3009
3010
 
3010
3011
  `shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
3011
3012
  Every call shares the same shutdown.
3013
+ Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
3014
+ A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
3012
3015
  `listen()`, called by the command line and a host's own entry point, exits the process with that code.
3013
3016
 
3014
3017
  ## Errors and their fixes
@@ -3263,6 +3266,7 @@ Import from the entries listed below; source area files are internal.
3263
3266
  | `HoldRule` | `pi-roundtable` | type |
3264
3267
  | `HostEnv` | `pi-roundtable` | type |
3265
3268
  | `HostEnvironment` | `pi-roundtable` | type |
3269
+ | `HostStoppingError` | `pi-roundtable` | value |
3266
3270
  | `HttpRoute` | `pi-roundtable` | type |
3267
3271
  | `IDENTITY` | `pi-roundtable` | value |
3268
3272
  | `IdentityError` | `pi-roundtable` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -331,7 +331,15 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
331
331
  export function schedulerPlugin(): RoundtablePlugin {
332
332
  return {
333
333
  name: "schedules",
334
- setup: ({ conversations, services, env, logger, surfaces, sessions }) => {
334
+ setup: ({
335
+ conversations,
336
+ services,
337
+ env,
338
+ logger,
339
+ surfaces,
340
+ sessions,
341
+ queue,
342
+ }) => {
335
343
  const schedules = services.get(SCHEDULES);
336
344
  const prechecks = services.find(PRECHECKS);
337
345
  const scheduler = new Scheduler({
@@ -339,6 +347,7 @@ export function schedulerPlugin(): RoundtablePlugin {
339
347
  runner: services.get(BACKGROUND_TURNS),
340
348
  ...(prechecks ? { prechecks } : {}),
341
349
  holds: () => sessions().holds,
350
+ draining: () => queue.closed,
342
351
  // The bot's own message: the surface drops it, so it starts no turn.
343
352
  notify: (schedule, note) =>
344
353
  surfaces.sendReply(schedule.channel, {
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import type { AgentSeed } from "../agents/agent-rules.ts";
4
4
  import { ConfigError } from "../domain/errors.ts";
5
5
  import type { InterimTextMode } from "../domain/interim.ts";
6
+ import { DRAIN_LIMIT_MS } from "../drain.ts";
6
7
  import { isLocale, type Locale } from "../i18n/index.ts";
7
8
  import type { AccessRules } from "../identity/access-policy.ts";
8
9
  import type { OwnerIdentity } from "../identity.ts";
@@ -164,6 +165,11 @@ export interface RoundtableConfig {
164
165
  * conversation or no plugin's claim owns it; the web chat takes no error reports.
165
166
  */
166
167
  ops?: { agent: string } | { conversation: string };
168
+ /**
169
+ * Seconds a shutdown waits for running turns before stopping them; default 180. Give the
170
+ * service manager a stop timeout a minute longer, such as systemd's `TimeoutStopSec`.
171
+ */
172
+ drainSeconds?: number;
167
173
  /**
168
174
  * Whether a turn posts the text it writes before its final answer as it goes: long or
169
175
  * structured text as ordinary messages, short narration and the tools called in one small
@@ -261,6 +267,7 @@ const schema = shape({
261
267
  ),
262
268
  memory: optional(bool),
263
269
  ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
270
+ drainSeconds: optional(integer(1, 3_600)),
264
271
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
265
272
  interimPrimaryChars: optional(integer(1, 100_000)),
266
273
  plugins: optional(
@@ -334,6 +341,7 @@ export interface ResolvedConfig {
334
341
  skills: false | { builtinDir?: string; reposDir?: string };
335
342
  memory: boolean;
336
343
  ops?: { agent: string } | { conversation: ChannelKey };
344
+ drainMs: number;
337
345
  interimText: InterimTextMode;
338
346
  interimPrimaryChars: number;
339
347
  plugins: RoundtablePlugin[];
@@ -483,6 +491,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
483
491
  skills: config.skills ?? (discord ? {} : false),
484
492
  memory: config.memory ?? true,
485
493
  ...(ops ? { ops } : {}),
494
+ drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
486
495
  interimText: config.interimText ?? "on",
487
496
  interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
488
497
  plugins: config.plugins ?? [],
@@ -259,6 +259,11 @@ export interface QueuePort {
259
259
  size(channel: ChannelKey): number;
260
260
  /** Channels with a task running or waiting. */
261
261
  busy(): ChannelKey[];
262
+ /**
263
+ * Whether the host is shutting down: a task that has not started is then refused with
264
+ * HostStoppingError, and a plugin that starts work on its own, such as a timer, starts none.
265
+ */
266
+ readonly closed: boolean;
262
267
  /** Called whenever a channel's count of running and waiting tasks changes. */
263
268
  onChange(listener: (channel: ChannelKey) => void): void;
264
269
  }
@@ -283,6 +283,7 @@ export async function defineRoundtable(
283
283
  ],
284
284
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
285
285
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
286
+ drain: { limitMs: config.drainMs },
286
287
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
287
288
  },
288
289
  plugins: [
package/src/core/drain.ts CHANGED
@@ -1,19 +1,32 @@
1
- /** How long a shutdown waits for running work before aborting it (self-maintenance spec behavior 11). */
2
- export const DRAIN_LIMIT_MS = 60 * 60_000;
1
+ import type { Logger } from "./log.ts";
2
+
3
+ /**
4
+ * How long a shutdown waits for running work before aborting it: long enough for a usual turn to
5
+ * answer, short enough that a hung one never holds a restart for an hour.
6
+ */
7
+ export const DRAIN_LIMIT_MS = 3 * 60_000;
8
+
9
+ /** How long work that was told to stop gets to end before the shutdown goes on without it. */
10
+ const ABORT_GRACE_MS = 10_000;
3
11
 
4
12
  export interface DrainOptions {
5
13
  /** What still runs or waits, one entry per piece of work; empty when idle. */
6
14
  busy: () => string[];
15
+ /** The most the drain waits for running work; DRAIN_LIMIT_MS by default. */
7
16
  limitMs?: number;
17
+ /** How often it looks again; one second by default. */
8
18
  intervalMs?: number;
19
+ /** The most work told to stop at the limit gets to end; ABORT_GRACE_MS by default. */
20
+ abortGraceMs?: number;
9
21
  sleep?: (ms: number) => Promise<void>;
10
22
  now?: () => number;
11
23
  }
12
24
 
13
25
  /**
14
- * Waits until nothing runs or waits, checking every interval, for at most the limit. Work that
15
- * arrives meanwhile is served as usual and waited for too. Returns what was still busy when the
16
- * limit ran out, or an empty list once idle.
26
+ * Waits until nothing runs or waits, checking every interval, for at most the limit. Returns what
27
+ * was still busy when the limit ran out, or an empty list once idle. It only waits: the host stops
28
+ * work from starting before it calls this, and a caller that does not must expect work that
29
+ * arrives meanwhile to be waited for too.
17
30
  */
18
31
  export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
19
32
  const {
@@ -31,3 +44,41 @@ export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
31
44
  await sleep(intervalMs);
32
45
  }
33
46
  }
47
+
48
+ /**
49
+ * Waits for running work up to the limit; if some is left, tells it to stop (`abort` says whether
50
+ * it told any) and gives it the abort grace to end. Returns what the limit ran out on, even when stopping it worked, so the
51
+ * caller can hand it over as cut short.
52
+ */
53
+ export async function drainWork(
54
+ options: DrainOptions & { abort: (left: string[]) => boolean },
55
+ ): Promise<string[]> {
56
+ const { abort, abortGraceMs = ABORT_GRACE_MS, ...waiting } = options;
57
+ const left = await waitUntilIdle(waiting);
58
+ if (left.length === 0) return left;
59
+ // Work nothing could be told to stop would only be waited for in vain.
60
+ if (abort(left)) await waitUntilIdle({ ...waiting, limitMs: abortGraceMs });
61
+ return left;
62
+ }
63
+
64
+ /**
65
+ * Tells each channel's running turn to stop, as the owner's stop does; a failure is logged and the
66
+ * rest are still told. Whether any turn was running to be told.
67
+ */
68
+ export function stopTurns(
69
+ channels: readonly string[],
70
+ stop: (channel: string) => boolean,
71
+ logger: Logger,
72
+ ): boolean {
73
+ let stopped = false;
74
+ for (const channel of new Set(channels)) {
75
+ try {
76
+ const told = stop(channel);
77
+ logger.warn({ channel, told }, "stopping the turn");
78
+ stopped ||= told;
79
+ } catch (error) {
80
+ logger.error({ channel, err: error }, "the turn could not be stopped");
81
+ }
82
+ }
83
+ return stopped;
84
+ }
@@ -27,3 +27,16 @@ export class JudgeError extends Error {
27
27
  export class ProviderError extends Error {
28
28
  override name = "ProviderError";
29
29
  }
30
+
31
+ /**
32
+ * Work refused because the host is shutting down: once the drain starts, no turn, queue task, or
33
+ * background run starts any more, and what was waiting behind a running one is dropped.
34
+ */
35
+ export class HostStoppingError extends Error {
36
+ override name = "HostStoppingError";
37
+ constructor() {
38
+ super(
39
+ "the host is shutting down and starts no new work; try again once it is back",
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,56 @@
1
+ import type { Logger } from "./log.ts";
2
+ import type { ServiceStartedEvent } from "./plugin.ts";
3
+ import type { Registry } from "./registry/contributions.ts";
4
+
5
+ type Attempt = { ok: true } | { ok: false; error: unknown };
6
+
7
+ /** Runs one step whose failure the caller reports and moves past. */
8
+ export async function attempt(
9
+ step: () => Promise<void> | void,
10
+ ): Promise<Attempt> {
11
+ try {
12
+ await step();
13
+ return { ok: true };
14
+ } catch (error) {
15
+ return { ok: false, error };
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Runs every service's background start at once and tells every plugin, as each ends, how it
21
+ * went; the rest of the process runs either way, and a failing start or handler never stops the
22
+ * others.
23
+ */
24
+ export async function startInBackground(
25
+ registry: Registry,
26
+ logger: Logger,
27
+ ): Promise<void> {
28
+ const { services, servicePlugins, handlers } = registry;
29
+ await Promise.all(
30
+ // pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
31
+ services.map(async (service) => {
32
+ if (!service.startInBackground) return;
33
+ const plugin = servicePlugins.get(service) ?? "unknown";
34
+ const started = await attempt(() => service.startInBackground?.());
35
+ if (started.ok) logger.info({ plugin, service: service.name }, "ready");
36
+ else
37
+ logger.error(
38
+ { plugin, service: service.name, err: started.error },
39
+ "service did not start in the background",
40
+ );
41
+ const event: ServiceStartedEvent = {
42
+ plugin,
43
+ service: service.name,
44
+ outcome: started.ok ? "ready" : "failed",
45
+ };
46
+ for (const { plugin: heard, events } of handlers) {
47
+ const handled = await attempt(() => events.serviceStarted?.(event));
48
+ if (!handled.ok)
49
+ logger.error(
50
+ { plugin: heard, err: handled.error },
51
+ "service started handler failed",
52
+ );
53
+ }
54
+ }),
55
+ );
56
+ }
package/src/core/host.ts CHANGED
@@ -2,9 +2,10 @@ import type { SQL } from "bun";
2
2
  import type { ConversationPort } from "./contract/channels.ts";
3
3
  import type { SurfacePort } from "./contract/surface.ts";
4
4
  import { openPool, runMigrations } from "./db/migrations.ts";
5
- import { type DrainOptions, waitUntilIdle } from "./drain.ts";
5
+ import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
6
6
  import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
7
  import { EventBus } from "./events.ts";
8
+ import { attempt, startInBackground } from "./host-background.ts";
8
9
  import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
10
  import { type Locale, setLocale } from "./i18n/index.ts";
10
11
  import { contactsOf } from "./identity/identity-view.ts";
@@ -15,7 +16,6 @@ import type {
15
16
  LinkedSessions,
16
17
  RoundtablePlugin,
17
18
  Service,
18
- ServiceStartedEvent,
19
19
  } from "./plugin.ts";
20
20
  import { refuseRemovedFields } from "./plugin.ts";
21
21
  import {
@@ -34,6 +34,7 @@ import {
34
34
  } from "./routing/conversation-turns.ts";
35
35
  import { surfacePort } from "./routing/surface-port.ts";
36
36
  import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
37
+ import type { ChannelKey } from "./sessions.ts";
37
38
  import { setTimeZone } from "./time.ts";
38
39
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
39
40
 
@@ -78,7 +79,11 @@ export interface RoundtableOptions {
78
79
  database?: { url: string };
79
80
  /** Receives the work a shutdown drain gave up on, before any service stops. */
80
81
  aborted?: (left: string[]) => Promise<void>;
81
- /** The drain's limit and clock; tests shorten them. */
82
+ /**
83
+ * The shutdown drain: it waits up to `limitMs` (3 minutes by default) for the turns already
84
+ * running, then stops them, gives them `abortGraceMs` (10 seconds) to end, and goes on without
85
+ * them. Nothing new starts once it begins. Tests shorten the limits and the clock.
86
+ */
82
87
  drain?: Omit<DrainOptions, "busy">;
83
88
  /** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
84
89
  exit?: (code: number) => void;
@@ -87,63 +92,13 @@ export interface RoundtableOptions {
87
92
  /** The host running in this process; the text catalog, time zone and environment are process-wide. */
88
93
  let running: Roundtable | undefined;
89
94
 
90
- type Attempt = { ok: true } | { ok: false; error: unknown };
91
-
92
- /** Runs one step whose failure the caller reports and moves past. */
93
- async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
94
- try {
95
- await step();
96
- return { ok: true };
97
- } catch (error) {
98
- return { ok: false, error };
99
- }
100
- }
101
-
102
- /**
103
- * Runs every service's background start at once and tells every plugin, as each ends, how it
104
- * went; the rest of the process runs either way, and a failing start or handler never stops the
105
- * others.
106
- */
107
- async function startInBackground(
108
- registry: Registry,
109
- logger: Logger,
110
- ): Promise<void> {
111
- const { services, servicePlugins, handlers } = registry;
112
- await Promise.all(
113
- // pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
114
- services.map(async (service) => {
115
- if (!service.startInBackground) return;
116
- const plugin = servicePlugins.get(service) ?? "unknown";
117
- const started = await attempt(() => service.startInBackground?.());
118
- if (started.ok) logger.info({ plugin, service: service.name }, "ready");
119
- else
120
- logger.error(
121
- { plugin, service: service.name, err: started.error },
122
- "service did not start in the background",
123
- );
124
- const event: ServiceStartedEvent = {
125
- plugin,
126
- service: service.name,
127
- outcome: started.ok ? "ready" : "failed",
128
- };
129
- for (const { plugin: heard, events } of handlers) {
130
- const handled = await attempt(() => events.serviceStarted?.(event));
131
- if (!handled.ok)
132
- logger.error(
133
- { plugin: heard, err: handled.error },
134
- "service started handler failed",
135
- );
136
- }
137
- }),
138
- );
139
- }
140
-
141
95
  /**
142
96
  * The process around the plugins: it sets them all up, links what they add (session parts,
143
97
  * HTTP routes) and runs the preflight, then starts their services in order and the
144
98
  * HTTP listeners last, then runs the services' background starts. Nothing reaches Discord or a listener unless
145
- * every setup, link, and the preflight succeeded. On shutdown it waits until no work runs or
146
- * waits before closing the listeners and stopping the services in reverse.
99
+ * every setup, link, and the preflight succeeded. On shutdown it starts no new work, waits for
100
+ * what runs for at most the drain limit, then closes the listeners and stops the services in
101
+ * reverse.
147
102
  */
148
103
  // pi-lens-ignore: large-class
149
104
  export class Roundtable {
@@ -243,6 +198,7 @@ export class Roundtable {
243
198
  registry: () => this.#services?.find(CONVERSATIONS),
244
199
  surfaces: this.#surfaces(),
245
200
  events: this.#events.sink,
201
+ stopping: () => this.#queue.closed,
246
202
  selection: () => {
247
203
  if (!this.#sessions)
248
204
  throw new NotLinkedError("session parts are not linked yet.");
@@ -423,7 +379,8 @@ export class Roundtable {
423
379
  }
424
380
 
425
381
  /**
426
- * Stops the host once no work runs or waits and returns the exit code: 0, or 1 when the
382
+ * Stops the host once no work runs or waits, or the drain limit has passed, and returns the
383
+ * exit code: 0, or 1 when the
427
384
  * boot had failed or a listener, service or the pool did not stop. Every call shares the one
428
385
  * shutdown.
429
386
  */
@@ -437,19 +394,27 @@ export class Roundtable {
437
394
  // A signal during the boot waits for it to settle; a failed boot has already stopped.
438
395
  await this.#booting?.catch(() => undefined);
439
396
  if (running !== this) return this.#bootFailed ? 1 : 0;
440
- // Everything keeps serving until nothing runs or waits, so a deploy never cuts a turn short.
397
+ // Turns already running finish, but nothing new starts from here on, whoever asks.
441
398
  logger.info({ signal }, "shutting down once idle");
442
- const left = await waitUntilIdle({
399
+ this.#queue.close();
400
+ const left = await drainWork({
443
401
  ...drain,
444
402
  busy: () => [
445
403
  ...this.#queue.busy(),
446
404
  ...this.#started.flatMap((service) => service.busy?.() ?? []),
447
405
  ],
406
+ // A turn the limit finds still running is stopped, as the owner's stop would.
407
+ abort: () =>
408
+ stopTurns(
409
+ this.#queue.busy(),
410
+ (channel) => this.#router?.stop(channel as ChannelKey) ?? false,
411
+ logger,
412
+ ),
448
413
  });
449
414
  if (left.length > 0) {
450
415
  logger.warn(
451
416
  { aborted: left },
452
- "still busy after the drain limit; aborting",
417
+ "still busy after the drain limit; aborted",
453
418
  );
454
419
  const recorded = await attempt(() => aborted?.(left));
455
420
  if (!recorded.ok)
@@ -6,6 +6,7 @@ export function conversationEn(ctx: CatalogContext) {
6
6
  failureNotice:
7
7
  "Sorry, this reply did not go through. Please try again later.",
8
8
  stoppedNotice: "-# Stopped.",
9
+ restartingNotice: `-# ${ctx.assistant} is restarting and takes no new messages right now; please send this again in a few minutes.`,
9
10
  roundLimitNotice:
10
11
  "-# This round already has 8 replies, so it stops here; call us again if you need more.",
11
12
  restartNotice: `-# ${ctx.assistant} restarted before this job finished, so nothing more will be reported here.`,
@@ -30,6 +31,7 @@ export function conversationZhTW(
30
31
  return {
31
32
  failureNotice: "抱歉,這次沒能完成回覆,請稍後再試一次。",
32
33
  stoppedNotice: "-# 已停止。",
34
+ restartingNotice: `-# ${ctx.assistant} 正在重新啟動,暫時不接新訊息;請過幾分鐘再傳一次。`,
33
35
  roundLimitNotice:
34
36
  "-# 這一輪已經有 8 則回覆,先停在這裡;需要的話請再叫我們。",
35
37
  restartNotice: `-# ${ctx.assistant} 在這項工作結束前重新啟動了,這裡不會再有回報。`,
@@ -49,6 +49,12 @@ export interface SchedulerOptions {
49
49
  holds?: () => HoldCheck;
50
50
  /** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
51
51
  notify?: (schedule: Schedule, note: string) => Promise<void>;
52
+ /**
53
+ * Whether the host is shutting down. A due schedule is then left unclaimed, so it stays due and
54
+ * fires after the next start, if it is not too late by then (LATE_LIMIT_MS), instead of being
55
+ * moved on to its next run and its turn refused.
56
+ */
57
+ draining?: () => boolean;
52
58
  logger: Logger;
53
59
  intervalMs?: number;
54
60
  /** Replaceable in tests. */
@@ -119,15 +125,20 @@ export class Scheduler {
119
125
  await this.#prechecks.drain(PRECHECK_STOP_MS);
120
126
  }
121
127
 
128
+ #draining(): boolean {
129
+ return this.#options.draining?.() ?? false;
130
+ }
131
+
122
132
  /** Claims every due schedule and starts its run; runs continue after tick resolves. */
123
133
  async tick(): Promise<void> {
124
- if (this.#ticking || this.#stopping.signal.aborted) return;
134
+ if (this.#ticking || this.#stopping.signal.aborted || this.#draining())
135
+ return;
125
136
  this.#ticking = true;
126
137
  const { store, logger } = this.#options;
127
138
  try {
128
139
  const now = this.#options.now?.() ?? new Date();
129
140
  for (const schedule of await store.due(now)) {
130
- if (this.#stopping.signal.aborted) break;
141
+ if (this.#stopping.signal.aborted || this.#draining()) break;
131
142
  const claimed = await store.claim(
132
143
  schedule,
133
144
  nextRun(schedule.recurrence, now),
@@ -1,3 +1,4 @@
1
+ import { HostStoppingError } from "../errors.ts";
1
2
  import type { ChannelKey } from "../sessions.ts";
2
3
 
3
4
  /** Runs tasks one at a time per channel, in arrival order; different channels run independently. */
@@ -5,6 +6,7 @@ export class ChannelQueue {
5
6
  readonly #tails = new Map<ChannelKey, Promise<unknown>>();
6
7
  readonly #sizes = new Map<ChannelKey, number>();
7
8
  readonly #listeners: ((channel: ChannelKey) => void)[] = [];
9
+ #closed = false;
8
10
 
9
11
  /** Called whenever a channel's count of running and waiting tasks changes. */
10
12
  onChange(listener: (channel: ChannelKey) => void): void {
@@ -15,11 +17,26 @@ export class ChannelQueue {
15
17
  for (const listener of this.#listeners) listener(channel);
16
18
  }
17
19
 
20
+ /** Whether the host is shutting down, so no task starts any more. */
21
+ get closed(): boolean {
22
+ return this.#closed;
23
+ }
24
+
25
+ /**
26
+ * Starts the shutdown: from now on a task that has not started, whether queued before or after
27
+ * this call, is refused with HostStoppingError. A running task finishes.
28
+ */
29
+ close(): void {
30
+ this.#closed = true;
31
+ }
32
+
18
33
  run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
34
+ const start = () =>
35
+ this.#closed ? Promise.reject(new HostStoppingError()) : task();
19
36
  this.#sizes.set(channel, this.size(channel) + 1);
20
37
  this.#changed(channel);
21
38
  const previous = this.#tails.get(channel) ?? Promise.resolve();
22
- const result = previous.then(task, task);
39
+ const result = previous.then(start, start);
23
40
  const tail = result.catch(() => undefined);
24
41
  this.#tails.set(channel, tail);
25
42
  void tail.then(() => {
@@ -12,7 +12,8 @@ import {
12
12
  } from "../contract/channels.ts";
13
13
  import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
14
14
  import { IdentityError } from "../domain/errors.ts";
15
- import { PluginError } from "../errors.ts";
15
+ import { HostStoppingError, PluginError } from "../errors.ts";
16
+ import { messages } from "../i18n/index.ts";
16
17
  import type { ActorFacts } from "../identity/actor-facts.ts";
17
18
  import type { Contact, ContactAssessor } from "../identity/contact.ts";
18
19
  import {
@@ -37,8 +38,11 @@ export interface ChannelRouterOptions {
37
38
  contacts?: ContactAssessor;
38
39
  /** Whom a background turn runs as; without it, only the host's own turns run. */
39
40
  principals?: Pick<IdentityService, "speakerFor">;
40
- /** The surfaces, so a conversation started over is marked in a channel that shows one; without it, none is. */
41
- surfaces?: Pick<SurfacePort, "of">;
41
+ /**
42
+ * The surfaces, so a conversation started over is marked in a channel that shows one, and a
43
+ * message that arrives while the host shuts down is told so; without it, neither is.
44
+ */
45
+ surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
42
46
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
43
47
  forwardJoinMs?: number;
44
48
  }
@@ -56,6 +60,12 @@ export function orderClaims(
56
60
  return claims.toSorted((a, b) => b.priority - a.priority);
57
61
  }
58
62
 
63
+ /** The outcome of a background turn the host did not start because it is shutting down. */
64
+ const stopping = (): ScheduledOutcome => ({
65
+ status: "skipped",
66
+ reason: new HostStoppingError().message,
67
+ });
68
+
59
69
  /**
60
70
  * Routes every conversation operation to the claim that owns its channel, each inside the
61
71
  * channel's queue: messages after joining a bare forward to its text, background turns,
@@ -69,6 +79,8 @@ export class ChannelRouter implements ConversationPort {
69
79
  readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
70
80
  /** The surfaces already warned that their messages carry no actor. */
71
81
  readonly #warned = new Set<string>();
82
+ /** The channels already told that the host is restarting, so a burst of messages gets one notice. */
83
+ readonly #told = new Set<ChannelKey>();
72
84
 
73
85
  constructor(options: ChannelRouterOptions) {
74
86
  this.#options = options;
@@ -100,7 +112,13 @@ export class ChannelRouter implements ConversationPort {
100
112
  if (!claim) return;
101
113
  const started = await this.#inOrder(message.channel, async () => {
102
114
  const admission = await this.#admit(claim, message);
103
- return admission && (await this.#start(message, admission));
115
+ if (!admission) return undefined;
116
+ // Once the host shuts down only the turns already running finish: this one starts nothing.
117
+ if (this.#options.queue.closed) {
118
+ await this.#refuse(message, admission);
119
+ return undefined;
120
+ }
121
+ return this.#start(message, admission);
104
122
  });
105
123
  await started?.done;
106
124
  }
@@ -209,6 +227,33 @@ export class ChannelRouter implements ConversationPort {
209
227
  };
210
228
  }
211
229
 
230
+ /**
231
+ * Drops a message the host no longer starts a turn for because it is shutting down, and tells
232
+ * its channel once that it restarts; no one is told for a bot's or an integration's message, such
233
+ * as a report, since a notice there would only start the next one.
234
+ */
235
+ async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
236
+ const { logger, surfaces } = this.#options;
237
+ const { channel } = message;
238
+ if (admission.kind === "turn") admission.dropped?.();
239
+ else
240
+ admission.unanswered({
241
+ status: "skipped",
242
+ reason: new HostStoppingError().message,
243
+ });
244
+ logger.info({ channel }, "message not run: the host is shutting down");
245
+ if (message.authorIsBot || message.integration || this.#told.has(channel))
246
+ return;
247
+ this.#told.add(channel);
248
+ try {
249
+ await surfaces?.sendReply?.(channel, {
250
+ chunks: [messages().restartingNotice],
251
+ });
252
+ } catch (error) {
253
+ logger.warn({ channel, err: error }, "restart notice not posted");
254
+ }
255
+ }
256
+
212
257
  /**
213
258
  * Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
214
259
  * during a turn is first offered to it, then marked as waiting.
@@ -238,12 +283,17 @@ export class ChannelRouter implements ConversationPort {
238
283
  return admission.run();
239
284
  })
240
285
  // pi-lens-ignore: no-unknown-parameters
241
- .catch((error: unknown) =>
286
+ .catch(async (error: unknown) => {
287
+ // The host began shutting down while this message waited behind a running turn.
288
+ if (error instanceof HostStoppingError) {
289
+ if (queued) void busy?.unreact(QUEUED_MARK);
290
+ return this.#refuse(message, admission);
291
+ }
242
292
  logger.error(
243
293
  { channel: message.channel, err: error },
244
294
  admission.failure,
245
- ),
246
- );
295
+ );
296
+ });
247
297
  return { done };
248
298
  }
249
299
 
@@ -257,6 +307,7 @@ export class ChannelRouter implements ConversationPort {
257
307
  * handed to another target's claim, and so is one whose author may not run it now.
258
308
  */
259
309
  async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
310
+ if (this.#options.queue.closed) return stopping();
260
311
  if (!this.#options.targets(turn.target))
261
312
  return {
262
313
  status: "skipped",
@@ -277,6 +328,7 @@ export class ChannelRouter implements ConversationPort {
277
328
  return claim.background({ ...turn, speaker: runs.speaker });
278
329
  });
279
330
  } catch (error) {
331
+ if (error instanceof HostStoppingError) return stopping();
280
332
  return { status: "failed", error: String(error) };
281
333
  }
282
334
  }
@@ -8,7 +8,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
8
8
  import type { TurnResult } from "../domain/conversation.ts";
9
9
  import type { TurnConversation } from "../domain/ports.ts";
10
10
  import type { TurnProgress } from "../domain/progress.ts";
11
- import { PluginError } from "../errors.ts";
11
+ import { HostStoppingError, PluginError } from "../errors.ts";
12
12
  import { messages } from "../i18n/index.ts";
13
13
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
14
14
  import type { Logger } from "../log.ts";
@@ -61,7 +61,8 @@ export interface ConversationTurns {
61
61
  * task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
62
62
  * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
63
63
  * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
64
- * It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
64
+ * It rejects during setup (NotLinkedError), once the host is shutting down (HostStoppingError),
65
+ * when the host has no runtime to run the turn on,
65
66
  * when the conversation cannot be recorded in the host's registry (a database error, or a
66
67
  * `private` conversation without a speaker), and when the conversation is recorded private to a
67
68
  * principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
@@ -75,6 +76,8 @@ export interface ConversationTurns {
75
76
  export interface ConversationTurnsOptions {
76
77
  /** Throws NotLinkedError until every plugin is set up and linked. */
77
78
  linked: () => void;
79
+ /** Whether the host is shutting down, so no turn starts; undefined or false while it serves. */
80
+ stopping?: () => boolean;
78
81
  /** The runtime that runs the turn; throws PluginError when the host has none yet. */
79
82
  runtime: () => AgentRuntime;
80
83
  /** Where each turn records its conversation; absent, or undefined, records none. */
@@ -143,6 +146,8 @@ export function conversationTurns(
143
146
  async run(input) {
144
147
  const { channel, kind, speaker } = input;
145
148
  options.linked();
149
+ // Refused before anything is shown or recorded, like a turn the host cannot run.
150
+ if (options.stopping?.()) throw new HostStoppingError();
146
151
  // A host without a runtime is a setup mistake, so it is refused before anything is shown.
147
152
  const runtime = options.runtime();
148
153
  // A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
@@ -0,0 +1,58 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import { AgentRunError } from "../domain/errors.ts";
3
+
4
+ /** How long a run told to abort gets to end before its session is disposed of; default for the guard. */
5
+ const ABORT_GRACE_MS = 15_000;
6
+
7
+ /**
8
+ * Aborts a Pi session's run and does not trust the abort to work. A model behind a subprocess that
9
+ * ignores the abort would leave the run, and the queue behind it, waiting for ever, so a run still
10
+ * going once the grace is over has its session disposed of, which drops its model connection, and
11
+ * `gaveUp` rejects so the caller can end the turn as failed.
12
+ */
13
+ export class AbortGuard {
14
+ /** Rejects when the abort did not end the run in time; never resolves. */
15
+ readonly gaveUp: Promise<never>;
16
+ readonly #session: Pick<AgentSession, "abort" | "dispose">;
17
+ readonly #graceMs: number;
18
+ readonly #onGiveUp: () => void;
19
+ #reject: (error: Error) => void = () => undefined;
20
+ #timer: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ constructor(
23
+ session: Pick<AgentSession, "abort" | "dispose">,
24
+ options: { graceMs?: number; onGiveUp?: () => void } = {},
25
+ ) {
26
+ this.#session = session;
27
+ this.#graceMs = options.graceMs ?? ABORT_GRACE_MS;
28
+ this.#onGiveUp = options.onGiveUp ?? (() => undefined);
29
+ this.gaveUp = new Promise<never>((_resolve, reject) => {
30
+ this.#reject = reject;
31
+ });
32
+ // Nobody waits on it unless the abort fails.
33
+ this.gaveUp.catch(() => undefined);
34
+ }
35
+
36
+ /** Aborts the run, and starts the grace the first time. */
37
+ abort(): void {
38
+ void this.#session.abort();
39
+ this.#timer ??= setTimeout(() => {
40
+ this.#onGiveUp();
41
+ try {
42
+ this.#session.dispose();
43
+ } catch {
44
+ // A session that cannot be disposed of is dropped all the same.
45
+ }
46
+ this.#reject(
47
+ new AgentRunError(
48
+ "the run did not stop after it was aborted, so its session was disposed of",
49
+ ),
50
+ );
51
+ }, this.#graceMs);
52
+ }
53
+
54
+ /** The run ended: no session is disposed of any more. */
55
+ release(): void {
56
+ clearTimeout(this.#timer);
57
+ }
58
+ }
@@ -262,6 +262,21 @@ export class ConversationSessions {
262
262
  );
263
263
  }
264
264
 
265
+ /**
266
+ * Forgets a session that was disposed of while its turn still ran, so the conversation's next
267
+ * turn opens a new one from its files; a newer session of the conversation stays.
268
+ */
269
+ drop(key: ChannelKey, disposed: ChannelSession): void {
270
+ const open = this.#sessions.get(key);
271
+ if (!open) return;
272
+ void open
273
+ .then((current) => {
274
+ if (current === disposed && this.#sessions.get(key) === open)
275
+ this.#sessions.delete(key);
276
+ })
277
+ .catch(() => undefined);
278
+ }
279
+
265
280
  /** Drops the open session and everything held for the conversation, leaving its files. */
266
281
  // pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
267
282
  async forget(channel: ChannelKey): Promise<void> {
@@ -1,6 +1,5 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import {
3
- type AgentSession,
4
3
  type ModelRuntime,
5
4
  SessionManager,
6
5
  } from "@earendil-works/pi-coding-agent";
@@ -16,20 +15,16 @@ import type {
16
15
  } from "../domain/conversation.ts";
17
16
  import { AgentRunError } from "../domain/errors.ts";
18
17
  import type { TurnRequest } from "../domain/ports.ts";
18
+ import { HostStoppingError } from "../errors.ts";
19
19
  import { assistantName } from "../i18n/index.ts";
20
20
  import { promptScopeOf } from "../interactions/prompts.ts";
21
- import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
22
21
  import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
23
22
  import { withoutReplyFiles } from "../reply-files.ts";
24
23
  import type { TransientTask } from "../sessions.ts";
25
24
  import type { Speaker, Tier } from "../speakers.ts";
26
25
  import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
27
- import {
28
- type AgentModelSettings,
29
- bridgeTurnRefusal,
30
- chosenAgentModel,
31
- useChosenAgentModel,
32
- } from "./bridge-guard.ts";
26
+ import { AbortGuard } from "./abort-guard.ts";
27
+ import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
33
28
  import { ConversationSessions } from "./conversation-sessions.ts";
34
29
  import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
35
30
  import { loadsMemory } from "./extensions/private-memory.ts";
@@ -47,14 +42,11 @@ import {
47
42
  } from "./session-conversation.ts";
48
43
  import { SessionFactory } from "./session-factory.ts";
49
44
  import { promptImages, SteerableRun } from "./steerable-run.ts";
45
+ import { thinkingLevel } from "./thinking-level.ts";
50
46
  import { runningCalls } from "./tool-call-scope.ts";
51
- import {
52
- lastReply,
53
- transcriptOf,
54
- turnAnswer,
55
- unspokenTurn,
56
- } from "./turn-answer.ts";
47
+ import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
57
48
  import { progressReporter } from "./turn-progress.ts";
49
+ import { wrapUpTurn } from "./turn-wrapup.ts";
58
50
  import { workerReport } from "./worker-task.ts";
59
51
 
60
52
  export {
@@ -118,6 +110,12 @@ export class PiAgentRuntime implements AgentRuntime {
118
110
  async runTurn(request: TurnRequest): Promise<TurnResult> {
119
111
  const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
112
  if (!request.speaker) return unspokenTurn();
113
+ // A turn that is only asked for now, such as a group's next member, starts nothing.
114
+ if (this.#options.stopping?.())
115
+ return {
116
+ ok: false,
117
+ error: new AgentRunError(new HostStoppingError().message),
118
+ };
121
119
  const key = request.agent?.session ?? request.channel;
122
120
  // Resolve current scope; refuse a foreign private speaker before touching its session.
123
121
  const conversation = await this.#sessions.conversation(key, request);
@@ -147,7 +145,17 @@ export class PiAgentRuntime implements AgentRuntime {
147
145
  : undefined;
148
146
  if (bridgeRefused)
149
147
  return { ok: false, error: new AgentRunError(bridgeRefused) };
150
- const level = await this.#thinkingLevel(key, session, request, agentModel);
148
+ const level = await thinkingLevel(
149
+ {
150
+ modelRuntime: this.#modelRuntime,
151
+ effort: this.#options.effort,
152
+ judged: this.#sessions.judged,
153
+ },
154
+ key,
155
+ session,
156
+ request,
157
+ agentModel,
158
+ );
151
159
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
152
160
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
153
161
  const pending = gate.pending();
@@ -240,17 +248,29 @@ export class PiAgentRuntime implements AgentRuntime {
240
248
  request.agent?.name ?? assistantName(),
241
249
  interim ? () => interim.flush() : undefined,
242
250
  );
251
+ // A model that ignores the abort must not hold the turn: its session is disposed of instead.
252
+ const guard = new AbortGuard(session, {
253
+ graceMs: this.#options.turnAbortGraceMs,
254
+ onGiveUp: () => {
255
+ logger.error(
256
+ { channel: request.channel },
257
+ "turn did not stop after its abort; disposing of its session",
258
+ );
259
+ this.#sessions.drop(key, channelSession);
260
+ },
261
+ });
243
262
  // Time spent waiting on the owner's cards does not count towards the timeout.
244
263
  const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
245
264
  logger.warn(
246
265
  { channel: request.channel, turnTimeoutMs },
247
266
  "turn timed out; aborting",
248
267
  );
249
- void session.abort();
268
+ guard.abort();
250
269
  });
251
270
  const running = new SteerableRun(
252
271
  session,
253
272
  () => request.steerable === true && !gate.pending(),
273
+ () => guard.abort(),
254
274
  );
255
275
  this.#running.set(key, running);
256
276
 
@@ -260,7 +280,7 @@ export class PiAgentRuntime implements AgentRuntime {
260
280
  request.confirmed && pending
261
281
  ? confirmedTurnText(pending, request.text, who)
262
282
  : request.text;
263
- await running.run(() =>
283
+ const prompted = running.run(() =>
264
284
  session.prompt(
265
285
  withAttachmentsBlock(text, attachments),
266
286
  attachments.images.length > 0
@@ -268,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
268
288
  : undefined,
269
289
  ),
270
290
  );
291
+ // A run abandoned by the guard may still reject later; nobody waits for it then.
292
+ prompted.catch(() => undefined);
293
+ await Promise.race([prompted, guard.gaveUp]);
271
294
  } catch (error) {
272
295
  if (running.stopped)
273
296
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
@@ -280,42 +303,20 @@ export class PiAgentRuntime implements AgentRuntime {
280
303
  this.#turns.delete(key);
281
304
  this.#memoryTurns.delete(key);
282
305
  cancelTimeout();
306
+ guard.release();
283
307
  slot.unbind();
284
308
  unsubscribe();
285
309
  progress?.close();
286
310
  await interim?.flush();
287
- gate.endTurn();
288
- const usage = session.getContextUsage();
289
- if (usage)
290
- this.#sessions.usage.set(key, {
291
- tokens: usage.tokens,
292
- contextWindow: usage.contextWindow,
293
- });
294
- const held = gate.pending();
295
- await this.#options.confirmations
296
- .save(key, held)
297
- // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
298
- .catch((error: unknown) =>
299
- logger.error({ channel: key, err: error }, "held actions not saved"),
300
- );
301
- if (held)
302
- logger.info(
303
- { channel: key, held: held.calls.map((call) => call.action) },
304
- "actions held for confirmation",
305
- );
306
- logger.info(
307
- {
308
- channel: key,
309
- selection: request.selection.id,
310
- toolCalls,
311
- thinking: session.thinkingLevel,
312
- // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
313
- ...(request.agent
314
- ? { model: `${session.model?.provider}/${session.model?.id}` }
315
- : {}),
316
- },
317
- "turn finished",
318
- );
311
+ await wrapUpTurn({
312
+ key,
313
+ request,
314
+ session,
315
+ gate,
316
+ toolCalls,
317
+ options: this.#options,
318
+ recordUsage: (usage) => this.#sessions.usage.set(key, usage),
319
+ });
319
320
  }
320
321
 
321
322
  if (running.stopped)
@@ -368,6 +369,8 @@ export class PiAgentRuntime implements AgentRuntime {
368
369
  ): Promise<string> {
369
370
  const { dataDir } = this.#options;
370
371
  const { signal } = task;
372
+ if (this.#options.stopping?.())
373
+ throw new AgentRunError(new HostStoppingError().message);
371
374
  // A task works at the tier of the turn that started it; without one there is no tier to take.
372
375
  const turn = this.#turns.get(scope.turn);
373
376
  if (!turn)
@@ -393,7 +396,11 @@ export class PiAgentRuntime implements AgentRuntime {
393
396
  const unsubscribe = session.subscribe((event) => {
394
397
  if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
395
398
  });
396
- const abort = () => void session.abort();
399
+ // A worker whose model ignores the abort is given up on, like a turn's.
400
+ const guard = new AbortGuard(session, {
401
+ graceMs: this.#options.turnAbortGraceMs,
402
+ });
403
+ const abort = () => guard.abort();
397
404
  signal?.addEventListener("abort", abort, { once: true });
398
405
  const timer = setTimeout(abort, task.timeoutMs);
399
406
  try {
@@ -411,13 +418,16 @@ export class PiAgentRuntime implements AgentRuntime {
411
418
  (name) => registered.has(name) && this.#tiers.allows(tier, name),
412
419
  );
413
420
  session.setActiveToolsByName([...worker.tools]);
414
- await withoutReplyFiles(() => session.prompt(task.text));
421
+ const prompted = withoutReplyFiles(() => session.prompt(task.text));
422
+ prompted.catch(() => undefined);
423
+ await Promise.race([prompted, guard.gaveUp]);
415
424
  return workerReport(
416
425
  session.messages,
417
426
  "the worker stopped before it reported",
418
427
  );
419
428
  } finally {
420
429
  clearTimeout(timer);
430
+ guard.release();
421
431
  signal?.removeEventListener("abort", abort);
422
432
  unsubscribe();
423
433
  session.dispose();
@@ -467,33 +477,6 @@ export class PiAgentRuntime implements AgentRuntime {
467
477
  return transcriptOf(await this.#sessions.messages(channel), limit);
468
478
  }
469
479
 
470
- /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
471
- async #thinkingLevel(
472
- key: ChannelKey,
473
- session: AgentSession,
474
- request: TurnRequest,
475
- agentModel?: AgentModelSettings,
476
- ): Promise<ThinkingLevel> {
477
- const setting = request.agent
478
- ? await useChosenAgentModel(
479
- session,
480
- this.#modelRuntime,
481
- request.agent.name,
482
- agentModel,
483
- )
484
- : AUTO_THINKING;
485
- if (setting !== AUTO_THINKING) {
486
- this.#sessions.judged.delete(key);
487
- return setting;
488
- }
489
- const level = await this.#options.effort.judge(request.text, {
490
- reply: lastReply(session.messages),
491
- level: this.#sessions.judged.get(key),
492
- });
493
- this.#sessions.judged.set(key, level);
494
- return level;
495
- }
496
-
497
480
  dispose(): void {
498
481
  this.#sessions.dispose();
499
482
  }
@@ -168,6 +168,7 @@ export function runtimePlugin(
168
168
  }),
169
169
  confirmations: heldActions,
170
170
  toolTiers: context.toolTiers,
171
+ stopping: () => context.queue.closed,
171
172
  get agents() {
172
173
  return agents();
173
174
  },
@@ -80,6 +80,14 @@ export interface PiAgentRuntimeOptions {
80
80
  toolTiers?: ToolTiers;
81
81
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
82
82
  turnTimeoutMs?: number;
83
+ /**
84
+ * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
85
+ * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
86
+ * abort would otherwise hold the turn, and the shutdown drain, for ever.
87
+ */
88
+ turnAbortGraceMs?: number;
89
+ /** Whether the host is shutting down: a turn or a task is then refused before it starts. */
90
+ stopping?: () => boolean;
83
91
  /**
84
92
  * Whether a turn given a place for interim posts shows the text it writes before its final
85
93
  * answer as it goes; default "on". "off" posts only the final reply.
@@ -33,13 +33,22 @@ export class SteerableRun {
33
33
  readonly #canSteer: () => boolean;
34
34
  /** Steered prompts in arrival order, each with its images. */
35
35
  readonly #steers: { text: string; images: PromptImages }[] = [];
36
+ readonly #abort: () => void;
36
37
  #stopped = false;
37
38
  #done = false;
38
39
 
39
- /** `canSteer` is asked at each steer: the turn is the owner's and holds no actions. */
40
- constructor(session: SteeringSession, canSteer: () => boolean) {
40
+ /**
41
+ * `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
42
+ * ends the session's run when the turn is stopped; by default the session's own abort.
43
+ */
44
+ constructor(
45
+ session: SteeringSession,
46
+ canSteer: () => boolean,
47
+ abort: () => void = () => void session.abort(),
48
+ ) {
41
49
  this.#session = session;
42
50
  this.#canSteer = canSteer;
51
+ this.#abort = abort;
43
52
  }
44
53
 
45
54
  get stopped(): boolean {
@@ -106,7 +115,7 @@ export class SteerableRun {
106
115
  if (this.#done) return false;
107
116
  this.#stopped = true;
108
117
  this.#session.clearQueue();
109
- void this.#session.abort();
118
+ this.#abort();
110
119
  return true;
111
120
  }
112
121
 
@@ -0,0 +1,47 @@
1
+ import type {
2
+ AgentSession,
3
+ ModelRuntime,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ChannelKey } from "../domain/conversation.ts";
6
+ import type { TurnRequest } from "../domain/ports.ts";
7
+ import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
8
+ import {
9
+ type AgentModelSettings,
10
+ useChosenAgentModel,
11
+ } from "./bridge-guard.ts";
12
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
13
+ import { lastReply } from "./turn-answer.ts";
14
+
15
+ /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
16
+ export async function thinkingLevel(
17
+ parts: {
18
+ modelRuntime: ModelRuntime;
19
+ effort: PiAgentRuntimeOptions["effort"];
20
+ /** The level last judged for each conversation. */
21
+ judged: Map<ChannelKey, ThinkingLevel>;
22
+ },
23
+ key: ChannelKey,
24
+ session: AgentSession,
25
+ request: TurnRequest,
26
+ agentModel?: AgentModelSettings,
27
+ ): Promise<ThinkingLevel> {
28
+ const { modelRuntime, effort, judged } = parts;
29
+ const setting = request.agent
30
+ ? await useChosenAgentModel(
31
+ session,
32
+ modelRuntime,
33
+ request.agent.name,
34
+ agentModel,
35
+ )
36
+ : AUTO_THINKING;
37
+ if (setting !== AUTO_THINKING) {
38
+ judged.delete(key);
39
+ return setting;
40
+ }
41
+ const level = await effort.judge(request.text, {
42
+ reply: lastReply(session.messages),
43
+ level: judged.get(key),
44
+ });
45
+ judged.set(key, level);
46
+ return level;
47
+ }
@@ -0,0 +1,63 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { TurnRequest } from "../domain/ports.ts";
4
+ import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
5
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
6
+
7
+ /**
8
+ * What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
9
+ * the conversation's context usage and held actions are recorded, and the turn is logged. A
10
+ * session disposed of under a turn that would not stop has no usage to read.
11
+ */
12
+ export async function wrapUpTurn(parts: {
13
+ key: ChannelKey;
14
+ request: TurnRequest;
15
+ session: AgentSession;
16
+ gate: ConfirmationGate;
17
+ toolCalls: readonly string[];
18
+ options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
19
+ recordUsage: (usage: {
20
+ tokens: number | null;
21
+ contextWindow: number;
22
+ }) => void;
23
+ }): Promise<void> {
24
+ const { key, request, session, gate, toolCalls, options, recordUsage } =
25
+ parts;
26
+ const { logger } = options;
27
+ gate.endTurn();
28
+ try {
29
+ const usage = session.getContextUsage();
30
+ if (usage)
31
+ recordUsage({
32
+ tokens: usage.tokens,
33
+ contextWindow: usage.contextWindow,
34
+ });
35
+ } catch (error) {
36
+ logger.warn({ channel: key, err: error }, "context usage not read");
37
+ }
38
+ const held = gate.pending();
39
+ await options.confirmations
40
+ .save(key, held)
41
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
42
+ .catch((error: unknown) =>
43
+ logger.error({ channel: key, err: error }, "held actions not saved"),
44
+ );
45
+ if (held)
46
+ logger.info(
47
+ { channel: key, held: held.calls.map((call) => call.action) },
48
+ "actions held for confirmation",
49
+ );
50
+ logger.info(
51
+ {
52
+ channel: key,
53
+ selection: request.selection.id,
54
+ toolCalls,
55
+ thinking: session.thinkingLevel,
56
+ // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
57
+ ...(request.agent
58
+ ? { model: `${session.model?.provider}/${session.model?.id}` }
59
+ : {}),
60
+ },
61
+ "turn finished",
62
+ );
63
+ }
package/src/index.ts CHANGED
@@ -113,6 +113,7 @@ export type { TurnConversation, TurnRequest } from "./core/domain/ports.ts";
113
113
  export type { TurnProgress } from "./core/domain/progress.ts";
114
114
  export type { DrainOptions } from "./core/drain.ts";
115
115
  export {
116
+ HostStoppingError,
116
117
  JudgeError,
117
118
  MigrationError,
118
119
  NotLinkedError,