@saadnvd1/agent-os 0.3.578 → 0.3.580

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.
@@ -4,43 +4,152 @@
4
4
  * orchestrator polls and calls; the other machine never reaches back.
5
5
  */
6
6
 
7
- import type { Session } from "../db";
7
+ import { db, type Session } from "../db";
8
8
  import {
9
+ HostApiError,
9
10
  cleanRemoteBlock,
10
11
  cleanRemoteText,
11
12
  hostApi,
13
+ hostLink,
12
14
  requireHostLink,
13
15
  } from "../hosts/remote-api";
14
16
  import type { Delivery } from "../bus/delivery";
17
+ import { wakeLine } from "../bus/format";
18
+ import { orchestratedStart } from "./held";
15
19
 
16
20
  const path = (id: string, leaf: string) =>
17
21
  `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
18
22
 
23
+ /**
24
+ * A task its orchestrator put on a linked machine, whether by moving it or
25
+ * by the queue placing it there: the one test every relayed call goes by.
26
+ * (held-replies.ts reads the same rule in SQL: started_by IS NOT NULL.)
27
+ */
28
+ export const isHeldRemote = (
29
+ s: Pick<Session, "started_by" | "task_prompt" | "host_id">
30
+ ): boolean => !!s.started_by && !!s.task_prompt && !!hostLink(s.host_id);
31
+
32
+ type Held = Pick<
33
+ Session,
34
+ "id" | "name" | "host_id" | "started_by" | "task_since" | "task_prompt"
35
+ >;
36
+
37
+ const ADOPT_RETRY_MS = 5 * 60 * 1000;
38
+ const STALE_MS = 24 * 60 * 60 * 1000;
39
+ const g = globalThis as unknown as { __agentosAdopted?: Map<string, number> };
40
+ const adopted = (g.__agentosAdopted ??= new Map());
41
+
42
+ /**
43
+ * A task the queue placed before machines held them is unheld there, so its
44
+ * calls are refused as "No such held task". Asks the machine to hold it for
45
+ * this one, then sends what was left undelivered. False when it can't.
46
+ */
47
+ async function adoptHeld(task: Held): Promise<boolean> {
48
+ const now = Date.now();
49
+ // Only failures are remembered, and only for the retry window.
50
+ for (const [id, at] of adopted)
51
+ if (now - at >= ADOPT_RETRY_MS) adopted.delete(id);
52
+ if (adopted.has(task.id)) return false;
53
+ adopted.set(task.id, now);
54
+ try {
55
+ const link = requireHostLink(task.host_id);
56
+ await hostApi(link, path(task.id, "hold"), {
57
+ body: {
58
+ orchestrated: orchestratedStart(
59
+ task.started_by!,
60
+ task.task_since ?? null
61
+ ),
62
+ },
63
+ timeout: 20000,
64
+ });
65
+ } catch {
66
+ return false;
67
+ }
68
+ adopted.delete(task.id);
69
+ await flushUndelivered(task);
70
+ return true;
71
+ }
72
+
73
+ /** The call, held first if that machine says it isn't, once. */
74
+ async function withHold<T>(task: Held, call: () => Promise<T>): Promise<T> {
75
+ try {
76
+ return await call();
77
+ } catch (error) {
78
+ if (!(error instanceof HostApiError) || error.status !== 404) throw error;
79
+ if (!(await adoptHeld(task))) throw error;
80
+ return call();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Messages stored for the task while it couldn't be reached: sent now, each
86
+ * keyed by its stored id so one that did land isn't typed twice. Ones older
87
+ * than a day are marked read, not replayed into a later context.
88
+ */
89
+ export async function flushUndelivered(
90
+ task: Pick<
91
+ Session,
92
+ "id" | "host_id" | "started_by" | "task_since" | "task_prompt" | "name"
93
+ >
94
+ ): Promise<void> {
95
+ const rows = db
96
+ .prepare(
97
+ `SELECT id, from_id, from_name, body, created_at FROM bus_messages
98
+ WHERE to_id = ? AND delivered_at IS NULL AND read_at IS NULL
99
+ AND held_pause_id IS NULL ORDER BY id`
100
+ )
101
+ .all(task.id) as {
102
+ id: number;
103
+ from_id: string | null;
104
+ from_name: string;
105
+ body: string;
106
+ created_at: string;
107
+ }[];
108
+ for (const m of rows) {
109
+ const age = Date.now() - Date.parse(`${m.created_at.replace(" ", "T")}Z`);
110
+ if (age > STALE_MS) {
111
+ db.prepare(
112
+ `UPDATE bus_messages SET read_at = datetime('now') WHERE id = ?`
113
+ ).run(m.id);
114
+ continue;
115
+ }
116
+ const line = wakeLine({
117
+ fromName: m.from_name,
118
+ fromId: m.from_id,
119
+ body: m.body,
120
+ });
121
+ const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
122
+ if (res.state === "failed") return;
123
+ db.prepare(
124
+ `UPDATE bus_messages SET delivered_at = datetime('now') WHERE id = ?`
125
+ ).run(m.id);
126
+ }
127
+ }
128
+
19
129
  /**
20
130
  * Delivered by that machine's AgentOS as a message from the orchestrator,
21
131
  * never as the user's own. Keyed by the stored message, so a retry that
22
132
  * already landed is not typed twice.
23
133
  */
24
134
  export async function relayToHeldTask(
25
- to: Session,
135
+ to: Held,
26
136
  line: string,
27
137
  fromName: string,
28
- messageId?: number
138
+ messageId?: number,
139
+ heal = true
29
140
  ): Promise<Delivery> {
30
141
  try {
31
142
  const link = requireHostLink(to.host_id);
32
- const { state } = await hostApi<{ state?: unknown }>(
33
- link,
34
- path(to.id, "message"),
35
- {
143
+ const send = () =>
144
+ hostApi<{ state?: unknown }>(link, path(to.id, "message"), {
36
145
  body: {
37
146
  id: messageId ? `bus-${messageId}` : undefined,
38
147
  line,
39
148
  from: fromName,
40
149
  },
41
150
  timeout: 20000,
42
- }
43
- );
151
+ });
152
+ const { state } = await (heal ? withHold(to, send) : send());
44
153
  return { state: state === "queued" ? "queued" : "delivered" };
45
154
  } catch (error) {
46
155
  const why = error instanceof Error ? error.message : String(error);
@@ -50,14 +159,16 @@ export async function relayToHeldTask(
50
159
 
51
160
  /** A held chat task's last lines of conversation, as that machine reads them. */
52
161
  export async function heldChatTail(
53
- task: Session,
162
+ task: Held,
54
163
  lines: number
55
164
  ): Promise<string | null> {
56
165
  const link = requireHostLink(task.host_id);
57
- const { text } = await hostApi<{ text?: unknown }>(
58
- link,
59
- `${path(task.id, "chat-tail")}?lines=${lines}`,
60
- { timeout: 10000 }
166
+ const { text } = await withHold(task, () =>
167
+ hostApi<{ text?: unknown }>(
168
+ link,
169
+ `${path(task.id, "chat-tail")}?lines=${lines}`,
170
+ { timeout: 10000 }
171
+ )
61
172
  ).catch(() => ({ text: undefined }));
62
173
  // Whole lines, as a local read has them (read.ts caps and trims them).
63
174
  return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
@@ -69,13 +180,15 @@ export async function heldChatTail(
69
180
  * which callers treat as unread, never as clear.
70
181
  */
71
182
  export async function heldChatFacts(
72
- task: Pick<Session, "id" | "host_id">
183
+ task: Held
73
184
  ): Promise<{ blocked: string | null; turn: string | null } | null> {
74
185
  const link = requireHostLink(task.host_id);
75
- const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
76
- link,
77
- path(task.id, "chat-facts"),
78
- { timeout: 10000 }
186
+ const res = await withHold(task, () =>
187
+ hostApi<{ blocked?: unknown; turn?: unknown }>(
188
+ link,
189
+ path(task.id, "chat-facts"),
190
+ { timeout: 10000 }
191
+ )
79
192
  ).catch(() => null);
80
193
  if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
81
194
  return null;
@@ -87,23 +200,21 @@ export async function heldChatFacts(
87
200
  }
88
201
 
89
202
  /** Stops its agent there; its conversation and worktree are kept. */
90
- export async function stopHeldTask(
91
- task: Pick<Session, "id" | "name" | "host_id">
92
- ): Promise<string> {
203
+ export async function stopHeldTask(task: Held): Promise<string> {
93
204
  const link = requireHostLink(task.host_id);
94
- const { text } = await hostApi<{ text?: unknown }>(
95
- link,
96
- path(task.id, "stop"),
97
- {
205
+ const { text } = await withHold(task, () =>
206
+ hostApi<{ text?: unknown }>(link, path(task.id, "stop"), {
98
207
  body: {},
99
208
  timeout: 30000,
100
- }
209
+ })
101
210
  );
102
211
  return cleanRemoteText(text) || `Stopped ${task.name}.`;
103
212
  }
104
213
 
105
214
  /** Done there: its agent stops and its worktree goes. Never a merge. */
106
- export async function doneHeldTask(task: Session): Promise<void> {
215
+ export async function doneHeldTask(task: Held): Promise<void> {
107
216
  const link = requireHostLink(task.host_id);
108
- await hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 });
217
+ await withHold(task, () =>
218
+ hostApi(link, path(task.id, "done"), { body: {}, timeout: 120000 })
219
+ );
109
220
  }
package/lib/tasks/held.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * a green CI, past the gates its orchestrator judges it by.
6
6
  */
7
7
 
8
+ import os from "os";
8
9
  import { db, type Session } from "../db";
10
+ import type { OrchestratedBundle } from "./move-bundle";
9
11
 
10
12
  export function holderRefusal(
11
13
  session: Pick<Session, "held_by_device" | "held_from">,
@@ -26,3 +28,107 @@ export function heldRefusal(
26
28
  .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
27
29
  return session ? holderRefusal(session, device) : null;
28
30
  }
31
+
32
+ /** Holds a task here for the paired device that sent it, and the orchestrator there. */
33
+ export function holdFor(
34
+ id: string,
35
+ holder: string,
36
+ held: {
37
+ heldFrom: string;
38
+ orchestrator?: { id: string; name: string };
39
+ since?: string | null;
40
+ }
41
+ ): void {
42
+ db.prepare(
43
+ `UPDATE sessions SET held_by_device = ?, held_from = ?, held_for = ?, held_for_name = ?,
44
+ task_since = COALESCE(?, task_since) WHERE id = ?`
45
+ ).run(
46
+ holder,
47
+ held.heldFrom,
48
+ held.orchestrator?.id ?? null,
49
+ held.orchestrator?.name ?? null,
50
+ held.since ?? null,
51
+ id
52
+ );
53
+ }
54
+
55
+ /**
56
+ * What a linked machine needs to hold a task this machine's orchestrator
57
+ * starts there: the same description a move carries, minus work already done.
58
+ */
59
+ export function orchestratedStart(
60
+ startedBy: string,
61
+ since: string | null = new Date().toISOString()
62
+ ): OrchestratedBundle {
63
+ const o = db
64
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
65
+ .get(startedBy) as { name: string } | undefined;
66
+ return {
67
+ heldFrom: os.hostname(),
68
+ since,
69
+ modelProvider: null,
70
+ modelOverrides: null,
71
+ planPhase: null,
72
+ planFile: null,
73
+ orchestrator: o ? { id: startedBy, name: o.name } : undefined,
74
+ };
75
+ }
76
+
77
+ /** Stands in for a starter nobody recorded: tasks already running when starters began to be recorded. */
78
+ const UNRECORDED_STARTER = "unrecorded";
79
+
80
+ /** Records the paired device that started a task here, the only one that may later hold it. */
81
+ export function recordStarter(id: string, device: string): void {
82
+ db.prepare(`UPDATE sessions SET started_by_device = ? WHERE id = ?`).run(
83
+ device,
84
+ id
85
+ );
86
+ }
87
+
88
+ type Holdable = Pick<
89
+ Session,
90
+ | "held_by_device"
91
+ | "started_by"
92
+ | "started_by_device"
93
+ | "task_status"
94
+ | "host_id"
95
+ | "task_prompt"
96
+ >;
97
+
98
+ /**
99
+ * Whether `device` may hold this task: a running task on this machine that
100
+ * nobody here (an orchestrator or a person) owns, which that device started.
101
+ */
102
+ export function mayHold(s: Holdable, device: string): boolean {
103
+ if (!s.task_prompt || s.task_status !== "running") return false;
104
+ if (s.host_id && s.host_id !== "local") return false;
105
+ if (s.started_by || s.held_by_device) return false;
106
+ return (
107
+ s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Holds a task already running here for the device that started it, and the
113
+ * orchestrator there. False when there's no such running task; a task this
114
+ * device didn't start, one an orchestrator here owns, or one another device
115
+ * holds is refused. Holding the same task again is a no-op.
116
+ */
117
+ export function holdExisting(
118
+ id: string,
119
+ device: string,
120
+ held: Parameters<typeof holdFor>[2]
121
+ ): boolean {
122
+ const s = db
123
+ .prepare(
124
+ `SELECT held_by_device, started_by, started_by_device, task_status, host_id, task_prompt
125
+ FROM sessions WHERE id = ?`
126
+ )
127
+ .get(id) as Holdable | undefined;
128
+ if (!s?.task_prompt || s.task_status !== "running") return false;
129
+ if (s.held_by_device === device) return true;
130
+ if (!mayHold(s, device))
131
+ throw new Error("Only the machine that started this task can hold it");
132
+ holdFor(id, device, held);
133
+ return true;
134
+ }
@@ -28,6 +28,7 @@ import { expandHome } from "./session";
28
28
  import { ensureProject } from "./project-ref";
29
29
  import { checkoutBranchWorktree } from "./branch-worktree";
30
30
  import { rewritePaths, writeTranscript } from "./transcript";
31
+ import { holdFor } from "./held";
31
32
  import { stepProgress } from "./move-progress";
32
33
  import { dropChat, unpackChat } from "./move-chat";
33
34
  import { sendChat, stopChat } from "../chat/runner";
@@ -171,20 +172,12 @@ async function arrive(
171
172
  // Held for the orchestrator's device. The orchestrator's own state
172
173
  // (started_by, its gates) never comes from the bundle: it stays on its
173
174
  // machine, and a task coming back takes it from the mirror's record.
174
- if (holder)
175
+ if (holder) {
176
+ holdFor(id, holder, orch);
175
177
  db.prepare(
176
- `UPDATE sessions SET held_by_device = ?, held_from = ?,
177
- held_for = ?, held_for_name = ?,
178
- model_provider = ?, model_overrides = ? WHERE id = ?`
179
- ).run(
180
- holder,
181
- orch.heldFrom,
182
- orch.orchestrator?.id ?? null,
183
- orch.orchestrator?.name ?? null,
184
- orch.modelProvider,
185
- orch.modelOverrides,
186
- id
187
- );
178
+ `UPDATE sessions SET model_provider = ?, model_overrides = ? WHERE id = ?`
179
+ ).run(orch.modelProvider, orch.modelOverrides, id);
180
+ }
188
181
  }
189
182
  carryTaskState(bundle.moveId, id);
190
183
  })();
@@ -47,7 +47,8 @@ import {
47
47
  type TaskState,
48
48
  } from "./state";
49
49
  import { expandHome, prFor, storedPR, taskSessions } from "./session";
50
- import { InProgressError } from "./move-bundle";
50
+ import { InProgressError, type OrchestratedBundle } from "./move-bundle";
51
+ import { holdFor, mayHold, recordStarter } from "./held";
51
52
  import { nameFor } from "../session-titles";
52
53
  import { taskSetupOf, type TaskSetup } from "./setup";
53
54
  import { taskPlan } from "./plan-view";
@@ -160,10 +161,24 @@ export async function createTask(opts: {
160
161
  // How its agent runs: as a chat (the default, DEFAULT_START_VIEW) or in
161
162
  // a terminal.
162
163
  view?: StartView;
164
+ // Started by another machine's orchestrator, which this one holds it for
165
+ // (held.ts): that machine's paired device and its description of the task.
166
+ held?: { device: string; orchestrated: OrchestratedBundle };
167
+ // The paired device that sent this keyed start: the only one that may hold it later.
168
+ startedByDevice?: string;
163
169
  }): Promise<Session> {
164
170
  if (opts.id) {
165
171
  const existing = queries.getSession(db).get(opts.id) as Session | undefined;
166
- if (existing?.task_status) return existing;
172
+ if (existing?.task_status) {
173
+ // A retry of a start that wasn't held holds it now, for the device
174
+ // that started it and no other.
175
+ if (opts.held && existing.held_by_device !== opts.held.device) {
176
+ if (!mayHold(existing, opts.held.device))
177
+ throw new Error("That id is taken");
178
+ holdFor(existing.id, opts.held.device, opts.held.orchestrated);
179
+ }
180
+ return existing;
181
+ }
167
182
  if (existing) throw new Error("That id is taken");
168
183
  if (starting.has(opts.id))
169
184
  throw new InProgressError("That task is starting already");
@@ -234,6 +249,7 @@ async function startTask(
234
249
  model: typeof opts.model === "string" ? opts.model : undefined,
235
250
  baseBranch: opts.baseBranch,
236
251
  view,
252
+ startedBy: opts.startedBy,
237
253
  });
238
254
  opts.onCreated?.(session.id);
239
255
  return session;
@@ -321,6 +337,9 @@ async function startTask(
321
337
  opts.startedBy ?? null,
322
338
  id
323
339
  );
340
+ const starter = opts.held?.device ?? opts.startedByDevice;
341
+ if (starter) recordStarter(id, starter);
342
+ if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
324
343
  opts.onCreated?.(id);
325
344
  void naming.refine?.(id);
326
345
 
@@ -127,7 +127,9 @@ function validateChat(c: ChatBundle): ChatBundle {
127
127
 
128
128
  const PHASES = ["planning", "implementing"];
129
129
 
130
- function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
130
+ export function validateOrchestrated(
131
+ o: OrchestratedBundle
132
+ ): OrchestratedBundle {
131
133
  if (!o || typeof o !== "object") throw new Error("Bad orchestrated task");
132
134
  if (
133
135
  !isStrOrNull(o.since) ||
@@ -18,7 +18,7 @@ import { sessionWorkspace } from "../models/resolve";
18
18
  import { arrived as arrivedHere, importTask } from "./import";
19
19
  import {
20
20
  forgetHostTasks,
21
- hostTasks,
21
+ requireCapability,
22
22
  postIdempotent,
23
23
  unknownOutcome,
24
24
  } from "./remote";
@@ -88,25 +88,6 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
88
88
  }
89
89
  }
90
90
 
91
- // A machine on an older AgentOS would take a chat task as a terminal one,
92
- // or an orchestrator's task as a plain one that anyone there could merge, or
93
- // refuse to hand one over.
94
- async function requireCapability(
95
- link: HostLink,
96
- capability: string,
97
- what: string
98
- ): Promise<void> {
99
- const { capabilities = [] } = await hostTasks(link, true).catch((err) => {
100
- throw new Error(
101
- `Can't ask ${link.hostName} whether it takes ${what}: ${(err as Error).message}`
102
- );
103
- });
104
- if (!capabilities.includes(capability))
105
- throw new Error(
106
- `${link.hostName}'s AgentOS can't move ${what} yet; update it first`
107
- );
108
- }
109
-
110
91
  const requireChatMoves = (link: HostLink) =>
111
92
  requireCapability(link, "chat-move", "chat tasks");
112
93
 
@@ -151,6 +151,8 @@ export interface PeerOption {
151
151
  name: string;
152
152
  view: MachineView;
153
153
  capabilities: string[];
154
+ // It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
155
+ holds?: boolean;
154
156
  // The project exists there (projectMachines said null).
155
157
  hasProject: boolean;
156
158
  loadPerCore: number;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Messages for a task still in the start queue. They're ordinary bus
3
+ * messages addressed to the queue id (the id the task starts as), flagged
4
+ * held_queued, and reach the task in its first prompt (tasks/start.ts).
5
+ * Nothing is deleted: a delivered or discarded message is marked, like the
6
+ * ones held for a paused task.
7
+ *
8
+ * Light on purpose: the message bus imports this, and must not pull in the
9
+ * queue's scheduler.
10
+ */
11
+
12
+ import { db } from "../db";
13
+ import type { HeldMessage } from "./pause-policy";
14
+
15
+ export function queuedLabel(r: { name: string | null; prompt: string }) {
16
+ if (r.name) return r.name;
17
+ const line = r.prompt.split("\n")[0].trim();
18
+ return line.length > 60 ? `${line.slice(0, 57)}...` : line;
19
+ }
20
+
21
+ export interface QueuedTarget {
22
+ id: string;
23
+ name: string;
24
+ status: string;
25
+ error: string | null;
26
+ }
27
+
28
+ // A task waiting to start (or failed to) by id, id prefix of 4+ characters
29
+ // or name; in one workspace when given.
30
+ export function findQueuedTarget(
31
+ ref: string,
32
+ opts: { workspaceId?: string } = {}
33
+ ): QueuedTarget | null {
34
+ const want = ref.trim();
35
+ if (!want) return null;
36
+ const rows = db
37
+ .prepare(
38
+ `SELECT q.id, q.name, q.prompt, q.status, q.error, p.name AS project
39
+ FROM task_queue q JOIN projects p ON p.id = q.project_id
40
+ WHERE q.status IN ('queued', 'starting', 'failed')
41
+ ${opts.workspaceId ? "AND p.workspace_id = @ws" : ""}`
42
+ )
43
+ .all(opts.workspaceId ? { ws: opts.workspaceId } : {}) as {
44
+ id: string;
45
+ name: string | null;
46
+ prompt: string;
47
+ status: string;
48
+ error: string | null;
49
+ project: string;
50
+ }[];
51
+ const lower = want.toLowerCase();
52
+ const hits = rows.filter((r) => {
53
+ const name = queuedLabel(r).toLowerCase();
54
+ return (
55
+ r.id === want ||
56
+ (want.length >= 4 && r.id.startsWith(want)) ||
57
+ name === lower ||
58
+ `${r.project}/${name}`.toLowerCase() === lower
59
+ );
60
+ });
61
+ if (hits.length > 1)
62
+ throw new Error(
63
+ `"${ref}" matches ${hits.length} queued tasks: ${hits.map((h) => `${queuedLabel(h)} (${h.id.slice(0, 8)})`).join(", ")}`
64
+ );
65
+ const r = hits[0];
66
+ return r
67
+ ? { id: r.id, name: queuedLabel(r), status: r.status, error: r.error }
68
+ : null;
69
+ }
70
+
71
+ // Refuses a task that failed to start: nothing will ever start it.
72
+ export function assertTakesMessages(q: QueuedTarget): void {
73
+ if (q.status === "failed")
74
+ throw new Error(
75
+ `${q.name} failed to start${q.error ? ` (${q.error})` : ""}; it takes no messages. Remove it and queue it again.`
76
+ );
77
+ }
78
+
79
+ export const heldQueuedNote = (name: string) =>
80
+ `Queued for ${name}: it hasn't started; it gets this when it starts.`;
81
+
82
+ const UNDELIVERED = `held_queued = 1 AND delivered_at IS NULL AND read_at IS NULL`;
83
+
84
+ export function queuedHeldMessages(id: string): HeldMessage[] {
85
+ return (
86
+ db
87
+ .prepare(
88
+ `SELECT from_name, body FROM bus_messages
89
+ WHERE to_id = ? AND ${UNDELIVERED} ORDER BY id`
90
+ )
91
+ .all(id) as { from_name: string; body: string }[]
92
+ ).map((r) => ({ from: r.from_name, text: r.body }));
93
+ }
94
+
95
+ // They're in the first prompt: delivered, and not for `aos inbox` again.
96
+ export function markQueuedHeldDelivered(id: string): void {
97
+ db.prepare(
98
+ `UPDATE bus_messages
99
+ SET delivered_at = datetime('now'), read_at = COALESCE(read_at, datetime('now'))
100
+ WHERE to_id = ? AND ${UNDELIVERED}`
101
+ ).run(id);
102
+ }
103
+
104
+ // The task left the queue without starting.
105
+ export function discardQueuedHeld(id: string): void {
106
+ db.prepare(
107
+ `UPDATE bus_messages SET read_at = datetime('now')
108
+ WHERE to_id = ? AND ${UNDELIVERED}`
109
+ ).run(id);
110
+ }
111
+
112
+ export function queuedMessagesSection(held: HeldMessage[]): string {
113
+ if (!held.length) return "";
114
+ return `\n\n## Messages while queued\nThese arrived before you started, oldest first. They are part of your instructions:\n${held.map((m, i) => `${i + 1}. From ${m.from}: ${m.text}`).join("\n")}`;
115
+ }