agent-relay-server 0.85.0 → 0.87.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 (104) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-DS4Hbn0k.js} +2 -2
  4. package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-DS4Hbn0k.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-BYlragEi.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-BYlragEi.js.map} +1 -1
  7. package/public/assets/{activity-C6RvknQQ.js → activity-CcGFS_sY.js} +2 -2
  8. package/public/assets/{activity-C6RvknQQ.js.map → activity-CcGFS_sY.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-BTh8SQiP.js} +2 -2
  12. package/public/assets/{agents-D7HZJ5eG.js.map → agents-BTh8SQiP.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-izYGpZyQ.js} +2 -2
  16. package/public/assets/{automation-BUazA3lr.js.map → automation-izYGpZyQ.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-ThyPRUPq.js} +2 -2
  22. package/public/assets/{chat-DBTEaf-H.js.map → chat-ThyPRUPq.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-DEYxGoiw.js} +2 -2
  27. package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-DEYxGoiw.js.map} +1 -1
  28. package/public/assets/index-Bd1NBLyd.js +23 -0
  29. package/public/assets/{index-TZskQfQL.js.map → index-Bd1NBLyd.js.map} +1 -1
  30. package/public/assets/index-BjbvhfFt.css +2 -0
  31. package/public/assets/{insights-JL1s7Uul.js → insights-DAVKQ_SF.js} +2 -2
  32. package/public/assets/{insights-JL1s7Uul.js.map → insights-DAVKQ_SF.js.map} +1 -1
  33. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  34. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  35. package/public/assets/{maintenance-CTMfhXFM.js → maintenance-BL4R9076.js} +2 -2
  36. package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-BL4R9076.js.map} +1 -1
  37. package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-C7n1gl7E.js} +2 -2
  38. package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-C7n1gl7E.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DK0wNWXJ.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DK0wNWXJ.js.map} +1 -1
  41. package/public/assets/{memory-DJPXFdgw.js → memory-BJEkDkwl.js} +2 -2
  42. package/public/assets/{memory-DJPXFdgw.js.map → memory-BJEkDkwl.js.map} +1 -1
  43. package/public/assets/{messages-RSD2g4Wx.js → messages-DBn9tXx2.js} +2 -2
  44. package/public/assets/{messages-RSD2g4Wx.js.map → messages-DBn9tXx2.js.map} +1 -1
  45. package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-Bc1eC16Q.js} +2 -2
  46. package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-Bc1eC16Q.js.map} +1 -1
  47. package/public/assets/{overview-ByE9V8pe.js → overview-Bw7zrSqR.js} +2 -2
  48. package/public/assets/{overview-ByE9V8pe.js.map → overview-Bw7zrSqR.js.map} +1 -1
  49. package/public/assets/{pairs-C284ntvD.js → pairs-BSwWnLK2.js} +2 -2
  50. package/public/assets/{pairs-C284ntvD.js.map → pairs-BSwWnLK2.js.map} +1 -1
  51. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  52. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  53. package/public/assets/{security-vDglScsg.js → security-wTBapq4K.js} +2 -2
  54. package/public/assets/{security-vDglScsg.js.map → security-wTBapq4K.js.map} +1 -1
  55. package/public/assets/{select-DaOiWDBR.js → select-D6HFOeLW.js} +2 -2
  56. package/public/assets/{select-DaOiWDBR.js.map → select-D6HFOeLW.js.map} +1 -1
  57. package/public/assets/{settings-DiLcbSqx.js → settings-CDzp7vqJ.js} +2 -2
  58. package/public/assets/{settings-DiLcbSqx.js.map → settings-CDzp7vqJ.js.map} +1 -1
  59. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  60. package/public/assets/store-DsqUEZOf.js.map +1 -0
  61. package/public/assets/{tasks-DWJdJra3.js → tasks-Bd7Wjy6e.js} +2 -2
  62. package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bd7Wjy6e.js.map} +1 -1
  63. package/public/assets/{teams-FejXkix4.js → teams-l3GUr2XV.js} +2 -2
  64. package/public/assets/{teams-FejXkix4.js.map → teams-l3GUr2XV.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-BQMMWctd.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-BQMMWctd.js.map} +1 -1
  67. package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-DcA69UJK.js} +2 -2
  68. package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-DcA69UJK.js.map} +1 -1
  69. package/public/assets/{work-queue-DZdv9kOb.js → work-queue-BYlFeJ1g.js} +2 -2
  70. package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-BYlFeJ1g.js.map} +1 -1
  71. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  72. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  73. package/public/index.html +3 -3
  74. package/runner/src/adapter.ts +4 -3
  75. package/src/agent-busy-health.ts +93 -0
  76. package/src/agent-card-projection.ts +4 -1
  77. package/src/agent-lifecycle-events.ts +6 -1
  78. package/src/branch-landed.ts +81 -0
  79. package/src/cli/index.ts +3 -3
  80. package/src/cli/reply.ts +6 -1
  81. package/src/config.ts +9 -0
  82. package/src/db/index.ts +1 -0
  83. package/src/db/migrations.ts +17 -0
  84. package/src/db/provider-quotas.ts +99 -21
  85. package/src/db/terminal-events.ts +56 -0
  86. package/src/maintenance/constants.ts +4 -0
  87. package/src/maintenance/jobs.ts +88 -0
  88. package/src/maintenance/teams.ts +116 -5
  89. package/src/maintenance/workspaces.ts +23 -10
  90. package/src/mcp/index.ts +2 -1
  91. package/src/mcp/tools-spawn.ts +119 -4
  92. package/src/notification-types.ts +6 -0
  93. package/src/provider-catalog-store.ts +3 -0
  94. package/src/routes/workspaces.ts +5 -0
  95. package/src/services/transient-agent-reaper.ts +4 -1
  96. package/src/spawn-targets.ts +8 -2
  97. package/src/steward-identity.ts +50 -0
  98. package/src/steward.ts +12 -4
  99. package/src/team-idle-tracker.ts +19 -0
  100. package/src/workspace-auto-merge.ts +35 -6
  101. package/src/workspace-pr-completion.ts +26 -0
  102. package/public/assets/index-LUIvWcx6.css +0 -2
  103. package/public/assets/index-TZskQfQL.js +0 -23
  104. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { getArtifactStorage } from "../artifact-storage";
2
+ import { deriveAgentBusyHealth } from "../agent-busy-health";
2
3
  import { expireStaleBusAgents } from "../bus";
3
4
  import { pruneOutbox } from "../bus-outbox";
4
5
  import { expireCommands } from "../commands-db";
@@ -8,9 +9,11 @@ import {
8
9
  evaluatePoolBindings,
9
10
  expireQueuedMessages,
10
11
  getAgent,
12
+ listAgents,
11
13
  pruneOrphanedSharedWorkspaces,
12
14
  pruneOfflineAgents,
13
15
  pruneOldMessages,
16
+ pruneProviderQuotaHistory,
14
17
  reapStaleAgents,
15
18
  reapStaleOrchestrators,
16
19
  releaseExpiredClaims,
@@ -34,6 +37,7 @@ import {
34
37
  emitPoolBindingChanged,
35
38
  emitTaskChanged,
36
39
  } from "../sse";
40
+ import { routeNotification } from "../notification-router";
37
41
  import { pruneExpiredTokenRecords } from "../token-db";
38
42
  import { reapTransientAgents } from "../services/transient-agent-reaper";
39
43
  import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
@@ -45,6 +49,8 @@ import {
45
49
  DAY_MS,
46
50
  ORPHAN_REAPER_INTERVAL_MS,
47
51
  OUTBOX_RETENTION_MS,
52
+ QUOTA_HISTORY_PRUNE_INTERVAL_MS,
53
+ QUOTA_HISTORY_RETENTION_MS,
48
54
  TOKEN_RECORD_RETENTION_SECONDS,
49
55
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
50
56
  WORKSPACE_GC_INTERVAL_MS,
@@ -60,6 +66,70 @@ import type { MaintenanceJobDefinition } from "./types";
60
66
 
61
67
  let dbMaintenanceRuns = 0;
62
68
 
69
+ function isManagedBusyWatchCandidate(agent: ReturnType<typeof listAgents>[number]): boolean {
70
+ if (agent.kind !== "provider") return false;
71
+ if (agent.status !== "busy") return false;
72
+ if (agent.id === "user" || agent.id === "system") return false;
73
+ return agent.meta?.runnerManaged === true ||
74
+ typeof agent.meta?.policyName === "string" ||
75
+ agent.providerCapabilities?.lifecycle?.managed === true;
76
+ }
77
+
78
+ function stuckBusyBody(agentId: string, health: NonNullable<ReturnType<typeof deriveAgentBusyHealth>>): string {
79
+ const staleMinutes = Math.max(1, Math.floor(health.staleForMs / 60_000));
80
+ const work = health.activeWorkLabel || health.activeWorkKind || health.activeWorkId || health.kind;
81
+ return `Agent \`${agentId}\` appears stuck busy in ${work} for ${staleMinutes} min with no provider progress. Use interrupt first if available; restart or shutdown if the turn is wedged.`;
82
+ }
83
+
84
+ export function runStuckBusyWatchdog(input: { now?: number } = {}): Record<string, unknown> {
85
+ const now = input.now ?? Date.now();
86
+ const stuckAgentIds: string[] = [];
87
+ const notifiedAgentIds: string[] = [];
88
+ const checkedAgentIds: string[] = [];
89
+
90
+ for (const agent of listAgents({ status: "busy" })) {
91
+ if (!isManagedBusyWatchCandidate(agent)) continue;
92
+ checkedAgentIds.push(agent.id);
93
+ const health = deriveAgentBusyHealth(agent, now);
94
+ if (health?.status !== "stuck-busy") continue;
95
+ stuckAgentIds.push(agent.id);
96
+
97
+ const idempotencyKey = `agent-stuck-busy:${agent.id}:${health.kind}:${health.activeWorkStartedAt}:${health.thresholdMs}`;
98
+ createActivityEvent({
99
+ clientId: idempotencyKey,
100
+ kind: "state",
101
+ title: "Agent stuck busy",
102
+ body: stuckBusyBody(agent.id, health),
103
+ icon: "ti-alert-triangle",
104
+ view: "agents",
105
+ agentId: agent.id,
106
+ metadata: {
107
+ source: "server",
108
+ maintenanceJobId: "stuck-busy-watchdog",
109
+ severity: "warning",
110
+ busyHealth: health,
111
+ },
112
+ });
113
+
114
+ const messages = routeNotification({
115
+ type: "agent.stuck_busy",
116
+ scope: { agentId: agent.id, parent: agent.spawnedBy, teamId: agent.teamId },
117
+ recipients: ["user"],
118
+ message: {
119
+ subject: "Agent stuck busy",
120
+ body: stuckBusyBody(agent.id, health),
121
+ payload: { kind: "agent.stuck_busy", agentId: agent.id, busyHealth: health },
122
+ idempotencyKey,
123
+ replyExpected: false,
124
+ },
125
+ });
126
+ if (messages.length > 0) notifiedAgentIds.push(agent.id);
127
+ emitAgentStatus(agent.id);
128
+ }
129
+
130
+ return { checkedAgentIds, stuckAgentIds, notifiedAgentIds };
131
+ }
132
+
63
133
  export const LEGACY_REAPER_JOB_IDS = [
64
134
  "claim-cleanup",
65
135
  "task-orphan-release",
@@ -216,6 +286,14 @@ export const definitions: MaintenanceJobDefinition[] = [
216
286
  },
217
287
  { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
218
288
  { id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: resumeRateLimitedAgents },
289
+ {
290
+ id: "stuck-busy-watchdog",
291
+ title: "Stuck busy watchdog",
292
+ description: "Warn when a managed provider stays busy past the threshold without provider progress (#53).",
293
+ intervalMs: REAP_INTERVAL_MS,
294
+ runOnStart: false,
295
+ handler: runStuckBusyWatchdog,
296
+ },
219
297
  {
220
298
  id: "team-reap",
221
299
  title: "Team lifecycle close-out",
@@ -280,6 +358,16 @@ export const definitions: MaintenanceJobDefinition[] = [
280
358
  return runDbMaintenance({ vacuum });
281
359
  },
282
360
  },
361
+ {
362
+ id: "quota-history-prune",
363
+ title: "Quota history prune",
364
+ description: "Delete per-provider quota history snapshots (incl. unavailable markers) beyond the 30-day retention window (#609).",
365
+ intervalMs: QUOTA_HISTORY_PRUNE_INTERVAL_MS,
366
+ runOnStart: true,
367
+ handler() {
368
+ return { prunedQuotaHistoryRows: pruneProviderQuotaHistory(Date.now() - QUOTA_HISTORY_RETENTION_MS) };
369
+ },
370
+ },
283
371
  {
284
372
  id: "bus-outbox-prune",
285
373
  title: "Bus outbox prune",
@@ -2,24 +2,33 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getAgent,
5
6
  getCrashReport,
7
+ hasTerminalEvent,
6
8
  getTeamBusyState,
7
9
  getTeamRoster,
8
10
  listActiveTeams,
9
11
  setTeamZeroMembersSince,
10
12
  } from "../db";
11
- import { STALE_TTL_MS } from "../config";
13
+ import { STALE_TTL_MS, stewardFallbackTarget } from "../config";
12
14
  import { agentProviderState } from "../provider-state";
13
15
  import { dissolveTeamById } from "../services/team";
16
+ import { resolveCoordinatorAgentId } from "../steward-identity";
14
17
  import { getTeamsConfig } from "../teams-config-store";
15
18
  import { teamReapGraceMs } from "./constants";
16
19
  import { notifyTarget } from "./events";
17
- import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
20
+ import {
21
+ teamCoordinatorWatchTracker,
22
+ teamIdleTracker,
23
+ teamMemberWatchTracker,
24
+ type TeamMemberWatch,
25
+ } from "../team-idle-tracker";
18
26
  import type { AgentCard } from "../types";
19
27
 
20
28
  export function resetTeamIdleWatchdogForTests(): void {
21
29
  teamIdleTracker.clear();
22
30
  teamMemberWatchTracker.clear();
31
+ teamCoordinatorWatchTracker.clear();
23
32
  }
24
33
 
25
34
  /** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
@@ -32,8 +41,10 @@ function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
32
41
  * #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
33
42
  * dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
34
43
  * each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
35
- * a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
36
- * already woken by the terminal event) this is the backstop for the NO-terminal-event case.
44
+ * a member recovers. Skips the ping when any terminal event already fired — a #633 crash report (a
45
+ * DEATH the coordinator was already woken on) OR a #645 clean terminus (a transient worker reaped
46
+ * after its land, a graceful shutdown). This watchdog must surface ONLY a genuine silent death:
47
+ * an agent that went quiet with NO terminal event of any kind.
37
48
  */
38
49
  function detectSilentMembers(
39
50
  teamId: string,
@@ -64,7 +75,9 @@ function detectSilentMembers(
64
75
  const gapMs = now - entry.lastActiveAt;
65
76
  if (gapMs < thresholdMs || entry.pinged) continue;
66
77
  entry.pinged = true;
67
- if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
78
+ // A terminal event already fired a #633 crash report (death) or a #645 clean terminus
79
+ // (transient reap after land / graceful shutdown). Either way this is not a silent death.
80
+ if (getCrashReport(memberId) || hasTerminalEvent(memberId)) continue;
68
81
  if (!coordinatorId) continue;
69
82
  const minutes = Math.floor(gapMs / 60_000);
70
83
  notifyTarget(
@@ -79,6 +92,95 @@ function detectSilentMembers(
79
92
  }
80
93
  }
81
94
 
95
+ /**
96
+ * #412/#468 — detect a team's COORDINATOR going silent and escalate to a human, instead of the team
97
+ * stalling forever. The member watchdog above SKIPS the coordinator (it's the ping recipient), and a
98
+ * missing/dead coordinator otherwise only gets recorded — never surfaced — so a silently-dead
99
+ * coordinator means nobody is driving and no member is even being listened to (#412's single point of
100
+ * failure). Reuses the SAME aliveness predicate (`memberIsAlive`) + terminal-event suppression
101
+ * (`getCrashReport || hasTerminalEvent`) as the member watch so a CLEANLY-exited coordinator does not
102
+ * false-positive. Escalates ONCE per gone-spell (re-arms on recovery) up the lineage — the gone
103
+ * coordinator's own coordinator (#639 resolver), falling back to the configured human/steward target
104
+ * — never dead-ending at `system`. A coordinator-gone team is always surfaced, never silently
105
+ * abandoned (no-silent-dissolve).
106
+ */
107
+ function detectGoneCoordinator(
108
+ teamId: string,
109
+ coordinatorId: string | undefined,
110
+ now: number,
111
+ thresholdMs: number,
112
+ out: { escalatedTeamIds: string[]; suppressedTeamIds: string[]; goneCoordinatorIds: string[] },
113
+ ): void {
114
+ if (!coordinatorId) return; // no coordinator on the row — nothing to watch (idle path records this)
115
+ const freshCutoff = now - thresholdMs;
116
+ const existing = teamCoordinatorWatchTracker.get(teamId);
117
+ // A re-elected/claimed coordinator is a fresh identity — drop a watch left over from the old one.
118
+ if (existing && existing.coordinatorId !== coordinatorId) teamCoordinatorWatchTracker.delete(teamId);
119
+
120
+ const coord = getAgent(coordinatorId);
121
+ if (coord && memberIsAlive(coord, freshCutoff)) {
122
+ const prior = existing?.coordinatorId === coordinatorId ? existing : undefined;
123
+ teamCoordinatorWatchTracker.set(teamId, {
124
+ coordinatorId,
125
+ lastActiveAt: Math.max(coord.lastSeen, prior?.lastActiveAt ?? 0),
126
+ escalated: false, // alive again ⇒ re-arm the escalation
127
+ ...(coord.label ? { label: coord.label } : {}),
128
+ });
129
+ return;
130
+ }
131
+
132
+ // Coordinator is NOT alive (offline, heartbeat-stale, or vanished). Only escalate a coordinator we
133
+ // actually observed alive — never fire on cold/unknown startup state (mirrors the member watch).
134
+ const watch = teamCoordinatorWatchTracker.get(teamId);
135
+ if (!watch || watch.coordinatorId !== coordinatorId || watch.escalated) return;
136
+ watch.escalated = true; // one escalation per gone-spell, regardless of outcome below
137
+ out.goneCoordinatorIds.push(coordinatorId);
138
+
139
+ // A terminal marker means a #645 clean terminus (graceful shutdown) or a #633 crash the operator was
140
+ // already surfaced on — not a SILENT death. Suppress.
141
+ if (getCrashReport(coordinatorId) || hasTerminalEvent(coordinatorId)) {
142
+ out.suppressedTeamIds.push(teamId);
143
+ return;
144
+ }
145
+
146
+ // Escalate up the lineage, falling back to the configured human/steward target (#639/#642 pattern).
147
+ const escalationTarget = resolveCoordinatorAgentId(coordinatorId) ?? stewardFallbackTarget();
148
+ const label = watch.label ?? coordinatorId;
149
+ const sent = escalationTarget
150
+ ? notifyTarget(
151
+ "team.coordinator_gone",
152
+ { teamId, agentId: coordinatorId, parent: resolveCoordinatorAgentId(coordinatorId) ?? undefined },
153
+ escalationTarget,
154
+ "Team coordinator went silent",
155
+ `Coordinator \`${label}\` of team \`${teamId}\` has gone silent with no terminal event — nobody is driving the team and its members are no longer being listened to. Take over coordination, restart the team, or stand it down.`,
156
+ { kind: "team.coordinator_gone", teamId, coordinatorId, escalationTarget, label: watch.label },
157
+ )
158
+ : null;
159
+ if (sent) out.escalatedTeamIds.push(teamId);
160
+
161
+ // No-silent-dissolve: surface the coordinator-gone team on the dashboard even when there is no
162
+ // escalation target configured — it must never be silently abandoned.
163
+ createActivityEvent({
164
+ clientId: `team-coordinator-gone-${teamId}-${coordinatorId}-${now}`,
165
+ kind: "state",
166
+ title: "Team coordinator went silent",
167
+ body: sent
168
+ ? `Coordinator ${label} of team ${teamId} went silent — escalated to ${sent}.`
169
+ : `Coordinator ${label} of team ${teamId} went silent — no escalation target configured (set AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK).`,
170
+ meta: teamId,
171
+ icon: "ti-user-x",
172
+ view: "teams",
173
+ agentId: coordinatorId,
174
+ metadata: {
175
+ source: "server",
176
+ maintenanceJobId: "team-idle-watchdog",
177
+ teamId,
178
+ coordinatorId,
179
+ escalationTarget: sent ?? null,
180
+ },
181
+ });
182
+ }
183
+
82
184
  function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
83
185
  const roster = getTeamRoster(teamId).map((member) => {
84
186
  const providerState = agentProviderState(member);
@@ -140,12 +242,17 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
140
242
  const alreadyPingedTeamIds: string[] = [];
141
243
  const missingCoordinatorTeamIds: string[] = [];
142
244
  const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
245
+ const coordinatorGone = { escalatedTeamIds: [] as string[], suppressedTeamIds: [] as string[], goneCoordinatorIds: [] as string[] };
143
246
 
144
247
  for (const team of listActiveTeams()) {
145
248
  seen.add(team.id);
146
249
  // #634 — runs every cycle, independent of the team-level idle gate below: a single silent
147
250
  // member must surface even while OTHER members keep the team "busy".
148
251
  detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
252
+ // #412/#468 — coordinator-gone escalation, also every cycle and independent of the idle gate: a
253
+ // dead coordinator must surface even if the team isn't (yet) idle, and the idle-ping path can't
254
+ // reach it (it would just ping the void).
255
+ detectGoneCoordinator(team.id, team.coordinatorId, now, thresholdMs, coordinatorGone);
149
256
  const busy = getTeamBusyState(team.id, cutoff);
150
257
  if (busy.busyMemberIds.length > 0) {
151
258
  teamIdleTracker.delete(team.id);
@@ -177,6 +284,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
177
284
 
178
285
  for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
179
286
  for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
287
+ for (const key of teamCoordinatorWatchTracker.keys()) if (!seen.has(key)) teamCoordinatorWatchTracker.delete(key);
180
288
 
181
289
  return {
182
290
  enabled: true,
@@ -189,6 +297,9 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
189
297
  missingCoordinatorTeamIds,
190
298
  silentMemberIds: memberSilence.pingedMembers,
191
299
  watchedMembers: memberSilence.watchedMembers,
300
+ coordinatorGoneTeamIds: coordinatorGone.escalatedTeamIds,
301
+ coordinatorGoneSuppressedTeamIds: coordinatorGone.suppressedTeamIds,
302
+ goneCoordinatorIds: coordinatorGone.goneCoordinatorIds,
192
303
  };
193
304
  }
194
305
 
@@ -13,7 +13,8 @@ import {
13
13
  updateWorkspaceStatus,
14
14
  } from "../db";
15
15
  import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
- import { reconcileLandedWorkspace } from "../branch-landed";
16
+ import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
17
+ import { resolveStewardIdentity } from "../steward-identity";
17
18
  import { getStewardConfig } from "../config-store";
18
19
  import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
19
20
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
@@ -97,15 +98,22 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
97
98
  if (woke) notifiedStewards.push(woke);
98
99
  } else if (ws.stewardAgentId) {
99
100
  try {
101
+ // #640 — carry the identity triangle (branch agent + its coordinator) so the
102
+ // elected steward can address them; #641/#642 notify owner + coordinator of the handoff.
103
+ const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
104
+ const coordinatorLine = coordinatorAgentId
105
+ ? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't resolve it.`
106
+ : "";
100
107
  notifyTarget(
101
108
  "workspace.conflict",
102
- { workspaceId: ws.id, repoRoot: ws.repoRoot },
109
+ { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
103
110
  ws.stewardAgentId,
104
111
  "Workspace merge conflict",
105
- `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
106
- { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
112
+ `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind).${ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : ""} As repo steward, please coordinate resolution.${coordinatorLine}`,
113
+ { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind, ownerAgentId, coordinatorAgentId },
107
114
  );
108
115
  notifiedStewards.push(ws.stewardAgentId);
116
+ notifyStewardHandoff({ workspace: ws, stewardId: ws.stewardAgentId, coordinatorAgentId, reason: "conflict" });
109
117
  } catch {}
110
118
  }
111
119
  } else if (p.conflict === false && ws.status === "conflict" && meta.autoConflict === true) {
@@ -168,13 +176,18 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
168
176
 
169
177
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
170
178
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
179
+ // #642 — escalate to the branch agent's coordinator (owner's `spawnedBy`), falling back to
180
+ // the configured `stewardFallbackTarget` only when no coordinator resolves. The bare
181
+ // fallback was an unset `system` dead-end in the live incident.
182
+ const { coordinatorAgentId } = resolveStewardIdentity(fresh);
183
+ const escalationTarget = coordinatorAgentId ?? fallbackTarget;
171
184
  const sent = notifyTarget(
172
185
  "workspace.stranded_escalation",
173
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
174
- fallbackTarget,
186
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot, author: fresh.ownerAgentId, parent: coordinatorAgentId ?? undefined },
187
+ escalationTarget,
175
188
  "Stranded workspace needs an owner",
176
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
177
- { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
189
+ `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h.${coordinatorAgentId ? ` You are the branch agent \`${fresh.ownerAgentId ?? "?"}\`'s coordinator.` : ""} Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
190
+ { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt, coordinatorAgentId },
178
191
  );
179
192
  if (sent) escalationTargets.push(sent);
180
193
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
@@ -183,11 +196,11 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
183
196
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
184
197
  kind: "state",
185
198
  title: "Workspace escalated",
186
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${fallbackTarget ? ` to ${fallbackTarget}` : " (no fallback configured)"}`,
199
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${escalationTarget ? ` to ${escalationTarget}${coordinatorAgentId ? " (coordinator)" : ""}` : " (no coordinator or fallback configured)"}`,
187
200
  meta: fresh.branch ?? fresh.id,
188
201
  icon: "ti-alert-octagon",
189
202
  view: "orchestrators",
190
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, fallback: fallbackTarget || null },
203
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTarget: escalationTarget || null, coordinatorAgentId: coordinatorAgentId ?? null },
191
204
  });
192
205
  }
193
206
 
package/src/mcp/index.ts CHANGED
@@ -17,7 +17,7 @@ import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "
17
17
  import { positiveIdOrUndefined, recordField, stringField } from "./validation";
18
18
  import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
19
19
  import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
20
- import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
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";
@@ -129,6 +129,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
129
129
  else if (name === "relay_recall") result = relayRecall(auth, args);
130
130
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
131
131
  else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
132
+ else if (name === "relay_my_agents") result = relayMyAgents(auth, args);
132
133
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
133
134
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
134
135
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
@@ -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
+ }
@@ -5,6 +5,7 @@ export type NotificationType =
5
5
  | "agent.exited"
6
6
  | "agent.spawn_failed"
7
7
  | "agent.context_threshold"
8
+ | "agent.stuck_busy"
8
9
  | "agent.quota_threshold"
9
10
  | "agent.resume_budget_warning"
10
11
  | "agent.resume_budget_exhausted"
@@ -12,8 +13,10 @@ export type NotificationType =
12
13
  | "agent.rate_limit_resume"
13
14
  | "team.idle"
14
15
  | "team.member_silent"
16
+ | "team.coordinator_gone"
15
17
  | "plan.bound_agent_exited"
16
18
  | "workspace.steward_task"
19
+ | "workspace.steward_handoff"
17
20
  | "workspace.conflict"
18
21
  | "workspace.steward_reassigned"
19
22
  | "workspace.stranded_escalation"
@@ -57,6 +60,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
57
60
  "agent.exited": null,
58
61
  "agent.spawn_failed": null,
59
62
  "agent.context_threshold": 15 * MINUTES,
63
+ "agent.stuck_busy": null,
60
64
  "agent.quota_threshold": 15 * MINUTES,
61
65
  "agent.resume_budget_warning": null,
62
66
  "agent.resume_budget_exhausted": null,
@@ -64,8 +68,10 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
64
68
  "agent.rate_limit_resume": 15 * MINUTES,
65
69
  "team.idle": null,
66
70
  "team.member_silent": null,
71
+ "team.coordinator_gone": null,
67
72
  "plan.bound_agent_exited": null,
68
73
  "workspace.steward_task": null,
74
+ "workspace.steward_handoff": null,
69
75
  "workspace.conflict": null,
70
76
  "workspace.steward_reassigned": null,
71
77
  "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" }),