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.
- package/docs/openapi.json +39 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-CevL-2jc.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-CY2Cbpet.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-CY2Cbpet.js.map} +1 -1
- package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
- package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
- package/public/assets/{agents-BWmmWj4Q.js → agents-Bj5KzrYJ.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-Bj5KzrYJ.js.map} +1 -1
- package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
- package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
- package/public/assets/{automation-DRtOENs0.js → automation-BOPk1oh_.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BOPk1oh_.js.map} +1 -1
- package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
- package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
- package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
- package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
- package/public/assets/{chat-gfUxbTus.js → chat-D5pI4ebq.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-D5pI4ebq.js.map} +1 -1
- package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
- package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
- package/public/assets/index-CEXhh5nz.js +23 -0
- package/public/assets/index-CEXhh5nz.js.map +1 -0
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-C4dEK49e.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-C4dEK49e.js.map} +1 -1
- package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
- package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
- package/public/assets/{maintenance-DsszLb92.js → maintenance-DJZUSsja.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-DJZUSsja.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-5wUMDKVi.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-BnhAD09c.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-BnhAD09c.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-Cc9YF8Wl.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-Cc9YF8Wl.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-B01iyiBA.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-B01iyiBA.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-DN8CUTVx.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-DN8CUTVx.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-BxuDMLhs.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-BxuDMLhs.js.map} +1 -1
- package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
- package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
- package/public/assets/{security-XhAAP6q3.js → security-CO0PBIau.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-CO0PBIau.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DHaFsLvt.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DHaFsLvt.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-De1DKwd6.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-De1DKwd6.js.map} +1 -1
- package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
- package/public/assets/store-DsqUEZOf.js.map +1 -0
- package/public/assets/{tasks-zwGo7hon.js → tasks-Bqd188ql.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-Bqd188ql.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-Mh6ypDPr.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-Mh6ypDPr.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-Dy5J3E_b.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-ClGgOUSu.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-ClGgOUSu.js.map} +1 -1
- package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
- package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/src/adapter.ts +4 -3
- package/src/agent-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +47 -19
- package/src/branch-landed.ts +81 -0
- package/src/bus.ts +18 -4
- package/src/cli/index.ts +3 -3
- package/src/cli/reply.ts +6 -1
- package/src/config.ts +4 -0
- package/src/db/agents.ts +35 -2
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +22 -0
- package/src/db/provider-quotas.ts +99 -21
- package/src/db/terminal-events.ts +56 -0
- package/src/maintenance/constants.ts +4 -0
- package/src/maintenance/jobs.ts +19 -1
- package/src/maintenance/teams.ts +73 -1
- package/src/maintenance/workspaces.ts +23 -10
- package/src/mcp/index.ts +4 -2
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/mcp/tools-spawn.ts +119 -4
- package/src/notification-types.ts +4 -0
- package/src/provider-catalog-store.ts +3 -0
- package/src/routes/agents.ts +11 -1
- package/src/routes/commands.ts +21 -3
- package/src/routes/index.ts +2 -1
- package/src/routes/orchestrator.ts +14 -1
- package/src/routes/workspaces.ts +5 -0
- package/src/services/terminal-provider-exit.ts +33 -0
- package/src/services/transient-agent-reaper.ts +4 -1
- package/src/spawn-targets.ts +25 -8
- package/src/steward-identity.ts +50 -0
- package/src/steward.ts +12 -4
- package/src/team-idle-tracker.ts +15 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +120 -8
- package/src/workspace-merge.ts +10 -0
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/index-D8TVUdlC.js +0 -22
- package/public/assets/index-D8TVUdlC.js.map +0 -1
- package/public/assets/index-DgZLDL0K.css +0 -2
- 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
|
|
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
|
|
15
|
-
// cleanly (`agent.exited`); one that never
|
|
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).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// a
|
|
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
|
|
74
|
-
* child that had become ready exited (`agent.exited`);
|
|
75
|
-
* (`agent.spawn_failed`). Routes to the `spawned_by` parent.
|
|
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
|
-
|
|
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
|
});
|
package/src/branch-landed.ts
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 --
|
|
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 --
|
|
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)
|
|
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 =
|
|
346
|
-
.run(JSON.stringify(merged),
|
|
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);
|