agent-relay-server 0.84.0 → 0.86.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.
Files changed (115) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-CevL-2jc.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-CY2Cbpet.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.js.map → activity-CY2Cbpet.js.map} +1 -1
  9. package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
  10. package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
  11. package/public/assets/{agents-BWmmWj4Q.js → agents-Bj5KzrYJ.js} +2 -2
  12. package/public/assets/{agents-BWmmWj4Q.js.map → agents-Bj5KzrYJ.js.map} +1 -1
  13. package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
  14. package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
  15. package/public/assets/{automation-DRtOENs0.js → automation-BOPk1oh_.js} +2 -2
  16. package/public/assets/{automation-DRtOENs0.js.map → automation-BOPk1oh_.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
  18. package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
  19. package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
  20. package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
  21. package/public/assets/{chat-gfUxbTus.js → chat-D5pI4ebq.js} +2 -2
  22. package/public/assets/{chat-gfUxbTus.js.map → chat-D5pI4ebq.js.map} +1 -1
  23. package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
  24. package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
  25. package/public/assets/display-CWMHll1J.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
  27. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
  28. package/public/assets/index-CEXhh5nz.js +23 -0
  29. package/public/assets/index-CEXhh5nz.js.map +1 -0
  30. package/public/assets/index-LUIvWcx6.css +2 -0
  31. package/public/assets/{insights-DPaJ5Hme.js → insights-C4dEK49e.js} +2 -2
  32. package/public/assets/{insights-DPaJ5Hme.js.map → insights-C4dEK49e.js.map} +1 -1
  33. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  34. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  35. package/public/assets/{maintenance-DsszLb92.js → maintenance-DJZUSsja.js} +2 -2
  36. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-DJZUSsja.js.map} +1 -1
  37. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-5wUMDKVi.js} +2 -2
  38. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
  41. package/public/assets/{memory-C2s9bJ38.js → memory-BnhAD09c.js} +2 -2
  42. package/public/assets/{memory-C2s9bJ38.js.map → memory-BnhAD09c.js.map} +1 -1
  43. package/public/assets/{messages-DHRJNAvy.js → messages-Cc9YF8Wl.js} +2 -2
  44. package/public/assets/{messages-DHRJNAvy.js.map → messages-Cc9YF8Wl.js.map} +1 -1
  45. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-B01iyiBA.js} +2 -2
  46. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-B01iyiBA.js.map} +1 -1
  47. package/public/assets/{overview-C1vZfyhW.js → overview-DN8CUTVx.js} +2 -2
  48. package/public/assets/{overview-C1vZfyhW.js.map → overview-DN8CUTVx.js.map} +1 -1
  49. package/public/assets/{pairs-C1uzo8x0.js → pairs-BxuDMLhs.js} +2 -2
  50. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-BxuDMLhs.js.map} +1 -1
  51. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  52. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  53. package/public/assets/{security-XhAAP6q3.js → security-CO0PBIau.js} +2 -2
  54. package/public/assets/{security-XhAAP6q3.js.map → security-CO0PBIau.js.map} +1 -1
  55. package/public/assets/{select-DEGUFEjx.js → select-DHaFsLvt.js} +2 -2
  56. package/public/assets/{select-DEGUFEjx.js.map → select-DHaFsLvt.js.map} +1 -1
  57. package/public/assets/{settings-DTbVUdlZ.js → settings-De1DKwd6.js} +2 -2
  58. package/public/assets/{settings-DTbVUdlZ.js.map → settings-De1DKwd6.js.map} +1 -1
  59. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  60. package/public/assets/store-DsqUEZOf.js.map +1 -0
  61. package/public/assets/{tasks-zwGo7hon.js → tasks-Bqd188ql.js} +2 -2
  62. package/public/assets/{tasks-zwGo7hon.js.map → tasks-Bqd188ql.js.map} +1 -1
  63. package/public/assets/{teams-Brc5D8ox.js → teams-Mh6ypDPr.js} +2 -2
  64. package/public/assets/{teams-Brc5D8ox.js.map → teams-Mh6ypDPr.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
  67. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-Dy5J3E_b.js} +2 -2
  68. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
  69. package/public/assets/{work-queue-ge4R7keo.js → work-queue-ClGgOUSu.js} +2 -2
  70. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-ClGgOUSu.js.map} +1 -1
  71. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  72. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  73. package/public/index.html +3 -3
  74. package/runner/src/adapter.ts +4 -3
  75. package/src/agent-death-diagnosis.ts +255 -0
  76. package/src/agent-lifecycle-events.ts +47 -19
  77. package/src/branch-landed.ts +81 -0
  78. package/src/bus.ts +18 -4
  79. package/src/cli/index.ts +3 -3
  80. package/src/cli/reply.ts +6 -1
  81. package/src/config.ts +4 -0
  82. package/src/db/agents.ts +35 -2
  83. package/src/db/crash-reports.ts +115 -0
  84. package/src/db/index.ts +2 -0
  85. package/src/db/migrations.ts +22 -0
  86. package/src/db/provider-quotas.ts +99 -21
  87. package/src/db/terminal-events.ts +56 -0
  88. package/src/maintenance/constants.ts +4 -0
  89. package/src/maintenance/jobs.ts +19 -1
  90. package/src/maintenance/teams.ts +73 -1
  91. package/src/maintenance/workspaces.ts +23 -10
  92. package/src/mcp/index.ts +4 -2
  93. package/src/mcp/tools-messaging.ts +38 -0
  94. package/src/mcp/tools-spawn.ts +119 -4
  95. package/src/notification-types.ts +4 -0
  96. package/src/provider-catalog-store.ts +3 -0
  97. package/src/routes/agents.ts +11 -1
  98. package/src/routes/commands.ts +21 -3
  99. package/src/routes/index.ts +2 -1
  100. package/src/routes/orchestrator.ts +14 -1
  101. package/src/routes/workspaces.ts +5 -0
  102. package/src/services/terminal-provider-exit.ts +33 -0
  103. package/src/services/transient-agent-reaper.ts +4 -1
  104. package/src/spawn-targets.ts +25 -8
  105. package/src/steward-identity.ts +50 -0
  106. package/src/steward.ts +12 -4
  107. package/src/team-idle-tracker.ts +15 -0
  108. package/src/workspace-actions.ts +5 -1
  109. package/src/workspace-auto-merge.ts +120 -8
  110. package/src/workspace-merge.ts +10 -0
  111. package/src/workspace-pr-completion.ts +26 -0
  112. package/public/assets/index-D8TVUdlC.js +0 -22
  113. package/public/assets/index-D8TVUdlC.js.map +0 -1
  114. package/public/assets/index-DgZLDL0K.css +0 -2
  115. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -0,0 +1,255 @@
1
+ // #636 — crash/exit post-mortem classifier. A self-contained, pure mapping from the evidence
2
+ // captured at an agent's death (orchestrator exit diagnostics + the server's own agent state)
3
+ // to a single likely-cause label + supporting tail. It does NO IO: every host-side probe (OOM
4
+ // cgroup/journal, tmux pane status, log tail) is gathered upstream (orchestrator
5
+ // `diagnoseSessionExit`, runner provider-exit diagnostics) and handed in here, so the verdict is
6
+ // deterministic and unit-testable per failure mode. The output rides the #633 terminal event and
7
+ // is persisted as a CrashReport so the WHY survives the reaper.
8
+
9
+ import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
10
+ import type { AgentCard, AgentDeathCause, AgentDeathDiagnosis, CrashReport, ManagedSessionExitDiagnostics } from "./types";
11
+
12
+ export interface DeathEvidence {
13
+ /** The offline cause the caller already has (e.g. "heartbeat lost", "shutdown", exit reason). */
14
+ reason: string;
15
+ /** Orchestrator/runner-captured exit diagnostics, when the death was observed as a session exit. */
16
+ exit?: ManagedSessionExitDiagnostics;
17
+ /** The dead agent's `meta` blob (lastError, lastExit, terminalProviderExit, terminalFailureReason, auth…). */
18
+ agentMeta?: Record<string, unknown>;
19
+ /** Pre-death agent status, for distinguishing a busy stall from an idle one. */
20
+ status?: string;
21
+ /** Server's last-seen heartbeat for the agent. */
22
+ lastHeartbeatAt?: number;
23
+ /** Last time the agent reported busy (turn activity). */
24
+ lastBusyAt?: number;
25
+ /** Active work titles at death. */
26
+ activeWork?: string[];
27
+ /** Evaluation clock (defaults to Date.now()). */
28
+ now?: number;
29
+ }
30
+
31
+ const SIGNAL_RE = /\bSIG(KILL|TERM|SEGV|ABRT|BUS|INT|HUP|QUIT)\b/i;
32
+ const OOM_RE = /\b(out of memory|oom[- ]?kill(?:ed|er)?|killed process \d+|cannot allocate memory|javascript heap out of memory)\b/i;
33
+ const TOKEN_401_RE = /\b(401|403\b.*token|unauthorized|invalid[_ ]token|token (?:expired|revoked|invalid)|authentication failed|please run\s+\/login|run \/login|oauth token)\b/i;
34
+ const ONBOARDING_RE = /(do you trust|trust this folder|onboarding|first[- ]run|select a theme|choose your theme|accept the terms|welcome to claude)/i;
35
+ const CRASH_RE = /\b(panic|segfault|segmentation fault|core dumped|traceback \(most recent|unhandled (?:exception|rejection)|fatal error|abort(?:ed)?\b|stack overflow)\b/i;
36
+ const TRANSPORT_RE = /\b(heartbeat|transport|websocket|connection (?:lost|closed|reset)|disconnect)\b/i;
37
+
38
+ function pushUnique(list: string[], value: string | undefined): void {
39
+ if (value && !list.includes(value)) list.push(value);
40
+ }
41
+
42
+ function record(meta: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
43
+ const value = meta?.[key];
44
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
45
+ }
46
+
47
+ function str(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.length > 0 ? value : undefined;
49
+ }
50
+
51
+ function num(value: unknown): number | undefined {
52
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
53
+ }
54
+
55
+ /** Resolve a termination signal name + exit code from the orchestrator exit diagnostics or agent meta. */
56
+ function resolveSignalAndCode(ev: DeathEvidence): { signal?: string; exitCode?: number } {
57
+ const exit = ev.exit;
58
+ let signal = exit?.signal;
59
+ let exitCode = exit?.exitCode;
60
+ const systemd = exit?.systemd;
61
+ if (!signal && systemd?.execMainCode && /killed|dumped|signal/i.test(systemd.execMainCode)) {
62
+ // systemd ExecMainStatus carries the signal name/number when ExecMainCode is killed/dumped.
63
+ signal = systemd.execMainStatus ? `SIG${systemd.execMainStatus.replace(/^SIG/i, "")}` : undefined;
64
+ }
65
+ if (exitCode === undefined && systemd?.execMainCode === "exited" && systemd.execMainStatus) {
66
+ const n = Number(systemd.execMainStatus);
67
+ if (Number.isFinite(n)) exitCode = n;
68
+ }
69
+ const tpe = record(ev.agentMeta, "terminalProviderExit");
70
+ if (!signal) signal = str(tpe?.signal);
71
+ if (exitCode === undefined) exitCode = num(tpe?.exitCode);
72
+ return { signal, exitCode };
73
+ }
74
+
75
+ /** Build the haystack of free text scanned by the known-pattern probes. */
76
+ function haystack(ev: DeathEvidence): string {
77
+ const parts: string[] = [ev.reason];
78
+ if (ev.exit?.logTail) parts.push(...ev.exit.logTail);
79
+ pushUnique(parts, ev.exit?.lastError);
80
+ pushUnique(parts, str(ev.agentMeta?.lastError));
81
+ pushUnique(parts, str(ev.agentMeta?.terminalFailureReason));
82
+ pushUnique(parts, str(ev.agentMeta?.terminalFailureMessage));
83
+ const lastExit = record(ev.agentMeta, "lastExit");
84
+ pushUnique(parts, str(lastExit?.lastError));
85
+ const tpe = record(ev.agentMeta, "terminalProviderExit");
86
+ pushUnique(parts, str(tpe?.reason));
87
+ return parts.join("\n");
88
+ }
89
+
90
+ function logTailOf(ev: DeathEvidence): string[] | undefined {
91
+ if (ev.exit?.logTail?.length) return ev.exit.logTail;
92
+ const lastExit = record(ev.agentMeta, "lastExit");
93
+ const tail = lastExit?.logTail;
94
+ if (Array.isArray(tail) && tail.length) return tail.filter((x): x is string => typeof x === "string");
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Classify an agent death into a single likely-cause label with supporting evidence (#636).
100
+ * Pure — all host probes are pre-gathered into {@link DeathEvidence}. Probe order is by
101
+ * conclusiveness: a clean exit code never masks a token-401 / onboarding gate that also exits 0.
102
+ */
103
+ export function classifyAgentDeath(ev: DeathEvidence): AgentDeathDiagnosis {
104
+ const now = ev.now ?? Date.now();
105
+ const text = haystack(ev);
106
+ const logTail = logTailOf(ev);
107
+ const { signal, exitCode } = resolveSignalAndCode(ev);
108
+ const evidence: string[] = [];
109
+
110
+ const systemdResult = ev.exit?.systemd?.result;
111
+ const resumeId = str(record(ev.agentMeta, "terminalProviderExit")?.claudeResumeId);
112
+ const heartbeatGapMs = ev.lastHeartbeatAt !== undefined ? Math.max(0, now - ev.lastHeartbeatAt) : undefined;
113
+
114
+ const base: Pick<AgentDeathDiagnosis, "signal" | "exitCode" | "logTail" | "lastHeartbeatAt" | "heartbeatGapMs" | "lastBusyAt" | "activeWork" | "detectedAt"> = {
115
+ ...(signal ? { signal } : {}),
116
+ ...(exitCode !== undefined ? { exitCode } : {}),
117
+ ...(logTail ? { logTail } : {}),
118
+ ...(ev.lastHeartbeatAt !== undefined ? { lastHeartbeatAt: ev.lastHeartbeatAt } : {}),
119
+ ...(heartbeatGapMs !== undefined ? { heartbeatGapMs } : {}),
120
+ ...(ev.lastBusyAt !== undefined ? { lastBusyAt: ev.lastBusyAt } : {}),
121
+ ...(ev.activeWork?.length ? { activeWork: ev.activeWork } : {}),
122
+ detectedAt: now,
123
+ };
124
+
125
+ const verdict = (cause: AgentDeathCause, confidence: AgentDeathDiagnosis["confidence"], summary: string, oom?: boolean): AgentDeathDiagnosis => ({
126
+ cause,
127
+ confidence,
128
+ summary,
129
+ evidence,
130
+ ...(oom !== undefined ? { oom } : {}),
131
+ ...base,
132
+ });
133
+
134
+ // 1) OOM — most conclusive: positive cgroup/journal/systemd signal, then log text.
135
+ if (ev.exit?.oom) {
136
+ pushUnique(evidence, `OOM detected via ${ev.exit.oom.source}${ev.exit.oom.detail ? `: ${ev.exit.oom.detail}` : ""}`);
137
+ return verdict("oom", "high", "Out-of-memory kill", true);
138
+ }
139
+ if (systemdResult === "oom-kill") {
140
+ pushUnique(evidence, "systemd Result=oom-kill");
141
+ return verdict("oom", "high", "Out-of-memory kill (systemd Result=oom-kill)", true);
142
+ }
143
+ if (OOM_RE.test(text)) {
144
+ pushUnique(evidence, "log/error text matched an out-of-memory pattern");
145
+ return verdict("oom", "medium", "Likely out-of-memory (log pattern)", true);
146
+ }
147
+
148
+ // 2) token-401 — auth expiry/revocation (cf. managed-token-revocation). Can exit 0, so before clean-exit.
149
+ if (TOKEN_401_RE.test(text)) {
150
+ pushUnique(evidence, "log/error text matched a token-401 / auth-failure pattern");
151
+ return verdict("token-401", "high", "Token expired/revoked (401 auth failure)");
152
+ }
153
+
154
+ // 3) onboarding-gate — first-run/trust gate crash-loop (cf. isolated-claude onboarding gate).
155
+ if (ONBOARDING_RE.test(text)) {
156
+ const fast = (ev.exit?.runtimeMs ?? Infinity) < 60_000;
157
+ if (fast) pushUnique(evidence, `died fast (${Math.round((ev.exit?.runtimeMs ?? 0) / 1000)}s) at a first-run/onboarding gate`);
158
+ else pushUnique(evidence, "log/error text matched an onboarding/trust-gate pattern");
159
+ return verdict("onboarding-gate", fast ? "high" : "medium", "Stuck at a first-run onboarding/trust gate");
160
+ }
161
+
162
+ // 4) provider-crash — CLI panic/segfault/model-unavailable, or a crash signal.
163
+ const modelUnavailable = extractClaudeModelUnavailableMessage(text);
164
+ if (modelUnavailable) {
165
+ pushUnique(evidence, modelUnavailable);
166
+ return verdict("provider-crash", "high", "Provider unavailable (model/CLI error)");
167
+ }
168
+ if (signal && /SEGV|ABRT|BUS/i.test(signal)) {
169
+ pushUnique(evidence, `provider terminated by ${signal}`);
170
+ return verdict("provider-crash", "high", `Provider crashed (${signal})`);
171
+ }
172
+ if (CRASH_RE.test(text)) {
173
+ pushUnique(evidence, "log/error text matched a crash pattern (panic/segfault/traceback)");
174
+ return verdict("provider-crash", "medium", "Likely provider crash (log pattern)");
175
+ }
176
+
177
+ // 5) killed — external SIGKILL/SIGTERM that isn't OOM and isn't a parent-initiated shutdown.
178
+ if (signal && /KILL|TERM|INT|HUP|QUIT/i.test(signal)) {
179
+ pushUnique(evidence, `process terminated by ${signal}`);
180
+ return verdict("killed", "high", `Process killed (${signal})`);
181
+ }
182
+
183
+ // 6) clean-exit — exit 0 / systemd success / Claude native auto-compaction resume handoff.
184
+ if (resumeId) {
185
+ pushUnique(evidence, `clean self-exit with a Claude resume id (${resumeId}) — native context auto-compaction`);
186
+ return verdict("clean-exit", "high", "Clean self-exit (Claude auto-compaction / resume handoff)");
187
+ }
188
+ if (exitCode === 0 || systemdResult === "success") {
189
+ pushUnique(evidence, exitCode === 0 ? "process exited 0" : "systemd Result=success");
190
+ return verdict("clean-exit", "high", "Clean exit (exit code 0)");
191
+ }
192
+ if (exitCode !== undefined && exitCode > 0) {
193
+ pushUnique(evidence, `process exited non-zero (code ${exitCode})`);
194
+ return verdict("provider-crash", "medium", `Non-zero exit (code ${exitCode})`);
195
+ }
196
+
197
+ // 7) transport-loss — heartbeat/bus gap with no exit diagnostics: the process may even be alive,
198
+ // but the relay lost the agent. This is the silent-stall backstop (#634).
199
+ if (TRANSPORT_RE.test(ev.reason) && !ev.exit) {
200
+ pushUnique(evidence, `lost contact: ${ev.reason}${heartbeatGapMs !== undefined ? ` (heartbeat gap ${Math.round(heartbeatGapMs / 1000)}s)` : ""}`);
201
+ return verdict("transport-loss", "medium", "Lost heartbeat/transport (no exit captured)");
202
+ }
203
+
204
+ pushUnique(evidence, ev.reason || "no conclusive signal");
205
+ return verdict("unknown", "low", "Cause undetermined");
206
+ }
207
+
208
+ function activeWorkTitles(meta: Record<string, unknown> | undefined): string[] | undefined {
209
+ const work = meta?.activeWork;
210
+ if (!Array.isArray(work) || work.length === 0) return undefined;
211
+ const titles = work
212
+ .map((item) => (item && typeof item === "object" ? str((item as Record<string, unknown>).title) ?? str((item as Record<string, unknown>).kind) : undefined))
213
+ .filter((x): x is string => Boolean(x));
214
+ return titles.length ? titles : undefined;
215
+ }
216
+
217
+ /**
218
+ * Assemble {@link DeathEvidence} from a live agent card + the offline reason (and any orchestrator
219
+ * exit diagnostics), then classify. The single seam every death-detection site calls so evidence
220
+ * extraction (heartbeat, active work, lastExit) stays DRY.
221
+ */
222
+ export function diagnoseAgentDeath(input: {
223
+ agent?: AgentCard | null;
224
+ reason: string;
225
+ exit?: ManagedSessionExitDiagnostics;
226
+ now?: number;
227
+ }): AgentDeathDiagnosis {
228
+ const meta = input.agent?.meta;
229
+ return classifyAgentDeath({
230
+ reason: input.reason,
231
+ exit: input.exit,
232
+ agentMeta: meta,
233
+ status: input.agent?.status,
234
+ lastHeartbeatAt: input.agent?.lastSeen,
235
+ activeWork: activeWorkTitles(meta),
236
+ now: input.now,
237
+ });
238
+ }
239
+
240
+ /** Build a persistable {@link CrashReport} from a dead agent's card + its diagnosis. */
241
+ export function crashReportFor(agent: AgentCard, reason: string, diagnosis: AgentDeathDiagnosis): CrashReport {
242
+ const provider = str(agent.meta?.provider) ?? agent.providerCapabilities?.model?.provider;
243
+ const spawnRequestId = str(agent.meta?.spawnRequestId);
244
+ return {
245
+ agentId: agent.id,
246
+ ...(agent.spawnedBy ? { parentId: agent.spawnedBy } : {}),
247
+ ...(agent.teamId ? { teamId: agent.teamId } : {}),
248
+ ...(spawnRequestId ? { spawnRequestId } : {}),
249
+ ...(provider ? { provider } : {}),
250
+ ...(agent.label ? { label: agent.label } : {}),
251
+ reason,
252
+ diagnosis,
253
+ createdAt: diagnosis.detectedAt,
254
+ };
255
+ }
@@ -1,8 +1,14 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
3
  import { routeNotification } from "./notification-router";
4
- import { getAgent } from "./db";
5
- import type { AgentCard } from "./types";
4
+ import { getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
5
+ import { crashReportFor } from "./agent-death-diagnosis";
6
+ import type { AgentCard, AgentDeathDiagnosis } from "./types";
7
+
8
+ /** One-line cause tag appended to a death notification body, e.g. " · why: Clean exit (exit code 0)". */
9
+ function diagnosisSuffix(diagnosis?: AgentDeathDiagnosis): string {
10
+ return diagnosis ? ` · why: ${diagnosis.summary}` : "";
11
+ }
6
12
 
7
13
  // #308/#239 — async spawn lifecycle signals. A spawn-capable agent fires a spawn, gets its
8
14
  // spawnRequestId back immediately, and is WOKEN on ready/exit/failure instead of burning its
@@ -11,15 +17,22 @@ import type { AgentCard } from "./types";
11
17
 
12
18
  // Spawned children that have fired `agent.ready` during this relay lifetime. Serves two jobs:
13
19
  // (1) dedup the ready push — it must fire exactly once per child, not on every heartbeat
14
- // re-registration; and (2) classify a later offline transition: a child IN this set exited
15
- // cleanly (`agent.exited`); one that never entered it went offline without ever becoming ready,
20
+ // re-registration; and (2) classify a later offline transition: a child that HAD become ready
21
+ // exited cleanly (`agent.exited`); one that never did went offline without ever becoming ready,
16
22
  // i.e. a spawn that failed to come up (`agent.spawn_failed` — the "process started ≠ ready"
17
- // crash-loop / onboarding-gate mode the isolated-Claude failure proves). In-memory by design: a
18
- // child still alive across a relay restart was ready long ago and its parent already knows, so a
19
- // post-restart exit reads as the benign `agent.exited` default — a restart must never manufacture
20
- // a spurious failure alert.
23
+ // crash-loop / onboarding-gate mode the isolated-Claude failure proves).
24
+ //
25
+ // This set is in-memory, so a relay restart wipes it. A restart must never manufacture a spurious
26
+ // failure alert for a child that was ready long ago — so readiness is ALSO persisted as a durable
27
+ // `readyNotifiedAt` meta stamp (markAgentReadyNotified). Classification consults the set first and
28
+ // falls back to the persisted stamp, so a post-restart clean exit still reads as `agent.exited`.
21
29
  const readyFired = new Set<string>();
22
30
 
31
+ /** A child counts as "had become ready" if the live set saw it OR the durable stamp survives a restart. */
32
+ function everReady(child: AgentCard): boolean {
33
+ return typeof child.meta?.readyNotifiedAt === "number";
34
+ }
35
+
23
36
  function describe(child: AgentCard): string {
24
37
  return child.label ? `${child.label} (\`${child.id}\`)` : `\`${child.id}\``;
25
38
  }
@@ -46,6 +59,9 @@ export function notifyAgentReady(childId: string): void {
46
59
  if (!child || !parent) return;
47
60
  if (readyFired.has(childId)) return;
48
61
  readyFired.add(childId);
62
+ // Persist a durable readiness stamp so a post-restart offline edge still classifies as exited
63
+ // (the in-memory set is wiped on restart). Idempotent — only stamped on first ready.
64
+ markAgentReadyNotified(childId);
49
65
 
50
66
  emitRelayEvent({
51
67
  type: "agent.ready",
@@ -70,24 +86,36 @@ export function notifyAgentReady(childId: string): void {
70
86
  }
71
87
 
72
88
  /**
73
- * A spawned child went offline (heartbeat-reaped, or shut down). Classifies via `readyFired`: a
74
- * child that had become ready exited (`agent.exited`); one that never did failed to come up
75
- * (`agent.spawn_failed`). Routes to the `spawned_by` parent. No-op for non-spawned agents.
89
+ * A spawned child went offline (heartbeat-reaped, or shut down). Classifies via readiness (live set
90
+ * or the durable `readyNotifiedAt` stamp): a child that had become ready exited (`agent.exited`);
91
+ * one that never did failed to come up (`agent.spawn_failed`). Routes to the `spawned_by` parent.
92
+ * No-op for non-spawned agents.
76
93
  * `reason` is the offline cause (e.g. "heartbeat lost", "shutdown").
77
94
  */
78
- export function notifyAgentOffline(childId: string, reason: string): void {
95
+ export function notifyAgentOffline(childId: string, reason: string, diagnosis?: AgentDeathDiagnosis): void {
79
96
  const child = getAgent(childId);
80
97
  const parent = child?.spawnedBy;
81
98
  if (!child || !parent) return;
82
- const wasReady = readyFired.delete(childId);
99
+ // Classify from the live set OR the durable readiness stamp. The `delete` always runs (cleanup of
100
+ // the in-memory set); the persisted stamp covers a child that became ready before a relay restart.
101
+ const wasReady = readyFired.delete(childId) || everReady(child);
83
102
  const config = getNotificationsConfig();
84
103
 
104
+ // #636 — persist the post-mortem keyed by agentId so the WHY survives the reaper, regardless of
105
+ // whether the parent push is enabled. Recorded once per terminal-event emission.
106
+ if (diagnosis) recordCrashReport(crashReportFor(child, reason, diagnosis));
107
+
108
+ // #645 — persist a terminal marker for ANY offline terminus (clean exit or spawn-fail) keyed by
109
+ // agentId so it outlives the reaper. The #634 watchdog reads this to skip a benign shutdown; it
110
+ // must fire ONLY on a genuine silent death that emitted no terminal event at all.
111
+ recordTerminalEvent({ agentId: childId, reason, kind: wasReady ? "agent.exited" : "agent.spawn_failed", createdAt: Date.now() });
112
+
85
113
  if (wasReady) {
86
114
  emitRelayEvent({
87
115
  type: "agent.exited",
88
116
  source: "server",
89
117
  subject: childId,
90
- data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child) },
118
+ data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child), ...(diagnosis ? { diagnosis } : {}) },
91
119
  });
92
120
  if (!config.enabled || !config.agentExited) return;
93
121
  routeNotification({
@@ -96,8 +124,8 @@ export function notifyAgentOffline(childId: string, reason: string): void {
96
124
  recipients: [parent],
97
125
  message: {
98
126
  subject: "Spawned agent exited",
99
- body: `⏹ Your spawned agent ${describe(child)} exited — ${reason}.`,
100
- payload: { kind: "agent.exited", agentId: childId, parent, reason },
127
+ body: `⏹ Your spawned agent ${describe(child)} exited — ${reason}${diagnosisSuffix(diagnosis)}.`,
128
+ payload: { kind: "agent.exited", agentId: childId, parent, reason, ...(diagnosis ? { diagnosis } : {}) },
101
129
  replyExpected: false,
102
130
  },
103
131
  });
@@ -108,7 +136,7 @@ export function notifyAgentOffline(childId: string, reason: string): void {
108
136
  type: "agent.spawn_failed",
109
137
  source: "server",
110
138
  subject: childId,
111
- data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child), neverReady: true },
139
+ data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child), neverReady: true, ...(diagnosis ? { diagnosis } : {}) },
112
140
  });
113
141
  if (!config.enabled || !config.agentSpawnFailed) return;
114
142
  routeNotification({
@@ -117,8 +145,8 @@ export function notifyAgentOffline(childId: string, reason: string): void {
117
145
  recipients: [parent],
118
146
  message: {
119
147
  subject: "Spawn failed",
120
- body: `❌ Your spawned agent ${describe(child)} went offline before ever becoming ready — ${reason}. Likely a crash-loop, onboarding gate, or bad launch; call relay_spawn_targets to re-check provider availability before retrying.`,
121
- payload: { kind: "agent.spawn_failed", agentId: childId, parent, reason },
148
+ body: `❌ Your spawned agent ${describe(child)} went offline before ever becoming ready — ${reason}${diagnosisSuffix(diagnosis)}. Likely a crash-loop, onboarding gate, or bad launch; call relay_spawn_targets to re-check provider availability before retrying.`,
149
+ payload: { kind: "agent.spawn_failed", agentId: childId, parent, reason, ...(diagnosis ? { diagnosis } : {}) },
122
150
  replyExpected: false,
123
151
  },
124
152
  });
@@ -257,6 +257,87 @@ export function notifyBranchLandFailed(input: {
257
257
  });
258
258
  }
259
259
 
260
+ /**
261
+ * Steward-handoff awareness wake (#641/#642). When the relay can't auto-merge a branch and
262
+ * hands the workspace to a steward, the two ends used to be mutually blind: the branch agent
263
+ * was told only that land failed (never which steward holds it), and its coordinator — the
264
+ * agent responsible for the merge landing — was never looped in at all. This fires BOTH
265
+ * notices off one handoff event:
266
+ * - the OWNER branch agent learns which steward took over (#641), and
267
+ * - the branch agent's COORDINATOR (owner's `spawnedBy`, resolved in steward-identity.ts)
268
+ * is proactively notified so it keeps the big picture (#642).
269
+ * The steward id is the managed steward's policy name (reachable via `policy:<name>`) or an
270
+ * elected steward's agent id, whichever the caller has in scope.
271
+ */
272
+ export function notifyStewardHandoff(input: {
273
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
274
+ /** The steward now holding the merge — managed steward policy name or elected agent id. */
275
+ stewardId: string;
276
+ /** The branch agent's coordinator (owner's `spawnedBy` lineage), or null when none resolves. */
277
+ coordinatorAgentId?: string | null;
278
+ /** Why it was handed over ("conflict", "merge state unknown", auto-merge back-off, …). */
279
+ reason?: string;
280
+ }): void {
281
+ const { workspace } = input;
282
+ const base = workspace.baseRef ?? "base";
283
+ const owner = workspace.ownerAgentId;
284
+ const coordinator = input.coordinatorAgentId?.trim() || undefined;
285
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
286
+ const reasonLabel = input.reason ? ` (${input.reason})` : "";
287
+ const payload = {
288
+ kind: "workspace.steward-handoff",
289
+ workspaceId: workspace.id,
290
+ repoRoot: workspace.repoRoot,
291
+ branch: workspace.branch,
292
+ base,
293
+ author: owner,
294
+ stewardId: input.stewardId,
295
+ coordinatorAgentId: coordinator,
296
+ reason: input.reason,
297
+ };
298
+
299
+ emitRelayEvent({ type: "workspace.steward_handoff", source: "server", subject: workspace.id, data: payload });
300
+
301
+ const config = getNotificationsConfig();
302
+ if (!config.enabled) return;
303
+
304
+ // #641 — tell the owner branch agent which steward now holds its merge, so it stops
305
+ // thinking the branch silently failed. Durable (ttl null): the owner must learn this
306
+ // even if it went idle. Mirror the "don't touch main" guardrail of the landed-failure wake.
307
+ if (owner) {
308
+ routeNotification({
309
+ type: "workspace.steward_handoff",
310
+ scope: { author: owner, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
311
+ recipients: [owner],
312
+ ttlMs: null,
313
+ message: {
314
+ subject: "Your merge is now with a steward",
315
+ body: `🛟 ${branchLabel} couldn't be auto-merged to \`${base}\` and is now being handled by steward agent \`${input.stewardId}\`${reasonLabel}. The steward will rebase/resolve and land it. Do NOT push, rebase, merge, or touch the main checkout yourself; stay idle until you're woken with the result.`,
316
+ payload: { ...payload, notification: "steward-handoff" },
317
+ replyExpected: false,
318
+ },
319
+ });
320
+ }
321
+
322
+ // #642 — proactively loop in the branch agent's coordinator so it keeps the big picture
323
+ // (it spawned the branch agent and owns the merge landing). Skip when the coordinator IS
324
+ // the owner (no real lineage) to avoid a duplicate ping.
325
+ if (coordinator && coordinator !== owner) {
326
+ routeNotification({
327
+ type: "workspace.steward_handoff",
328
+ scope: { author: owner, parent: coordinator, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
329
+ recipients: [coordinator],
330
+ ttlMs: null,
331
+ message: {
332
+ subject: "A branch agent's merge is now with a steward",
333
+ body: `🛟 Your branch agent \`${owner ?? "?"}\`'s merge (${branchLabel} → \`${base}\`) couldn't be auto-merged and is now with steward agent \`${input.stewardId}\`${reasonLabel}. The steward will land it or escalate to you if it can't.`,
334
+ payload: { ...payload, notification: "steward-handoff", audience: "coordinator" },
335
+ replyExpected: false,
336
+ },
337
+ });
338
+ }
339
+ }
340
+
260
341
  /**
261
342
  * Finalize a workspace whose work landed in base out-of-band — a pr-strategy land whose
262
343
  * PR merged (by anyone, incl. `gh pr merge`), or a squash/cherry-pick already in base
package/src/bus.ts CHANGED
@@ -7,6 +7,7 @@ import { getCommand, updateCommand } from "./commands-db";
7
7
  import { emitCommandEvent } from "./command-events";
8
8
  import { getLifecycleManager } from "./lifecycle-manager";
9
9
  import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
10
+ import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
10
11
  import { registerAgent } from "./services/register-agent";
11
12
  import { authContextFromBus } from "./services/auth-context";
12
13
  import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
@@ -143,10 +144,19 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
143
144
  case "heartbeat":
144
145
  withRegistered(ws, frame, (conn) => {
145
146
  if (conn.agentId) {
146
- const runtime = runtimeFromMeta(frame.payload.meta);
147
- heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
148
- if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
149
- emitAgentStatusEvent(conn.agentId);
147
+ // #633 a runner whose provider terminally exited stays alive and keeps sending idle
148
+ // keepalives (semanticStatus is never "offline"). Those must NOT resurrect an agent the
149
+ // server already marked offline for that terminal exit, or the death would keep masking
150
+ // itself. Ignore the keepalive (the row ages out via the normal offline-prune); a genuine
151
+ // resume sends a `status`/`register` frame, which clears the hold below.
152
+ const current = getAgent(conn.agentId);
153
+ const heldOffline = current?.status === "offline" && isRecord(current.meta?.terminalProviderExit);
154
+ if (!heldOffline) {
155
+ const runtime = runtimeFromMeta(frame.payload.meta);
156
+ heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
157
+ if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
158
+ emitAgentStatusEvent(conn.agentId);
159
+ }
150
160
  }
151
161
  if (frame.id) send(ws, { type: "ack", payload: { frameId: frame.id } });
152
162
  });
@@ -180,6 +190,10 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
180
190
  emitAgentStatusEvent(conn.agentId);
181
191
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
182
192
  probeWorkspaceOnTurnEnd(before, after);
193
+ // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
194
+ // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
195
+ // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
196
+ reconcileTerminalProviderExit(conn.agentId, before, after);
183
197
  });
184
198
  return;
185
199
  case "ack":
package/src/cli/index.ts CHANGED
@@ -72,7 +72,7 @@ Pair examples:
72
72
  agent-relay /message codex "Can you look at that failing action?"
73
73
  agent-relay /reply 206 "Sounds good, I'll take a look"
74
74
  agent-relay /react 206 👍
75
- agent-relay /reply 206 --stdin < result.md
75
+ agent-relay /reply 206 --body-file result.md
76
76
  agent-relay get-message 206 --json
77
77
  agent-relay /reply 206 --format markdown "Here is **formatted** text"
78
78
  agent-relay /send-claimable tag:backend "Please claim and fix the failing API test"
@@ -166,8 +166,8 @@ Current session
166
166
 
167
167
  Replying
168
168
  agent-relay /reply <messageId> "<response>"
169
- agent-relay /reply <messageId> --stdin < response.md
170
169
  agent-relay /reply <messageId> --body-file response.md
170
+ printf '%s\\n' "<response>" | agent-relay /reply <messageId> --stdin
171
171
  Reply to the sender of a relay message. Prefer this over /message when
172
172
  handling an incoming relay message because the server keeps the routing
173
173
  and channel context. Large replies are uploaded as an artifact
@@ -246,7 +246,7 @@ Rules of thumb
246
246
  agent-relay get-message 123
247
247
 
248
248
  If your reply is long, avoid shell quoting and use:
249
- agent-relay /reply 123 --stdin < response.md
249
+ agent-relay /reply 123 --body-file response.md
250
250
 
251
251
  If you need to know who you are in Relay, run:
252
252
  agent-relay /status --json
package/src/cli/reply.ts CHANGED
@@ -41,6 +41,7 @@ export async function handleReplyCommand(args: string[]): Promise<void> {
41
41
  const body = (await resolveBodyInput({ bodyParts, stdinBody, bodyFile })).trim();
42
42
  if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
43
43
  if (!body) throw new Error("Reply body required.");
44
+ if (body === "--stdin") throw new Error('Refusing to send literal "--stdin" as a reply body. Provide real reply text, use --body-file PATH, or pipe non-empty content into --stdin.');
44
45
 
45
46
  const prepared = await prepareReplyBody({ body, from, replyTo, format, subject });
46
47
  const message = await apiRequest("POST", "/api/messages", {
@@ -75,7 +76,11 @@ async function resolveBodyInput(options: {
75
76
  options.bodyFile ? "body file" : "",
76
77
  ].filter(Boolean);
77
78
  if (sources.length > 1) throw new Error("Reply body must come from exactly one source: arguments, --stdin, or --body-file.");
78
- if (options.stdinBody) return readStdin();
79
+ if (options.stdinBody) {
80
+ const body = await readStdin();
81
+ if (!body.trim()) throw new Error("--stdin was given but no input was received.");
82
+ return body;
83
+ }
79
84
  if (options.bodyFile) return readFileSync(resolve(options.bodyFile), "utf8");
80
85
  return options.bodyParts.join(" ");
81
86
  }
package/src/config.ts CHANGED
@@ -129,6 +129,10 @@ export function maxArtifactBytesFromEnv(fallback: number): number {
129
129
  }
130
130
 
131
131
  export const OUTBOX_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_OUTBOX_RETENTION_MS", 60 * 60 * 1000);
132
+ // Per-provider quota history retention (#609) — durable burn-rate history for the sparkline/analytics
133
+ // charts (#607/#608). Default 30 days; the quota-history-prune maintenance reaper drops older entries.
134
+ export const QUOTA_HISTORY_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_RETENTION_MS", 30 * DAY_MS);
135
+ export const QUOTA_HISTORY_PRUNE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_PRUNE_INTERVAL_MS", 6 * 60 * 60 * 1000);
132
136
  export const TOKEN_RECORD_RETENTION_SECONDS = envNumberOrDefault("AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS", 7 * 24 * 60 * 60);
133
137
  export const CONFLICT_SCAN_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS", 10 * 60 * 1000);
134
138
  export const WORKSPACE_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_RETENTION_MS", DAY_MS);
package/src/db/agents.ts CHANGED
@@ -331,6 +331,36 @@ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?
331
331
  return result.changes > 0;
332
332
  }
333
333
 
334
+ /** #633/#634 — persist a durable "this child became ready" marker. Death classification
335
+ * (`notifyAgentOffline`) keys `agent.exited` vs `agent.spawn_failed` off readiness, but the live
336
+ * signal is an in-memory set that a relay restart wipes — so a long-ready child's later clean exit
337
+ * would mis-fire `spawn_failed`. This stamp survives the restart in SQLite, so the offline edge
338
+ * still classifies as `agent.exited`. Idempotent: only stamped once (first ready). */
339
+ export function markAgentReadyNotified(id: string, at: number = Date.now()): boolean {
340
+ const agent = getAgent(id);
341
+ if (!agent) return false;
342
+ if (typeof agent.meta?.readyNotifiedAt === "number") return false;
343
+ const merged = { ...(agent.meta ?? {}), readyNotifiedAt: at };
344
+ return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(merged), id).changes > 0;
345
+ }
346
+
347
+ /** Delete specific keys from an agent's meta blob. Returns true if anything changed. Used to clear
348
+ * the #633 `terminalProviderExit` hold when a dead provider genuinely resumes. */
349
+ export function clearAgentMetaKeys(id: string, keys: string[]): boolean {
350
+ const agent = getAgent(id);
351
+ if (!agent?.meta) return false;
352
+ const meta = { ...agent.meta };
353
+ let changed = false;
354
+ for (const key of keys) {
355
+ if (key in meta) {
356
+ delete meta[key];
357
+ changed = true;
358
+ }
359
+ }
360
+ if (!changed) return false;
361
+ return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id).changes > 0;
362
+ }
363
+
334
364
  export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
335
365
  const agent = getAgent(id);
336
366
  if (!agent) return null;
@@ -341,9 +371,12 @@ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessi
341
371
  lastExit: diagnostics,
342
372
  lastExitAt: diagnostics.detectedAt || now,
343
373
  };
374
+ // #636 — do NOT clobber last_seen with `now`: it is the agent's genuine last heartbeat, and the
375
+ // death diagnosis derives the real heartbeat gap from it (the orchestrator exit path diagnoses
376
+ // off the row returned here). "When we detected the exit" lives in meta.lastExitAt instead.
344
377
  const result = getDb()
345
- .query("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
346
- .run(JSON.stringify(merged), now, id);
378
+ .query("UPDATE agents SET status = 'offline', ready = 0, meta = ? WHERE id = ?")
379
+ .run(JSON.stringify(merged), id);
347
380
  if (result.changes <= 0) return null;
348
381
  closeOpenPairsForAgent(id, now);
349
382
  return getAgent(id);