agent-relay-server 0.50.0 → 0.52.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 +206 -1
- package/package.json +2 -2
- package/public/assets/{PlanGraphPanel-BYgnvJZR.js → PlanGraphPanel-BnYmYHnY.js} +2 -2
- package/public/assets/{PlanGraphPanel-BYgnvJZR.js.map → PlanGraphPanel-BnYmYHnY.js.map} +1 -1
- package/public/assets/{activity-CCOlARUq.js → activity-BNLuP717.js} +2 -2
- package/public/assets/{activity-CCOlARUq.js.map → activity-BNLuP717.js.map} +1 -1
- package/public/assets/{agent-profiles-CGQq2EyX.js → agent-profiles-BhVEehP8.js} +2 -2
- package/public/assets/{agent-profiles-CGQq2EyX.js.map → agent-profiles-BhVEehP8.js.map} +1 -1
- package/public/assets/{agents-Cl9a1uBD.js → agents-CqQhC2aN.js} +2 -2
- package/public/assets/{agents-Cl9a1uBD.js.map → agents-CqQhC2aN.js.map} +1 -1
- package/public/assets/{analytics-DkABnOKb.js → analytics-44eqtAsx.js} +2 -2
- package/public/assets/{analytics-DkABnOKb.js.map → analytics-44eqtAsx.js.map} +1 -1
- package/public/assets/{automation-CS2V5WAh.js → automation-x65cxlk4.js} +2 -2
- package/public/assets/{automation-CS2V5WAh.js.map → automation-x65cxlk4.js.map} +1 -1
- package/public/assets/{branch-state-badge-DhTKagJn.js → branch-state-badge-Dp6Jk4LH.js} +2 -2
- package/public/assets/{branch-state-badge-DhTKagJn.js.map → branch-state-badge-Dp6Jk4LH.js.map} +1 -1
- package/public/assets/{channels-BcUZKNmr.js → channels-CmfUfiV0.js} +2 -2
- package/public/assets/{channels-BcUZKNmr.js.map → channels-CmfUfiV0.js.map} +1 -1
- package/public/assets/{chat-TFFCz8AI.js → chat-nugo0m55.js} +2 -2
- package/public/assets/{chat-TFFCz8AI.js.map → chat-nugo0m55.js.map} +1 -1
- package/public/assets/{connectors-Dd5gUsM2.js → connectors-Bf74bw_9.js} +2 -2
- package/public/assets/{connectors-Dd5gUsM2.js.map → connectors-Bf74bw_9.js.map} +1 -1
- package/public/assets/display-CXhnBwGf.js.map +1 -1
- package/public/assets/{formatted-body-impl-B_mZW3Vj.js → formatted-body-impl-UeUBqdRz.js} +2 -2
- package/public/assets/{formatted-body-impl-B_mZW3Vj.js.map → formatted-body-impl-UeUBqdRz.js.map} +1 -1
- package/public/assets/index-C0aWKi0g.css +2 -0
- package/public/assets/{index-CWs6iPpQ.js → index-D--CyG66.js} +6 -6
- package/public/assets/{index-CWs6iPpQ.js.map → index-D--CyG66.js.map} +1 -1
- package/public/assets/{insights-D5e1k0IU.js → insights-C9k7mCoJ.js} +2 -2
- package/public/assets/{insights-D5e1k0IU.js.map → insights-C9k7mCoJ.js.map} +1 -1
- package/public/assets/{integrations-DVdvwQaZ.js → integrations-By3hMfqC.js} +2 -2
- package/public/assets/{integrations-DVdvwQaZ.js.map → integrations-By3hMfqC.js.map} +1 -1
- package/public/assets/{maintenance-ChmWW3iQ.js → maintenance-tgb7AMTv.js} +2 -2
- package/public/assets/{maintenance-ChmWW3iQ.js.map → maintenance-tgb7AMTv.js.map} +1 -1
- package/public/assets/{managed-agents-C6cbTB9d.js → managed-agents-DJ3TfBJ5.js} +2 -2
- package/public/assets/{managed-agents-C6cbTB9d.js.map → managed-agents-DJ3TfBJ5.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DjmZIwx5.js → markdown-preview-impl-DcHq1Hto.js} +2 -2
- package/public/assets/{markdown-preview-impl-DjmZIwx5.js.map → markdown-preview-impl-DcHq1Hto.js.map} +1 -1
- package/public/assets/{memory-DYRuGhD9.js → memory-CMdemdtb.js} +2 -2
- package/public/assets/{memory-DYRuGhD9.js.map → memory-CMdemdtb.js.map} +1 -1
- package/public/assets/{messages-q5N9kJ4f.js → messages-CLuLC9wC.js} +2 -2
- package/public/assets/{messages-q5N9kJ4f.js.map → messages-CLuLC9wC.js.map} +1 -1
- package/public/assets/{orchestrators-bbIEU7xO.js → orchestrators-d27zYyT9.js} +2 -2
- package/public/assets/{orchestrators-bbIEU7xO.js.map → orchestrators-d27zYyT9.js.map} +1 -1
- package/public/assets/{overview-B3TxMRHV.js → overview-BY-cs5SI.js} +2 -2
- package/public/assets/{overview-B3TxMRHV.js.map → overview-BY-cs5SI.js.map} +1 -1
- package/public/assets/{pairs-CF3XEcbG.js → pairs-CmyQx_4F.js} +2 -2
- package/public/assets/{pairs-CF3XEcbG.js.map → pairs-CmyQx_4F.js.map} +1 -1
- package/public/assets/{security-Bffy1FyV.js → security-CxCnWTlk.js} +2 -2
- package/public/assets/{security-Bffy1FyV.js.map → security-CxCnWTlk.js.map} +1 -1
- package/public/assets/{select-Bz0VV6tj.js → select-B6iG4oYX.js} +2 -2
- package/public/assets/{select-Bz0VV6tj.js.map → select-B6iG4oYX.js.map} +1 -1
- package/public/assets/{settings-DUucPX58.js → settings-vSYPv8kE.js} +2 -2
- package/public/assets/{settings-DUucPX58.js.map → settings-vSYPv8kE.js.map} +1 -1
- package/public/assets/{store-C7dqPL_V.js → store-BLZA3u7Y.js} +2 -2
- package/public/assets/store-BLZA3u7Y.js.map +1 -0
- package/public/assets/{tasks-Dv1CeteT.js → tasks-BYUAWEL-.js} +2 -2
- package/public/assets/{tasks-Dv1CeteT.js.map → tasks-BYUAWEL-.js.map} +1 -1
- package/public/assets/teams-gWvyw7ET.js +2 -0
- package/public/assets/teams-gWvyw7ET.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js → terminal-viewer-impl-CHP0w79T.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js.map → terminal-viewer-impl-CHP0w79T.js.map} +1 -1
- package/public/assets/{work-queue-9iUCo-hM.js → work-queue-CBjXlNuG.js} +2 -2
- package/public/assets/{work-queue-9iUCo-hM.js.map → work-queue-CBjXlNuG.js.map} +1 -1
- package/public/assets/{workspaces-B0zGEd0M.js → workspaces-PdCwiSQx.js} +2 -2
- package/public/assets/{workspaces-B0zGEd0M.js.map → workspaces-PdCwiSQx.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/bus.ts +2 -2
- package/src/config-store.ts +10 -16
- package/src/db/agents.ts +6 -2
- package/src/db/mappers.ts +1 -0
- package/src/db/migrations.ts +5 -2
- package/src/db/plans.ts +11 -0
- package/src/db/schema.ts +1 -1
- package/src/db/teams.ts +120 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +3 -3
- package/src/mcp-agent-action.ts +45 -0
- package/src/mcp-team-template-tools.ts +133 -0
- package/src/mcp-team-tools.ts +4 -1
- package/src/mcp.ts +9 -11
- package/src/routes/index.ts +8 -1
- package/src/routes/team-templates.ts +65 -0
- package/src/routes/teams.ts +14 -2
- package/src/services/agent-action.ts +138 -0
- package/src/services/plan-graph.ts +49 -11
- package/src/services/plan-live-bindings.ts +88 -0
- package/src/services/spawn-agent.ts +5 -1
- package/src/services/team-templates.ts +172 -0
- package/src/services/transient-agent-reaper.ts +108 -0
- package/src/spawn-command.ts +2 -0
- package/src/team-template-config.ts +100 -0
- package/public/assets/index-B8ND8v33.css +0 -2
- package/public/assets/store-C7dqPL_V.js.map +0 -1
- package/public/assets/teams-DEASyStU.js +0 -2
- package/public/assets/teams-DEASyStU.js.map +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createCommand } from "../commands-db";
|
|
2
|
+
import { ValidationError, createActivityEvent, getAgent, sendMessage } from "../db";
|
|
3
|
+
import { emitCommandEvent } from "../command-events";
|
|
4
|
+
import { emitActivityEvent, emitNewMessage } from "../sse";
|
|
5
|
+
import { authorizeShutdown, ShutdownAuthError, ShutdownTargetError } from "./shutdown-agent";
|
|
6
|
+
import type { AuthContext } from "./auth-context";
|
|
7
|
+
import type { AgentCard, Command } from "../types";
|
|
8
|
+
|
|
9
|
+
export type AgentAction = "compact" | "clearContext" | "inject";
|
|
10
|
+
|
|
11
|
+
export interface AgentActionInput {
|
|
12
|
+
agentId: string;
|
|
13
|
+
action: AgentAction;
|
|
14
|
+
message?: string;
|
|
15
|
+
requestedVia: "mcp";
|
|
16
|
+
requestedBy?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentActionResult {
|
|
20
|
+
action: AgentAction;
|
|
21
|
+
command: Command;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AgentActionTargetError extends Error {}
|
|
25
|
+
|
|
26
|
+
function commandType(action: AgentAction): "agent.compact" | "agent.clearContext" | "prompt.inject" {
|
|
27
|
+
if (action === "compact") return "agent.compact";
|
|
28
|
+
if (action === "clearContext") return "agent.clearContext";
|
|
29
|
+
return "prompt.inject";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function title(action: AgentAction): string {
|
|
33
|
+
if (action === "compact") return "Agent compaction requested";
|
|
34
|
+
if (action === "clearContext") return "Agent context clear requested";
|
|
35
|
+
return "Agent prompt injected";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function icon(action: AgentAction): string {
|
|
39
|
+
if (action === "compact") return "ti-fold";
|
|
40
|
+
if (action === "clearContext") return "ti-clear-all";
|
|
41
|
+
return "ti-message-bolt";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
45
|
+
const alwaysReload = agent.tags
|
|
46
|
+
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
47
|
+
.map((tag) => tag.slice("memory-reload:".length).trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
if (!alwaysReload.length) return {};
|
|
50
|
+
return { memory: { alwaysReload } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertSupported(agent: AgentCard, action: AgentAction): void {
|
|
54
|
+
if (agent.id === "user" || agent.id === "system" || agent.meta?.kind === "channel" || agent.tags.includes("channel")) {
|
|
55
|
+
throw new ValidationError("agent cannot receive lifecycle actions");
|
|
56
|
+
}
|
|
57
|
+
if (agent.status === "offline") throw new ValidationError("agent is offline");
|
|
58
|
+
if (action === "compact" && agent.providerCapabilities?.context?.compact !== true) {
|
|
59
|
+
throw new ValidationError("agent does not support compact");
|
|
60
|
+
}
|
|
61
|
+
if (action === "clearContext" && agent.providerCapabilities?.context?.clear !== true) {
|
|
62
|
+
throw new ValidationError("agent does not support clearContext");
|
|
63
|
+
}
|
|
64
|
+
if (action === "inject" && agent.providerCapabilities?.liveSession?.inject !== true) {
|
|
65
|
+
throw new ValidationError("agent does not support inject");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sessionMessageForInject(agentId: string, ctx: AuthContext, body: string) {
|
|
70
|
+
const from = ctx.callerAgentId && getAgent(ctx.callerAgentId) ? ctx.callerAgentId : "system";
|
|
71
|
+
const message = sendMessage({
|
|
72
|
+
from,
|
|
73
|
+
to: agentId,
|
|
74
|
+
kind: "session",
|
|
75
|
+
body,
|
|
76
|
+
});
|
|
77
|
+
emitNewMessage(message);
|
|
78
|
+
return message;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function dispatchAgentAction(input: AgentActionInput, ctx: AuthContext): AgentActionResult {
|
|
82
|
+
const agent = getAgent(input.agentId);
|
|
83
|
+
if (!agent) throw new AgentActionTargetError(`agent ${input.agentId} not found`);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
authorizeShutdown(ctx, { agentId: input.agentId, requestedVia: input.requestedVia });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e instanceof ShutdownAuthError || e instanceof ShutdownTargetError) throw e;
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
assertSupported(agent, input.action);
|
|
92
|
+
|
|
93
|
+
const body = input.action === "inject"
|
|
94
|
+
? (input.message?.trim() || (() => { throw new ValidationError("message is required for inject"); })())
|
|
95
|
+
: undefined;
|
|
96
|
+
const sessionMessage = body ? sessionMessageForInject(agent.id, ctx, body) : undefined;
|
|
97
|
+
const command = createCommand({
|
|
98
|
+
type: commandType(input.action),
|
|
99
|
+
source: "system",
|
|
100
|
+
target: agent.id,
|
|
101
|
+
correlationId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
|
|
102
|
+
params: {
|
|
103
|
+
action: input.action,
|
|
104
|
+
agentId: agent.id,
|
|
105
|
+
...(typeof agent.meta?.sessionName === "string" ? { sessionName: agent.meta.sessionName } : {}),
|
|
106
|
+
...(typeof agent.meta?.tmuxSession === "string" ? { tmuxSession: agent.meta.tmuxSession } : {}),
|
|
107
|
+
...(typeof agent.meta?.policyName === "string" ? { policyName: agent.meta.policyName } : {}),
|
|
108
|
+
...(typeof agent.meta?.spawnRequestId === "string" ? { spawnRequestId: agent.meta.spawnRequestId } : {}),
|
|
109
|
+
...(input.action === "compact" ? compactMemoryParams(agent) : {}),
|
|
110
|
+
...(body ? { body, messageId: sessionMessage!.id } : {}),
|
|
111
|
+
requestedBy: input.requestedBy ?? ctx.callerAgentId ?? ctx.actor.id,
|
|
112
|
+
requestedVia: input.requestedVia,
|
|
113
|
+
requestedAt: Date.now(),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
emitCommandEvent(command, "command.requested");
|
|
117
|
+
const event = createActivityEvent({
|
|
118
|
+
clientId: `server-agent-${agent.id}-action-${input.action}-${command.id}`,
|
|
119
|
+
kind: "state",
|
|
120
|
+
title: title(input.action),
|
|
121
|
+
body: input.action,
|
|
122
|
+
meta: agent.id,
|
|
123
|
+
icon: icon(input.action),
|
|
124
|
+
view: "agents",
|
|
125
|
+
agentId: agent.id,
|
|
126
|
+
messageId: sessionMessage?.id,
|
|
127
|
+
metadata: {
|
|
128
|
+
action: input.action,
|
|
129
|
+
commandId: command.id,
|
|
130
|
+
actor: ctx.actor.id,
|
|
131
|
+
actorKind: ctx.actor.kind,
|
|
132
|
+
requestedVia: input.requestedVia,
|
|
133
|
+
source: "server",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
emitActivityEvent(event);
|
|
137
|
+
return { action: input.action, command };
|
|
138
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
2
|
+
import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, listPlansWithBoundOwner, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
4
|
import { cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
5
5
|
import type { AuthContext } from "./auth-context";
|
|
6
|
-
import type { Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
6
|
+
import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
7
7
|
|
|
8
8
|
export const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
|
|
9
9
|
|
|
@@ -86,10 +86,22 @@ function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | un
|
|
|
86
86
|
return refs;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function cleanStatusSource(value: unknown): "
|
|
89
|
+
function cleanStatusSource(value: unknown): PlanNode["statusSource"] {
|
|
90
90
|
const statusSource = optionalEnum(value, "statusSource", ["manual", "bound"] as const, "manual");
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
return statusSource;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function boundStatusForAgent(agent: Pick<AgentCard, "status"> | null | undefined): PlanStatus | undefined {
|
|
95
|
+
if (!agent) return undefined;
|
|
96
|
+
if (agent.status === "busy") return "in-progress";
|
|
97
|
+
return "pending";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyBoundStatus(node: PlanNode): PlanNode {
|
|
101
|
+
if (node.statusSource !== "bound") return node;
|
|
102
|
+
const agentId = node.ownerRef?.agentId;
|
|
103
|
+
if (!agentId) throw new ValidationError("bound plan nodes require ownerRef.agentId");
|
|
104
|
+
return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
@@ -99,17 +111,18 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
|
99
111
|
const lane = cleanString(input.lane, "node.lane", { max: 120 });
|
|
100
112
|
const refs = cleanRefs(input.refs);
|
|
101
113
|
const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
|
|
102
|
-
|
|
114
|
+
const statusSource = cleanStatusSource(input.statusSource);
|
|
115
|
+
return applyBoundStatus({
|
|
103
116
|
id: cleanString(input.id, "node.id", { required: true, max: 120 })!,
|
|
104
117
|
label: cleanString(input.label, "node.label", { required: true, max: 240 })!,
|
|
105
118
|
...(scopeBlurb ? { scopeBlurb } : {}),
|
|
106
119
|
...(ownerRef ? { ownerRef } : {}),
|
|
107
120
|
...(lane ? { lane } : {}),
|
|
108
121
|
status: optionalEnum(input.status, "node.status", PLAN_STATUSES, "pending") as PlanStatus,
|
|
109
|
-
statusSource
|
|
122
|
+
statusSource,
|
|
110
123
|
...(refs ? { refs } : {}),
|
|
111
124
|
...(blackboardRefs ? { blackboardRefs } : {}),
|
|
112
|
-
};
|
|
125
|
+
});
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
|
|
@@ -233,8 +246,8 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
233
246
|
const plan = existingPlan(planId);
|
|
234
247
|
const existing = plan.nodes.find((node) => node.id === nodeId);
|
|
235
248
|
if (!existing) throw new ValidationError(`node ${nodeId} not found`);
|
|
236
|
-
|
|
237
|
-
const
|
|
249
|
+
const statusSource = patch.statusSource !== undefined ? cleanStatusSource(patch.statusSource) : existing.statusSource;
|
|
250
|
+
const base: PlanNode = {
|
|
238
251
|
...existing,
|
|
239
252
|
...(patch.label !== undefined ? { label: cleanString(patch.label, "label", { required: true, max: 240 })! } : {}),
|
|
240
253
|
...(patch.scopeBlurb !== undefined ? optionalProp("scopeBlurb", cleanString(patch.scopeBlurb, "scopeBlurb", { max: 2000 })) : {}),
|
|
@@ -243,8 +256,9 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
243
256
|
...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
|
|
244
257
|
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
|
|
245
258
|
...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
|
|
246
|
-
statusSource
|
|
259
|
+
statusSource,
|
|
247
260
|
};
|
|
261
|
+
const next = applyBoundStatus(base);
|
|
248
262
|
const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
|
|
249
263
|
emitPlan("plan.changed", updated, actorId(ctx));
|
|
250
264
|
return updated;
|
|
@@ -294,6 +308,30 @@ export function deletePlanGraph(planId: string, ctx: AuthContext): { id: string;
|
|
|
294
308
|
return { id: plan.id, deleted: true };
|
|
295
309
|
}
|
|
296
310
|
|
|
311
|
+
export function updateBoundPlanNodesForAgent(agentId: string, status: PlanStatus, actor = "server"): Plan[] {
|
|
312
|
+
const updated: Plan[] = [];
|
|
313
|
+
for (const plan of listPlansWithBoundOwner(agentId)) {
|
|
314
|
+
let changed = false;
|
|
315
|
+
const nodes = plan.nodes.map((node) => {
|
|
316
|
+
if (node.statusSource !== "bound" || node.ownerRef?.agentId !== agentId) return node;
|
|
317
|
+
if (node.status === "landed") return node;
|
|
318
|
+
if (node.status === status) return node;
|
|
319
|
+
changed = true;
|
|
320
|
+
return { ...node, status };
|
|
321
|
+
});
|
|
322
|
+
if (!changed) continue;
|
|
323
|
+
const next = replacePlan({ ...plan, nodes }, actor);
|
|
324
|
+
emitPlan("plan.changed", next, actor);
|
|
325
|
+
updated.push(next);
|
|
326
|
+
}
|
|
327
|
+
return updated;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function updateBoundPlanNodesFromAgent(agentId: string, actor = "server"): Plan[] {
|
|
331
|
+
const status = boundStatusForAgent(getAgent(agentId));
|
|
332
|
+
return status ? updateBoundPlanNodesForAgent(agentId, status, actor) : [];
|
|
333
|
+
}
|
|
334
|
+
|
|
297
335
|
export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown }): Plan | null {
|
|
298
336
|
const planId = cleanString(input.planId, "planId", { max: 120 });
|
|
299
337
|
if (planId) return getPlan(planId);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getTeam } from "../db";
|
|
2
|
+
import { subscribeRelayEvents, type RelayEvent } from "../events";
|
|
3
|
+
import { notifySystemMessage } from "../notify";
|
|
4
|
+
import { updateBoundPlanNodesForAgent, updateBoundPlanNodesFromAgent } from "./plan-graph";
|
|
5
|
+
|
|
6
|
+
let unsubscribe: (() => void) | undefined;
|
|
7
|
+
|
|
8
|
+
const GRACEFUL_EXIT_REASONS = new Set([
|
|
9
|
+
"transient-complete",
|
|
10
|
+
"shutdown",
|
|
11
|
+
"manual-stop",
|
|
12
|
+
"manual-restart",
|
|
13
|
+
"policy-deleted",
|
|
14
|
+
"task-complete",
|
|
15
|
+
"mcp-shutdown",
|
|
16
|
+
"http-shutdown",
|
|
17
|
+
"bus-shutdown",
|
|
18
|
+
"self-resume",
|
|
19
|
+
"self-resume-continuation",
|
|
20
|
+
"compact",
|
|
21
|
+
"clear",
|
|
22
|
+
"resume",
|
|
23
|
+
"restart",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function stringField(value: unknown): string | undefined {
|
|
27
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isGracefulExitReason(reason: string | undefined): boolean {
|
|
31
|
+
if (!reason) return false;
|
|
32
|
+
if (GRACEFUL_EXIT_REASONS.has(reason)) return true;
|
|
33
|
+
return reason.startsWith("self-resume budget exhausted:");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function agentIdFromEvent(event: RelayEvent): string | undefined {
|
|
37
|
+
return stringField(event.data.agentId) ?? stringField(event.data.id) ?? event.subject;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function notifyBoundExit(event: RelayEvent, updatedPlans: ReturnType<typeof updateBoundPlanNodesForAgent>): void {
|
|
41
|
+
const agentId = agentIdFromEvent(event);
|
|
42
|
+
if (!agentId) return;
|
|
43
|
+
const reason = stringField(event.data.reason);
|
|
44
|
+
|
|
45
|
+
for (const plan of updatedPlans) {
|
|
46
|
+
if (!plan.teamId) continue;
|
|
47
|
+
const team = getTeam(plan.teamId);
|
|
48
|
+
const target = team?.coordinatorId ?? `team:${plan.teamId}`;
|
|
49
|
+
const blockedNodes = plan.nodes.filter((node) => node.statusSource === "bound" && node.ownerRef?.agentId === agentId && node.status === "blocked");
|
|
50
|
+
if (blockedNodes.length === 0) continue;
|
|
51
|
+
notifySystemMessage(target, {
|
|
52
|
+
subject: "Bound plan node blocked",
|
|
53
|
+
body: `Agent \`${agentId}\` exited${reason ? ` (${reason})` : ""}; blocked plan node(s) ${blockedNodes.map((node) => `\`${node.id}\``).join(", ")} in \`${plan.id}\`.`,
|
|
54
|
+
payload: { kind: "plan.bound_agent_exited", planId: plan.id, agentId, nodeIds: blockedNodes.map((node) => node.id), reason },
|
|
55
|
+
replyExpected: false,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handlePlanBindingEvent(event: RelayEvent): void {
|
|
61
|
+
if (event.type === "agent.status") {
|
|
62
|
+
const agentId = agentIdFromEvent(event);
|
|
63
|
+
if (agentId) updateBoundPlanNodesFromAgent(agentId, event.source || "server");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (event.type === "branch.landed") {
|
|
67
|
+
const agentId = stringField(event.data.author);
|
|
68
|
+
if (agentId) updateBoundPlanNodesForAgent(agentId, "landed", event.source || "server");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (event.type === "agent.exited") {
|
|
72
|
+
const agentId = agentIdFromEvent(event);
|
|
73
|
+
if (!agentId) return;
|
|
74
|
+
if (isGracefulExitReason(stringField(event.data.reason))) return;
|
|
75
|
+
const updated = updateBoundPlanNodesForAgent(agentId, "blocked", event.source || "server");
|
|
76
|
+
notifyBoundExit(event, updated);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function startPlanLiveBindings(): void {
|
|
81
|
+
if (unsubscribe) return;
|
|
82
|
+
unsubscribe = subscribeRelayEvents(handlePlanBindingEvent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function __resetPlanLiveBindingsForTests(): void {
|
|
86
|
+
unsubscribe?.();
|
|
87
|
+
unsubscribe = undefined;
|
|
88
|
+
}
|
|
@@ -36,7 +36,7 @@ import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
|
36
36
|
import { isPathWithinBase } from "../utils";
|
|
37
37
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
38
38
|
import type { AuthContext } from "./auth-context";
|
|
39
|
-
import type { AgentCard, Command, SpawnApprovalMode, WorkspaceMode } from "../types";
|
|
39
|
+
import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, WorkspaceMode } from "../types";
|
|
40
40
|
|
|
41
41
|
/** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
|
|
42
42
|
const SPAWN_SCOPE = "command:spawn";
|
|
@@ -56,6 +56,7 @@ export interface SpawnAgentInput {
|
|
|
56
56
|
effort?: string;
|
|
57
57
|
approvalMode?: SpawnApprovalMode;
|
|
58
58
|
workspaceMode?: WorkspaceMode;
|
|
59
|
+
lifecycle?: AgentLifecycle;
|
|
59
60
|
label?: string;
|
|
60
61
|
policyName?: string;
|
|
61
62
|
profile?: string;
|
|
@@ -140,6 +141,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
140
141
|
// branch worker that auto-lands); non-agent callers (admin/dashboard) keep the orchestrator's
|
|
141
142
|
// `inherit` fallback. Explicit wins.
|
|
142
143
|
const workspaceMode = input.workspaceMode ?? (callerId ? ("isolated" as WorkspaceMode) : undefined);
|
|
144
|
+
const lifecycle = input.lifecycle ?? "persistent";
|
|
143
145
|
|
|
144
146
|
const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
|
|
145
147
|
|
|
@@ -212,6 +214,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
212
214
|
modelParams,
|
|
213
215
|
cwd: resolvedCwd,
|
|
214
216
|
...(workspaceMode ? { workspaceMode } : {}),
|
|
217
|
+
lifecycle,
|
|
215
218
|
label: input.label,
|
|
216
219
|
profile: input.profile || undefined,
|
|
217
220
|
agentProfile,
|
|
@@ -250,6 +253,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
250
253
|
provider: input.provider,
|
|
251
254
|
orchestratorId: orchestrator.id,
|
|
252
255
|
approvalMode,
|
|
256
|
+
lifecycle,
|
|
253
257
|
workspaceMode: workspaceMode ?? null,
|
|
254
258
|
label: input.label ?? null,
|
|
255
259
|
commandId: command.id,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, APPROVAL_MODES, VALID_AGENT_LIFECYCLES } from "agent-relay-sdk";
|
|
2
|
+
import { getTeam, ValidationError } from "../db";
|
|
3
|
+
import { deleteConfig, getConfig, listConfig, setConfig } from "../config-store";
|
|
4
|
+
import { TEAM_TEMPLATE_NAMESPACE } from "../team-template-config";
|
|
5
|
+
import { cleanString, optionalEnum } from "../validation";
|
|
6
|
+
import type { AuthContext } from "./auth-context";
|
|
7
|
+
import { setCallerTeamBrief } from "./team";
|
|
8
|
+
import { spawnAgent, type SpawnAgentInput, type SpawnAgentResult } from "./spawn-agent";
|
|
9
|
+
import type { AgentLifecycle, SpawnApprovalMode, SpawnProvider, TeamTemplate, TeamTemplateCompositionEntry, WorkspaceMode } from "../types";
|
|
10
|
+
|
|
11
|
+
export interface TeamTemplateInstantiateInput {
|
|
12
|
+
slug: string;
|
|
13
|
+
orchestratorId?: unknown;
|
|
14
|
+
cwd?: unknown;
|
|
15
|
+
approvalMode?: unknown;
|
|
16
|
+
workspaceMode?: unknown;
|
|
17
|
+
lifecycle?: unknown;
|
|
18
|
+
prompt?: unknown;
|
|
19
|
+
waitForRegistrationMs?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TeamTemplateInstantiateResult {
|
|
23
|
+
template: TeamTemplate;
|
|
24
|
+
team: ReturnType<typeof setCallerTeamBrief>["team"];
|
|
25
|
+
spawned: SpawnAgentResult[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slugify(value: string): string {
|
|
29
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
|
|
30
|
+
return slug || "team-template";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function templateSlug(input: Record<string, unknown>): string {
|
|
34
|
+
return cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(cleanString(input.title, "title", { required: true, max: 240 })!);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nowIso(): string {
|
|
38
|
+
return new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withTimestamps(template: TeamTemplate, existing?: TeamTemplate): TeamTemplate {
|
|
42
|
+
const now = nowIso();
|
|
43
|
+
return {
|
|
44
|
+
...template,
|
|
45
|
+
createdAt: existing?.createdAt ?? template.createdAt ?? now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function saveTeamTemplate(input: Record<string, unknown>, updatedBy?: string): TeamTemplate {
|
|
51
|
+
if (input.teamId !== undefined && input.composition !== undefined) {
|
|
52
|
+
throw new ValidationError("teamId capture and manual composition are mutually exclusive");
|
|
53
|
+
}
|
|
54
|
+
if (input.teamId !== undefined) return captureTeamTemplateFromTeam(cleanString(input.teamId, "teamId", { required: true, max: 120 })!, input, updatedBy);
|
|
55
|
+
const slug = templateSlug(input);
|
|
56
|
+
const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
|
|
57
|
+
const template = withTimestamps({
|
|
58
|
+
id: slug,
|
|
59
|
+
slug,
|
|
60
|
+
title: cleanString(input.title, "title", { required: true, max: 240 })!,
|
|
61
|
+
...(cleanString(input.description, "description", { max: 2000 }) ? { description: cleanString(input.description, "description", { max: 2000 }) } : {}),
|
|
62
|
+
composition: input.composition as TeamTemplateCompositionEntry[],
|
|
63
|
+
...(cleanString(input.brief, "brief", { max: 16_000 }) ? { brief: cleanString(input.brief, "brief", { max: 16_000 }) } : {}),
|
|
64
|
+
...(cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) ? { definitionOfDone: cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) } : {}),
|
|
65
|
+
...(input.planSkeleton !== undefined ? { planSkeleton: input.planSkeleton as TeamTemplate["planSkeleton"] } : {}),
|
|
66
|
+
}, existing);
|
|
67
|
+
return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listTeamTemplates(): TeamTemplate[] {
|
|
71
|
+
return listConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE).map((entry) => entry.value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getTeamTemplate(slug: string): TeamTemplate | null {
|
|
75
|
+
return getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function deleteTeamTemplate(slug: string, updatedBy?: string): boolean {
|
|
79
|
+
return deleteConfig(TEAM_TEMPLATE_NAMESPACE, slug, updatedBy);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseFrozenComposition(raw: string): Array<Record<string, unknown>> {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
85
|
+
if (!Array.isArray(parsed)) throw new Error("not array");
|
|
86
|
+
return parsed.filter(isRecord);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new ValidationError("team composition snapshot is invalid JSON");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compositionFromFrozenSnapshot(raw: string): TeamTemplateCompositionEntry[] {
|
|
93
|
+
const grouped = new Map<string, TeamTemplateCompositionEntry>();
|
|
94
|
+
for (const member of parseFrozenComposition(raw)) {
|
|
95
|
+
const provider = cleanString(member.provider, "composition.provider", { max: 40 });
|
|
96
|
+
if (!provider) continue;
|
|
97
|
+
if (!SPAWN_PROVIDERS.includes(provider as SpawnProvider)) throw new ValidationError(`unsupported captured provider: ${provider}`);
|
|
98
|
+
const model = cleanString(member.model, "composition.model", { max: 120 });
|
|
99
|
+
const label = cleanString(member.label ?? member.role, "composition.label", { max: 120 }) ?? provider;
|
|
100
|
+
const key = JSON.stringify({ label, provider, model });
|
|
101
|
+
const existing = grouped.get(key);
|
|
102
|
+
if (existing) existing.count += 1;
|
|
103
|
+
else grouped.set(key, { label, provider: provider as SpawnProvider, ...(model ? { model } : {}), count: 1 });
|
|
104
|
+
}
|
|
105
|
+
const composition = [...grouped.values()];
|
|
106
|
+
if (!composition.length) throw new ValidationError("team composition has no spawnable provider members");
|
|
107
|
+
return composition;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function captureTeamTemplateFromTeam(teamId: string, input: Record<string, unknown> = {}, updatedBy?: string): TeamTemplate {
|
|
111
|
+
const team = getTeam(teamId);
|
|
112
|
+
if (!team) throw new ValidationError(`team ${teamId} not found`);
|
|
113
|
+
if (!team.composition) throw new ValidationError("team has no frozen composition snapshot; dissolve it before capture");
|
|
114
|
+
const title = cleanString(input.title, "title", { max: 240 }) ?? `${team.title ?? team.id} template`;
|
|
115
|
+
const slug = cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(title);
|
|
116
|
+
const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
|
|
117
|
+
const template = withTimestamps({
|
|
118
|
+
id: slug,
|
|
119
|
+
slug,
|
|
120
|
+
title,
|
|
121
|
+
...(cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary ? { description: cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary } : {}),
|
|
122
|
+
composition: compositionFromFrozenSnapshot(team.composition),
|
|
123
|
+
...(team.brief ? { brief: team.brief } : {}),
|
|
124
|
+
...(team.definitionOfDone ? { definitionOfDone: team.definitionOfDone } : {}),
|
|
125
|
+
}, existing);
|
|
126
|
+
return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cleanWait(value: unknown): number {
|
|
130
|
+
if (value === undefined || value === null) return 0;
|
|
131
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 30_000) {
|
|
132
|
+
throw new ValidationError("waitForRegistrationMs must be an integer between 0 and 30000");
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function spawnInputForEntry(template: TeamTemplate, entry: TeamTemplateCompositionEntry, index: number, count: number, input: TeamTemplateInstantiateInput): SpawnAgentInput {
|
|
138
|
+
return {
|
|
139
|
+
provider: entry.provider,
|
|
140
|
+
orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { max: 200 }),
|
|
141
|
+
cwd: cleanString(input.cwd, "cwd", { max: 500 }),
|
|
142
|
+
model: entry.model,
|
|
143
|
+
effort: entry.effort,
|
|
144
|
+
approvalMode: optionalEnum(input.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
145
|
+
workspaceMode: optionalEnum(input.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
146
|
+
lifecycle: optionalEnum(input.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
|
|
147
|
+
profile: entry.profile,
|
|
148
|
+
label: count === 1 ? entry.label : `${entry.label} ${index + 1}`,
|
|
149
|
+
prompt: cleanString(input.prompt, "prompt", { max: 16_000 }) ?? template.brief,
|
|
150
|
+
tags: ["team-template", `template:${template.slug}`],
|
|
151
|
+
requestedVia: "team-template",
|
|
152
|
+
waitForRegistrationMs: cleanWait(input.waitForRegistrationMs),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function instantiateTeamTemplate(input: TeamTemplateInstantiateInput, ctx: AuthContext): Promise<TeamTemplateInstantiateResult> {
|
|
157
|
+
const slug = cleanString(input.slug, "slug", { required: true, max: 120 })!;
|
|
158
|
+
const template = getTeamTemplate(slug);
|
|
159
|
+
if (!template) throw new ValidationError(`team template ${slug} not found`);
|
|
160
|
+
const teamPayload = setCallerTeamBrief(ctx, {
|
|
161
|
+
title: template.title,
|
|
162
|
+
brief: template.brief,
|
|
163
|
+
definitionOfDone: template.definitionOfDone,
|
|
164
|
+
});
|
|
165
|
+
const spawned: SpawnAgentResult[] = [];
|
|
166
|
+
for (const entry of template.composition) {
|
|
167
|
+
for (let i = 0; i < entry.count; i++) {
|
|
168
|
+
spawned.push(await spawnAgent(spawnInputForEntry(template, entry, i, entry.count, input), ctx));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { template, team: teamPayload.team, spawned };
|
|
172
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createCommand } from "../commands-db";
|
|
2
|
+
import { emitRelayEvent } from "../events";
|
|
3
|
+
import { createActivityEvent, getAgent, listAgents, listWorkspaces } from "../db";
|
|
4
|
+
import { notifySystemMessage } from "../notify";
|
|
5
|
+
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
6
|
+
|
|
7
|
+
const envMsOrDefault = (name: string, fallback: number): number => {
|
|
8
|
+
const v = Number(process.env[name]);
|
|
9
|
+
return Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
10
|
+
};
|
|
11
|
+
const transientGraceMs = () => envMsOrDefault("AGENT_RELAY_TRANSIENT_GRACE_MS", 2 * 60 * 1000);
|
|
12
|
+
const transientMaxLandWaitMs = () => envMsOrDefault("AGENT_RELAY_TRANSIENT_MAX_LAND_WAIT_MS", 30 * 60 * 1000);
|
|
13
|
+
const transientTracker = new Map<string, { firstIdleAt: number; lastReapAt?: number; heldNoticeAt?: number }>();
|
|
14
|
+
|
|
15
|
+
export function resetTransientTrackerForTests(): void {
|
|
16
|
+
transientTracker.clear();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function transientLandComplete(agentId: string): { complete: boolean; workspaceId?: string; status?: string; reason?: string } {
|
|
20
|
+
const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
|
|
21
|
+
if (!isolated.length) return { complete: true, reason: "no-isolated-workspace" };
|
|
22
|
+
const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
23
|
+
if (!live) return { complete: true, workspaceId: isolated[0]?.id, status: isolated[0]?.status, reason: "terminal-workspace" };
|
|
24
|
+
const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
|
|
25
|
+
const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
|
|
26
|
+
if (live.status === "active" && mergedAt !== undefined && mergedAt > (landReportedAt ?? 0)) {
|
|
27
|
+
return { complete: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
|
|
28
|
+
}
|
|
29
|
+
return { complete: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function notifyTransientHeld(agentId: string, workspaceId: string | undefined, status: string | undefined, waitMs: number): void {
|
|
33
|
+
const agent = getAgent(agentId);
|
|
34
|
+
const parent = agent?.spawnedBy;
|
|
35
|
+
const payload = { kind: "agent.transient_land_hold", agentId, parent, workspaceId, workspaceStatus: status, waitMs };
|
|
36
|
+
emitRelayEvent({ type: "agent.transient_land_hold", source: "server", subject: agentId, data: payload });
|
|
37
|
+
createActivityEvent({
|
|
38
|
+
clientId: `transient-agent-held-${agentId}-${Date.now()}`,
|
|
39
|
+
kind: "state",
|
|
40
|
+
title: "Transient agent held pending land",
|
|
41
|
+
body: `${agentId} is idle but its isolated workspace has not landed; holding unreaped to protect work`,
|
|
42
|
+
meta: agentId,
|
|
43
|
+
icon: "ti-hourglass",
|
|
44
|
+
view: "agents",
|
|
45
|
+
agentId,
|
|
46
|
+
metadata: { source: "server", maintenanceJobId: "transient-agent-reaper", ...payload },
|
|
47
|
+
});
|
|
48
|
+
if (parent) {
|
|
49
|
+
notifySystemMessage(parent, {
|
|
50
|
+
subject: "Transient worker held pending land",
|
|
51
|
+
body: `Transient agent \`${agentId}\` is idle but still unreaped because workspace \`${workspaceId ?? "unknown"}\` is \`${status ?? "unknown"}\` after ${Math.round(waitMs / 60_000)} min. Its work is being preserved; resolve the land before cleanup.`,
|
|
52
|
+
payload,
|
|
53
|
+
replyExpected: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reapTransientAgents(): Record<string, unknown> {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const grace = transientGraceMs();
|
|
61
|
+
const maxLandWait = transientMaxLandWaitMs();
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
const reaped: string[] = [], held: string[] = [], waiting: string[] = [];
|
|
64
|
+
for (const agent of listAgents()) {
|
|
65
|
+
if (agent.lifecycle !== "transient") continue;
|
|
66
|
+
seen.add(agent.id);
|
|
67
|
+
if (agent.status !== "idle" || !agent.ready) { transientTracker.delete(agent.id); continue; }
|
|
68
|
+
const entry = transientTracker.get(agent.id) ?? { firstIdleAt: now };
|
|
69
|
+
transientTracker.set(agent.id, entry);
|
|
70
|
+
const idleForMs = now - entry.firstIdleAt;
|
|
71
|
+
if (idleForMs < grace) { waiting.push(agent.id); continue; }
|
|
72
|
+
const land = transientLandComplete(agent.id);
|
|
73
|
+
if (!land.complete) {
|
|
74
|
+
if (idleForMs >= grace + maxLandWait) {
|
|
75
|
+
held.push(agent.id);
|
|
76
|
+
if (!entry.heldNoticeAt) {
|
|
77
|
+
entry.heldNoticeAt = now;
|
|
78
|
+
notifyTransientHeld(agent.id, land.workspaceId, land.status, idleForMs);
|
|
79
|
+
}
|
|
80
|
+
} else waiting.push(agent.id);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry.lastReapAt) continue;
|
|
84
|
+
entry.lastReapAt = now;
|
|
85
|
+
const command = createCommand({
|
|
86
|
+
type: "agent.shutdown",
|
|
87
|
+
source: "system",
|
|
88
|
+
target: agent.id,
|
|
89
|
+
params: { action: "shutdown", agentId: agent.id, graceful: true, timeoutMs: 10_000, reason: "transient-complete", requestedBy: "transient-agent-reaper", requestedAt: now },
|
|
90
|
+
});
|
|
91
|
+
emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
|
|
92
|
+
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 } });
|
|
93
|
+
createActivityEvent({
|
|
94
|
+
clientId: `transient-agent-reaped-${agent.id}-${now}`,
|
|
95
|
+
kind: "state",
|
|
96
|
+
title: "Transient agent complete",
|
|
97
|
+
body: "Agent idle grace elapsed and branch work is land-complete; shutdown requested",
|
|
98
|
+
meta: agent.id,
|
|
99
|
+
icon: "ti-check",
|
|
100
|
+
view: "agents",
|
|
101
|
+
agentId: agent.id,
|
|
102
|
+
metadata: { source: "server", maintenanceJobId: "transient-agent-reaper", commandId: command.id, reason: "transient-complete", workspaceId: land.workspaceId, landReason: land.reason },
|
|
103
|
+
});
|
|
104
|
+
reaped.push(agent.id);
|
|
105
|
+
}
|
|
106
|
+
for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
|
|
107
|
+
return { scanned: seen.size, waiting, held, reaped, tracked: transientTracker.size };
|
|
108
|
+
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -82,6 +82,7 @@ interface BuildSpawnCommandOptions {
|
|
|
82
82
|
modelParams?: SpawnModelParams;
|
|
83
83
|
|
|
84
84
|
workspaceMode?: string;
|
|
85
|
+
lifecycle?: string;
|
|
85
86
|
label?: string;
|
|
86
87
|
tags?: string[];
|
|
87
88
|
capabilities?: string[];
|
|
@@ -123,6 +124,7 @@ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string
|
|
|
123
124
|
...(opts.modelParams ?? {}),
|
|
124
125
|
cwd: opts.cwd,
|
|
125
126
|
...def(opts.workspaceMode, "workspaceMode"),
|
|
127
|
+
...def(opts.lifecycle, "lifecycle"),
|
|
126
128
|
...def(opts.profile, "profile"),
|
|
127
129
|
...def(opts.agentProfile, "agentProfile"),
|
|
128
130
|
...workspaceSpawnParams(),
|