talon-agent 5.26.6 → 5.28.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.
- package/package.json +1 -1
- package/src/app.ts +44 -13
- package/src/backend/claude-sdk/factory.ts +2 -0
- package/src/backend/claude-sdk/one-shot.ts +22 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/one-shot.ts +68 -19
- package/src/backend/runtime/one-shot-hooks.ts +24 -0
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +2 -0
- package/src/core/agents/prompt.ts +75 -0
- package/src/core/agents/registry.ts +317 -7
- package/src/core/agents/runner.ts +331 -10
- package/src/core/backup/{restore-guard.ts → restore/guard.ts} +5 -5
- package/src/core/backup/restore/notice.ts +164 -0
- package/src/core/backup/restore.ts +17 -5
- package/src/core/mesh/credentials/store.ts +9 -1
- package/src/core/mesh/persist.ts +9 -0
- package/src/core/types.ts +15 -0
- package/src/frontend/discord/actions/channels.ts +6 -3
- package/src/frontend/discord/actions/index.ts +24 -0
- package/src/frontend/discord/commands/backup.ts +1 -0
- package/src/frontend/discord/handlers/registry.ts +35 -0
- package/src/frontend/native/commands/backup.ts +1 -0
- package/src/frontend/native/turn/actions.ts +29 -1
- package/src/frontend/telegram/commands/backup.ts +1 -0
- package/src/storage/agents/repo.ts +200 -0
- package/src/storage/sql/agents.sql +43 -0
- package/src/storage/sql/schema.sql +54 -0
- package/src/storage/sql/statements.generated.ts +87 -1
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
});
|