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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-DS4Hbn0k.js} +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-DS4Hbn0k.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-BYlragEi.js} +2 -2
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-BYlragEi.js.map} +1 -1
- package/public/assets/{activity-C6RvknQQ.js → activity-CcGFS_sY.js} +2 -2
- package/public/assets/{activity-C6RvknQQ.js.map → activity-CcGFS_sY.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-D7HZJ5eG.js → agents-BTh8SQiP.js} +2 -2
- package/public/assets/{agents-D7HZJ5eG.js.map → agents-BTh8SQiP.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-BUazA3lr.js → automation-izYGpZyQ.js} +2 -2
- package/public/assets/{automation-BUazA3lr.js.map → automation-izYGpZyQ.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-DBTEaf-H.js → chat-ThyPRUPq.js} +2 -2
- package/public/assets/{chat-DBTEaf-H.js.map → chat-ThyPRUPq.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-KG0iExZ-.js → formatted-body-impl-DEYxGoiw.js} +2 -2
- package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-DEYxGoiw.js.map} +1 -1
- package/public/assets/index-Bd1NBLyd.js +23 -0
- package/public/assets/{index-TZskQfQL.js.map → index-Bd1NBLyd.js.map} +1 -1
- package/public/assets/index-BjbvhfFt.css +2 -0
- package/public/assets/{insights-JL1s7Uul.js → insights-DAVKQ_SF.js} +2 -2
- package/public/assets/{insights-JL1s7Uul.js.map → insights-DAVKQ_SF.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-CTMfhXFM.js → maintenance-BL4R9076.js} +2 -2
- package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-BL4R9076.js.map} +1 -1
- package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-C7n1gl7E.js} +2 -2
- package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-C7n1gl7E.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DK0wNWXJ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DK0wNWXJ.js.map} +1 -1
- package/public/assets/{memory-DJPXFdgw.js → memory-BJEkDkwl.js} +2 -2
- package/public/assets/{memory-DJPXFdgw.js.map → memory-BJEkDkwl.js.map} +1 -1
- package/public/assets/{messages-RSD2g4Wx.js → messages-DBn9tXx2.js} +2 -2
- package/public/assets/{messages-RSD2g4Wx.js.map → messages-DBn9tXx2.js.map} +1 -1
- package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-Bc1eC16Q.js} +2 -2
- package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-Bc1eC16Q.js.map} +1 -1
- package/public/assets/{overview-ByE9V8pe.js → overview-Bw7zrSqR.js} +2 -2
- package/public/assets/{overview-ByE9V8pe.js.map → overview-Bw7zrSqR.js.map} +1 -1
- package/public/assets/{pairs-C284ntvD.js → pairs-BSwWnLK2.js} +2 -2
- package/public/assets/{pairs-C284ntvD.js.map → pairs-BSwWnLK2.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-vDglScsg.js → security-wTBapq4K.js} +2 -2
- package/public/assets/{security-vDglScsg.js.map → security-wTBapq4K.js.map} +1 -1
- package/public/assets/{select-DaOiWDBR.js → select-D6HFOeLW.js} +2 -2
- package/public/assets/{select-DaOiWDBR.js.map → select-D6HFOeLW.js.map} +1 -1
- package/public/assets/{settings-DiLcbSqx.js → settings-CDzp7vqJ.js} +2 -2
- package/public/assets/{settings-DiLcbSqx.js.map → settings-CDzp7vqJ.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-DWJdJra3.js → tasks-Bd7Wjy6e.js} +2 -2
- package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bd7Wjy6e.js.map} +1 -1
- package/public/assets/{teams-FejXkix4.js → teams-l3GUr2XV.js} +2 -2
- package/public/assets/{teams-FejXkix4.js.map → teams-l3GUr2XV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-BQMMWctd.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-BQMMWctd.js.map} +1 -1
- package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-DcA69UJK.js} +2 -2
- package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-DcA69UJK.js.map} +1 -1
- package/public/assets/{work-queue-DZdv9kOb.js → work-queue-BYlFeJ1g.js} +2 -2
- package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-BYlFeJ1g.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-busy-health.ts +93 -0
- package/src/agent-card-projection.ts +4 -1
- package/src/agent-lifecycle-events.ts +6 -1
- package/src/branch-landed.ts +81 -0
- package/src/cli/index.ts +3 -3
- package/src/cli/reply.ts +6 -1
- package/src/config.ts +9 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +17 -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 +88 -0
- package/src/maintenance/teams.ts +116 -5
- package/src/maintenance/workspaces.ts +23 -10
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-spawn.ts +119 -4
- package/src/notification-types.ts +6 -0
- package/src/provider-catalog-store.ts +3 -0
- package/src/routes/workspaces.ts +5 -0
- package/src/services/transient-agent-reaper.ts +4 -1
- package/src/spawn-targets.ts +8 -2
- package/src/steward-identity.ts +50 -0
- package/src/steward.ts +12 -4
- package/src/team-idle-tracker.ts +19 -0
- package/src/workspace-auto-merge.ts +35 -6
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/index-LUIvWcx6.css +0 -2
- package/public/assets/index-TZskQfQL.js +0 -23
- package/public/assets/store-DYaQAXRv.js.map +0 -1
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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",
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
36
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
@@ -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);
|
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
|
+
}
|
|
@@ -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,
|
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" }),
|