agent-relay-server 0.84.0 → 0.85.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 (79) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
  9. package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
  10. package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
  11. package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
  12. package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
  13. package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
  14. package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
  15. package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
  16. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
  17. package/public/assets/index-LUIvWcx6.css +2 -0
  18. package/public/assets/index-TZskQfQL.js +23 -0
  19. package/public/assets/index-TZskQfQL.js.map +1 -0
  20. package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
  21. package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
  22. package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
  23. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
  24. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
  25. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
  26. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
  27. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
  28. package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
  29. package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
  30. package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
  31. package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
  32. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
  33. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
  34. package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
  35. package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
  36. package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
  37. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
  38. package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
  39. package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
  40. package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
  41. package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
  42. package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
  43. package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
  44. package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
  45. package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
  46. package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
  47. package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
  48. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
  49. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
  50. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
  51. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
  52. package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
  53. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
  54. package/public/index.html +2 -2
  55. package/src/agent-death-diagnosis.ts +255 -0
  56. package/src/agent-lifecycle-events.ts +42 -19
  57. package/src/bus.ts +18 -4
  58. package/src/db/agents.ts +35 -2
  59. package/src/db/crash-reports.ts +115 -0
  60. package/src/db/index.ts +1 -0
  61. package/src/db/migrations.ts +5 -0
  62. package/src/maintenance/jobs.ts +6 -1
  63. package/src/maintenance/teams.ts +68 -1
  64. package/src/mcp/index.ts +2 -1
  65. package/src/mcp/tools-messaging.ts +38 -0
  66. package/src/notification-types.ts +2 -0
  67. package/src/routes/agents.ts +11 -1
  68. package/src/routes/commands.ts +21 -3
  69. package/src/routes/index.ts +2 -1
  70. package/src/routes/orchestrator.ts +14 -1
  71. package/src/services/terminal-provider-exit.ts +33 -0
  72. package/src/spawn-targets.ts +19 -8
  73. package/src/team-idle-tracker.ts +15 -0
  74. package/src/workspace-actions.ts +5 -1
  75. package/src/workspace-auto-merge.ts +87 -4
  76. package/src/workspace-merge.ts +10 -0
  77. package/public/assets/index-D8TVUdlC.js +0 -22
  78. package/public/assets/index-D8TVUdlC.js.map +0 -1
  79. package/public/assets/index-DgZLDL0K.css +0 -2
@@ -0,0 +1,115 @@
1
+ // #636 — persisted crash post-mortems. Stored in their own table (no FK to agents) so a death
2
+ // verdict survives `pruneOfflineAgents` / `deleteAgent` and stays retrievable by agentId after the
3
+ // worktree/session reaper has cleaned up. One row per (agentId, createdAt); the latest wins on read.
4
+ import { getDb } from "./connection.ts";
5
+ import { parseJson } from "../utils";
6
+ import type { AgentDeathDiagnosis, CrashReport } from "../types";
7
+
8
+ interface CrashReportRow {
9
+ agent_id: string;
10
+ parent_id: string | null;
11
+ team_id: string | null;
12
+ spawn_request_id: string | null;
13
+ provider: string | null;
14
+ label: string | null;
15
+ cause: string;
16
+ reason: string;
17
+ diagnosis: string;
18
+ created_at: number;
19
+ }
20
+
21
+ export function ensureCrashReportsSchema(): void {
22
+ getDb().run(`
23
+ CREATE TABLE IF NOT EXISTS crash_reports (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ agent_id TEXT NOT NULL,
26
+ parent_id TEXT,
27
+ team_id TEXT,
28
+ spawn_request_id TEXT,
29
+ provider TEXT,
30
+ label TEXT,
31
+ cause TEXT NOT NULL,
32
+ reason TEXT NOT NULL,
33
+ diagnosis TEXT NOT NULL,
34
+ created_at INTEGER NOT NULL
35
+ )
36
+ `);
37
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_agent ON crash_reports(agent_id, created_at DESC)");
38
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_parent ON crash_reports(parent_id, created_at DESC)");
39
+ }
40
+
41
+ function rowToCrashReport(row: CrashReportRow): CrashReport {
42
+ return {
43
+ agentId: row.agent_id,
44
+ ...(row.parent_id ? { parentId: row.parent_id } : {}),
45
+ ...(row.team_id ? { teamId: row.team_id } : {}),
46
+ ...(row.spawn_request_id ? { spawnRequestId: row.spawn_request_id } : {}),
47
+ ...(row.provider ? { provider: row.provider } : {}),
48
+ ...(row.label ? { label: row.label } : {}),
49
+ reason: row.reason,
50
+ diagnosis: parseJson<AgentDeathDiagnosis>(row.diagnosis, {
51
+ cause: "unknown",
52
+ confidence: "low",
53
+ summary: "Cause undetermined",
54
+ evidence: [],
55
+ detectedAt: row.created_at,
56
+ }),
57
+ createdAt: row.created_at,
58
+ };
59
+ }
60
+
61
+ export function recordCrashReport(report: CrashReport): void {
62
+ getDb()
63
+ .query(
64
+ `INSERT INTO crash_reports (agent_id, parent_id, team_id, spawn_request_id, provider, label, cause, reason, diagnosis, created_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
66
+ )
67
+ .run(
68
+ report.agentId,
69
+ report.parentId ?? null,
70
+ report.teamId ?? null,
71
+ report.spawnRequestId ?? null,
72
+ report.provider ?? null,
73
+ report.label ?? null,
74
+ report.diagnosis.cause,
75
+ report.reason,
76
+ JSON.stringify(report.diagnosis),
77
+ report.createdAt,
78
+ );
79
+ }
80
+
81
+ /** The most recent crash report for an agent, or null if none was ever recorded. */
82
+ export function getCrashReport(agentId: string): CrashReport | null {
83
+ const row = getDb()
84
+ .query("SELECT * FROM crash_reports WHERE agent_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
85
+ .get(agentId) as CrashReportRow | undefined;
86
+ return row ? rowToCrashReport(row) : null;
87
+ }
88
+
89
+ export interface ListCrashReportsFilter {
90
+ /** Restrict to children of this spawn-parent (the parent/self scope for the MCP tool). */
91
+ parentId?: string;
92
+ /** Restrict to a single agent. */
93
+ agentId?: string;
94
+ limit?: number;
95
+ }
96
+
97
+ export function listCrashReports(filter: ListCrashReportsFilter = {}): CrashReport[] {
98
+ const where: string[] = [];
99
+ const params: (string | number)[] = [];
100
+ if (filter.parentId) {
101
+ where.push("parent_id = ?");
102
+ params.push(filter.parentId);
103
+ }
104
+ if (filter.agentId) {
105
+ where.push("agent_id = ?");
106
+ params.push(filter.agentId);
107
+ }
108
+ const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
109
+ const rows = getDb()
110
+ .query(
111
+ `SELECT * FROM crash_reports ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id DESC LIMIT ?`,
112
+ )
113
+ .all(...params, limit) as CrashReportRow[];
114
+ return rows.map(rowToCrashReport);
115
+ }
package/src/db/index.ts CHANGED
@@ -27,6 +27,7 @@ export * from "./workspaces.ts";
27
27
  export * from "./merge-lease.ts";
28
28
  export * from "./continuations.ts";
29
29
  export * from "./continuation-archives.ts";
30
+ export * from "./crash-reports.ts";
30
31
  export * from "./plans.ts";
31
32
  export * from "./teams.ts";
32
33
  export * from "./blackboard.ts";
@@ -20,6 +20,7 @@ import { ensureAuthIdentitiesSchema } from "./auth-identities.ts";
20
20
  import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
21
21
  import { ensureDriveLeaseSchema } from "./drive-leases.ts";
22
22
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
23
+ import { ensureCrashReportsSchema } from "./crash-reports.ts";
23
24
  import { normalizeReactionEmoji } from "./mappers.ts";
24
25
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
25
26
  import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
@@ -795,6 +796,10 @@ export function applyMigrations(): void {
795
796
  )
796
797
  `);
797
798
 
799
+ // #636 — persisted crash post-mortems. Own table, no FK to agents, so a death verdict outlives
800
+ // the offline-prune/reaper and stays retrievable by agentId.
801
+ ensureCrashReportsSchema();
802
+
798
803
  // First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
799
804
  // and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
800
805
  // bare 'user' == user:default resolve to users.id='default' via the SDK resolver; no bytes move.
@@ -7,6 +7,7 @@ import {
7
7
  createActivityEvent,
8
8
  evaluatePoolBindings,
9
9
  expireQueuedMessages,
10
+ getAgent,
10
11
  pruneOrphanedSharedWorkspaces,
11
12
  pruneOfflineAgents,
12
13
  pruneOldMessages,
@@ -19,6 +20,7 @@ import {
19
20
  sweepArtifacts,
20
21
  } from "../db";
21
22
  import { notifyAgentOffline } from "../agent-lifecycle-events";
23
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
22
24
  import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
23
25
  import { reapOrphanedWorktrees } from "../workspace-orphans";
24
26
  import { getLifecycleManager } from "../lifecycle-manager";
@@ -173,7 +175,10 @@ export const definitions: MaintenanceJobDefinition[] = [
173
175
  for (const id of reapedAgentIds) {
174
176
  emitAgentStatus(id);
175
177
  getLifecycleManager().onAgentDisappeared(id);
176
- notifyAgentOffline(id, "heartbeat lost");
178
+ // #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
179
+ // known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
180
+ const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
181
+ notifyAgentOffline(id, "heartbeat lost", diagnosis);
177
182
  createActivityEvent({
178
183
  clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
179
184
  kind: "state",
@@ -2,6 +2,7 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getCrashReport,
5
6
  getTeamBusyState,
6
7
  getTeamRoster,
7
8
  listActiveTeams,
@@ -13,10 +14,69 @@ import { dissolveTeamById } from "../services/team";
13
14
  import { getTeamsConfig } from "../teams-config-store";
14
15
  import { teamReapGraceMs } from "./constants";
15
16
  import { notifyTarget } from "./events";
16
- import { teamIdleTracker } from "../team-idle-tracker";
17
+ import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
18
+ import type { AgentCard } from "../types";
17
19
 
18
20
  export function resetTeamIdleWatchdogForTests(): void {
19
21
  teamIdleTracker.clear();
22
+ teamMemberWatchTracker.clear();
23
+ }
24
+
25
+ /** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
26
+ * heartbeating — i.e. a status that implies it became ready, not a never-started spawn. */
27
+ function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
28
+ return (member.status === "idle" || member.status === "busy" || member.status === "online") && member.lastSeen > freshCutoff;
29
+ }
30
+
31
+ /**
32
+ * #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
33
+ * dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
34
+ * each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
35
+ * a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
36
+ * already woken by the terminal event) — this is the backstop for the NO-terminal-event case.
37
+ */
38
+ function detectSilentMembers(
39
+ teamId: string,
40
+ coordinatorId: string | undefined,
41
+ now: number,
42
+ thresholdMs: number,
43
+ out: { pingedMembers: string[]; watchedMembers: number },
44
+ ): void {
45
+ const freshCutoff = now - thresholdMs;
46
+ const watch = teamMemberWatchTracker.get(teamId) ?? new Map<string, TeamMemberWatch>();
47
+ teamMemberWatchTracker.set(teamId, watch);
48
+
49
+ for (const member of getTeamRoster(teamId)) {
50
+ if (member.id === coordinatorId) continue; // the coordinator is the recipient, not a watched worker
51
+ if (!memberIsAlive(member, freshCutoff)) continue;
52
+ const existing = watch.get(member.id);
53
+ // Recovery re-arms the ping: a member seen alive again after a silence can re-trigger later.
54
+ const recovered = existing ? member.lastSeen > existing.lastActiveAt : false;
55
+ watch.set(member.id, {
56
+ lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
57
+ pinged: recovered ? false : existing?.pinged ?? false,
58
+ ...(member.label ? { label: member.label } : {}),
59
+ });
60
+ }
61
+
62
+ out.watchedMembers += watch.size;
63
+ for (const [memberId, entry] of watch) {
64
+ const gapMs = now - entry.lastActiveAt;
65
+ if (gapMs < thresholdMs || entry.pinged) continue;
66
+ entry.pinged = true;
67
+ if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
68
+ if (!coordinatorId) continue;
69
+ const minutes = Math.floor(gapMs / 60_000);
70
+ notifyTarget(
71
+ "team.member_silent",
72
+ { teamId, agentId: memberId },
73
+ coordinatorId,
74
+ "Team member went silent",
75
+ `Member \`${entry.label ?? memberId}\` in team \`${teamId}\` has sent no heartbeat for ${minutes} min — it may have died without a terminal event. Check it and restart its unfinished work, or stand it down.`,
76
+ { kind: "team.member_silent", teamId, coordinatorId, memberId, label: entry.label, silentForMs: gapMs, lastActiveAt: entry.lastActiveAt },
77
+ );
78
+ out.pingedMembers.push(memberId);
79
+ }
20
80
  }
21
81
 
22
82
  function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
@@ -79,9 +139,13 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
79
139
  const pingedTeamIds: string[] = [];
80
140
  const alreadyPingedTeamIds: string[] = [];
81
141
  const missingCoordinatorTeamIds: string[] = [];
142
+ const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
82
143
 
83
144
  for (const team of listActiveTeams()) {
84
145
  seen.add(team.id);
146
+ // #634 — runs every cycle, independent of the team-level idle gate below: a single silent
147
+ // member must surface even while OTHER members keep the team "busy".
148
+ detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
85
149
  const busy = getTeamBusyState(team.id, cutoff);
86
150
  if (busy.busyMemberIds.length > 0) {
87
151
  teamIdleTracker.delete(team.id);
@@ -112,6 +176,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
112
176
  }
113
177
 
114
178
  for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
179
+ for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
115
180
 
116
181
  return {
117
182
  enabled: true,
@@ -122,6 +187,8 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
122
187
  pingedTeamIds,
123
188
  alreadyPingedTeamIds,
124
189
  missingCoordinatorTeamIds,
190
+ silentMemberIds: memberSilence.pingedMembers,
191
+ watchedMembers: memberSilence.watchedMembers,
125
192
  };
126
193
  }
127
194
 
package/src/mcp/index.ts CHANGED
@@ -16,7 +16,7 @@ import { errMessage, isRecord } from "agent-relay-sdk";
16
16
  import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "./types";
17
17
  import { positiveIdOrUndefined, recordField, stringField } from "./validation";
18
18
  import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
19
- import { MESSAGING_TOOLS, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
19
+ import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
20
20
  import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
21
21
  import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
22
22
 
@@ -122,6 +122,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
122
122
  else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
123
123
  else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
124
124
  else if (name === "relay_agent_status") result = relayAgentStatus(args);
125
+ else if (name === "relay_agent_crash_report") result = relayAgentCrashReport(auth, args);
125
126
  else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
126
127
  else if (name === "relay_whoami") result = relayWhoami(auth);
127
128
  else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
@@ -8,7 +8,9 @@ import {
8
8
  createArtifact,
9
9
  getAgent,
10
10
  getArtifact,
11
+ getCrashReport,
11
12
  getMessage,
13
+ listCrashReports,
12
14
  getOrchestrator,
13
15
  getTask,
14
16
  getThread,
@@ -190,6 +192,19 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
190
192
  additionalProperties: false,
191
193
  },
192
194
  },
195
+ {
196
+ name: "relay_agent_crash_report",
197
+ description: "Retrieve the post-mortem for a dead agent (#636): a likely-cause label (oom · killed · clean-exit · token-401 · onboarding-gate · provider-crash · transport-loss · unknown) + log tail + heartbeat/active-work context. Pass agentId for one report (yourself or a child you spawned); omit it to list your own spawned children's reports. The report survives the reaper, so it answers 'why did my worker die?' after it's gone.",
198
+ requiredScopes: ["agents:read", "agent:read"],
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ agentId: { type: "string", description: "The dead agent's id. Must be yourself or one of your spawned children. Omit to list your own children's crash reports." },
203
+ limit: { type: "integer", minimum: 1, maximum: 200, description: "Max reports when listing (default 50)." },
204
+ },
205
+ additionalProperties: false,
206
+ },
207
+ },
193
208
  ];
194
209
 
195
210
  function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
@@ -450,6 +465,29 @@ export function relayFindAgents(auth: McpAuthContext, args: Record<string, unkno
450
465
  return { agents: safe, total, count: safe.length, truncated: true, nextOffset: offset + safe.length };
451
466
  }
452
467
 
468
+ // #636 — self/parent-scoped crash post-mortem retrieval. A caller may read its OWN report or any of
469
+ // its spawned children's; never an unrelated agent's. Survives the reaper (separate crash_reports table).
470
+ export function relayAgentCrashReport(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
471
+ const caller = callerAgentId(auth);
472
+ const agentId = optionalString(args.agentId, "agentId", 240);
473
+ if (agentId) {
474
+ // Authorize FIRST, regardless of whether a report exists — the self/parent contract must hold
475
+ // even for an agentId with no report (else any agent:read caller leaks `{report:null}`). Prefer
476
+ // the live agent row's lineage; fall back to the report's recorded parent once the agent is reaped.
477
+ const agent = getAgent(agentId);
478
+ const report = getCrashReport(agentId);
479
+ const parent = agent?.spawnedBy ?? report?.parentId;
480
+ const authorized = !!caller && (caller === agentId || (parent !== undefined && caller === parent));
481
+ if (!authorized) throw new McpAuthError("crash report is scoped to the agent itself or its spawn-parent");
482
+ return { agentId, report: report ?? null };
483
+ }
484
+ // No agentId: list the caller's own spawned children's reports.
485
+ if (!caller) throw new McpAuthError("relay_agent_crash_report requires a resolvable caller agent to list reports");
486
+ const limit = optionalPositiveInt(args.limit, "limit");
487
+ const reports = listCrashReports({ parentId: caller, ...(limit ? { limit } : {}) });
488
+ return { reports, count: reports.length };
489
+ }
490
+
453
491
  function payloadWithAttachments(
454
492
  payload: Record<string, unknown> | undefined,
455
493
  attachments: AttachmentRef[] | undefined,
@@ -11,6 +11,7 @@ export type NotificationType =
11
11
  | "agent.transient_land_hold"
12
12
  | "agent.rate_limit_resume"
13
13
  | "team.idle"
14
+ | "team.member_silent"
14
15
  | "plan.bound_agent_exited"
15
16
  | "workspace.steward_task"
16
17
  | "workspace.conflict"
@@ -62,6 +63,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
62
63
  "agent.transient_land_hold": null,
63
64
  "agent.rate_limit_resume": 15 * MINUTES,
64
65
  "team.idle": null,
66
+ "team.member_silent": null,
65
67
  "plan.bound_agent_exited": null,
66
68
  "workspace.steward_task": null,
67
69
  "workspace.conflict": null,
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents.
2
2
  import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
3
- import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
3
+ import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
5
  import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
6
6
  import { cleanStringArray } from "../validation";
@@ -68,6 +68,16 @@ export const getAgentById: Handler = (req, params) => {
68
68
  return json(attachIssueRepo(attachBranchState(agent)));
69
69
  };
70
70
 
71
+ // #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
72
+ // so this resolves even after the reaper removed the agent. Existence-hiding still applies WHILE the
73
+ // agent exists; once reaped, an authenticated operator can read the post-mortem.
74
+ export const getAgentCrashReportRoute: Handler = (req, params) => {
75
+ const id = params.id!;
76
+ const agent = getAgent(id);
77
+ if (agent && !canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
78
+ return json({ agentId: id, report: getCrashReport(id) });
79
+ };
80
+
71
81
  export const getAgentReplyObligations: Handler = (_req, params) => {
72
82
  const agent = getAgent(params.id!);
73
83
  return agent ? json(listPendingReplyObligations(agent.id)) : error("agent not found", 404);
@@ -16,7 +16,7 @@ import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
16
  import { seedProviderConfigs } from "../provider-config-store";
17
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
18
18
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
19
- import { scheduleRepoMerge } from "../workspace-auto-merge";
19
+ import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
20
20
  import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
21
21
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
22
22
 
@@ -220,6 +220,11 @@ export const patchCommand: Handler = async (req, params) => {
220
220
  mergeResult: command.result,
221
221
  mergeCommandId: command.id,
222
222
  mergedAt: Date.now(),
223
+ // A merge that settles `succeeded` made genuine progress (land / recycle /
224
+ // noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
225
+ // is attempted normally next time (#638).
226
+ ...CLEAR_MERGE_NO_PROGRESS,
227
+ mergeError: undefined,
223
228
  ...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
224
229
  // The land consumes the steward's claim (#208 steward contract / vent
225
230
  // #62): a successful land must release it, or `steward inspect` keeps
@@ -259,12 +264,25 @@ export const patchCommand: Handler = async (req, params) => {
259
264
  }
260
265
  }
261
266
  } else if (command.status === "failed" && command.correlationId) {
262
- // Merge couldn't complete — don't leave it stuck in merge_planned.
267
+ // Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
268
+ // merge now settles `failed` (orchestrator #638) carrying the result; honor the
269
+ // orchestrator's intended target status when it gave a valid one so a real
270
+ // `conflict` routes to the steward (a non-landable status the auto-merge job
271
+ // skips, which itself stops the loop) rather than bouncing back to the landable
272
+ // `review_requested`. Surface the error and count the no-progress attempt so the
273
+ // auto-merge job backs off after N instead of busy-looping (#638).
263
274
  const current = getWorkspace(command.correlationId);
264
275
  if (current && current.status === "merge_planned") {
265
- updateWorkspaceStatus(command.correlationId, "review_requested", {
276
+ const failedResultStatus = isRecord(command.result)
277
+ ? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
278
+ : undefined;
279
+ const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
280
+ ? failedResultStatus
281
+ : "review_requested";
282
+ updateWorkspaceStatus(command.correlationId, nextStatus, {
266
283
  mergeCommandId: command.id,
267
284
  mergeError: command.error ?? "merge failed",
285
+ ...noteMergeNoProgress(current.metadata),
268
286
  });
269
287
  notifyBranchLandFailed({
270
288
  workspace: current,
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
2
- import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
2
+ import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
3
3
  import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
4
4
  import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
5
  import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
@@ -121,6 +121,7 @@ const routes: Route[] = [
121
121
  route("GET", "/api/agents/:id/timeline", getAgentTimelineRoute),
122
122
  route("GET", "/api/agents/:id/active-memories", getAgentActiveMemories),
123
123
  route("GET", "/api/agents/:id/reply-obligations", getAgentReplyObligations),
124
+ route("GET", "/api/agents/:id/crash-report", getAgentCrashReportRoute),
124
125
  route("DELETE", "/api/agents/:id/active-memories", deleteAgentActiveMemories),
125
126
  route("GET", "/api/agents/:id", getAgentById),
126
127
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
@@ -2,6 +2,7 @@
2
2
  import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "../contracts";
4
4
  import { notifyAgentOffline } from "../agent-lifecycle-events";
5
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
5
6
  import { VERSION } from "../config";
6
7
  import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
7
8
  import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
@@ -209,7 +210,10 @@ export const patchOrchestratorAgents: Handler = async (req, params) => {
209
210
  // (its WHERE excludes status='offline'), guaranteeing a single notifyAgentOffline emission.
210
211
  // notifyAgentOffline no-ops for non-spawned agents. Only fire when we actually recorded the
211
212
  // exit (agent !== null) so an already-offline row can't double-notify.
212
- notifyAgentOffline(exited.agentId, exitReason(exited));
213
+ // #636 — classify the death from the captured exit diagnostics + agent state and ride the
214
+ // verdict on the terminal event (also persisted as a crash report inside notifyAgentOffline).
215
+ const diagnosis = diagnoseAgentDeath({ agent, reason: exitReason(exited), exit: exited });
216
+ notifyAgentOffline(exited.agentId, exitReason(exited), diagnosis);
213
217
  }
214
218
  auditEvent({
215
219
  clientId: [
@@ -324,6 +328,15 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
324
328
  runnerInfoFile: cleanString(value.runnerInfoFile, `exitedAgents[${index}].runnerInfoFile`, { max: 500 }),
325
329
  runnerInfoPresent: typeof value.runnerInfoPresent === "boolean" ? value.runnerInfoPresent : undefined,
326
330
  systemd,
331
+ // #636 — termination signal / exit code / OOM probe captured by the orchestrator.
332
+ signal: cleanString(value.signal, `exitedAgents[${index}].signal`, { max: 40 }),
333
+ exitCode: cleanSafeNumber(value.exitCode),
334
+ oom: isRecord(value.oom)
335
+ ? {
336
+ source: cleanString(value.oom.source, `exitedAgents[${index}].oom.source`, { required: true, max: 80 })!,
337
+ detail: cleanString(value.oom.detail, `exitedAgents[${index}].oom.detail`, { max: 240 }),
338
+ }
339
+ : undefined,
327
340
  unavailable,
328
341
  lastError: cleanString(value.lastError, `exitedAgents[${index}].lastError`, { required: true, max: 1000 })!,
329
342
  };
@@ -0,0 +1,33 @@
1
+ // #633 — owning service for the silent clean-exit terminal event. A runner whose provider
2
+ // terminally exits stays alive (manual-resume hold), so neither the orchestrator tmux-gone path nor
3
+ // the heartbeat reaper fires. The runner marks the offline status edge with a `terminalProviderExit`
4
+ // blob; this service turns that edge into a terminal `agent.exited` event with a #636 diagnosis, and
5
+ // clears the hold when the provider genuinely resumes. Kept out of the bus transport so the
6
+ // transport stays thin (transport-thinness ratchet, #348).
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { clearAgentMetaKeys } from "../db";
9
+ import { notifyAgentOffline } from "../agent-lifecycle-events";
10
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
11
+ import type { AgentCard } from "../types";
12
+
13
+ /**
14
+ * React to an agent status transition observed on the bus.
15
+ * - dead→live with a stale marker: clear the hold so a keepalive can resurrect normally and a future
16
+ * death isn't mislabelled off a stale marker.
17
+ * - live→offline carrying a `terminalProviderExit` marker: fire the terminal event once (edge), with
18
+ * a death diagnosis (also persisted as a crash report inside notifyAgentOffline). No-op for
19
+ * non-spawned agents (notifyAgentOffline guards on lineage) and graceful shutdowns (no marker).
20
+ */
21
+ export function reconcileTerminalProviderExit(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
22
+ if (after && after.status !== "offline" && before?.status === "offline" && isRecord(after.meta?.terminalProviderExit)) {
23
+ clearAgentMetaKeys(agentId, ["terminalProviderExit"]);
24
+ return;
25
+ }
26
+ if (!after || after.status !== "offline") return;
27
+ if (before && before.status === "offline") return;
28
+ const marker = after.meta?.terminalProviderExit;
29
+ if (!isRecord(marker)) return;
30
+ const reasonText = typeof marker.reason === "string" ? marker.reason : "provider exited";
31
+ const reason = `provider exited (${reasonText})`;
32
+ notifyAgentOffline(agentId, reason, diagnoseAgentDeath({ agent: after, reason }));
33
+ }
@@ -48,28 +48,39 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
48
48
  interface SpawnCatalogish {
49
49
  provider: SpawnProvider;
50
50
  defaultModel?: string;
51
- models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string }>;
51
+ models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean }>;
52
52
  }
53
53
 
54
54
  function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
55
55
  const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
56
56
  const global = effectiveProviderCatalogList();
57
+ // #643 — drop disabled models so relay_spawn_targets advertises only spawnable models (parity
58
+ // with the #485 dashboard pickers' `!model.disabled` filter). The disabled flag is a relay-side
59
+ // override (provider_model_overrides) that the effective catalog carries but the orchestrator-
60
+ // REPORTED catalog does not, so cross-reference the effective catalog by alias regardless of
61
+ // which source supplies the matrix entry — otherwise the reported path keeps advertising a model
62
+ // the server then rejects with "model is disabled".
63
+ const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
64
+ new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.disabled).map((m) => m.alias) ?? []);
57
65
  return o.providers.map((provider) => {
58
66
  const cat: SpawnCatalogish | undefined =
59
67
  reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
68
+ const disabled = disabledAliasesFor(provider);
60
69
  return {
61
70
  provider,
62
71
  available: true,
63
72
  ...(cat
64
73
  ? {
65
74
  defaultModel: cat.defaultModel,
66
- models: cat.models.map((m) => ({
67
- id: m.alias,
68
- providerModel: m.providerModel,
69
- default: cat.defaultModel === m.alias,
70
- effortLevels: m.efforts,
71
- ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
72
- })),
75
+ models: cat.models
76
+ .filter((m) => !m.disabled && !disabled.has(m.alias))
77
+ .map((m) => ({
78
+ id: m.alias,
79
+ providerModel: m.providerModel,
80
+ default: cat.defaultModel === m.alias,
81
+ effortLevels: m.efforts,
82
+ ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
83
+ })),
73
84
  }
74
85
  : { models: [] }),
75
86
  };
@@ -11,3 +11,18 @@ export const teamIdleTracker = new Map<string, TeamIdleEntry>();
11
11
  export function getTeamIdleEntry(teamId: string): TeamIdleEntry | undefined {
12
12
  return teamIdleTracker.get(teamId);
13
13
  }
14
+
15
+ // #634 — per-member heartbeat-gap watch. Kept in-memory (not derived from the live roster) ON
16
+ // PURPOSE: a member that was ready/busy and then vanished from the roster entirely escapes any
17
+ // roster-only enumeration, so the watch must remember it independently to detect the silence gap.
18
+ export interface TeamMemberWatch {
19
+ /** Most recent time the member was seen alive (non-offline heartbeat). */
20
+ lastActiveAt: number;
21
+ /** Whether the member's CURRENT silence has already pinged the coordinator (re-armed on recovery). */
22
+ pinged: boolean;
23
+ /** Member label for a readable ping. */
24
+ label?: string;
25
+ }
26
+
27
+ // teamId → (memberId → watch)
28
+ export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
25
25
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
26
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
27
27
  import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
- import { scheduleRepoMerge } from "./workspace-auto-merge";
28
+ import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
29
29
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
30
30
 
31
31
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -235,6 +235,10 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
235
235
  ...metadata,
236
236
  ...(detail ? { detail } : {}),
237
237
  ...(agentId ? { updatedByAgentId: agentId } : {}),
238
+ // #638 — a fresh ready/request-review hand-off means the owner did more work, so a
239
+ // prior auto-merge no-progress back-off should clear: the new commits may resolve
240
+ // whatever blocked the land (e.g. a base that's since been synced). Re-arm the lane.
241
+ ...(READY_TO_LAND_STATUSES.has(nextStatus) ? CLEAR_MERGE_NO_PROGRESS : {}),
238
242
  lastWorkspaceAction: action,
239
243
  lastWorkspaceActionAt: Date.now(),
240
244
  });