talon-agent 5.26.5 → 5.27.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.
@@ -6,10 +6,12 @@
6
6
  * it owns no timers, no backends and no delivery. The runner drives it, the
7
7
  * gateway actions read it, and `GET /agents` serves `list()`.
8
8
  *
9
- * In-memory by design, same reasoning as the task table: an agent is a live
10
- * run and a daemon restart ends every run, so persisted rows could only
11
- * describe work that no longer exists. What *happened* is already durable —
12
- * every `agent.*` event lands in the journal (see `docs/bus.md`).
9
+ * The live map is in memory, but every lifecycle change is mirrored to the
10
+ * `agents` table through the `persist` hook (the singleton wires
11
+ * `storage/agents/repo.ts`), so a daemon restart no longer kills an
12
+ * agent: the next boot finds its row still `running` and resumes it —
13
+ * `restore()` is the re-entry point. Tests constructing their own registry
14
+ * pass no hook and stay purely in memory.
13
15
  */
14
16
 
15
17
  import { randomBytes } from "node:crypto";
@@ -24,6 +26,8 @@ import type {
24
26
  import type { TaskUsage } from "../tasks/types.js";
25
27
  import type { AgentSettledEvent, AgentSpawnedEvent } from "../bus/events.js";
26
28
  import type { ReasoningEffortLevel } from "../types.js";
29
+ import type { PersistedAgent } from "../../storage/agents/repo.js";
30
+ import * as agentsRepo from "../../storage/agents/repo.js";
27
31
  import { bus } from "../bus/index.js";
28
32
  import { logWarn } from "../../util/log.js";
29
33
  import { RunKilledError } from "./abort-reason.js";
@@ -40,6 +44,14 @@ export interface AgentRegistration {
40
44
  readonly parent: AgentParent;
41
45
  readonly backendId: string;
42
46
  readonly reasoningEffort?: ReasoningEffortLevel;
47
+ /** The model the caller asked for (unset = backend default). */
48
+ readonly requestedModel?: string;
49
+ /** The run's hard wall-clock cap. */
50
+ readonly timeoutMs?: number;
51
+ /** Working directory the run executes in. */
52
+ readonly cwd?: string;
53
+ /** The spawn asked for the pre-flight lane instruction. */
54
+ readonly preflight?: boolean;
43
55
  }
44
56
 
45
57
  /** What the runner knows once the run is actually under way. */
@@ -74,6 +86,15 @@ export interface AgentRegistryOptions {
74
86
  readonly publish?: (event: AgentSpawnedEvent | AgentSettledEvent) => void;
75
87
  /** Id factory; overridden in tests for deterministic ids. */
76
88
  readonly newId?: () => string;
89
+ /**
90
+ * Durable mirror of every lifecycle change — the singleton writes the
91
+ * `agents` table here so a restart can resume the agent. Must not throw
92
+ * (the registry guards it anyway: persistence is never allowed to break a
93
+ * run).
94
+ */
95
+ readonly persist?: (snapshot: PersistedAgent) => void;
96
+ /** Drop a persisted row (a registration that never started). */
97
+ readonly unpersist?: (id: string) => void;
77
98
  }
78
99
 
79
100
  type MutableAgentRecord = {
@@ -87,6 +108,21 @@ interface LiveAgent {
87
108
  abort?: AbortController;
88
109
  reported: boolean;
89
110
  killRequested: boolean;
111
+ /** Persistence-only facts — see `AgentRegistration` / `PersistedAgent`. */
112
+ requestedModel?: string;
113
+ timeoutMs?: number;
114
+ cwd?: string;
115
+ preflight?: boolean;
116
+ sessionId?: string;
117
+ elapsedMs: number;
118
+ resumeCount: number;
119
+ interruptedAt?: number;
120
+ /**
121
+ * Set when a daemon shutdown interrupts the run. The abort that follows
122
+ * must not be recorded as a kill: the row stays `running` so the next boot
123
+ * resumes it, and the parent is not told the agent died.
124
+ */
125
+ interrupted: boolean;
90
126
  }
91
127
 
92
128
  /**
@@ -114,6 +150,68 @@ function snapshot(entry: LiveAgent): AgentRecord {
114
150
  };
115
151
  }
116
152
 
153
+ /**
154
+ * The abort reason for a kill. A daemon shutdown parks the run rather than
155
+ * killing it (it resumes on the next boot), so its abort must not carry
156
+ * `RunKilledError` — the run log would say "killed on request".
157
+ */
158
+ function killReason(entry: LiveAgent, id: string): Error {
159
+ return entry.interrupted
160
+ ? new Error(`agent ${id} interrupted by a daemon shutdown`)
161
+ : new RunKilledError(`agent ${id} killed`);
162
+ }
163
+
164
+ /** The durable shape of one live entry. */
165
+ function toPersisted(entry: LiveAgent): PersistedAgent {
166
+ const { record } = entry;
167
+ const saved: PersistedAgent = {
168
+ id: record.id,
169
+ label: record.label,
170
+ brief: record.brief,
171
+ parentKind: record.parent.kind,
172
+ parentId:
173
+ record.parent.kind === "chat"
174
+ ? record.parent.chatId
175
+ : record.parent.agentId,
176
+ backendId: record.backendId,
177
+ depth: record.depth,
178
+ state: record.state,
179
+ createdAt: record.createdAt,
180
+ updatedAt: Date.now(),
181
+ inbox: entry.mailbox.map((m) => ({ from: m.from, text: m.text, at: m.at })),
182
+ reported: entry.reported,
183
+ elapsedMs: entry.elapsedMs,
184
+ resumeCount: entry.resumeCount,
185
+ };
186
+ if (record.parent.kind === "chat") {
187
+ saved.parentNumericChatId = record.parent.numericChatId;
188
+ }
189
+ if (record.model !== undefined) saved.model = record.model;
190
+ if (entry.requestedModel !== undefined) {
191
+ saved.requestedModel = entry.requestedModel;
192
+ }
193
+ if (record.reasoningEffort !== undefined) {
194
+ saved.reasoningEffort = record.reasoningEffort;
195
+ }
196
+ if (entry.timeoutMs !== undefined) saved.timeoutMs = entry.timeoutMs;
197
+ if (entry.cwd !== undefined) saved.cwd = entry.cwd;
198
+ if (entry.preflight) saved.preflight = true;
199
+ if (record.startedAt !== undefined) saved.startedAt = record.startedAt;
200
+ if (record.endedAt !== undefined) saved.endedAt = record.endedAt;
201
+ if (entry.sessionId !== undefined) saved.sessionId = entry.sessionId;
202
+ if (record.result) {
203
+ saved.resultSummary = record.result.summary;
204
+ if (record.result.details !== undefined) {
205
+ saved.resultDetails = record.result.details;
206
+ }
207
+ }
208
+ if (record.error !== undefined) saved.error = record.error;
209
+ if (entry.interruptedAt !== undefined) {
210
+ saved.interruptedAt = entry.interruptedAt;
211
+ }
212
+ return saved;
213
+ }
214
+
117
215
  export class AgentRegistry {
118
216
  private readonly live = new Map<string, LiveAgent>();
119
217
  private readonly history: AgentRecord[] = [];
@@ -123,6 +221,8 @@ export class AgentRegistry {
123
221
  event: AgentSpawnedEvent | AgentSettledEvent,
124
222
  ) => void;
125
223
  private readonly newId: () => string;
224
+ private readonly persistHook?: (snapshot: PersistedAgent) => void;
225
+ private readonly unpersistHook?: (id: string) => void;
126
226
 
127
227
  constructor(options: AgentRegistryOptions = {}) {
128
228
  this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
@@ -130,6 +230,8 @@ export class AgentRegistry {
130
230
  if (options.publish) this.publish = options.publish;
131
231
  this.newId =
132
232
  options.newId ?? (() => `agt_${randomBytes(4).toString("hex")}`);
233
+ if (options.persist) this.persistHook = options.persist;
234
+ if (options.unpersist) this.unpersistHook = options.unpersist;
133
235
  }
134
236
 
135
237
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -188,14 +290,188 @@ export class AgentRegistry {
188
290
  waiters: new Set(),
189
291
  reported: false,
190
292
  killRequested: false,
293
+ elapsedMs: 0,
294
+ resumeCount: 0,
295
+ interrupted: false,
191
296
  };
297
+ if (spec.requestedModel !== undefined) {
298
+ entry.requestedModel = spec.requestedModel;
299
+ }
300
+ if (spec.timeoutMs !== undefined) entry.timeoutMs = spec.timeoutMs;
301
+ if (spec.cwd !== undefined) entry.cwd = spec.cwd;
302
+ if (spec.preflight) entry.preflight = true;
192
303
  this.live.set(id, entry);
193
304
  if (spec.parent.kind === "agent") {
194
305
  this.live.get(spec.parent.agentId)?.record.children.push(id);
195
306
  }
307
+ this.persist(entry);
196
308
  return { ok: true, record: snapshot(entry) };
197
309
  }
198
310
 
311
+ /**
312
+ * Bring a persisted agent back into the live map after a restart, under
313
+ * its original id, with its mailbox, report and resume bookkeeping. It
314
+ * re-enters as `queued` — the runner `start()`s it again like any spawn.
315
+ * Caps are not re-checked: the agent was admitted before the restart.
316
+ *
317
+ * Refused (null) when the id is already live, or when its parent is an
318
+ * agent that did not come back (its report would have nowhere to go).
319
+ */
320
+ restore(saved: PersistedAgent): AgentRecord | null {
321
+ if (this.live.has(saved.id)) return null;
322
+ let parent: AgentParent;
323
+ if (saved.parentKind === "agent") {
324
+ if (!this.live.has(saved.parentId)) return null;
325
+ parent = { kind: "agent", agentId: saved.parentId };
326
+ } else {
327
+ parent = {
328
+ kind: "chat",
329
+ chatId: saved.parentId,
330
+ numericChatId: saved.parentNumericChatId ?? Number(saved.parentId),
331
+ };
332
+ }
333
+ const record: MutableAgentRecord = {
334
+ id: saved.id,
335
+ label: saved.label,
336
+ brief: saved.brief,
337
+ parent,
338
+ backendId: saved.backendId,
339
+ state: "queued",
340
+ depth: saved.depth,
341
+ createdAt: saved.createdAt,
342
+ result: null,
343
+ children: [],
344
+ inboxDepth: 0,
345
+ };
346
+ if (saved.model !== undefined) record.model = saved.model;
347
+ if (saved.reasoningEffort !== undefined) {
348
+ record.reasoningEffort = saved.reasoningEffort as ReasoningEffortLevel;
349
+ }
350
+ if (saved.reported && saved.resultSummary !== undefined) {
351
+ record.result = {
352
+ summary: saved.resultSummary,
353
+ ...(saved.resultDetails !== undefined
354
+ ? { details: saved.resultDetails }
355
+ : {}),
356
+ };
357
+ }
358
+ const entry: LiveAgent = {
359
+ record,
360
+ mailbox: saved.inbox.map((m) => ({ ...m })),
361
+ waiters: new Set(),
362
+ reported: saved.reported,
363
+ killRequested: false,
364
+ elapsedMs: saved.elapsedMs,
365
+ resumeCount: saved.resumeCount,
366
+ interrupted: false,
367
+ };
368
+ if (saved.requestedModel !== undefined) {
369
+ entry.requestedModel = saved.requestedModel;
370
+ }
371
+ if (saved.timeoutMs !== undefined) entry.timeoutMs = saved.timeoutMs;
372
+ if (saved.cwd !== undefined) entry.cwd = saved.cwd;
373
+ if (saved.preflight) entry.preflight = true;
374
+ if (saved.sessionId !== undefined) entry.sessionId = saved.sessionId;
375
+ if (saved.interruptedAt !== undefined) {
376
+ entry.interruptedAt = saved.interruptedAt;
377
+ }
378
+ this.live.set(saved.id, entry);
379
+ if (parent.kind === "agent") {
380
+ this.live.get(parent.agentId)?.record.children.push(saved.id);
381
+ }
382
+ return snapshot(entry);
383
+ }
384
+
385
+ /** Count one more restart-resume against this agent and persist it. */
386
+ markResumed(id: string): void {
387
+ const entry = this.live.get(id);
388
+ if (!entry) return;
389
+ entry.resumeCount += 1;
390
+ this.persist(entry);
391
+ }
392
+
393
+ /**
394
+ * Record the backend's conversation handle (Claude SDK session id, Codex
395
+ * thread id) as soon as the run reports it — the thing a restart resumes.
396
+ */
397
+ setSessionId(id: string, sessionId: string): void {
398
+ const entry = this.live.get(id);
399
+ if (!entry || entry.sessionId === sessionId) return;
400
+ entry.sessionId = sessionId;
401
+ this.persist(entry);
402
+ }
403
+
404
+ /** The persistence-only facts a resume needs, for a live agent. */
405
+ resumeInfo(id: string): {
406
+ sessionId?: string;
407
+ requestedModel?: string;
408
+ timeoutMs?: number;
409
+ cwd?: string;
410
+ elapsedMs: number;
411
+ resumeCount: number;
412
+ interruptedAt?: number;
413
+ } | null {
414
+ const entry = this.live.get(id);
415
+ if (!entry) return null;
416
+ return {
417
+ ...(entry.sessionId !== undefined ? { sessionId: entry.sessionId } : {}),
418
+ ...(entry.requestedModel !== undefined
419
+ ? { requestedModel: entry.requestedModel }
420
+ : {}),
421
+ ...(entry.timeoutMs !== undefined ? { timeoutMs: entry.timeoutMs } : {}),
422
+ ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
423
+ elapsedMs: entry.elapsedMs,
424
+ resumeCount: entry.resumeCount,
425
+ ...(entry.interruptedAt !== undefined
426
+ ? { interruptedAt: entry.interruptedAt }
427
+ : {}),
428
+ };
429
+ }
430
+
431
+ /**
432
+ * A daemon shutdown is about to abort every run. Stamp each live agent as
433
+ * interrupted — charging the time it has run so far against its timeout —
434
+ * and persist that while the row is still `running`, so the next boot
435
+ * resumes it. From here on the abort that tears the run down is not a
436
+ * kill: `isInterrupted` tells the runner to leave the row alone.
437
+ * Returns how many agents were parked.
438
+ */
439
+ interruptAll(now: number = Date.now()): number {
440
+ let parked = 0;
441
+ for (const entry of this.live.values()) {
442
+ if (entry.interrupted) continue;
443
+ entry.interrupted = true;
444
+ if (entry.record.startedAt !== undefined) {
445
+ entry.elapsedMs += Math.max(0, now - entry.record.startedAt);
446
+ }
447
+ entry.interruptedAt = now;
448
+ this.persist(entry);
449
+ parked++;
450
+ }
451
+ return parked;
452
+ }
453
+
454
+ /** Whether a shutdown interrupted this (still live) agent. */
455
+ isInterrupted(id: string): boolean {
456
+ return this.live.get(id)?.interrupted ?? false;
457
+ }
458
+
459
+ /**
460
+ * Drop an interrupted agent from the live map WITHOUT recording a terminal
461
+ * state — the persisted row stays `running` for the next boot. Waiters are
462
+ * released with the current snapshot; no `agent.settled` is published,
463
+ * because nothing settled.
464
+ */
465
+ releaseInterrupted(id: string): AgentRecord | null {
466
+ const entry = this.live.get(id);
467
+ if (!entry || !entry.interrupted) return null;
468
+ const record = snapshot(entry);
469
+ this.live.delete(id);
470
+ for (const waiter of entry.waiters) waiter(record);
471
+ entry.waiters.clear();
472
+ return record;
473
+ }
474
+
199
475
  /**
200
476
  * Drop a registration that never started (backend/model resolution failed).
201
477
  * It leaves no trace: nothing ran, so there is nothing to report on.
@@ -204,6 +480,7 @@ export class AgentRegistry {
204
480
  const entry = this.live.get(id);
205
481
  if (!entry) return;
206
482
  this.live.delete(id);
483
+ this.unpersist(id);
207
484
  const parent = entry.record.parent;
208
485
  if (parent.kind === "agent") {
209
486
  const children = this.live.get(parent.agentId)?.record.children;
@@ -225,7 +502,7 @@ export class AgentRegistry {
225
502
  // right after start() and settles the run as "killed".
226
503
  if (entry.killRequested) {
227
504
  try {
228
- binding.abort.abort(new RunKilledError(`agent ${id} killed`));
505
+ binding.abort.abort(killReason(entry, id));
229
506
  } catch (err) {
230
507
  logWarn(
231
508
  "agents",
@@ -237,6 +514,7 @@ export class AgentRegistry {
237
514
  entry.record.state = "running";
238
515
  entry.record.startedAt = Date.now();
239
516
  if (binding.taskId !== undefined) entry.record.taskId = binding.taskId;
517
+ this.persist(entry);
240
518
  const { record } = entry;
241
519
  this.publish?.({
242
520
  type: "agent.spawned",
@@ -262,6 +540,7 @@ export class AgentRegistry {
262
540
  if (!entry || entry.reported) return false;
263
541
  entry.reported = true;
264
542
  entry.record.result = result;
543
+ this.persist(entry);
265
544
  return true;
266
545
  }
267
546
 
@@ -282,6 +561,7 @@ export class AgentRegistry {
282
561
  if (patch.usage !== undefined) record.usage = patch.usage;
283
562
 
284
563
  const settled = snapshot(entry);
564
+ this.persist(entry);
285
565
  this.live.delete(id);
286
566
  this.history.push(settled);
287
567
  if (this.history.length > this.historyLimit) {
@@ -313,7 +593,7 @@ export class AgentRegistry {
313
593
  if (!entry.killRequested) {
314
594
  entry.killRequested = true;
315
595
  try {
316
- entry.abort?.abort(new RunKilledError(`agent ${id} killed`));
596
+ entry.abort?.abort(killReason(entry, id));
317
597
  } catch (err) {
318
598
  // An abort hook must not be able to break the kill path — but a
319
599
  // throwing one may leave the agent running, so say so.
@@ -352,6 +632,7 @@ export class AgentRegistry {
352
632
  if (!entry) return false;
353
633
  if (entry.mailbox.length >= this.mailboxLimit) return false;
354
634
  entry.mailbox.push(message);
635
+ this.persist(entry);
355
636
  return true;
356
637
  }
357
638
 
@@ -359,7 +640,9 @@ export class AgentRegistry {
359
640
  drain(id: string): AgentMessage[] {
360
641
  const entry = this.live.get(id);
361
642
  if (!entry) return [];
362
- return entry.mailbox.splice(0, entry.mailbox.length);
643
+ const drained = entry.mailbox.splice(0, entry.mailbox.length);
644
+ if (drained.length > 0) this.persist(entry);
645
+ return drained;
363
646
  }
364
647
 
365
648
  /** The mailbox cap, for the error text the actions surface. */
@@ -461,6 +744,31 @@ export class AgentRegistry {
461
744
 
462
745
  // ── Internals ─────────────────────────────────────────────────────────────
463
746
 
747
+ /** Mirror one entry to the durable store. Never throws into a run. */
748
+ private persist(entry: LiveAgent): void {
749
+ if (!this.persistHook) return;
750
+ try {
751
+ this.persistHook(toPersisted(entry));
752
+ } catch (err) {
753
+ logWarn(
754
+ "agents",
755
+ `persist failed agent=${entry.record.id}: ${err instanceof Error ? err.message : String(err)}`,
756
+ );
757
+ }
758
+ }
759
+
760
+ private unpersist(id: string): void {
761
+ if (!this.unpersistHook) return;
762
+ try {
763
+ this.unpersistHook(id);
764
+ } catch (err) {
765
+ logWarn(
766
+ "agents",
767
+ `unpersist failed agent=${id}: ${err instanceof Error ? err.message : String(err)}`,
768
+ );
769
+ }
770
+ }
771
+
464
772
  /**
465
773
  * Depth a child of this parent would have, or null when the parent is an
466
774
  * agent that is no longer live (its children would have nowhere to report).
@@ -488,4 +796,6 @@ export class AgentRegistry {
488
796
  /** The daemon-wide registry. Tests needing isolation construct their own. */
489
797
  export const agentRegistry = new AgentRegistry({
490
798
  publish: (event) => bus.publish(event),
799
+ persist: (saved) => agentsRepo.upsert(saved),
800
+ unpersist: (id) => void agentsRepo.remove(id),
491
801
  });