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
@@ -13,7 +13,8 @@ import {
13
13
  updateWorkspaceStatus,
14
14
  } from "../db";
15
15
  import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
- import { reconcileLandedWorkspace } from "../branch-landed";
16
+ import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
17
+ import { resolveStewardIdentity } from "../steward-identity";
17
18
  import { getStewardConfig } from "../config-store";
18
19
  import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
19
20
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
@@ -97,15 +98,22 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
97
98
  if (woke) notifiedStewards.push(woke);
98
99
  } else if (ws.stewardAgentId) {
99
100
  try {
101
+ // #640 — carry the identity triangle (branch agent + its coordinator) so the
102
+ // elected steward can address them; #641/#642 notify owner + coordinator of the handoff.
103
+ const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
104
+ const coordinatorLine = coordinatorAgentId
105
+ ? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't resolve it.`
106
+ : "";
100
107
  notifyTarget(
101
108
  "workspace.conflict",
102
- { workspaceId: ws.id, repoRoot: ws.repoRoot },
109
+ { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
103
110
  ws.stewardAgentId,
104
111
  "Workspace merge conflict",
105
- `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
106
- { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
112
+ `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind).${ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : ""} As repo steward, please coordinate resolution.${coordinatorLine}`,
113
+ { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind, ownerAgentId, coordinatorAgentId },
107
114
  );
108
115
  notifiedStewards.push(ws.stewardAgentId);
116
+ notifyStewardHandoff({ workspace: ws, stewardId: ws.stewardAgentId, coordinatorAgentId, reason: "conflict" });
109
117
  } catch {}
110
118
  }
111
119
  } else if (p.conflict === false && ws.status === "conflict" && meta.autoConflict === true) {
@@ -168,13 +176,18 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
168
176
 
169
177
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
170
178
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
179
+ // #642 — escalate to the branch agent's coordinator (owner's `spawnedBy`), falling back to
180
+ // the configured `stewardFallbackTarget` only when no coordinator resolves. The bare
181
+ // fallback was an unset `system` dead-end in the live incident.
182
+ const { coordinatorAgentId } = resolveStewardIdentity(fresh);
183
+ const escalationTarget = coordinatorAgentId ?? fallbackTarget;
171
184
  const sent = notifyTarget(
172
185
  "workspace.stranded_escalation",
173
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
174
- fallbackTarget,
186
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot, author: fresh.ownerAgentId, parent: coordinatorAgentId ?? undefined },
187
+ escalationTarget,
175
188
  "Stranded workspace needs an owner",
176
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
177
- { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
189
+ `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h.${coordinatorAgentId ? ` You are the branch agent \`${fresh.ownerAgentId ?? "?"}\`'s coordinator.` : ""} Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
190
+ { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt, coordinatorAgentId },
178
191
  );
179
192
  if (sent) escalationTargets.push(sent);
180
193
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
@@ -183,11 +196,11 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
183
196
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
184
197
  kind: "state",
185
198
  title: "Workspace escalated",
186
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${fallbackTarget ? ` to ${fallbackTarget}` : " (no fallback configured)"}`,
199
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${escalationTarget ? ` to ${escalationTarget}${coordinatorAgentId ? " (coordinator)" : ""}` : " (no coordinator or fallback configured)"}`,
187
200
  meta: fresh.branch ?? fresh.id,
188
201
  icon: "ti-alert-octagon",
189
202
  view: "orchestrators",
190
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, fallback: fallbackTarget || null },
203
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTarget: escalationTarget || null, coordinatorAgentId: coordinatorAgentId ?? null },
191
204
  });
192
205
  }
193
206
 
package/src/mcp/index.ts CHANGED
@@ -16,8 +16,8 @@ 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";
20
- import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
19
+ import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
20
+ import { SPAWN_TOOLS, relayMyAgents, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
21
21
  import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
22
22
 
23
23
  const MCP_PROTOCOL_VERSION = "2024-11-05";
@@ -122,12 +122,14 @@ 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);
128
129
  else if (name === "relay_recall") result = relayRecall(auth, args);
129
130
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
130
131
  else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
132
+ else if (name === "relay_my_agents") result = relayMyAgents(auth, args);
131
133
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
132
134
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
133
135
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(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,
@@ -4,15 +4,16 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
4
4
  import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
- import { ValidationError } from "../db";
7
+ import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
8
+ import { listCommands } from "../commands-db";
8
9
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
9
10
  import { optionalEnum } from "../validation";
10
- import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
11
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
11
12
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
12
- import type { AgentLifecycle, SpawnApprovalMode, SpawnProvider, WorkspaceMode } from "../types";
13
+ import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceMode, WorkspaceRecord } from "../types";
13
14
  import type { McpAuthContext, ToolDefinition } from "./types";
14
15
  import { callerAgentId } from "./identity";
15
- import { enumField, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
16
+ import { enumField, optionalBoolean, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
16
17
 
17
18
  export const SPAWN_TOOLS: ToolDefinition[] = [
18
19
  {
@@ -57,6 +58,19 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
57
58
  additionalProperties: false,
58
59
  },
59
60
  },
61
+ {
62
+ name: "relay_my_agents",
63
+ description: "List the spawned children owned by YOUR token lineage, with lean lifecycle rows and spawn quota at a glance. This is the canonical spawn-tracking tool: use it after relay_spawn_agent to see your own children and choose a spawnRequestId for relay_shutdown_agent. Self-scoped: no agentId/spawnedBy parameter is accepted.",
64
+ requiredScopes: ["command:spawn"],
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ includeReaped: { type: "boolean", description: "Include recent exited/offline child rows in addition to live/pending children. Default false." },
69
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Maximum rows to return when includeReaped is true. Default 20, capped at 50." },
70
+ },
71
+ additionalProperties: false,
72
+ },
73
+ },
60
74
  {
61
75
  name: "relay_shutdown_agent",
62
76
  description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children, addressed by agentId or spawnRequestId (policyName/tmuxSession are admin-only broad targets). Admin tokens keep full reach.",
@@ -112,6 +126,53 @@ export function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown>
112
126
  });
113
127
  }
114
128
 
129
+ type MyAgentRow = {
130
+ label: string | null;
131
+ spawnRequestId: string | null;
132
+ agentId: string | null;
133
+ status: string;
134
+ provider: string | null;
135
+ model: string | null;
136
+ machine: string | null;
137
+ branch: string | null;
138
+ landState: string | null;
139
+ lastSeen: number;
140
+ };
141
+
142
+ export function relayMyAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
143
+ const callerId = callerAgentId(auth);
144
+ if (!callerId) throw new McpAuthError("relay_my_agents requires a single caller agent identity");
145
+ const includeReaped = optionalBoolean(args.includeReaped, "includeReaped") === true;
146
+ const limit = Math.min(optionalPositiveInt(args.limit, "limit") ?? 20, 50);
147
+ const quotaMax = auth.component?.constraints?.maxSpawnedAgents ?? 0;
148
+ const liveChildren = countLiveSpawnedAgents(callerId);
149
+ const registered = listAgents()
150
+ .filter((agent) => agent.spawnedBy === callerId)
151
+ .filter((agent) => includeReaped || isLiveChild(agent))
152
+ .map(agentChildRow);
153
+ const registeredSpawnRequestIds = new Set(registered.map((row) => row.spawnRequestId).filter((value): value is string => Boolean(value)));
154
+ const pending = activeSpawnCommandsFor(callerId)
155
+ .filter((command) => {
156
+ const spawnRequestId = stringValue(command.params.spawnRequestId) ?? command.correlationId;
157
+ return Boolean(spawnRequestId && !registeredSpawnRequestIds.has(spawnRequestId));
158
+ })
159
+ .map(commandChildRow);
160
+ const agents = [...pending, ...registered]
161
+ .sort((a, b) => b.lastSeen - a.lastSeen)
162
+ .slice(0, includeReaped ? limit : undefined);
163
+
164
+ return {
165
+ quota: {
166
+ liveChildren,
167
+ max: quotaMax,
168
+ remaining: Math.max(0, quotaMax - liveChildren),
169
+ },
170
+ agents,
171
+ count: agents.length,
172
+ includeReaped,
173
+ };
174
+ }
175
+
115
176
  export function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
116
177
  const agentId = optionalString(args.agentId, "agentId", 240);
117
178
  const policyName = optionalString(args.policyName, "policyName", 120);
@@ -144,3 +205,57 @@ export function relayShutdownAgent(auth: McpAuthContext, args: Record<string, un
144
205
  throw e;
145
206
  }
146
207
  }
208
+
209
+ function isLiveChild(agent: AgentCard): boolean {
210
+ return agent.ready && agent.status !== "offline" && agent.status !== "stale";
211
+ }
212
+
213
+ function agentChildRow(agent: AgentCard): MyAgentRow {
214
+ const workspace = latestWorkspaceFor(agent.id);
215
+ return {
216
+ label: agent.label ?? agent.name ?? null,
217
+ spawnRequestId: stringValue(agent.meta?.spawnRequestId) ?? null,
218
+ agentId: agent.id,
219
+ status: childLifecycleStatus(agent),
220
+ provider: agent.providerCapabilities?.model?.provider ?? stringValue(agent.meta?.provider) ?? null,
221
+ model: agent.providerCapabilities?.model?.id ?? stringValue(agent.meta?.model) ?? stringValue(agent.meta?.providerModel) ?? null,
222
+ machine: agent.machine ?? null,
223
+ branch: workspace?.branch ?? null,
224
+ landState: workspace?.status ?? null,
225
+ lastSeen: agent.lastSeen,
226
+ };
227
+ }
228
+
229
+ function commandChildRow(command: Command): MyAgentRow {
230
+ return {
231
+ label: stringValue(command.params.label) ?? null,
232
+ spawnRequestId: stringValue(command.params.spawnRequestId) ?? command.correlationId ?? null,
233
+ agentId: null,
234
+ status: "spawning",
235
+ provider: stringValue(command.params.provider) ?? null,
236
+ model: stringValue(command.params.model) ?? stringValue(command.params.providerModel) ?? null,
237
+ machine: stringValue(command.params.orchestratorId) ?? null,
238
+ branch: null,
239
+ landState: null,
240
+ lastSeen: command.updatedAt,
241
+ };
242
+ }
243
+
244
+ function childLifecycleStatus(agent: AgentCard): string {
245
+ const action = stringValue(agent.meta?.lifecycleAction);
246
+ if (action) return action;
247
+ if (agent.status === "offline" || agent.status === "stale") return "exited";
248
+ if (!agent.ready) return "starting";
249
+ if (agent.status === "online") return "ready";
250
+ return agent.status;
251
+ }
252
+
253
+ function activeSpawnCommandsFor(callerId: string): Command[] {
254
+ return listCommands({ type: "agent.spawn", limit: 500 })
255
+ .filter((command) => ["pending", "accepted", "running"].includes(command.status))
256
+ .filter((command) => stringValue(command.params.spawnedBy) === callerId);
257
+ }
258
+
259
+ function latestWorkspaceFor(agentId: string): WorkspaceRecord | undefined {
260
+ return listWorkspaces({ ownerAgentId: agentId })[0];
261
+ }
@@ -11,8 +11,10 @@ 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"
17
+ | "workspace.steward_handoff"
16
18
  | "workspace.conflict"
17
19
  | "workspace.steward_reassigned"
18
20
  | "workspace.stranded_escalation"
@@ -62,8 +64,10 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
62
64
  "agent.transient_land_hold": null,
63
65
  "agent.rate_limit_resume": 15 * MINUTES,
64
66
  "team.idle": null,
67
+ "team.member_silent": null,
65
68
  "plan.bound_agent_exited": null,
66
69
  "workspace.steward_task": null,
70
+ "workspace.steward_handoff": null,
67
71
  "workspace.conflict": null,
68
72
  "workspace.steward_reassigned": null,
69
73
  "workspace.stranded_escalation": null,
@@ -150,6 +150,9 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
150
150
  if (contextThreshold) entry.contextThreshold = contextThreshold;
151
151
  else delete entry.contextThreshold;
152
152
  delete entry.disabled;
153
+ // #368 — unavailable is a static catalog fact (provider-side suspension), not an operator toggle;
154
+ // strip it from DB overrides so an admin can't accidentally clear a catalog-level suspension.
155
+ delete entry.unavailable;
153
156
  return {
154
157
  ...input,
155
158
  alias,
@@ -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
  };
@@ -13,6 +13,7 @@ import { resolve } from "node:path";
13
13
  import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
14
14
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
15
15
  import { workspaceActiveClaim } from "../workspace-claim";
16
+ import { resolveCoordinatorAgentId } from "../steward-identity";
16
17
  import { relayToken } from "../config";
17
18
 
18
19
  export const getWorkspaces: Handler = (req) => {
@@ -337,6 +338,9 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
337
338
  if (!workspace) return error("workspace not found", 404);
338
339
  const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
339
340
  const ownerOnline = isOwnerAlive(workspace.ownerAgentId);
341
+ // #640 — surface the branch agent's coordinator (owner's `spawnedBy` lineage) so a steward
342
+ // inspecting the workspace knows who to escalate to, not just `repoRoot`.
343
+ const coordinatorAgentId = resolveCoordinatorAgentId(workspace.ownerAgentId);
340
344
  const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
341
345
  const orchOnline = Boolean(orch) && orch!.status === "online";
342
346
  const fetched = await fetchWorkspaceGitState(workspace);
@@ -354,6 +358,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
354
358
  baseRef: workspace.baseRef,
355
359
  branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
356
360
  owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
361
+ coordinator: { id: coordinatorAgentId, online: coordinatorAgentId ? isOwnerAlive(coordinatorAgentId) : false },
357
362
  orchestrator: { id: orch?.id, online: orchOnline },
358
363
  ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
359
364
  ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "../commands-db";
2
2
  import { emitRelayEvent } from "../events";
3
- import { createActivityEvent, getAgent, listAgents, listWorkspaces } from "../db";
3
+ import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTerminalEvent } from "../db";
4
4
  import { routeNotification } from "../notification-router";
5
5
  import { isProviderBlocked } from "../provider-state";
6
6
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
@@ -135,6 +135,9 @@ export function reapTransientAgents(): Record<string, unknown> {
135
135
  });
136
136
  emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
137
137
  emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: "transient-complete", workspaceId: land.workspaceId, workspaceStatus: land.status } });
138
+ // #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
139
+ // completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
140
+ recordTerminalEvent({ agentId: agent.id, reason: "transient-complete", kind: "agent.exited", createdAt: now });
138
141
  createActivityEvent({
139
142
  clientId: `transient-agent-reaped-${agent.id}-${now}`,
140
143
  kind: "state",
@@ -48,28 +48,44 @@ 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; unavailable?: string }>;
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
+ // #368 — also drop models marked unavailable in the static catalog (provider-side suspension,
64
+ // e.g. export-control; semantically distinct from the operator-toggle disabled flag).
65
+ const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
66
+ new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.disabled).map((m) => m.alias) ?? []);
67
+ const unavailableAliasesFor = (provider: SpawnProvider): Set<string> =>
68
+ new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.unavailable).map((m) => m.alias) ?? []);
57
69
  return o.providers.map((provider) => {
58
70
  const cat: SpawnCatalogish | undefined =
59
71
  reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
72
+ const disabled = disabledAliasesFor(provider);
73
+ const unavailable = unavailableAliasesFor(provider);
60
74
  return {
61
75
  provider,
62
76
  available: true,
63
77
  ...(cat
64
78
  ? {
65
79
  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
- })),
80
+ models: cat.models
81
+ .filter((m) => !m.disabled && !disabled.has(m.alias) && !m.unavailable && !unavailable.has(m.alias))
82
+ .map((m) => ({
83
+ id: m.alias,
84
+ providerModel: m.providerModel,
85
+ default: cat.defaultModel === m.alias,
86
+ effortLevels: m.efforts,
87
+ ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
88
+ })),
73
89
  }
74
90
  : { models: [] }),
75
91
  };
@@ -111,6 +127,7 @@ export function spawnCapablePrimer(input: { canSpawn: boolean; quota?: number })
111
127
  "- Default to your OWN host. Only set orchestratorId to spawn onto another host when it runs a provider/model yours doesn't, or the work must run there.",
112
128
  `- Prefer a named profile (profile: "<name>") over hand-assembling provider+model+effort+approvalMode. Omitting profile applies ${DEFAULT_SPAWN_PROFILE_NAME}; omitting model/effort uses the provider default. Lean to the cheapest provider/model/effort that fits; escalate only for complex work.`,
113
129
  "- Set spawnRequestId for idempotency so a retried spawn doesn't double-create.",
130
+ "- After spawning, use relay_my_agents for your self-scoped child roster + quota and copy a returned spawnRequestId into relay_shutdown_agent when you need to reap.",
114
131
  "- Spawn is fire-and-forget: pass waitForRegistrationMs:0 and keep working — you'll be PUSHED agent.ready when the child is genuinely ready (past onboarding), or agent.spawn_failed / agent.exited otherwise. Don't block-poll.",
115
132
  "- Spawned children cannot themselves spawn (no grandchildren).",
116
133
  ].join("\n");