opencode-courier 0.6.0 → 0.7.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.
@@ -7,6 +7,8 @@ declare const Summary: Schema.Struct<{
7
7
  readonly waiting: Schema.Int;
8
8
  /** Not running: done, or waiting for a message. */
9
9
  readonly idle: Schema.Int;
10
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
11
+ readonly stalled: Schema.optionalKey<Schema.Int>;
10
12
  readonly groups: Schema.$Array<Schema.Struct<{
11
13
  readonly name: Schema.String;
12
14
  readonly reported: Schema.Int;
@@ -33,8 +35,13 @@ declare const Row: Schema.Struct<{
33
35
  readonly title: Schema.String;
34
36
  /** A child of the panel's session is at depth 1. */
35
37
  readonly depth: Schema.Int;
36
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
37
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
38
+ /**
39
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
40
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
41
+ */
42
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
43
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
44
+ readonly stalled: Schema.optionalKey<Schema.Number>;
38
45
  /** How its last turn ended. */
39
46
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
40
47
  /** Set when courier_stop stopped it since its last prompt. */
@@ -81,8 +88,13 @@ declare const Tree: Schema.Struct<{
81
88
  readonly title: Schema.String;
82
89
  /** A child of the panel's session is at depth 1. */
83
90
  readonly depth: Schema.Int;
84
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
85
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
91
+ /**
92
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
93
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
94
+ */
95
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
96
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
97
+ readonly stalled: Schema.optionalKey<Schema.Number>;
86
98
  /** How its last turn ended. */
87
99
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
88
100
  /** Set when courier_stop stopped it since its last prompt. */
@@ -123,6 +135,8 @@ declare const Tree: Schema.Struct<{
123
135
  }>>;
124
136
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
125
137
  readonly truncated: Schema.optionalKey<Schema.Int>;
138
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
139
+ readonly stalled: Schema.optionalKey<Schema.Number>;
126
140
  }>;
127
141
  export type Row = typeof Row.Type;
128
142
  export type Tree = typeof Tree.Type;
@@ -159,22 +173,24 @@ export declare const treeRpc: {
159
173
  }>;
160
174
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
161
175
  readonly running: number;
162
- readonly waiting: number;
163
176
  readonly idle: number;
164
177
  readonly groups: readonly {
165
178
  readonly name: string;
166
179
  readonly reported: number;
167
180
  readonly members: number;
168
181
  }[];
182
+ readonly waiting: number;
183
+ readonly stalled?: number | undefined;
169
184
  }, {
170
185
  readonly running: number;
171
- readonly waiting: number;
172
186
  readonly idle: number;
173
187
  readonly groups: readonly {
174
188
  readonly name: string;
175
189
  readonly reported: number;
176
190
  readonly members: number;
177
191
  }[];
192
+ readonly waiting: number;
193
+ readonly stalled?: number | undefined;
178
194
  }> & Schema.Struct<{
179
195
  /** Running, and waiting on no request or form. */
180
196
  readonly running: Schema.Int;
@@ -182,6 +198,8 @@ export declare const treeRpc: {
182
198
  readonly waiting: Schema.Int;
183
199
  /** Not running: done, or waiting for a message. */
184
200
  readonly idle: Schema.Int;
201
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
202
+ readonly stalled: Schema.optionalKey<Schema.Int>;
185
203
  readonly groups: Schema.$Array<Schema.Struct<{
186
204
  readonly name: Schema.String;
187
205
  readonly reported: Schema.Int;
@@ -205,7 +223,7 @@ export declare const treeRpc: {
205
223
  readonly depth: number;
206
224
  readonly created: number;
207
225
  readonly isolated: boolean;
208
- readonly state: "running" | "idle" | "gone" | "waiting";
226
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
209
227
  readonly waits: readonly ({
210
228
  readonly type: "permission";
211
229
  readonly action: string;
@@ -228,6 +246,7 @@ export declare const treeRpc: {
228
246
  readonly state: "reported" | "ended" | "owed";
229
247
  readonly status?: string | undefined;
230
248
  } | undefined;
249
+ readonly stalled?: number | undefined;
231
250
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
232
251
  readonly tool?: {
233
252
  readonly name: string;
@@ -235,6 +254,7 @@ export declare const treeRpc: {
235
254
  } | undefined;
236
255
  readonly cost?: number | undefined;
237
256
  }[];
257
+ readonly stalled?: number | undefined;
238
258
  readonly truncated?: number | undefined;
239
259
  }, {
240
260
  readonly rows: readonly {
@@ -244,7 +264,7 @@ export declare const treeRpc: {
244
264
  readonly depth: number;
245
265
  readonly created: number;
246
266
  readonly isolated: boolean;
247
- readonly state: "running" | "idle" | "gone" | "waiting";
267
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
248
268
  readonly waits: readonly ({
249
269
  readonly type: "permission";
250
270
  readonly action: string;
@@ -267,6 +287,7 @@ export declare const treeRpc: {
267
287
  readonly state: "reported" | "ended" | "owed";
268
288
  readonly status?: string | undefined;
269
289
  } | undefined;
290
+ readonly stalled?: number | undefined;
270
291
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
271
292
  readonly tool?: {
272
293
  readonly name: string;
@@ -274,6 +295,7 @@ export declare const treeRpc: {
274
295
  } | undefined;
275
296
  readonly cost?: number | undefined;
276
297
  }[];
298
+ readonly stalled?: number | undefined;
277
299
  readonly truncated?: number | undefined;
278
300
  }> & Schema.Struct<{
279
301
  readonly rows: Schema.$Array<Schema.Struct<{
@@ -282,8 +304,13 @@ export declare const treeRpc: {
282
304
  readonly title: Schema.String;
283
305
  /** A child of the panel's session is at depth 1. */
284
306
  readonly depth: Schema.Int;
285
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
286
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
307
+ /**
308
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
309
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
310
+ */
311
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
312
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
313
+ readonly stalled: Schema.optionalKey<Schema.Number>;
287
314
  /** How its last turn ended. */
288
315
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
289
316
  /** Set when courier_stop stopped it since its last prompt. */
@@ -324,6 +351,8 @@ export declare const treeRpc: {
324
351
  }>>;
325
352
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
326
353
  readonly truncated: Schema.optionalKey<Schema.Int>;
354
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
355
+ readonly stalled: Schema.optionalKey<Schema.Number>;
327
356
  }>;
328
357
  };
329
358
  /** Stops `sessionID` and everything under it, as courier_stop called from `by`, which must be above it. */
package/dist/tree/rpc.js CHANGED
@@ -15,6 +15,8 @@ const Summary = Schema.Struct({
15
15
  waiting: Count,
16
16
  /** Not running: done, or waiting for a message. */
17
17
  idle: Count,
18
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
19
+ stalled: Schema.optionalKey(Count),
18
20
  groups: Schema.Array(Group),
19
21
  });
20
22
  /** A request a session waits on until the person answers it: a permission request, its first question, or a form it shows. */
@@ -30,8 +32,13 @@ const Row = Schema.Struct({
30
32
  title: Schema.String,
31
33
  /** A child of the panel's session is at depth 1. */
32
34
  depth: Count,
33
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
34
- state: Schema.Literals(["running", "waiting", "idle", "gone"]),
35
+ /**
36
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
37
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
38
+ */
39
+ state: Schema.Literals(["running", "waiting", "idle", "stalled", "gone"]),
40
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
41
+ stalled: Schema.optionalKey(Schema.Number),
35
42
  /** How its last turn ended. */
36
43
  outcome: Schema.optionalKey(Schema.Literals(["succeeded", "failed", "interrupted"])),
37
44
  /** Set when courier_stop stopped it since its last prompt. */
@@ -55,6 +62,8 @@ const Tree = Schema.Struct({
55
62
  rows: Schema.Array(Row),
56
63
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
57
64
  truncated: Schema.optionalKey(Count),
65
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
66
+ stalled: Schema.optionalKey(Schema.Number),
58
67
  });
59
68
  /** What a stop did to each session, deepest first, as courier_stop reports it. */
60
69
  const Stopped = Schema.Struct({
package/dist/tree/stop.js CHANGED
@@ -4,6 +4,7 @@ import { cleanup } from "./cleanup.js";
4
4
  import { childStore } from "./store.js";
5
5
  import { subtree } from "./subtree.js";
6
6
  import { stoppedNotice } from "./text.js";
7
+ import { forgetWakes } from "./wake.js";
7
8
  /** Rounds of looking again for sessions started while others were being stopped. */
8
9
  const ROUNDS = 3;
9
10
  /**
@@ -62,6 +63,7 @@ async function silence(ports, sessionID, cancelled) {
62
63
  catch (error) {
63
64
  log(`courier_stop: could not cancel the messages scheduled for ${sessionID}: ${String(error)}`);
64
65
  }
66
+ await forgetWakes(ports.courier, sessionID).catch((error) => log(`courier_stop: could not drop the wake watch of ${sessionID}: ${String(error)}`));
65
67
  }
66
68
  async function interrupt(ports, { entry, depth }) {
67
69
  const { sessionID, title } = entry;
@@ -1,5 +1,5 @@
1
1
  import { type Storage } from "../platform/storage.js";
2
- import type { ChildStore, GroupStore } from "./ports.js";
2
+ import type { ChildStore, GroupStore, WakeStore } from "./ports.js";
3
3
  /**
4
4
  * What the reverse index holds for a child: the sessions above it, its parent first and the top
5
5
  * session, which courier_spawn did not start, last; each is the parent of the one before it.
@@ -18,7 +18,10 @@ export declare const memberKey: (parentID: string, group: string, sessionID: str
18
18
  /** The key prefix of a parent's groups, and of one group's members. */
19
19
  export declare const groupsPrefix: (parentID: string) => string;
20
20
  export declare const groupPrefix: (parentID: string, group: string) => string;
21
+ export declare const wakeKey: (sessionID: string, inboxID: string) => string;
21
22
  /** The `ChildStore` over the plugin's storage. */
22
23
  export declare function childStore(storage: Storage): ChildStore;
23
24
  /** The `GroupStore` over the plugin's storage. */
24
25
  export declare function groupStore(storage: Storage): GroupStore;
26
+ /** The `WakeStore` over the plugin's storage. */
27
+ export declare function wakeStore(storage: Storage): WakeStore;
@@ -42,6 +42,12 @@ export const memberKey = (parentID, group, sessionID) => `${GROUP_PREFIX}${paren
42
42
  /** The key prefix of a parent's groups, and of one group's members. */
43
43
  export const groupsPrefix = (parentID) => `${GROUP_PREFIX}${parentID}/`;
44
44
  export const groupPrefix = (parentID, group) => `${groupsPrefix(parentID)}${group}/`;
45
+ /**
46
+ * The courier messages waiting in a session's inbox, each under `wake/<sessionID>/<inboxID>` until it is delivered, for
47
+ * the scheduler to wake a session whose wake was missed. docs/maintainers/architecture.md, § Storage.
48
+ */
49
+ const WAKE_PREFIX = "wake/";
50
+ export const wakeKey = (sessionID, inboxID) => `${WAKE_PREFIX}${sessionID}/${inboxID}`;
45
51
  function isReverse(value) {
46
52
  const ancestors = value?.ancestors;
47
53
  return Array.isArray(ancestors) && ancestors.length > 0 && ancestors.every((id) => typeof id === "string");
@@ -262,3 +268,48 @@ export function groupStore(storage) {
262
268
  },
263
269
  };
264
270
  }
271
+ /** A watched message as stored under its key's session and inbox id; one that cannot be read is left out. */
272
+ function readWake(sessionID, inboxID, value) {
273
+ const given = obj(value);
274
+ const at = num(given.at);
275
+ const text = str(given.text);
276
+ if (at === undefined || text === undefined || typeof given.running !== "boolean")
277
+ return undefined;
278
+ const optional = (field) => {
279
+ const time = num(given[field]);
280
+ return time === undefined ? {} : { [field]: time };
281
+ };
282
+ const description = str(given.description);
283
+ return {
284
+ sessionID,
285
+ inboxID,
286
+ at,
287
+ text,
288
+ ...(description !== undefined ? { description } : {}),
289
+ running: given.running,
290
+ ...optional("idleAt"),
291
+ ...optional("rewoken"),
292
+ ...optional("stalled"),
293
+ };
294
+ }
295
+ /** The `WakeStore` over the plugin's storage. */
296
+ export function wakeStore(storage) {
297
+ const under = async (prefix) => {
298
+ const entries = await scanEntries(storage, prefix);
299
+ return entries.flatMap(({ key, value }) => {
300
+ const parts = `${prefix.slice(WAKE_PREFIX.length)}${key}`.split("/");
301
+ const wake = parts.length === 2 ? readWake(parts[0], parts[1], value) : undefined;
302
+ return wake ? [wake] : [];
303
+ });
304
+ };
305
+ return {
306
+ async put({ sessionID, inboxID, ...wake }) {
307
+ await storage.set(wakeKey(sessionID, inboxID), { ...wake });
308
+ },
309
+ of: (sessionID) => under(`${WAKE_PREFIX}${sessionID}/`),
310
+ all: () => under(WAKE_PREFIX),
311
+ async remove(sessionID, inboxID) {
312
+ await storage.remove(wakeKey(sessionID, inboxID));
313
+ },
314
+ };
315
+ }
@@ -1,7 +1,7 @@
1
1
  import type { CourierPorts } from "./ports.js";
2
2
  import type { Summary } from "./rpc.js";
3
3
  /**
4
- * Every session started under a session, counted by what it is doing, and its open join groups, those of its own
5
- * children, with how far each has reported.
4
+ * Every session started under a session, counted by what it is doing (the session itself too, when it is stalled), and its
5
+ * open join groups, those of its own children, with how far each has reported.
6
6
  */
7
7
  export declare function summary(ports: CourierPorts, sessionID: string): Promise<Summary>;
@@ -2,26 +2,35 @@ import { busy } from "./domain.js";
2
2
  import { groupStandings } from "./group.js";
3
3
  import { groupStore } from "./store.js";
4
4
  import { lookUp } from "./subtree.js";
5
- /** What a session is doing: waiting on a request it made or a form it shows, running, or idle; undefined when OpenCode no longer knows it. */
6
- function activity({ info, pending, forms }) {
5
+ import { stalledAmong } from "./wake.js";
6
+ /**
7
+ * What a session is doing: waiting on a request it made or a form it shows, stalled, running, or idle; undefined when
8
+ * OpenCode no longer knows it. A message waiting in its inbox makes a stalled session look busy.
9
+ */
10
+ function activity({ info, pending, forms, entry }, stalled) {
7
11
  if (!info)
8
12
  return undefined;
9
13
  if (pending.length || forms.length)
10
14
  return "waiting";
15
+ if (stalled.has(entry.sessionID))
16
+ return "stalled";
11
17
  return busy(info.time) ? "running" : "idle";
12
18
  }
13
19
  /**
14
- * Every session started under a session, counted by what it is doing, and its open join groups, those of its own
15
- * children, with how far each has reported.
20
+ * Every session started under a session, counted by what it is doing (the session itself too, when it is stalled), and its
21
+ * open join groups, those of its own children, with how far each has reported.
16
22
  */
17
23
  export async function summary(ports, sessionID) {
18
24
  const [looked, standings] = await Promise.all([lookUp(ports, sessionID), groupStandings(groupStore(ports.storage), sessionID)]);
19
- const activities = looked.map(activity);
25
+ const stalled = await stalledAmong(ports, [sessionID, ...looked.map((one) => one.entry.sessionID)]);
26
+ const activities = looked.map((one) => activity(one, stalled));
20
27
  const count = (kind) => activities.filter((item) => item === kind).length;
28
+ const stalls = count("stalled") + (stalled.has(sessionID) ? 1 : 0);
21
29
  return {
22
30
  running: count("running"),
23
31
  waiting: count("waiting"),
24
32
  idle: count("idle"),
33
+ ...(stalls ? { stalled: stalls } : {}),
25
34
  groups: standings.map(({ name, reported, members }) => ({ name, reported, members })),
26
35
  };
27
36
  }
package/dist/tree/text.js CHANGED
@@ -47,7 +47,7 @@ function splitRule(parentID, depth, limits) {
47
47
  `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
48
48
  "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
49
49
  `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
50
- "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session.",
50
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task.",
51
51
  `- Say which in one line of your reply: ${SPLIT_LINE}.`,
52
52
  "- If you split, you orchestrate: end your turn while they work, check and integrate each part yourself when it " +
53
53
  `reports (never hand that checking to another session), then send ${parentID} one combined report.`,
@@ -34,11 +34,15 @@ export declare function waitLine(wait: Wait): string;
34
34
  export declare function statusLine(row: Row): string;
35
35
  /** A leaf's activity: how long it has run, its last tool call and how long ago, and its cost. */
36
36
  export declare function activityLine(row: Row, now: number): string;
37
- /** The lines a row shows under its title: an orchestrator's bar, a paused session's requests, or else a live leaf's activity. */
37
+ /** What a stalled session's row, or the panel of one, says of it: "a message waits in its inbox since 2m 05s, woken again in vain". */
38
+ export declare function stalledLine(since: number, now: number): string;
39
+ /** The lines a row shows under its title: an orchestrator's bar, a paused or stalled session's wait, or else a live leaf's activity. */
38
40
  export declare function detailLines(row: Row, rows: ReadonlyArray<Row>, now: number): string[];
41
+ /** How often the footer and the panel ask again with no event: the scheduler flags a stalled session without one. */
42
+ export declare const REREAD_MS = 15000;
39
43
  /**
40
44
  * Adds the tree's entry to the prompt's footer: what the sessions under the open session do, asked of the RPC after each
41
- * change; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting` with the open session.
45
+ * change and every `REREAD_MS`; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting`.
42
46
  */
43
47
  export declare function addTreeStatus(ctx: Context, onWaiting?: (sessionID: string) => void): () => void;
44
48
  /** The toast after a stop: how many sessions were interrupted, and how many scheduled messages were cancelled. */
@@ -0,0 +1,69 @@
1
+ import type { GroupPorts, Wake, WatchPorts } from "./ports.js";
2
+ /** How long a courier message may wait in an idle session's inbox before its wake counts as missed. */
3
+ export declare const WAKE_MS = 30000;
4
+ /** OpenCode's `session.inbox.enqueued`, with the item's payload, which carries the courier's metadata. */
5
+ export interface Enqueued {
6
+ readonly created?: number;
7
+ readonly data: {
8
+ readonly sessionID: string;
9
+ readonly inboxID: string;
10
+ readonly item: {
11
+ readonly type: string;
12
+ readonly payload?: {
13
+ readonly text?: unknown;
14
+ readonly description?: unknown;
15
+ readonly metadata?: {
16
+ readonly source?: unknown;
17
+ readonly resume?: unknown;
18
+ };
19
+ };
20
+ };
21
+ };
22
+ }
23
+ /** OpenCode's `session.inbox.delivered` and `session.inbox.cancelled`. */
24
+ export interface InboxSettled {
25
+ readonly data: {
26
+ readonly sessionID: string;
27
+ readonly inboxID: string;
28
+ };
29
+ }
30
+ /** OpenCode's `session.execution.*` events, which frame a session's busy period. */
31
+ export interface Execution {
32
+ readonly created?: number;
33
+ readonly data: {
34
+ readonly sessionID: string;
35
+ };
36
+ }
37
+ /**
38
+ * Watches a courier message enqueued for a session until it is delivered, noting whether the session runs.
39
+ * Returns whether it is watched, which one sent with `resume: false` is not.
40
+ */
41
+ export declare function watchWake(ports: WatchPorts, event: Enqueued): Promise<boolean>;
42
+ /** Stops watching a message once it is delivered or cancelled. */
43
+ export declare function settleWake(ports: Pick<WatchPorts, "storage">, event: InboxSettled): Promise<void>;
44
+ /** A turn started in a session: the messages waiting there are its to take. */
45
+ export declare function wakeStarted(ports: Pick<WatchPorts, "storage">, event: Execution): Promise<void>;
46
+ /**
47
+ * A session's turn ended: a message it took is no longer watched, as its delivery may have been missed by this
48
+ * subscription; one still waiting is watched as waiting in an idle session from now.
49
+ */
50
+ export declare function wakeEnded(ports: WatchPorts, event: Execution & {
51
+ readonly data: {
52
+ readonly reason?: string;
53
+ };
54
+ }): Promise<void>;
55
+ /** Forgets the messages watched in a session's inbox: it was deleted, or stopped, which nothing is to wake again. */
56
+ export declare function forgetWakes(ports: Pick<WatchPorts, "storage">, sessionID: string): Promise<void>;
57
+ /** The ports the scheduler's tick wakes sessions through: what every hub member's `later` ports have. */
58
+ export type WakePorts = GroupPorts & {
59
+ readonly now: () => number;
60
+ };
61
+ /**
62
+ * Sends once more, under its inbox id, each courier message an idle session has left waiting for `WAKE_MS`, and flags
63
+ * stalled one still waiting `WAKE_MS` later; run by the scheduler's owner, `claimed` keeping a tick alongside off it.
64
+ */
65
+ export declare function wakeMissed(ports: WakePorts, claimed: Set<string>): Promise<void>;
66
+ /** Whether a message has waited in an idle session past `WAKE_MS`, and since a wake again, if one was sent; a stalled one is done. */
67
+ export declare function missed(wake: Wake, now: number): boolean;
68
+ /** The sessions among `sessionIDs` flagged stalled, each with when its oldest stalled message was enqueued. */
69
+ export declare function stalledAmong(ports: Pick<WatchPorts, "storage">, sessionIDs: ReadonlyArray<string>): Promise<Map<string, number>>;
@@ -0,0 +1,122 @@
1
+ import { wakeStore } from "./store.js";
2
+ /** How long a courier message may wait in an idle session's inbox before its wake counts as missed. */
3
+ export const WAKE_MS = 30_000;
4
+ /** Whether a session's stored messages end with the marker of an ended turn; one with none yet is taken to run. */
5
+ const endsIdle = (messages) => messages.at(-1)?.type === "idle";
6
+ /**
7
+ * Watches a courier message enqueued for a session until it is delivered, noting whether the session runs.
8
+ * Returns whether it is watched, which one sent with `resume: false` is not.
9
+ */
10
+ export async function watchWake(ports, event) {
11
+ const { sessionID, inboxID, item } = event.data;
12
+ const payload = item.payload;
13
+ if (item.type !== "synthetic" || payload?.metadata?.source !== "courier" || typeof payload.text !== "string")
14
+ return false;
15
+ // Sent without a wake: it waits for the next turn, which nothing owes it.
16
+ if (payload.metadata.resume === false)
17
+ return false;
18
+ const at = event.created ?? ports.now();
19
+ // Read after the enqueue: a turn it started has stored messages since, and an unknown session is taken to run.
20
+ const idle = await ports.session.context({ sessionID }).then(endsIdle, () => false);
21
+ await wakeStore(ports.storage).put({
22
+ sessionID,
23
+ inboxID,
24
+ at,
25
+ text: payload.text,
26
+ ...(typeof payload.description === "string" ? { description: payload.description } : {}),
27
+ running: !idle,
28
+ ...(idle ? { idleAt: at } : {}),
29
+ });
30
+ return true;
31
+ }
32
+ /** Stops watching a message once it is delivered or cancelled. */
33
+ export async function settleWake(ports, event) {
34
+ await wakeStore(ports.storage).remove(event.data.sessionID, event.data.inboxID);
35
+ }
36
+ /** A turn started in a session: the messages waiting there are its to take. */
37
+ export async function wakeStarted(ports, event) {
38
+ const store = wakeStore(ports.storage);
39
+ const waiting = await store.of(event.data.sessionID);
40
+ await Promise.all(waiting.filter((wake) => !wake.running).map(({ idleAt: _, ...wake }) => store.put({ ...wake, running: true })));
41
+ }
42
+ /**
43
+ * A session's turn ended: a message it took is no longer watched, as its delivery may have been missed by this
44
+ * subscription; one still waiting is watched as waiting in an idle session from now.
45
+ */
46
+ export async function wakeEnded(ports, event) {
47
+ if (event.data.reason === "shutdown")
48
+ return;
49
+ const { sessionID } = event.data;
50
+ const store = wakeStore(ports.storage);
51
+ const waiting = await store.of(sessionID);
52
+ if (!waiting.length)
53
+ return;
54
+ const at = event.created ?? ports.now();
55
+ const stored = new Set((await ports.session.context({ sessionID }).catch(() => [])).map((message) => message.id));
56
+ await Promise.all(waiting.map((wake) => (stored.has(wake.inboxID) ? store.remove(sessionID, wake.inboxID) : store.put({ ...wake, running: false, idleAt: at }))));
57
+ }
58
+ /** Forgets the messages watched in a session's inbox: it was deleted, or stopped, which nothing is to wake again. */
59
+ export async function forgetWakes(ports, sessionID) {
60
+ const store = wakeStore(ports.storage);
61
+ await Promise.all((await store.of(sessionID)).map((wake) => store.remove(sessionID, wake.inboxID)));
62
+ }
63
+ /**
64
+ * Sends once more, under its inbox id, each courier message an idle session has left waiting for `WAKE_MS`, and flags
65
+ * stalled one still waiting `WAKE_MS` later; run by the scheduler's owner, `claimed` keeping a tick alongside off it.
66
+ */
67
+ export async function wakeMissed(ports, claimed) {
68
+ const store = wakeStore(ports.storage);
69
+ const now = ports.now();
70
+ const due = (await store.all()).filter((wake) => missed(wake, now) && !claimed.has(claimOf(wake)));
71
+ for (const wake of due)
72
+ claimed.add(claimOf(wake));
73
+ await Promise.all(due.map((wake) => wakeAgain(ports, store, wake, now).finally(() => claimed.delete(claimOf(wake)))));
74
+ }
75
+ const claimOf = (wake) => `wake ${wake.sessionID} ${wake.inboxID}`;
76
+ /** Whether a message has waited in an idle session past `WAKE_MS`, and since a wake again, if one was sent; a stalled one is done. */
77
+ export function missed(wake, now) {
78
+ if (wake.running || wake.stalled !== undefined)
79
+ return false;
80
+ const since = Math.max(wake.idleAt ?? wake.at, wake.rewoken ?? 0);
81
+ return now - since >= WAKE_MS;
82
+ }
83
+ async function wakeAgain(ports, store, wake, now) {
84
+ try {
85
+ // Re-read: the message may have been delivered, or the session woken, since the scan.
86
+ const current = (await store.of(wake.sessionID)).find((one) => one.inboxID === wake.inboxID);
87
+ if (!current || !missed(current, now))
88
+ return;
89
+ if (current.rewoken !== undefined) {
90
+ ports.log(`courier wake: ${wake.sessionID} has not taken message ${wake.inboxID} since it was woken again; flagged stalled`);
91
+ return await store.put({ ...current, stalled: now });
92
+ }
93
+ // Noted before the send: the turn it starts may take the message, and drop the watch, before the send returns.
94
+ await store.put({ ...current, rewoken: now });
95
+ await ports.session.synthetic({
96
+ sessionID: wake.sessionID,
97
+ id: wake.inboxID,
98
+ text: wake.text,
99
+ ...(wake.description !== undefined ? { description: wake.description } : {}),
100
+ metadata: { source: "courier", wake: "again" },
101
+ delivery: "steer",
102
+ }).catch(async (error) => {
103
+ await store.put(current);
104
+ throw error;
105
+ });
106
+ ports.log(`courier wake: ${wake.sessionID} left message ${wake.inboxID} waiting while idle; woken again`);
107
+ }
108
+ catch (error) {
109
+ ports.log(`courier wake: could not wake ${wake.sessionID} again for ${wake.inboxID}: ${String(error)}`);
110
+ }
111
+ }
112
+ /** The sessions among `sessionIDs` flagged stalled, each with when its oldest stalled message was enqueued. */
113
+ export async function stalledAmong(ports, sessionIDs) {
114
+ const wanted = new Set(sessionIDs);
115
+ const stalled = new Map();
116
+ for (const wake of await wakeStore(ports.storage).all()) {
117
+ if (wake.stalled === undefined || !wanted.has(wake.sessionID))
118
+ continue;
119
+ stalled.set(wake.sessionID, Math.min(stalled.get(wake.sessionID) ?? wake.at, wake.at));
120
+ }
121
+ return stalled;
122
+ }