agent-relay-server 0.11.3 → 0.11.5
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 +5 -2
- package/package.json +3 -2
- package/public/index.html +55 -12
- package/src/db.ts +32 -2
- package/src/dev.ts +2 -2
- package/src/lifecycle-manager.ts +31 -54
- package/src/maintenance.ts +100 -1
- package/src/managed-policy.ts +80 -0
- package/src/mcp.ts +16 -8
- package/src/routes.ts +88 -61
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.11.3",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
|
@@ -2725,7 +2725,7 @@
|
|
|
2725
2725
|
"tags": [
|
|
2726
2726
|
"Orchestrators"
|
|
2727
2727
|
],
|
|
2728
|
-
"description": "Dispatches a host action to the orchestrator over the command bus. `restart`/`shutdown` target a managed agent (omit `agentId` for all). `upgrade` triggers a remote self-upgrade: the relay stamps a desired version and the orchestrator installs
|
|
2728
|
+
"description": "Dispatches a host action to the orchestrator over the command bus. `restart`/`shutdown` target a managed agent (omit `agentId` for all). `upgrade` triggers a remote self-upgrade: the relay stamps a desired version and the orchestrator installs the selected runtime packages, then restarts itself decoupled so the bus keepalive survives its own teardown. Runtime-prefix installs use npm directly; non-prefix installs require an available `agent-relay upgrade --no-restart` CLI. The upgrade command is intentionally left `running` — the relay settles it (succeeded/failed) by reconciling the version the orchestrator reports on its post-restart re-register, within a deadline, or immediately when the command fails. Safety rails: semver-only `targetVersion` (defaults to the relay's own version), explicit self-upgrade capability, systemd/launchd supervision, and no downgrade unless `force` is set.",
|
|
2729
2729
|
"parameters": [
|
|
2730
2730
|
{
|
|
2731
2731
|
"name": "id",
|
|
@@ -6603,6 +6603,9 @@
|
|
|
6603
6603
|
},
|
|
6604
6604
|
"defaultTags": {
|
|
6605
6605
|
"type": "string"
|
|
6606
|
+
},
|
|
6607
|
+
"chatCaptureMode": {
|
|
6608
|
+
"type": "string"
|
|
6606
6609
|
}
|
|
6607
6610
|
}
|
|
6608
6611
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"CONTRIBUTING.md"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"agent-relay-sdk": "0.2.
|
|
35
|
+
"agent-relay-sdk": "0.2.4"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
+
"prepack": "cd dashboard && npx vite build >&2",
|
|
38
39
|
"postinstall": "node scripts/install-bin-shim.cjs",
|
|
39
40
|
"start": "bun run src/index.ts",
|
|
40
41
|
"dev": "bun --watch run src/index.ts",
|
package/public/index.html
CHANGED
|
@@ -123101,7 +123101,9 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123101
123101
|
const terminal = workspace.status === "cleaned" || workspace.status === "merged" || workspace.status === "abandoned";
|
|
123102
123102
|
const disabled = terminal || workspace.status === "cleanup_requested";
|
|
123103
123103
|
const openPath = workspace.worktreePath || workspace.sourceCwd || workspace.repoRoot;
|
|
123104
|
-
const
|
|
123104
|
+
const gitState = useRelayStore((s) => s.workspaceGitState[workspace.id]);
|
|
123105
|
+
const landed = !!gitState && gitState.available !== false && gitState.landed === true;
|
|
123106
|
+
const mergeable = workspace.mode === "isolated" && Boolean(workspace.worktreePath) && MERGEABLE_STATUSES.has(workspace.status) && !landed;
|
|
123105
123107
|
async function copyPath() {
|
|
123106
123108
|
await navigator.clipboard?.writeText(openPath);
|
|
123107
123109
|
}
|
|
@@ -123162,7 +123164,7 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123162
123164
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
123163
123165
|
size: "icon-sm",
|
|
123164
123166
|
variant: "default",
|
|
123165
|
-
title: "Merge & land work",
|
|
123167
|
+
title: landed ? "Already merged into base — nothing to land" : "Merge & land work",
|
|
123166
123168
|
disabled: !mergeable,
|
|
123167
123169
|
onClick: () => void merge(),
|
|
123168
123170
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitMerge, { className: "h-3.5 w-3.5" })
|
|
@@ -123185,10 +123187,10 @@ function WorkspaceActions({ workspace, expanded, onToggleDetails }) {
|
|
|
123185
123187
|
]
|
|
123186
123188
|
});
|
|
123187
123189
|
}
|
|
123188
|
-
function MergePreviewHint({ workspace,
|
|
123190
|
+
function MergePreviewHint({ workspace, unmerged }) {
|
|
123189
123191
|
const preview = useRelayStore((s) => s.workspaceMergePreview[workspace.id]);
|
|
123190
123192
|
const fetchWorkspaceMergePreview = useRelayStore((s) => s.fetchWorkspaceMergePreview);
|
|
123191
|
-
const eligible =
|
|
123193
|
+
const eligible = unmerged > 0 && MERGEABLE_STATUSES.has(workspace.status);
|
|
123192
123194
|
(0, import_react.useEffect)(() => {
|
|
123193
123195
|
if (eligible && preview === void 0) fetchWorkspaceMergePreview(workspace.id);
|
|
123194
123196
|
}, [
|
|
@@ -123251,9 +123253,11 @@ function GitState({ workspace }) {
|
|
|
123251
123253
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "git error" }), refresh]
|
|
123252
123254
|
});
|
|
123253
123255
|
const ahead = gitState.ahead ?? 0;
|
|
123256
|
+
const landed = gitState.landed === true;
|
|
123257
|
+
const unmerged = landed ? 0 : gitState.unmergedAhead ?? ahead;
|
|
123254
123258
|
const behind = gitState.behind ?? 0;
|
|
123255
123259
|
const dirty = gitState.dirtyCount ?? 0;
|
|
123256
|
-
const clean =
|
|
123260
|
+
const clean = unmerged === 0 && dirty === 0 && !landed;
|
|
123257
123261
|
const commit = gitState.lastCommit;
|
|
123258
123262
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
123259
123263
|
className: "space-y-0.5",
|
|
@@ -123263,10 +123267,14 @@ function GitState({ workspace }) {
|
|
|
123263
123267
|
className: "text-muted-foreground",
|
|
123264
123268
|
children: "no unmerged work"
|
|
123265
123269
|
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
123266
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123267
|
-
className:
|
|
123268
|
-
title:
|
|
123269
|
-
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
123270
|
+
landed ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123271
|
+
className: "flex items-center gap-0.5 text-emerald-400",
|
|
123272
|
+
title: `Work already landed in ${gitState.baseRef || "base"} via squash/cherry-pick — safe to clean up`,
|
|
123273
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Check, { className: "h-3 w-3" }), "landed"]
|
|
123274
|
+
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123275
|
+
className: unmerged > 0 ? "flex items-center text-emerald-400" : "flex items-center text-muted-foreground",
|
|
123276
|
+
title: ahead !== unmerged ? `${unmerged} unmerged commit(s) (${ahead} ahead of base)` : `${unmerged} commit(s) ahead of base`,
|
|
123277
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ArrowUp, { className: "h-3 w-3" }), unmerged]
|
|
123270
123278
|
}),
|
|
123271
123279
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123272
123280
|
className: behind > 0 ? "flex items-center text-amber-400" : "flex items-center text-muted-foreground",
|
|
@@ -123278,9 +123286,9 @@ function GitState({ workspace }) {
|
|
|
123278
123286
|
title: `${dirty} uncommitted change(s)`,
|
|
123279
123287
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FilePen, { className: "h-3 w-3" }), dirty]
|
|
123280
123288
|
}),
|
|
123281
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergePreviewHint, {
|
|
123289
|
+
!landed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergePreviewHint, {
|
|
123282
123290
|
workspace,
|
|
123283
|
-
|
|
123291
|
+
unmerged
|
|
123284
123292
|
})
|
|
123285
123293
|
] }), refresh]
|
|
123286
123294
|
}), commit && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
@@ -123527,6 +123535,32 @@ function OrphanPanel() {
|
|
|
123527
123535
|
})]
|
|
123528
123536
|
});
|
|
123529
123537
|
}
|
|
123538
|
+
function prLabel(url) {
|
|
123539
|
+
const match = url.match(/\/pull\/(\d+)/);
|
|
123540
|
+
return match ? `PR #${match[1]}` : "View PR";
|
|
123541
|
+
}
|
|
123542
|
+
function MergeOutcome({ workspace }) {
|
|
123543
|
+
const result = workspace.metadata?.mergeResult;
|
|
123544
|
+
const prUrl = result?.prUrl;
|
|
123545
|
+
const error = result?.error || workspace.metadata?.mergeError;
|
|
123546
|
+
if (!prUrl && !error) return null;
|
|
123547
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [prUrl && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("a", {
|
|
123548
|
+
href: prUrl,
|
|
123549
|
+
target: "_blank",
|
|
123550
|
+
rel: "noreferrer",
|
|
123551
|
+
title: prUrl,
|
|
123552
|
+
className: "inline-flex items-center gap-0.5 rounded border border-violet-500/30 px-1.5 py-0 text-[10px] text-violet-400 hover:text-violet-300",
|
|
123553
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitMerge, { className: "h-2.5 w-2.5" }), prLabel(prUrl)]
|
|
123554
|
+
}), error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Badge$1, {
|
|
123555
|
+
variant: "outline",
|
|
123556
|
+
className: "max-w-[16rem] border-amber-500/30 text-amber-400 text-[10px]",
|
|
123557
|
+
title: error,
|
|
123558
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TriangleAlert, { className: "mr-0.5 h-2.5 w-2.5 shrink-0" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
123559
|
+
className: "truncate",
|
|
123560
|
+
children: ["merge: ", error]
|
|
123561
|
+
})]
|
|
123562
|
+
})] });
|
|
123563
|
+
}
|
|
123530
123564
|
function WorkspaceRow({ workspace }) {
|
|
123531
123565
|
const now = useNow();
|
|
123532
123566
|
const [expanded, setExpanded] = (0, import_react.useState)(false);
|
|
@@ -123565,7 +123599,8 @@ function WorkspaceRow({ workspace }) {
|
|
|
123565
123599
|
variant: "outline",
|
|
123566
123600
|
className: `text-[10px] ${statusTone(workspace.status)}`,
|
|
123567
123601
|
children: STATUS_LABEL[workspace.status] || workspace.status
|
|
123568
|
-
})
|
|
123602
|
+
}),
|
|
123603
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergeOutcome, { workspace })
|
|
123569
123604
|
]
|
|
123570
123605
|
})]
|
|
123571
123606
|
}),
|
|
@@ -152711,6 +152746,10 @@ if ("serviceWorker" in navigator) {
|
|
|
152711
152746
|
max-width: 12rem;
|
|
152712
152747
|
}
|
|
152713
152748
|
|
|
152749
|
+
.max-w-\[16rem\] {
|
|
152750
|
+
max-width: 16rem;
|
|
152751
|
+
}
|
|
152752
|
+
|
|
152714
152753
|
.max-w-\[18rem\] {
|
|
152715
152754
|
max-width: 18rem;
|
|
152716
152755
|
}
|
|
@@ -155661,6 +155700,10 @@ if ("serviceWorker" in navigator) {
|
|
|
155661
155700
|
color: var(--color-red-400);
|
|
155662
155701
|
}
|
|
155663
155702
|
|
|
155703
|
+
.hover\:text-violet-300:hover {
|
|
155704
|
+
color: var(--color-violet-300);
|
|
155705
|
+
}
|
|
155706
|
+
|
|
155664
155707
|
.hover\:text-zinc-100:hover {
|
|
155665
155708
|
color: var(--color-zinc-100);
|
|
155666
155709
|
}
|
package/src/db.ts
CHANGED
|
@@ -4951,6 +4951,14 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
4951
4951
|
const workspace = agent.workspace;
|
|
4952
4952
|
if (!workspace) return null;
|
|
4953
4953
|
if (workspace.mode === "isolated" && workspace.id && workspace.repoRoot && workspace.worktreePath) {
|
|
4954
|
+
// A live agent re-registers its workspace on every heartbeat. That must not
|
|
4955
|
+
// clobber progress the workspace has made past the live editing state
|
|
4956
|
+
// (a merge that opened a PR, ready/review/conflict, terminal states) nor
|
|
4957
|
+
// wipe accumulated metadata such as the merge result / PR link. Only the
|
|
4958
|
+
// "active" live state is refreshable from registration; everything else is
|
|
4959
|
+
// preserved and metadata is merged, not replaced.
|
|
4960
|
+
const existing = getWorkspace(workspace.id);
|
|
4961
|
+
const preserveStatus = existing != null && existing.status !== "active";
|
|
4954
4962
|
return upsertWorkspace({
|
|
4955
4963
|
id: workspace.id,
|
|
4956
4964
|
repoRoot: workspace.repoRoot,
|
|
@@ -4961,11 +4969,11 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
4961
4969
|
baseSha: workspace.baseSha,
|
|
4962
4970
|
mode: workspace.mode,
|
|
4963
4971
|
requestedMode: workspace.requestedMode,
|
|
4964
|
-
status: workspace.status ?? "active",
|
|
4972
|
+
status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
|
|
4965
4973
|
ownerAgentId: agent.agentId || undefined,
|
|
4966
4974
|
ownerPolicyName: agent.policyName,
|
|
4967
4975
|
ownerAutomationRunId: agent.automationRunId,
|
|
4968
|
-
metadata: { provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
4976
|
+
metadata: { ...(existing?.metadata ?? {}), provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
4969
4977
|
});
|
|
4970
4978
|
}
|
|
4971
4979
|
// Shared-mode occupancy: one row per agent sharing a git repo, with no
|
|
@@ -5057,6 +5065,28 @@ export function deleteWorkspace(id: string): boolean {
|
|
|
5057
5065
|
return db.prepare("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5058
5066
|
}
|
|
5059
5067
|
|
|
5068
|
+
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
5069
|
+
// mean something while their owner is online. Deletion is normally driven by
|
|
5070
|
+
// the reaper's onAgentDisappeared hook, but agents also leave via clean
|
|
5071
|
+
// SessionEnd (setStatus offline) and via pruneOfflineAgents (raw agent delete),
|
|
5072
|
+
// neither of which touches workspaces — so orphaned shared rows accumulate and
|
|
5073
|
+
// bloat the workspace panel. This sweep is the catch-all: drop any non-cleaned
|
|
5074
|
+
// shared row whose owner is missing or offline, regardless of how it leaked.
|
|
5075
|
+
export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
5076
|
+
return db.transaction(() => {
|
|
5077
|
+
const orphanCondition = `
|
|
5078
|
+
mode = 'shared' AND status != 'cleaned' AND (
|
|
5079
|
+
owner_agent_id IS NULL
|
|
5080
|
+
OR owner_agent_id NOT IN (SELECT id FROM agents)
|
|
5081
|
+
OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
|
|
5082
|
+
)`;
|
|
5083
|
+
const rows = db.prepare(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
|
|
5084
|
+
if (!rows.length) return [];
|
|
5085
|
+
db.prepare(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
|
|
5086
|
+
return rows.map((r) => r.id);
|
|
5087
|
+
})();
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5060
5090
|
export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
|
|
5061
5091
|
const existing = getWorkspace(id);
|
|
5062
5092
|
if (!existing) return null;
|
package/src/dev.ts
CHANGED
|
@@ -154,7 +154,7 @@ export async function executeDevInstallPlan(plan: DevInstallPlan, options: { dry
|
|
|
154
154
|
const packed = await executeDevPackPlan(plan.packPlan);
|
|
155
155
|
await mkdir(plan.prefix, { recursive: true });
|
|
156
156
|
ensureDevPrefixPackageJson(plan.prefix);
|
|
157
|
-
const command = ["install", "--prefix", plan.prefix, "--no-audit", "--no-fund", ...packed.tarballs];
|
|
157
|
+
const command = ["install", "--prefix", plan.prefix, "--no-audit", "--no-fund", "--force", ...packed.tarballs];
|
|
158
158
|
const result = await execFileAsync("npm", command);
|
|
159
159
|
const output = [
|
|
160
160
|
packed.output,
|
|
@@ -353,7 +353,7 @@ export function formatDevInstallPlan(plan: DevInstallPlan): string {
|
|
|
353
353
|
formatDevPackPlan(plan.packPlan),
|
|
354
354
|
"",
|
|
355
355
|
"Install command:",
|
|
356
|
-
` npm install --prefix ${plan.prefix} --no-audit --no-fund <packed tarballs...>`,
|
|
356
|
+
` npm install --prefix ${plan.prefix} --no-audit --no-fund --force <packed tarballs...>`,
|
|
357
357
|
].join("\n");
|
|
358
358
|
}
|
|
359
359
|
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
-
import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
|
|
3
2
|
import { createCommand } from "./commands-db";
|
|
4
3
|
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
|
|
5
4
|
import {
|
|
@@ -9,8 +8,8 @@ import {
|
|
|
9
8
|
upsertManagedAgentState,
|
|
10
9
|
} from "./config-store";
|
|
11
10
|
import { emitRelayEvent } from "./events";
|
|
12
|
-
import {
|
|
13
|
-
import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy
|
|
11
|
+
import { buildManagedSpawnParams } from "./managed-policy";
|
|
12
|
+
import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
|
|
14
13
|
|
|
15
14
|
const DEFAULT_TICK_MS = 10_000;
|
|
16
15
|
// A spawn that never reaches "running" (runner crashed, never registered, host
|
|
@@ -227,34 +226,8 @@ export class LifecycleManager {
|
|
|
227
226
|
target: orch.agentId,
|
|
228
227
|
correlationId: spawnRequestId,
|
|
229
228
|
params: {
|
|
230
|
-
|
|
231
|
-
provider: policy.provider,
|
|
232
|
-
cwd: policy.cwd,
|
|
233
|
-
workspaceMode: effectivePolicyWorkspaceMode(policy),
|
|
234
|
-
...(policy.rig ? { rig: policy.rig } : {}),
|
|
235
|
-
...resolvedModelParams(policy),
|
|
236
|
-
label: policy.label,
|
|
237
|
-
tags: policy.tags,
|
|
238
|
-
capabilities: policy.capabilities,
|
|
239
|
-
approvalMode: policy.permissionMode,
|
|
240
|
-
permissionMode: policy.permissionMode,
|
|
241
|
-
providerArgs: policy.providerArgs,
|
|
242
|
-
prompt: policy.prompt,
|
|
243
|
-
headless: true,
|
|
244
|
-
policyName: policy.name,
|
|
245
|
-
spawnRequestId,
|
|
246
|
-
env: runnerRuntimeTokenEnv({
|
|
247
|
-
orchestratorId: policy.orchestratorId,
|
|
248
|
-
cwd: policy.cwd,
|
|
249
|
-
provider: policy.provider,
|
|
250
|
-
label: policy.label,
|
|
251
|
-
policyName: policy.name,
|
|
252
|
-
spawnRequestId,
|
|
253
|
-
createdBy: "lifecycle-manager",
|
|
254
|
-
}),
|
|
229
|
+
...buildManagedSpawnParams(policy, spawnRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() }),
|
|
255
230
|
reason,
|
|
256
|
-
requestedBy: "lifecycle-manager",
|
|
257
|
-
requestedAt: this.now(),
|
|
258
231
|
},
|
|
259
232
|
});
|
|
260
233
|
this.emitCommand(command);
|
|
@@ -302,7 +275,11 @@ export class LifecycleManager {
|
|
|
302
275
|
}
|
|
303
276
|
|
|
304
277
|
restartAgent(policy: SpawnPolicy, reason = "reconcile-restart"): Command | null {
|
|
278
|
+
const orch = getOrchestrator(policy.orchestratorId);
|
|
279
|
+
if (!orch) return null;
|
|
305
280
|
const state = getManagedAgentState(policy.name);
|
|
281
|
+
const restartRequestId = `sp_${crypto.randomUUID()}`;
|
|
282
|
+
const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
|
|
306
283
|
const restarted = upsertManagedAgentState({
|
|
307
284
|
policyName: policy.name,
|
|
308
285
|
status: "stopping",
|
|
@@ -310,14 +287,35 @@ export class LifecycleManager {
|
|
|
310
287
|
orchestratorId: policy.orchestratorId,
|
|
311
288
|
provider: policy.provider,
|
|
312
289
|
tmuxSession: state?.tmuxSession,
|
|
313
|
-
spawnRequestId:
|
|
314
|
-
lastSpawnAt:
|
|
290
|
+
spawnRequestId: restartRequestId,
|
|
291
|
+
lastSpawnAt: this.now(),
|
|
315
292
|
lastStopAt: this.now(),
|
|
316
293
|
restartCount: (state?.restartCount ?? 0) + 1,
|
|
317
294
|
consecutiveFailures: state?.consecutiveFailures ?? 0,
|
|
318
295
|
});
|
|
319
296
|
this.emitState(restarted, `restart: ${reason}`);
|
|
320
|
-
|
|
297
|
+
const command = createCommand({
|
|
298
|
+
type: "agent.restart",
|
|
299
|
+
source: "system",
|
|
300
|
+
target: orch.agentId,
|
|
301
|
+
correlationId: state?.spawnRequestId,
|
|
302
|
+
params: {
|
|
303
|
+
action: "restart",
|
|
304
|
+
policyName: policy.name,
|
|
305
|
+
spawnRequestId: state?.spawnRequestId,
|
|
306
|
+
agentId: state?.agentId,
|
|
307
|
+
tmuxSession: state?.tmuxSession,
|
|
308
|
+
graceful: true,
|
|
309
|
+
timeoutMs: 10_000,
|
|
310
|
+
reason,
|
|
311
|
+
orchestratorId: orch.id,
|
|
312
|
+
restartSpawn,
|
|
313
|
+
requestedBy: "lifecycle-manager",
|
|
314
|
+
requestedAt: this.now(),
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
this.emitCommand(command);
|
|
318
|
+
return command;
|
|
321
319
|
}
|
|
322
320
|
|
|
323
321
|
private reconcilePolicy(policy: SpawnPolicy): void {
|
|
@@ -641,24 +639,3 @@ function alwaysReloadTags(tags: string[]): string[] {
|
|
|
641
639
|
.filter(Boolean);
|
|
642
640
|
}
|
|
643
641
|
|
|
644
|
-
function effectivePolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
|
|
645
|
-
if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
|
|
646
|
-
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
|
|
650
|
-
if (!policy.model && !policy.effort) return {};
|
|
651
|
-
try {
|
|
652
|
-
const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
653
|
-
return {
|
|
654
|
-
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
655
|
-
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
656
|
-
...(selection.effort ? { effort: selection.effort } : {}),
|
|
657
|
-
};
|
|
658
|
-
} catch {
|
|
659
|
-
return {
|
|
660
|
-
...(policy.model ? { model: policy.model } : {}),
|
|
661
|
-
...(policy.effort ? { effort: policy.effort } : {}),
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
}
|
package/src/maintenance.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { getArtifactStorage } from "./artifact-storage";
|
|
3
3
|
import { expireStaleBusAgents } from "./bus";
|
|
4
4
|
import { pruneOutbox } from "./bus-outbox";
|
|
5
|
-
import { expireCommands } from "./commands-db";
|
|
5
|
+
import { createCommand, expireCommands } from "./commands-db";
|
|
6
6
|
import { DAY_MS, OFFLINE_PRUNE_MS, REAP_INTERVAL_MS, STALE_TTL_MS } from "./config";
|
|
7
7
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
8
|
import {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
listWorkspaces,
|
|
15
15
|
pruneOfflineAgents,
|
|
16
16
|
pruneOldMessages,
|
|
17
|
+
deleteWorkspace,
|
|
18
|
+
pruneOrphanedSharedWorkspaces,
|
|
17
19
|
reapStaleAgents,
|
|
18
20
|
reapStaleOrchestrators,
|
|
19
21
|
releaseExpiredClaims,
|
|
@@ -44,9 +46,13 @@ const SCHEDULER_TICK_MS = 10_000;
|
|
|
44
46
|
const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
|
|
45
47
|
const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
|
|
46
48
|
const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 2 * 60 * 1000;
|
|
49
|
+
const WORKSPACE_RETENTION_MS = Number(process.env.AGENT_RELAY_WORKSPACE_RETENTION_MS) || DAY_MS;
|
|
50
|
+
const WORKSPACE_REVIEW_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_REVIEW_TTL_MS) || 3 * DAY_MS;
|
|
51
|
+
const WORKSPACE_GC_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS) || 60 * 60 * 1000;
|
|
47
52
|
// Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
|
|
48
53
|
// in-flight (cleanup_requested) states are skipped.
|
|
49
54
|
const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
55
|
+
const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
50
56
|
|
|
51
57
|
interface MaintenanceJobDefinition {
|
|
52
58
|
id: string;
|
|
@@ -279,6 +285,17 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
279
285
|
return { prunedTokenJtis };
|
|
280
286
|
},
|
|
281
287
|
},
|
|
288
|
+
{
|
|
289
|
+
id: "workspace-orphan-sweep",
|
|
290
|
+
title: "Workspace orphan sweep",
|
|
291
|
+
description: "Delete shared-mode workspace occupancy rows whose owner agent is offline or gone.",
|
|
292
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
293
|
+
runOnStart: true,
|
|
294
|
+
handler() {
|
|
295
|
+
const prunedSharedWorkspaceIds = pruneOrphanedSharedWorkspaces();
|
|
296
|
+
return { prunedSharedWorkspaceIds };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
282
299
|
{
|
|
283
300
|
id: "workspace-conflict-scan",
|
|
284
301
|
title: "Workspace conflict scan",
|
|
@@ -288,6 +305,15 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
288
305
|
timeoutMs: 60 * 1000,
|
|
289
306
|
handler: scanWorkspaceConflicts,
|
|
290
307
|
},
|
|
308
|
+
{
|
|
309
|
+
id: "workspace-gc",
|
|
310
|
+
title: "Workspace GC",
|
|
311
|
+
description: "Prune terminal workspace rows past retention, auto-abandon stale review_requested worktrees, and trigger git worktree prune on orchestrators.",
|
|
312
|
+
intervalMs: WORKSPACE_GC_INTERVAL_MS,
|
|
313
|
+
runOnStart: false,
|
|
314
|
+
timeoutMs: 60 * 1000,
|
|
315
|
+
handler: workspaceGC,
|
|
316
|
+
},
|
|
291
317
|
];
|
|
292
318
|
|
|
293
319
|
function workspacePathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
@@ -387,6 +413,79 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
387
413
|
return { scanned: candidates.length, flagged, cleared, notifiedStewards };
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
const cutoff = now - WORKSPACE_RETENTION_MS;
|
|
419
|
+
const reviewCutoff = now - WORKSPACE_REVIEW_TTL_MS;
|
|
420
|
+
|
|
421
|
+
// 1. Prune terminal rows past retention
|
|
422
|
+
const all = listWorkspaces();
|
|
423
|
+
const terminalIds: string[] = [];
|
|
424
|
+
for (const ws of all) {
|
|
425
|
+
if (TERMINAL_WORKSPACE_STATUSES.has(ws.status) && ws.updatedAt < cutoff) {
|
|
426
|
+
deleteWorkspace(ws.id);
|
|
427
|
+
terminalIds.push(ws.id);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 2. Auto-abandon stale review_requested worktrees
|
|
432
|
+
const abandonedIds: string[] = [];
|
|
433
|
+
const notifiedStewards: string[] = [];
|
|
434
|
+
for (const ws of all) {
|
|
435
|
+
if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
|
|
436
|
+
updateWorkspaceStatus(ws.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
|
|
437
|
+
abandonedIds.push(ws.id);
|
|
438
|
+
if (ws.stewardAgentId) {
|
|
439
|
+
try {
|
|
440
|
+
const msg = sendMessage({
|
|
441
|
+
from: "system",
|
|
442
|
+
to: ws.stewardAgentId,
|
|
443
|
+
kind: "system",
|
|
444
|
+
subject: "Workspace auto-abandoned",
|
|
445
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
|
|
446
|
+
payload: { kind: "workspace.auto-abandoned", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch },
|
|
447
|
+
});
|
|
448
|
+
emitNewMessage(msg);
|
|
449
|
+
notifiedStewards.push(ws.stewardAgentId);
|
|
450
|
+
} catch {
|
|
451
|
+
// Steward gone — activity event is enough.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
createActivityEvent({
|
|
455
|
+
clientId: `workspace-gc-abandon-${ws.id}-${now}`,
|
|
456
|
+
kind: "state",
|
|
457
|
+
title: "Workspace auto-abandoned",
|
|
458
|
+
body: `${ws.branch ?? ws.id} in ${ws.repoRoot} — review_requested for ${Math.round((now - ws.updatedAt) / DAY_MS)}d`,
|
|
459
|
+
meta: ws.branch ?? ws.id,
|
|
460
|
+
icon: "ti-clock-x",
|
|
461
|
+
view: "orchestrators",
|
|
462
|
+
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: ws.id },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 3. Trigger git worktree prune on orchestrators that own isolated workspaces
|
|
468
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
|
|
469
|
+
const reposWithWorkspaces = new Set(
|
|
470
|
+
all.filter((ws) => ws.mode === "isolated" && Boolean(ws.worktreePath)).map((ws) => ws.repoRoot),
|
|
471
|
+
);
|
|
472
|
+
const pruneCommands: string[] = [];
|
|
473
|
+
for (const repoRoot of reposWithWorkspaces) {
|
|
474
|
+
const orch = orchestrators.find((candidate) => workspacePathWithinBase(repoRoot, candidate.baseDir));
|
|
475
|
+
if (!orch?.agentId) continue;
|
|
476
|
+
const command = createCommand({
|
|
477
|
+
type: "workspace.prune",
|
|
478
|
+
source: "system",
|
|
479
|
+
target: orch.agentId,
|
|
480
|
+
params: { repoRoot, requestedBy: "workspace-gc", requestedAt: now },
|
|
481
|
+
});
|
|
482
|
+
emitCommand(command);
|
|
483
|
+
pruneCommands.push(command.id);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { prunedTerminal: terminalIds, autoAbandoned: abandonedIds, notifiedStewards, pruneCommands };
|
|
487
|
+
}
|
|
488
|
+
|
|
390
489
|
let timer: Timer | null = null;
|
|
391
490
|
|
|
392
491
|
export function startMaintenanceScheduler(): void {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
|
|
2
|
+
import { getAgentProfile } from "./config-store";
|
|
3
|
+
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
4
|
+
import type { SpawnPolicy, WorkspaceMode } from "./types";
|
|
5
|
+
|
|
6
|
+
export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
|
|
7
|
+
if (policy.provider === "claude" && policy.mode === "always-on" && policy.prompt?.trim()) {
|
|
8
|
+
return [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
|
|
9
|
+
}
|
|
10
|
+
return policy.providerArgs;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
|
|
14
|
+
if (policy.provider === "claude" && policy.mode === "always-on") return undefined;
|
|
15
|
+
return policy.prompt;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
|
|
19
|
+
if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
|
|
20
|
+
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
|
|
24
|
+
if (!policy.model && !policy.effort) return {};
|
|
25
|
+
try {
|
|
26
|
+
const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
27
|
+
return {
|
|
28
|
+
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
29
|
+
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
30
|
+
...(selection.effort ? { effort: selection.effort } : {}),
|
|
31
|
+
};
|
|
32
|
+
} catch {
|
|
33
|
+
return {
|
|
34
|
+
...(policy.model ? { model: policy.model } : {}),
|
|
35
|
+
...(policy.effort ? { effort: policy.effort } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ManagedSpawnContext {
|
|
41
|
+
createdBy: string;
|
|
42
|
+
requestedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
|
|
46
|
+
const providerArgs = managedPolicyProviderArgs(policy);
|
|
47
|
+
const prompt = managedPolicyLaunchPrompt(policy);
|
|
48
|
+
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
49
|
+
return {
|
|
50
|
+
action: "spawn",
|
|
51
|
+
provider: policy.provider,
|
|
52
|
+
cwd: policy.cwd,
|
|
53
|
+
workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
|
|
54
|
+
...(policy.rig ? { rig: policy.rig } : {}),
|
|
55
|
+
...resolvedModelParams(policy),
|
|
56
|
+
...(policy.profile ? { profile: policy.profile } : {}),
|
|
57
|
+
...(agentProfile ? { agentProfile } : {}),
|
|
58
|
+
label: policy.label,
|
|
59
|
+
tags: policy.tags,
|
|
60
|
+
capabilities: policy.capabilities,
|
|
61
|
+
approvalMode: policy.permissionMode,
|
|
62
|
+
permissionMode: policy.permissionMode,
|
|
63
|
+
providerArgs,
|
|
64
|
+
...(prompt ? { prompt } : {}),
|
|
65
|
+
headless: true,
|
|
66
|
+
policyName: policy.name,
|
|
67
|
+
spawnRequestId: requestId,
|
|
68
|
+
env: runnerRuntimeTokenEnv({
|
|
69
|
+
orchestratorId: policy.orchestratorId,
|
|
70
|
+
cwd: policy.cwd,
|
|
71
|
+
provider: policy.provider,
|
|
72
|
+
label: policy.label,
|
|
73
|
+
policyName: policy.name,
|
|
74
|
+
spawnRequestId: requestId,
|
|
75
|
+
createdBy: ctx.createdBy,
|
|
76
|
+
}),
|
|
77
|
+
requestedBy: ctx.createdBy,
|
|
78
|
+
requestedAt: ctx.requestedAt ?? Date.now(),
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -520,8 +520,8 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
|
|
|
520
520
|
|
|
521
521
|
function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
522
522
|
const provider = enumField(args.provider, "provider", VALID_SPAWN_PROVIDERS) as SpawnProvider;
|
|
523
|
-
const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200));
|
|
524
523
|
const cwd = optionalString(args.cwd, "cwd", 500);
|
|
524
|
+
const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd);
|
|
525
525
|
const resolvedCwd = cwd || orchestrator.baseDir;
|
|
526
526
|
if (cwd && !pathWithinBase(cwd, orchestrator.baseDir)) {
|
|
527
527
|
throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
|
|
@@ -704,13 +704,21 @@ function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolic
|
|
|
704
704
|
};
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
-
function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: string): NonNullable<ReturnType<typeof getOrchestrator>> {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
707
|
+
function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: string, cwd?: string): NonNullable<ReturnType<typeof getOrchestrator>> {
|
|
708
|
+
if (orchestratorId) {
|
|
709
|
+
const orchestrator = getOrchestrator(orchestratorId);
|
|
710
|
+
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
711
|
+
if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
|
|
712
|
+
if (!orchestrator.providers.includes(provider)) throw new ValidationError(`orchestrator does not have provider available: ${provider}`);
|
|
713
|
+
return orchestrator;
|
|
714
|
+
}
|
|
715
|
+
const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
|
|
716
|
+
if (cwd) {
|
|
717
|
+
const match = candidates.find((item) => pathWithinBase(cwd, item.baseDir));
|
|
718
|
+
if (match) return match;
|
|
719
|
+
}
|
|
720
|
+
const orchestrator = candidates[0];
|
|
721
|
+
if (!orchestrator) throw new McpNotFoundError(`no orchestrator available for provider: ${provider}`);
|
|
714
722
|
return orchestrator;
|
|
715
723
|
}
|
|
716
724
|
|
package/src/routes.ts
CHANGED
|
@@ -143,6 +143,7 @@ import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeP
|
|
|
143
143
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
144
144
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
145
145
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
146
|
+
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
146
147
|
import {
|
|
147
148
|
getComponentAuth,
|
|
148
149
|
getIntegrationAuth,
|
|
@@ -2232,16 +2233,35 @@ function restartSpawnParamsForAgent(
|
|
|
2232
2233
|
const label = metaString(agent.meta, "label") ?? agent.label;
|
|
2233
2234
|
const approvalMode = metaString(agent.meta, "approvalMode") as SpawnApprovalMode | undefined;
|
|
2234
2235
|
const model = metaString(agent.meta, "model");
|
|
2235
|
-
const effort = metaString(agent.meta, "effort");
|
|
2236
|
-
const providerModel = metaString(agent.meta, "providerModel");
|
|
2236
|
+
const effort = metaString(agent.meta, "effort") as ProviderEffort | undefined;
|
|
2237
2237
|
const providerArgs = metaStringArray(agent.meta, "providerArgs");
|
|
2238
|
+
const profileName = metaString(agent.meta, "profile");
|
|
2239
|
+
const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
|
|
2240
|
+
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
2241
|
+
let resolvedModel: Record<string, string> = {};
|
|
2242
|
+
if (model || effort) {
|
|
2243
|
+
try {
|
|
2244
|
+
const selection = resolveProviderSelection({ provider, model, effort });
|
|
2245
|
+
resolvedModel = {
|
|
2246
|
+
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
2247
|
+
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
2248
|
+
...(selection.effort ? { effort: selection.effort } : {}),
|
|
2249
|
+
};
|
|
2250
|
+
} catch {
|
|
2251
|
+
resolvedModel = {
|
|
2252
|
+
...(model ? { model } : {}),
|
|
2253
|
+
...(effort ? { effort } : {}),
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2238
2257
|
return {
|
|
2239
2258
|
action: "spawn",
|
|
2240
2259
|
provider,
|
|
2241
|
-
...
|
|
2242
|
-
...(providerModel ? { providerModel } : {}),
|
|
2243
|
-
...(effort ? { effort } : {}),
|
|
2260
|
+
...resolvedModel,
|
|
2244
2261
|
cwd,
|
|
2262
|
+
workspaceMode,
|
|
2263
|
+
...(profileName ? { profile: profileName } : {}),
|
|
2264
|
+
...(agentProfile ? { agentProfile } : {}),
|
|
2245
2265
|
...(label ? { label } : {}),
|
|
2246
2266
|
agentId: agent.id,
|
|
2247
2267
|
tags: agent.tags,
|
|
@@ -2250,6 +2270,7 @@ function restartSpawnParamsForAgent(
|
|
|
2250
2270
|
permissionMode: approvalMode ?? "guarded",
|
|
2251
2271
|
...(providerArgs.length ? { providerArgs } : {}),
|
|
2252
2272
|
...(policyName ? { policyName } : {}),
|
|
2273
|
+
headless: true,
|
|
2253
2274
|
spawnRequestId: requestId,
|
|
2254
2275
|
env: runnerRuntimeTokenEnv({
|
|
2255
2276
|
orchestratorId: orchestrator.id,
|
|
@@ -2269,6 +2290,15 @@ function spawnRequestIdForRestart(): string {
|
|
|
2269
2290
|
return `sp_${crypto.randomUUID()}`;
|
|
2270
2291
|
}
|
|
2271
2292
|
|
|
2293
|
+
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
2294
|
+
const alwaysReload = agent.tags
|
|
2295
|
+
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
2296
|
+
.map((tag) => tag.slice("memory-reload:".length).trim())
|
|
2297
|
+
.filter(Boolean);
|
|
2298
|
+
if (!alwaysReload.length) return {};
|
|
2299
|
+
return { memory: { alwaysReload } };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2272
2302
|
const postAgentAction: Handler = async (req, params) => {
|
|
2273
2303
|
const parsed = await parseBody<unknown>(req);
|
|
2274
2304
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -2304,6 +2334,7 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2304
2334
|
...(metaPolicyName ? { policyName: metaPolicyName } : {}),
|
|
2305
2335
|
...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
|
|
2306
2336
|
...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
|
|
2337
|
+
...(action === "compact" ? compactMemoryParams(agent) : {}),
|
|
2307
2338
|
requestedBy: "dashboard",
|
|
2308
2339
|
requestedAt: Date.now(),
|
|
2309
2340
|
},
|
|
@@ -3509,6 +3540,21 @@ function compareSemverCore(a: string, b: string): number {
|
|
|
3509
3540
|
return 0;
|
|
3510
3541
|
}
|
|
3511
3542
|
|
|
3543
|
+
function orchestratorSelfUpgradeError(orch: ReturnType<typeof getOrchestrator>): string | undefined {
|
|
3544
|
+
if (!orch) return "orchestrator not found";
|
|
3545
|
+
if (orch.status !== "online") return "orchestrator is offline";
|
|
3546
|
+
if (!orch.capabilities?.relayCommandBus) {
|
|
3547
|
+
return `orchestrator ${orch.id} does not support relay command bus upgrades; upgrade it manually first`;
|
|
3548
|
+
}
|
|
3549
|
+
if (!orch.capabilities?.selfUpgrade) {
|
|
3550
|
+
return `orchestrator ${orch.id}${orch.version ? ` v${orch.version}` : ""} does not advertise self-upgrade support; upgrade it manually once to ${VERSION} or newer`;
|
|
3551
|
+
}
|
|
3552
|
+
if ((orch.supervisor !== "systemd" && orch.supervisor !== "launchd") || !orch.selfUnit) {
|
|
3553
|
+
return `orchestrator ${orch.id} cannot self-upgrade under supervisor ${orch.supervisor ?? "unknown"}; upgrade it manually`;
|
|
3554
|
+
}
|
|
3555
|
+
return undefined;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3512
3558
|
/**
|
|
3513
3559
|
* Settle an in-flight orchestrator upgrade by comparing the version the
|
|
3514
3560
|
* orchestrator now reports against the desired version (decision 2a). Called on
|
|
@@ -3517,11 +3563,18 @@ function compareSemverCore(a: string, b: string): number {
|
|
|
3517
3563
|
function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
|
|
3518
3564
|
if (!orch) return;
|
|
3519
3565
|
const up = orch.upgrade;
|
|
3520
|
-
if (!up
|
|
3566
|
+
if (!up) return;
|
|
3521
3567
|
const reported = orch.version;
|
|
3522
|
-
if (reported && reported === up.desiredVersion) {
|
|
3568
|
+
if (reported && reported === up.desiredVersion && up.status === "succeeded" && up.error) {
|
|
3569
|
+
const { error: _previousError, ...successState } = up;
|
|
3570
|
+
setOrchestratorUpgradeState(orch.id, successState);
|
|
3571
|
+
emitOrchestratorStatus(orch.id);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
if (reported && reported === up.desiredVersion && (up.status === "pending" || up.status === "failed")) {
|
|
3523
3575
|
if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
|
|
3524
|
-
|
|
3576
|
+
const { error: _previousError, ...successState } = up;
|
|
3577
|
+
setOrchestratorUpgradeState(orch.id, { ...successState, status: "succeeded", settledAt: Date.now() });
|
|
3525
3578
|
auditEvent({
|
|
3526
3579
|
clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
|
|
3527
3580
|
kind: "state",
|
|
@@ -3533,7 +3586,7 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
|
|
|
3533
3586
|
metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
|
|
3534
3587
|
});
|
|
3535
3588
|
emitOrchestratorStatus(orch.id);
|
|
3536
|
-
} else if (Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
|
|
3589
|
+
} else if (up.status === "pending" && Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
|
|
3537
3590
|
const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
|
|
3538
3591
|
if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
|
|
3539
3592
|
setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
|
|
@@ -3551,6 +3604,28 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
|
|
|
3551
3604
|
}
|
|
3552
3605
|
}
|
|
3553
3606
|
|
|
3607
|
+
function settleFailedOrchestratorUpgrade(command: Command): void {
|
|
3608
|
+
if (command.type !== "orchestrator.upgrade" || command.status !== "failed") return;
|
|
3609
|
+
const orchestratorId = typeof command.params?.orchestratorId === "string" ? command.params.orchestratorId : undefined;
|
|
3610
|
+
if (!orchestratorId) return;
|
|
3611
|
+
const orch = getOrchestrator(orchestratorId);
|
|
3612
|
+
const up = orch?.upgrade;
|
|
3613
|
+
if (!orch || !up || up.status !== "pending" || up.commandId !== command.id) return;
|
|
3614
|
+
const errMsg = command.error ?? "orchestrator upgrade failed";
|
|
3615
|
+
setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
|
|
3616
|
+
auditEvent({
|
|
3617
|
+
clientId: `server-orchestrator-upgrade-command-failed-${orch.id}-${command.id}`,
|
|
3618
|
+
kind: "state",
|
|
3619
|
+
title: "Orchestrator upgrade failed",
|
|
3620
|
+
body: errMsg,
|
|
3621
|
+
meta: orch.id,
|
|
3622
|
+
icon: "ti-alert-triangle",
|
|
3623
|
+
view: "orchestrators",
|
|
3624
|
+
metadata: { orchestratorId: orch.id, commandId: command.id },
|
|
3625
|
+
});
|
|
3626
|
+
emitOrchestratorStatus(orch.id);
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3554
3629
|
const postOrchestratorAction: Handler = async (req, params) => {
|
|
3555
3630
|
const parsed = await parseBody<unknown>(req);
|
|
3556
3631
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -3571,6 +3646,8 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
3571
3646
|
const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
|
|
3572
3647
|
.filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
|
|
3573
3648
|
if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
|
|
3649
|
+
const selfUpgradeError = orchestratorSelfUpgradeError(orch);
|
|
3650
|
+
if (selfUpgradeError) return error(selfUpgradeError, 409);
|
|
3574
3651
|
if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
|
|
3575
3652
|
return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
|
|
3576
3653
|
}
|
|
@@ -4284,6 +4361,7 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4284
4361
|
}
|
|
4285
4362
|
}
|
|
4286
4363
|
}
|
|
4364
|
+
settleFailedOrchestratorUpgrade(command);
|
|
4287
4365
|
emitCommand(command);
|
|
4288
4366
|
auditCommandOutcome(command);
|
|
4289
4367
|
return json(command);
|
|
@@ -4429,60 +4507,9 @@ function policyStatusPayload(policy: SpawnPolicy) {
|
|
|
4429
4507
|
}
|
|
4430
4508
|
|
|
4431
4509
|
function managedSpawnParams(policy: SpawnPolicy, requestId: string): Record<string, unknown> {
|
|
4432
|
-
|
|
4433
|
-
const providerArgs = managedPolicyProviderArgs(policy);
|
|
4434
|
-
const prompt = managedPolicyLaunchPrompt(policy);
|
|
4435
|
-
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
4436
|
-
return {
|
|
4437
|
-
action: "spawn",
|
|
4438
|
-
provider: policy.provider,
|
|
4439
|
-
model: selection.modelAlias,
|
|
4440
|
-
providerModel: selection.providerModel,
|
|
4441
|
-
effort: selection.effort,
|
|
4442
|
-
profile: policy.profile,
|
|
4443
|
-
agentProfile,
|
|
4444
|
-
cwd: policy.cwd,
|
|
4445
|
-
workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
|
|
4446
|
-
label: policy.label,
|
|
4447
|
-
tags: policy.tags,
|
|
4448
|
-
capabilities: policy.capabilities,
|
|
4449
|
-
approvalMode: policy.permissionMode,
|
|
4450
|
-
permissionMode: policy.permissionMode,
|
|
4451
|
-
providerArgs,
|
|
4452
|
-
...(prompt ? { prompt } : {}),
|
|
4453
|
-
headless: true,
|
|
4454
|
-
policyName: policy.name,
|
|
4455
|
-
spawnRequestId: requestId,
|
|
4456
|
-
env: runnerRuntimeTokenEnv({
|
|
4457
|
-
orchestratorId: policy.orchestratorId,
|
|
4458
|
-
cwd: policy.cwd,
|
|
4459
|
-
provider: policy.provider,
|
|
4460
|
-
label: policy.label,
|
|
4461
|
-
policyName: policy.name,
|
|
4462
|
-
spawnRequestId: requestId,
|
|
4463
|
-
createdBy: "managed-agent",
|
|
4464
|
-
}),
|
|
4465
|
-
requestedBy: "managed-agent",
|
|
4466
|
-
requestedAt: Date.now(),
|
|
4467
|
-
};
|
|
4510
|
+
return buildManagedSpawnParams(policy, requestId, { createdBy: "managed-agent" });
|
|
4468
4511
|
}
|
|
4469
4512
|
|
|
4470
|
-
function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
|
|
4471
|
-
if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
|
|
4472
|
-
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
4473
|
-
}
|
|
4474
|
-
|
|
4475
|
-
function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
|
|
4476
|
-
if (policy.provider === "claude" && policy.mode === "always-on" && policy.prompt?.trim()) {
|
|
4477
|
-
return [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
|
|
4478
|
-
}
|
|
4479
|
-
return policy.providerArgs;
|
|
4480
|
-
}
|
|
4481
|
-
|
|
4482
|
-
function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
|
|
4483
|
-
if (policy.provider === "claude" && policy.mode === "always-on") return undefined;
|
|
4484
|
-
return policy.prompt;
|
|
4485
|
-
}
|
|
4486
4513
|
|
|
4487
4514
|
function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
|
|
4488
4515
|
const entry = getSpawnPolicy(name);
|