pi-roundtable 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ import type { Logger } from "../log.ts";
2
+ import { AlertThrottle } from "./alert-throttle.ts";
3
+
4
+ const DEFAULT_WINDOW_MS = 60 * 60_000;
5
+ const DEFAULT_KIND_CAP = 8;
6
+
7
+ export interface OpsAlertsOptions {
8
+ /**
9
+ * Posts one alert's text where the people who run the host read it. Resolves true when it was
10
+ * posted and false when the sink chose not to (it logs why itself); a throw is a failed post.
11
+ * Either way the key and the slot are given back, so the next raise tries again. Absent: alerts are
12
+ * only recorded and logged, the key still held so the log has one line per window.
13
+ */
14
+ sink?: (text: string) => Promise<boolean>;
15
+ logger: Logger;
16
+ /** What `faults()` calls each kind; a kind not listed is called by itself. */
17
+ kinds?: Readonly<Record<string, string>>;
18
+ /** Default one hour. */
19
+ windowMs?: number;
20
+ /** The most alerts of one kind posted inside a window, whatever their keys; default 8. */
21
+ kindCap?: number;
22
+ now?: () => number;
23
+ }
24
+
25
+ export interface OpsRaiseOptions {
26
+ /** A short code (letters, digits, `_`, `:`) shown in `faults()`; never a name or anyone's words. */
27
+ code?: string;
28
+ /** News, not a fault: posted at most once per key, not counted against the kind's cap, not listed. */
29
+ info?: boolean;
30
+ }
31
+
32
+ /** The last fault of one kind since the start, and how often it came. */
33
+ export interface OpsAlertKind {
34
+ kind: string;
35
+ name: string;
36
+ at: number;
37
+ code: string | undefined;
38
+ /** Raised since the start, posted or not. */
39
+ raised: number;
40
+ /** Of those, held back because the key or the kind had been raised inside the window. */
41
+ suppressed: number;
42
+ }
43
+
44
+ /** The kind of an alert is the word before the first colon of its key. */
45
+ function kindOf(key: string): string {
46
+ const colon = key.indexOf(":");
47
+ return colon === -1 ? key : key.slice(0, colon);
48
+ }
49
+
50
+ /**
51
+ * Short alerts for the people who run a host. Every alert has a key; its kind is the key's word
52
+ * before the first colon. A key is posted at most once a window and a kind at most `kindCap` times,
53
+ * so a fault that touches everything cannot flood the sink; what is held back is counted, never
54
+ * summed up in a post. An alert is posted when it is raised, not queued. A failed post is logged by
55
+ * the error's name and never thrown.
56
+ */
57
+ export class OpsAlerts {
58
+ readonly #options: OpsAlertsOptions;
59
+ readonly #throttle: AlertThrottle;
60
+ readonly #kinds = new Map<string, OpsAlertKind>();
61
+
62
+ constructor(options: OpsAlertsOptions) {
63
+ this.#options = options;
64
+ this.#throttle = new AlertThrottle({
65
+ windowMs: options.windowMs ?? DEFAULT_WINDOW_MS,
66
+ groupCap: options.kindCap ?? DEFAULT_KIND_CAP,
67
+ now: options.now ?? Date.now,
68
+ });
69
+ }
70
+
71
+ /** Whether alerts reach a sink. */
72
+ get posts(): boolean {
73
+ return this.#options.sink !== undefined;
74
+ }
75
+
76
+ /** The last fault of each kind, newest first; memory only. */
77
+ faults(): OpsAlertKind[] {
78
+ return [...this.#kinds.values()].sort((a, b) => b.at - a.at);
79
+ }
80
+
81
+ /** Posts the alert unless its key or its kind is held inside the window; whether it was posted. */
82
+ async raise(
83
+ key: string,
84
+ text: string,
85
+ options: OpsRaiseOptions = {},
86
+ ): Promise<boolean> {
87
+ const { sink, logger } = this.#options;
88
+ const kind = kindOf(key);
89
+ const info = options.info === true;
90
+ const admission = this.#throttle.admit(key, kind, info);
91
+ if (!info) this.#record(kind, options.code, admission !== "open");
92
+ if (admission !== "open") return false;
93
+ // Taken before the post, so two alerts raised together for one key post once.
94
+ this.#throttle.hold(key);
95
+ if (sink === undefined) {
96
+ // Nowhere to post: the key stays held so that the log has one line per window, not one per event.
97
+ logger.warn(
98
+ { kind, ...(options.code ? { code: options.code } : {}) },
99
+ "an ops alert was raised and there is no sink to post it to",
100
+ );
101
+ return false;
102
+ }
103
+ if (!info) this.#throttle.slot(kind);
104
+ const undo = () => this.#throttle.release(key, info ? undefined : kind);
105
+ try {
106
+ if (await sink(text)) return true;
107
+ undo();
108
+ return false;
109
+ } catch (error) {
110
+ // Asked again by the next event instead of staying silent for the whole window.
111
+ undo();
112
+ logger.warn(
113
+ { error: error instanceof Error ? error.name : "unknown" },
114
+ "an ops alert could not be posted",
115
+ );
116
+ return false;
117
+ }
118
+ }
119
+
120
+ #record(kind: string, code: string | undefined, suppressed: boolean): void {
121
+ const before = this.#kinds.get(kind);
122
+ const { kinds, now } = this.#options;
123
+ this.#kinds.set(kind, {
124
+ kind,
125
+ name: kinds && Object.hasOwn(kinds, kind) ? (kinds[kind] ?? kind) : kind,
126
+ at: (now ?? Date.now)(),
127
+ code,
128
+ raised: (before?.raised ?? 0) + 1,
129
+ suppressed: (before?.suppressed ?? 0) + (suppressed ? 1 : 0),
130
+ });
131
+ }
132
+ }
@@ -1,6 +1,7 @@
1
1
  import type { HoldRefusal } from "../domain/progress.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
3
  import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
4
+ import { turnBudget } from "./turn-budget.ts";
4
5
 
5
6
  /**
6
7
  * A run's way to ask the owner while it works. A turn they started or a report turn binds the
@@ -101,22 +102,13 @@ export function promptSlot(): PromptSlot {
101
102
 
102
103
  /**
103
104
  * Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
104
- * owner's cards; returns a function that cancels it.
105
+ * owner's cards; returns a function that cancels it. It is a turn budget with nothing to renew,
106
+ * so it looks again at a run that waits on a card no oftener than `turnBudget` does.
105
107
  */
106
108
  export function workTimeout(
107
109
  ms: number,
108
110
  slot: Pick<PromptSlot, "waitedMs">,
109
111
  expire: () => void,
110
112
  ): () => void {
111
- const started = Date.now();
112
- let timer: ReturnType<typeof setTimeout> | undefined;
113
- const arm = (delay: number) => {
114
- timer = setTimeout(() => {
115
- const left = ms - (Date.now() - started - slot.waitedMs());
116
- if (left > 0) return arm(left);
117
- expire();
118
- }, delay);
119
- };
120
- arm(ms);
121
- return () => clearTimeout(timer);
113
+ return turnBudget(ms, ms, slot, () => expire()).cancel;
122
114
  }
@@ -97,7 +97,6 @@ export function runtimePlugin(
97
97
  ) => Promise<HeldActionStore> = (context) =>
98
98
  PendingConfirmationStore.attach(context.database()),
99
99
  ): RoundtablePlugin {
100
- let runtime: AgentRuntime | undefined;
101
100
  let identity: IdentityService | undefined;
102
101
  let warn: ((message: string) => void) | undefined;
103
102
  // Admission is a startup risk warning, not proof that this conversation holds private history.
@@ -119,7 +118,6 @@ export function runtimePlugin(
119
118
  `${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
120
119
  );
121
120
  }
122
- await runtime?.preflight?.();
123
121
  },
124
122
  setup: async (context) => {
125
123
  const { logger, providers, services } = context;
@@ -194,7 +192,6 @@ export function runtimePlugin(
194
192
  ? { interimPrimaryChars: options.interimPrimaryChars }
195
193
  : {}),
196
194
  });
197
- runtime = running;
198
195
  context.services.provide(RUNTIME, running);
199
196
  return {
200
197
  services: [{ name: "runtime", stop: () => running.dispose?.() }],
@@ -31,6 +31,15 @@ const REAL_CLOCK: BudgetClock = {
31
31
  },
32
32
  };
33
33
 
34
+ /**
35
+ * The shortest wait before a budget looks again at a turn that still has time. While a card is
36
+ * open the turn's work time stands still, so a timer that fires with a few milliseconds left would
37
+ * otherwise re-arm for those milliseconds, again and again, for as long as the card waits. The
38
+ * floor bounds that to one look a second; the cost is that a limit that waiting on a card pushed
39
+ * back, by less than the floor, may fire up to this much late.
40
+ */
41
+ const REARM_FLOOR_MS = 1_000;
42
+
34
43
  /**
35
44
  * Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
36
45
  * `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
@@ -54,7 +63,7 @@ export function turnBudget(
54
63
  const arm = (delay: number) => {
55
64
  cancelTimer = clock.schedule(() => {
56
65
  const left = deadline - worked();
57
- if (left > 0) return arm(left);
66
+ if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
58
67
  expired = true;
59
68
  expire(deadline >= maxMs ? "turnMax" : "turn");
60
69
  }, delay);