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
|
@@ -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)
|
|
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
|
-
|
|
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${
|
|
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,
|
|
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,
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -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,
|
package/src/routes/agents.ts
CHANGED
|
@@ -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);
|
package/src/routes/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/routes/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -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",
|
package/src/spawn-targets.ts
CHANGED
|
@@ -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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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");
|