agent-relay-server 0.85.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 (98) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-CevL-2jc.js} +2 -2
  4. package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
  7. package/public/assets/{activity-C6RvknQQ.js → activity-CY2Cbpet.js} +2 -2
  8. package/public/assets/{activity-C6RvknQQ.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-D7HZJ5eG.js → agents-Bj5KzrYJ.js} +2 -2
  12. package/public/assets/{agents-D7HZJ5eG.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-BUazA3lr.js → automation-BOPk1oh_.js} +2 -2
  16. package/public/assets/{automation-BUazA3lr.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-DBTEaf-H.js → chat-D5pI4ebq.js} +2 -2
  22. package/public/assets/{chat-DBTEaf-H.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-KG0iExZ-.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
  27. package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
  28. package/public/assets/{index-TZskQfQL.js → index-CEXhh5nz.js} +6 -6
  29. package/public/assets/{index-TZskQfQL.js.map → index-CEXhh5nz.js.map} +1 -1
  30. package/public/assets/{insights-JL1s7Uul.js → insights-C4dEK49e.js} +2 -2
  31. package/public/assets/{insights-JL1s7Uul.js.map → insights-C4dEK49e.js.map} +1 -1
  32. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  33. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  34. package/public/assets/{maintenance-CTMfhXFM.js → maintenance-DJZUSsja.js} +2 -2
  35. package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-DJZUSsja.js.map} +1 -1
  36. package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-5wUMDKVi.js} +2 -2
  37. package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
  40. package/public/assets/{memory-DJPXFdgw.js → memory-BnhAD09c.js} +2 -2
  41. package/public/assets/{memory-DJPXFdgw.js.map → memory-BnhAD09c.js.map} +1 -1
  42. package/public/assets/{messages-RSD2g4Wx.js → messages-Cc9YF8Wl.js} +2 -2
  43. package/public/assets/{messages-RSD2g4Wx.js.map → messages-Cc9YF8Wl.js.map} +1 -1
  44. package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-B01iyiBA.js} +2 -2
  45. package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-B01iyiBA.js.map} +1 -1
  46. package/public/assets/{overview-ByE9V8pe.js → overview-DN8CUTVx.js} +2 -2
  47. package/public/assets/{overview-ByE9V8pe.js.map → overview-DN8CUTVx.js.map} +1 -1
  48. package/public/assets/{pairs-C284ntvD.js → pairs-BxuDMLhs.js} +2 -2
  49. package/public/assets/{pairs-C284ntvD.js.map → pairs-BxuDMLhs.js.map} +1 -1
  50. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  51. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  52. package/public/assets/{security-vDglScsg.js → security-CO0PBIau.js} +2 -2
  53. package/public/assets/{security-vDglScsg.js.map → security-CO0PBIau.js.map} +1 -1
  54. package/public/assets/{select-DaOiWDBR.js → select-DHaFsLvt.js} +2 -2
  55. package/public/assets/{select-DaOiWDBR.js.map → select-DHaFsLvt.js.map} +1 -1
  56. package/public/assets/{settings-DiLcbSqx.js → settings-De1DKwd6.js} +2 -2
  57. package/public/assets/{settings-DiLcbSqx.js.map → settings-De1DKwd6.js.map} +1 -1
  58. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  59. package/public/assets/store-DsqUEZOf.js.map +1 -0
  60. package/public/assets/{tasks-DWJdJra3.js → tasks-Bqd188ql.js} +2 -2
  61. package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bqd188ql.js.map} +1 -1
  62. package/public/assets/{teams-FejXkix4.js → teams-Mh6ypDPr.js} +2 -2
  63. package/public/assets/{teams-FejXkix4.js.map → teams-Mh6ypDPr.js.map} +1 -1
  64. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
  65. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
  66. package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-Dy5J3E_b.js} +2 -2
  67. package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
  68. package/public/assets/{work-queue-DZdv9kOb.js → work-queue-ClGgOUSu.js} +2 -2
  69. package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-ClGgOUSu.js.map} +1 -1
  70. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  71. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  72. package/public/index.html +2 -2
  73. package/runner/src/adapter.ts +4 -3
  74. package/src/agent-lifecycle-events.ts +6 -1
  75. package/src/branch-landed.ts +81 -0
  76. package/src/cli/index.ts +3 -3
  77. package/src/cli/reply.ts +6 -1
  78. package/src/config.ts +4 -0
  79. package/src/db/index.ts +1 -0
  80. package/src/db/migrations.ts +17 -0
  81. package/src/db/provider-quotas.ts +99 -21
  82. package/src/db/terminal-events.ts +56 -0
  83. package/src/maintenance/constants.ts +4 -0
  84. package/src/maintenance/jobs.ts +13 -0
  85. package/src/maintenance/teams.ts +8 -3
  86. package/src/maintenance/workspaces.ts +23 -10
  87. package/src/mcp/index.ts +2 -1
  88. package/src/mcp/tools-spawn.ts +119 -4
  89. package/src/notification-types.ts +2 -0
  90. package/src/provider-catalog-store.ts +3 -0
  91. package/src/routes/workspaces.ts +5 -0
  92. package/src/services/transient-agent-reaper.ts +4 -1
  93. package/src/spawn-targets.ts +8 -2
  94. package/src/steward-identity.ts +50 -0
  95. package/src/steward.ts +12 -4
  96. package/src/workspace-auto-merge.ts +35 -6
  97. package/src/workspace-pr-completion.ts +26 -0
  98. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -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
+ }
@@ -14,6 +14,7 @@ export type NotificationType =
14
14
  | "team.member_silent"
15
15
  | "plan.bound_agent_exited"
16
16
  | "workspace.steward_task"
17
+ | "workspace.steward_handoff"
17
18
  | "workspace.conflict"
18
19
  | "workspace.steward_reassigned"
19
20
  | "workspace.stranded_escalation"
@@ -66,6 +67,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
66
67
  "team.member_silent": null,
67
68
  "plan.bound_agent_exited": null,
68
69
  "workspace.steward_task": null,
70
+ "workspace.steward_handoff": null,
69
71
  "workspace.conflict": null,
70
72
  "workspace.steward_reassigned": null,
71
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,
@@ -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" }),
@@ -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,7 +48,7 @@ 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; disabled?: boolean }>;
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>> {
@@ -60,12 +60,17 @@ function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
60
60
  // REPORTED catalog does not, so cross-reference the effective catalog by alias regardless of
61
61
  // which source supplies the matrix entry — otherwise the reported path keeps advertising a model
62
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).
63
65
  const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
64
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) ?? []);
65
69
  return o.providers.map((provider) => {
66
70
  const cat: SpawnCatalogish | undefined =
67
71
  reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
68
72
  const disabled = disabledAliasesFor(provider);
73
+ const unavailable = unavailableAliasesFor(provider);
69
74
  return {
70
75
  provider,
71
76
  available: true,
@@ -73,7 +78,7 @@ function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
73
78
  ? {
74
79
  defaultModel: cat.defaultModel,
75
80
  models: cat.models
76
- .filter((m) => !m.disabled && !disabled.has(m.alias))
81
+ .filter((m) => !m.disabled && !disabled.has(m.alias) && !m.unavailable && !unavailable.has(m.alias))
77
82
  .map((m) => ({
78
83
  id: m.alias,
79
84
  providerModel: m.providerModel,
@@ -122,6 +127,7 @@ export function spawnCapablePrimer(input: { canSpawn: boolean; quota?: number })
122
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.",
123
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.`,
124
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.",
125
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.",
126
132
  "- Spawned children cannot themselves spawn (no grandchildren).",
127
133
  ].join("\n");
@@ -0,0 +1,50 @@
1
+ // The steward ↔ branch-agent ↔ coordinator identity triangle (#639/#640).
2
+ //
3
+ // When the relay hands a workspace to a steward, the steward used to know only the
4
+ // `repoRoot`. To make a handoff legible end-to-end it needs the people-graph:
5
+ // - the workspace's BRANCH AGENT = `ws.ownerAgentId` (already stored on the row), and
6
+ // - that agent's COORDINATOR = the owner's authoritative `spawnedBy` parent (the agent
7
+ // that spawned the branch worker), resolved to a LIVE instance via the shared
8
+ // agent-ref matchers so a coordinator that restarted under a new session id still
9
+ // resolves to its live twin.
10
+ //
11
+ // One home for this resolution so the wake path, the diagnostics/inspect surface, and the
12
+ // escalation path can never drift on "who is the coordinator for this workspace?".
13
+
14
+ import { getAgent, listAgents } from "./db";
15
+ import { resolveAgentRef } from "./agent-ref";
16
+ import type { WorkspaceRecord } from "./types";
17
+
18
+ export interface StewardIdentityTriangle {
19
+ /** The workspace's branch agent (owner), or undefined when the row has no owner. */
20
+ ownerAgentId?: string;
21
+ /** The branch agent's coordinator (owner's `spawnedBy` lineage) resolved to a live id, or null when none resolves. */
22
+ coordinatorAgentId: string | null;
23
+ }
24
+
25
+ /**
26
+ * Resolve the branch agent's coordinator: the owner's authoritative `spawnedBy` parent,
27
+ * preferring a live instance. Returns the recorded parent id when no live match exists
28
+ * (store-ahead still delivers to a momentarily-offline coordinator), or null when the
29
+ * owner is unknown or wasn't spawned by anyone (e.g. a human-launched agent).
30
+ */
31
+ export function resolveCoordinatorAgentId(ownerAgentId: string | undefined): string | null {
32
+ if (!ownerAgentId) return null;
33
+ const spawnedBy = getAgent(ownerAgentId)?.spawnedBy?.trim();
34
+ if (!spawnedBy) return null;
35
+ const resolved = resolveAgentRef(spawnedBy, listAgents());
36
+ if (resolved.status === "resolved") return resolved.agent.id;
37
+ if (resolved.status === "ambiguous") {
38
+ // Prefer the exact recorded id among live twins; else the most-recently-seen.
39
+ return (resolved.candidates.find((a) => a.id === spawnedBy) ?? resolved.candidates[0])!.id;
40
+ }
41
+ // No live match — keep the authoritative recorded parent id so escalation/handoff
42
+ // notices still address the coordinator (store-ahead delivers when it reconnects).
43
+ return spawnedBy;
44
+ }
45
+
46
+ /** Resolve the full identity triangle for a workspace handoff (#640). */
47
+ export function resolveStewardIdentity(ws: Pick<WorkspaceRecord, "ownerAgentId">): StewardIdentityTriangle {
48
+ const ownerAgentId = ws.ownerAgentId ?? undefined;
49
+ return { ownerAgentId, coordinatorAgentId: resolveCoordinatorAgentId(ownerAgentId) };
50
+ }
package/src/steward.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { basename, resolve } from "node:path";
3
+ import { stewardFallbackTarget } from "./config";
3
4
  import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
4
5
  import { listOrchestrators } from "./db";
5
6
  import { getLifecycleManager } from "./lifecycle-manager";
@@ -20,23 +21,30 @@ export function repoStewardPolicyName(repoRoot: string): string {
20
21
  * its diagnostics, rebase/resolve/check, land green ones, release on escalation.
21
22
  */
22
23
  export function buildStewardPrompt(repoRoot: string): string {
24
+ // #642 — escalation routes to the branch agent's COORDINATOR (named per-workspace in the
25
+ // wake/task message via #640), not a static `system` dead-end. The fallback target below is
26
+ // used ONLY when a wake message names no coordinator.
27
+ const fallback = stewardFallbackTarget();
28
+ const fallbackTarget = fallback ? `\`${fallback}\`` : "the human operator who configured this relay";
23
29
  return [
24
30
  `You are the autonomous repository steward for ${repoRoot}.`,
25
31
  "",
26
32
  "Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
27
33
  "",
34
+ "Each workspace belongs to a BRANCH AGENT (the owner who did the work) who in turn has a COORDINATOR (the agent that spawned it and is responsible for the merge landing). Your wake/task message names both — the `ownerAgentId` and the `coordinatorAgentId` — and `agent-relay steward inspect <id>` shows them. When you can't land work yourself, the coordinator is who you escalate to.",
35
+ "",
28
36
  "Use the `agent-relay` CLI for EVERY relay interaction — it wraps the relay API and is the supported toolkit, and it already attaches your session token with the right scopes. Do NOT hand-curl the HTTP API (`/api/workspaces`, …): the CLI exists so you never have to. If you ever truly must call the API directly, authenticate with the `X-Agent-Relay-Token: <your session token>` header — NOT `Authorization: Bearer $AGENT_RELAY_TOKEN`; the admin env token is not present in your environment, so Bearer will 401.",
29
37
  "Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
30
38
  "",
31
39
  "1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
32
40
  "2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
33
- "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
34
- "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
41
+ "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (the branch agent owner + its coordinator, owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
42
+ "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate to the coordinator.",
35
43
  "5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
36
44
  "6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
37
- "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the fallback/human target.",
45
+ "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the workspace's COORDINATOR (the `coordinatorAgentId` from the wake message) — they own the work and can act on it. Reply to the wake message or message that agent id directly.",
38
46
  "",
39
- "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
47
+ `Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. ALWAYS escalate to the branch agent's coordinator (the \`coordinatorAgentId\` in the wake message). Only if the wake message names NO coordinator, escalate to ${fallbackTarget}. Never force-merge a red branch, never \`git push --force\` to a shared base, never discard committed work.`,
40
48
  "",
41
49
  "Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
42
50
  ].join("\n");
@@ -25,6 +25,8 @@ import {
25
25
  listWorkspaces,
26
26
  patchWorkspaceMetadata,
27
27
  } from "./db";
28
+ import { notifyBranchLandFailed, notifyStewardHandoff } from "./branch-landed";
29
+ import { resolveStewardIdentity } from "./steward-identity";
28
30
  import { emitCommandEvent } from "./command-events";
29
31
  import { getLandingConfig, getStewardConfig } from "./config-store";
30
32
  import { getLifecycleManager } from "./lifecycle-manager";
@@ -51,20 +53,29 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
51
53
  if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return null;
52
54
  const policyName = ensureRepoSteward(ws.repoRoot);
53
55
  if (!policyName) return null;
56
+ // #640 — resolve the identity triangle so the steward isn't blind to the people-graph:
57
+ // the workspace's branch agent (owner) and that agent's coordinator (owner's `spawnedBy`).
58
+ const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
59
+ const coordinatorLine = coordinatorAgentId
60
+ ? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't land it.`
61
+ : "";
62
+ const ownerLine = ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : "";
54
63
  try {
55
64
  // Obligation (ttl null by type default): a steward task must survive until claimed/handled.
56
65
  routeNotification({
57
66
  type: "workspace.steward_task",
58
- scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
67
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
59
68
  recipients: [`policy:${policyName}`],
60
69
  message: {
61
70
  subject: `Steward: ${ws.status} workspace needs attention`,
62
- body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
63
- payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
71
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}).${ownerLine} Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.${coordinatorLine}`,
72
+ payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason, ownerAgentId, coordinatorAgentId },
64
73
  },
65
74
  });
66
75
  getLifecycleManager().onMessageForPolicy(policyName);
67
- patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
76
+ patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName, coordinatorAgentId: coordinatorAgentId ?? undefined });
77
+ // #641/#642 — tell the owner which steward took over and proactively loop in its coordinator.
78
+ notifyStewardHandoff({ workspace: ws, stewardId: policyName, coordinatorAgentId, reason });
68
79
  return policyName;
69
80
  } catch {
70
81
  return null;
@@ -117,12 +128,30 @@ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
117
128
  if (getStewardConfig().enabled) {
118
129
  wakeRepoSteward(ws, `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`);
119
130
  }
131
+ // Emit the owner-facing `landed-failure` wake — the SAME actionable notice the live
132
+ // merge_planned→failed settle fires (routes/commands.ts → notifyBranchLandFailed). The
133
+ // loop-breaker stops dispatching once a lane hits the no-progress cap, so without this
134
+ // the dispatch-coupled wake never fires again and a backed-off lane (e.g. a dirty base
135
+ // checkout, #638/#644) goes silent until a human re-arms it via manual request-review.
136
+ // The branch worker is parked specifically on `landed-failure`; the `stranded_escalation`
137
+ // below is a separate fallback/steward signal, not the wake the owner awaits. Cooldown is
138
+ // already enforced by the early-return above, so this fires at most once per backoff.
139
+ // (Coordinator routing — owner's `spawnedBy` — is the #642 coordinator-awareness epic.)
140
+ notifyBranchLandFailed({
141
+ workspace: ws,
142
+ reason: `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`,
143
+ });
144
+ // #642 — escalation must reach the branch agent's coordinator (owner's `spawnedBy`), not
145
+ // just the configured fallback. When steward is enabled, wakeRepoSteward above already
146
+ // notified the coordinator of the handoff; including it here covers the steward-disabled
147
+ // path too so a backed-off lane always reaches the agent responsible for the work.
148
+ const { coordinatorAgentId } = resolveStewardIdentity(ws);
120
149
  const fallback = stewardFallbackTarget();
121
- const recipients = [...new Set([fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
150
+ const recipients = [...new Set([coordinatorAgentId, fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
122
151
  if (recipients.length) {
123
152
  routeNotification({
124
153
  type: "workspace.stranded_escalation",
125
- scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId },
154
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId, parent: coordinatorAgentId ?? undefined },
126
155
  recipients,
127
156
  message: {
128
157
  subject: "Workspace can't auto-land — needs attention",
@@ -155,6 +155,12 @@ function prRefreshForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMe
155
155
  if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false };
156
156
  if (preview.conflict === true) return { ok: false };
157
157
  if ((preview.behind ?? 0) <= 0) return { ok: false };
158
+ // #458: self-heal ONLY a PR that went red *purely because its base advanced* — its checks are
159
+ // failing AND the branch is behind base. A green (or still-pending) PR is never refreshed: it's
160
+ // left to the merge path / native auto-merge, so we don't re-trigger CI on a branch that isn't
161
+ // actually red. A red+up-to-date PR (behind=0, handled above) is red on its own merits, not by
162
+ // divergence, so it must not be refreshed into a loop either.
163
+ if (!statusChecksRed(preview.statusCheckRollup)) return { ok: false };
158
164
 
159
165
  const mergeable = text(preview.mergeable)?.toUpperCase();
160
166
  if (mergeable && mergeable !== "MERGEABLE") return { ok: false };
@@ -233,6 +239,26 @@ function statusChecksGreen(rollup: unknown): boolean {
233
239
  return rollup.every(statusNodeGreen);
234
240
  }
235
241
 
242
+ // "Red" = at least one check has a definitive FAILING conclusion/state. Distinct from
243
+ // `!statusChecksGreen` on purpose: a still-pending or no-checks-yet rollup is NOT red, so the
244
+ // #458 base-divergence refresh never interrupts an in-flight run or a PR that hasn't reported.
245
+ const FAILING_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]);
246
+ const FAILING_STATES = new Set(["FAILURE", "ERROR"]);
247
+
248
+ function statusChecksRed(rollup: unknown): boolean {
249
+ if (!Array.isArray(rollup) || rollup.length === 0) return false;
250
+ return rollup.some(statusNodeRed);
251
+ }
252
+
253
+ function statusNodeRed(node: unknown): boolean {
254
+ if (!isRecord(node)) return false;
255
+ const conclusion = text(node.conclusion)?.toUpperCase();
256
+ if (conclusion) return FAILING_CONCLUSIONS.has(conclusion);
257
+ const state = text(node.state)?.toUpperCase();
258
+ if (state) return FAILING_STATES.has(state);
259
+ return false;
260
+ }
261
+
236
262
  function prMergeable(preview: WorkspaceMergePreview): boolean {
237
263
  const mergeable = text(preview.mergeable)?.toUpperCase();
238
264
  if (mergeable !== "MERGEABLE") return false;