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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.10.21",
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 it via the bundled `agent-relay upgrade --no-restart` CLI, then restarts itself decoupled (systemd-run) so the bus keepalive survives its own teardown. 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. Safety rails: semver-only `targetVersion` (defaults to the relay's own version), npm-registry providers only, and no downgrade unless `force` is set. Requires systemd --user supervision on the orchestrator host.",
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",
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.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 mergeable = workspace.mode === "isolated" && Boolean(workspace.worktreePath) && MERGEABLE_STATUSES.has(workspace.status);
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, ahead }) {
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 = ahead > 0 && MERGEABLE_STATUSES.has(workspace.status);
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 = ahead === 0 && dirty === 0;
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: ahead > 0 ? "flex items-center text-emerald-400" : "flex items-center text-muted-foreground",
123268
- title: `${ahead} commit(s) ahead of base`,
123269
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ArrowUp, { className: "h-3 w-3" }), ahead]
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
- ahead
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
 
@@ -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 { runnerRuntimeTokenEnv } from "./runtime-tokens";
13
- import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy, WorkspaceMode } from "./types";
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
- action: "spawn",
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: state?.spawnRequestId,
314
- lastSpawnAt: state?.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
- return this.stopAgent(policy, true, reason);
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
- }
@@ -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
- const orchestrator = orchestratorId
709
- ? getOrchestrator(orchestratorId)
710
- : listOrchestrators().find((item) => item.status === "online" && item.providers.includes(provider));
711
- if (!orchestrator) throw new McpNotFoundError(orchestratorId ? `orchestrator ${orchestratorId} not found` : `no orchestrator available for provider: ${provider}`);
712
- if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
713
- if (!orchestrator.providers.includes(provider)) throw new ValidationError(`orchestrator does not have provider available: ${provider}`);
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
- ...(model ? { model } : {}),
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 || up.status !== "pending") return;
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
- setOrchestratorUpgradeState(orch.id, { ...up, status: "succeeded", settledAt: Date.now() });
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
- const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
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);