agent-relay-server 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -85,6 +85,8 @@ import {
85
85
  getWorkspace,
86
86
  listWorkspaces,
87
87
  updateWorkspaceStatus,
88
+ setWorkspaceBranch,
89
+ patchWorkspaceMetadata,
88
90
  releaseMergeLease,
89
91
  listRepoStewards,
90
92
  listMergeLeases,
@@ -96,6 +98,7 @@ import {
96
98
  setMessageReaction,
97
99
  ValidationError,
98
100
  } from "./db";
101
+ import { cleanString } from "./validation";
99
102
  import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
100
103
  import {
101
104
  deleteConfig,
@@ -110,7 +113,6 @@ import {
110
113
  setInsightsConfig,
111
114
  getWorkspaceConfigEntry,
112
115
  setWorkspaceConfig,
113
- workspaceSpawnParams,
114
116
  listAgentProfiles,
115
117
  listSpawnPolicies,
116
118
  listConfig,
@@ -152,10 +154,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
152
154
  import { listHostDirectories } from "./agent-spawn";
153
155
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
154
156
  import type { ProviderConfig } from "../runner/src/adapter";
155
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
157
+ import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
158
+ import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
156
159
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
157
160
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
161
+ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
158
162
  import { requestWorkspaceMerge } from "./workspace-merge";
163
+ import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
164
+ import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
159
165
  import {
160
166
  getComponentAuth,
161
167
  getIntegrationAuth,
@@ -192,7 +198,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
192
198
  import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
193
199
  import { postMcp } from "./mcp";
194
200
  import { readFileSync } from "node:fs";
195
- import { isAbsolute, relative, resolve } from "node:path";
201
+ import { resolve } from "node:path";
202
+ import { isPathWithinBase } from "./utils";
196
203
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
197
204
  import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
198
205
  import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
@@ -323,14 +330,10 @@ function parseQueryInt(
323
330
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
324
331
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
325
332
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
326
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
327
333
  const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
328
334
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
329
335
  const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
330
336
  const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
331
- const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
332
- const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
333
- const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
334
337
  const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
335
338
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
336
339
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
@@ -349,34 +352,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
349
352
  const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
350
353
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
351
354
 
352
- function isRecord(value: unknown): value is Record<string, unknown> {
353
- return typeof value === "object" && value !== null && !Array.isArray(value);
354
- }
355
-
356
- function pathWithinBase(path: string, baseDir: string): boolean {
357
- const base = resolve(baseDir);
358
- const target = resolve(path);
359
- const rel = relative(base, target);
360
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
361
- }
362
-
363
- function cleanString(
364
- value: unknown,
365
- field: string,
366
- opts: { required?: boolean; max?: number } = {},
367
- ): string | undefined {
368
- if (value === undefined || value === null) {
369
- if (opts.required) throw new ValidationError(`${field} required`);
370
- return undefined;
371
- }
372
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
373
- const trimmed = value.trim();
374
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
375
- if (opts.max && trimmed.length > opts.max) {
376
- throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
377
- }
378
- return trimmed || undefined;
379
- }
380
355
 
381
356
  function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
382
357
  if (value === undefined) return undefined;
@@ -2281,39 +2256,22 @@ function restartSpawnParamsForAgent(
2281
2256
  const profileName = metaString(agent.meta, "profile");
2282
2257
  const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
2283
2258
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
2284
- let resolvedModel: Record<string, string> = {};
2285
- if (model || effort) {
2286
- try {
2287
- const selection = resolveProviderSelection({ provider, model, effort });
2288
- resolvedModel = {
2289
- ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
2290
- ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
2291
- ...(selection.effort ? { effort: selection.effort } : {}),
2292
- };
2293
- } catch {
2294
- resolvedModel = {
2295
- ...(model ? { model } : {}),
2296
- ...(effort ? { effort } : {}),
2297
- };
2298
- }
2299
- }
2300
- const params = {
2301
- action: "spawn",
2259
+ const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
2260
+ const params = buildSpawnCommand({
2302
2261
  provider,
2303
- ...resolvedModel,
2262
+ modelParams: resolvedModel,
2304
2263
  cwd,
2305
2264
  workspaceMode,
2306
- ...(profileName ? { profile: profileName } : {}),
2307
- ...(agentProfile ? { agentProfile } : {}),
2308
- ...workspaceSpawnParams(),
2309
- ...(label ? { label } : {}),
2265
+ profile: profileName || undefined,
2266
+ agentProfile,
2267
+ label: label || undefined,
2310
2268
  agentId: agent.id,
2311
2269
  tags: agent.tags,
2312
2270
  capabilities: agent.capabilities,
2313
2271
  approvalMode: approvalMode ?? "guarded",
2314
2272
  permissionMode: approvalMode ?? "guarded",
2315
- ...(providerArgs.length ? { providerArgs } : {}),
2316
- ...(policyName ? { policyName } : {}),
2273
+ providerArgs: providerArgs.length ? providerArgs : undefined,
2274
+ policyName: policyName || undefined,
2317
2275
  headless: true,
2318
2276
  spawnRequestId: requestId,
2319
2277
  env: runnerRuntimeTokenEnv({
@@ -2327,12 +2285,12 @@ function restartSpawnParamsForAgent(
2327
2285
  }),
2328
2286
  requestedBy,
2329
2287
  requestedAt: Date.now(),
2330
- };
2288
+ });
2331
2289
  return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2332
2290
  }
2333
2291
 
2334
2292
  function spawnRequestIdForRestart(): string {
2335
- return `sp_${crypto.randomUUID()}`;
2293
+ return generateSpawnRequestId();
2336
2294
  }
2337
2295
 
2338
2296
  function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
@@ -2633,10 +2591,10 @@ const postAgentSpawn: Handler = async (req) => {
2633
2591
  if (!parsed.ok) return error(parsed.error, parsed.status);
2634
2592
  try {
2635
2593
  if (!isRecord(parsed.body)) return error("provider required");
2636
- const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
2594
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2637
2595
  if (!provider) return error("provider required");
2638
2596
  const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
2639
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
2597
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2640
2598
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2641
2599
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2642
2600
  const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
@@ -2646,7 +2604,7 @@ const postAgentSpawn: Handler = async (req) => {
2646
2604
  );
2647
2605
  const orch = orchestrators[0];
2648
2606
  if (!orch) return error("no orchestrator available for provider: " + provider);
2649
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
2607
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
2650
2608
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
2651
2609
  }
2652
2610
  const requestId = spawnRequestId();
@@ -2660,10 +2618,9 @@ const postAgentSpawn: Handler = async (req) => {
2660
2618
  source: "system",
2661
2619
  target: orch.agentId,
2662
2620
  correlationId: requestId,
2663
- params: {
2664
- action: "spawn",
2621
+ params: buildSpawnCommand({
2665
2622
  provider,
2666
- ...selection,
2623
+ modelParams: selection,
2667
2624
  cwd: cwd || orch.baseDir,
2668
2625
  workspaceMode,
2669
2626
  label,
@@ -2679,7 +2636,7 @@ const postAgentSpawn: Handler = async (req) => {
2679
2636
  spawnRequestId: requestId,
2680
2637
  createdBy: "dashboard",
2681
2638
  }),
2682
- },
2639
+ }),
2683
2640
  });
2684
2641
  emitCommand(command);
2685
2642
  auditEvent({
@@ -2949,7 +2906,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2949
2906
  if (!parsed.ok) return error(parsed.error, parsed.status);
2950
2907
  try {
2951
2908
  if (!isRecord(parsed.body)) return error("runtime token body required");
2952
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
2909
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
2953
2910
  const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
2954
2911
  const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
2955
2912
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
@@ -3131,9 +3088,6 @@ const postTokenRevoke: Handler = (_req, params) => {
3131
3088
 
3132
3089
  // --- Orchestrator routes ---
3133
3090
 
3134
- const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
3135
- const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
3136
-
3137
3091
  function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3138
3092
  if (value === undefined || value === null) return undefined;
3139
3093
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
@@ -3141,27 +3095,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3141
3095
  return value;
3142
3096
  }
3143
3097
 
3144
- function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): { model?: string; effort?: ProviderEffort; providerModel?: string } {
3098
+ function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
3145
3099
  const model = cleanString(body.model, "model", { max: 120 });
3146
- const effort = cleanEnum(body.effort, "effort", VALID_PROVIDER_EFFORTS) as ProviderEffort | undefined;
3147
- let resolved;
3148
- try {
3149
- resolved = resolveProviderSelection({ provider, model, effort });
3150
- } catch (error) {
3151
- throw new ValidationError(error instanceof Error ? error.message : String(error));
3152
- }
3153
- return {
3154
- model: resolved.modelAlias,
3155
- providerModel: resolved.providerModel,
3156
- effort: resolved.effort,
3157
- };
3100
+ const effort = cleanEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
3101
+ return resolveSpawnModelParams(provider, model, effort);
3158
3102
  }
3159
3103
 
3160
3104
  function validateSpawnSelectionForOrchestrator(
3161
3105
  orch: NonNullable<ReturnType<typeof getOrchestrator>>,
3162
3106
  provider: SpawnProvider,
3163
3107
  body: Record<string, unknown>,
3164
- ): { model?: string; effort?: ProviderEffort; providerModel?: string } | Response {
3108
+ ): SpawnModelParams | Response {
3165
3109
  if (!orch.providers.includes(provider)) {
3166
3110
  return error(`orchestrator does not have provider available: ${provider}`, 409);
3167
3111
  }
@@ -3221,8 +3165,8 @@ const postOrchestrator: Handler = async (req) => {
3221
3165
  const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3222
3166
  if (providers) {
3223
3167
  for (const p of providers) {
3224
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3225
- return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3168
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3169
+ return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3226
3170
  }
3227
3171
  }
3228
3172
  }
@@ -3305,7 +3249,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3305
3249
  };
3306
3250
  if (runtime.providers) {
3307
3251
  for (const p of runtime.providers) {
3308
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3252
+ if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3309
3253
  }
3310
3254
  }
3311
3255
  const orch = orchestratorHeartbeat(params.id!, runtime);
@@ -3333,8 +3277,8 @@ const postOrchestratorBootstrap: Handler = async (req) => {
3333
3277
  const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
3334
3278
  const providerList = providers?.length ? providers : ["claude", "codex"];
3335
3279
  for (const p of providerList) {
3336
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3337
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3280
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3281
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3338
3282
  }
3339
3283
  }
3340
3284
  const bootstrapToken = createToken({
@@ -3425,7 +3369,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3425
3369
  ?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
3426
3370
  return {
3427
3371
  agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
3428
- provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
3372
+ provider: cleanEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
3429
3373
  sessionName,
3430
3374
  tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
3431
3375
  supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
@@ -3437,7 +3381,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3437
3381
  workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3438
3382
  workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
3439
3383
  label: cleanString(a.label, "label", { max: 120 }),
3440
- approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
3384
+ approvalMode: (cleanEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
3441
3385
  policyName: cleanString(a.policyName, "policyName", { max: 120 }),
3442
3386
  spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
3443
3387
  automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
@@ -3502,7 +3446,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3502
3446
 
3503
3447
  function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
3504
3448
  if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
3505
- const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, VALID_ORCHESTRATOR_PROVIDERS);
3449
+ const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
3506
3450
  if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
3507
3451
  const systemd = isRecord(value.systemd)
3508
3452
  ? {
@@ -3576,15 +3520,15 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3576
3520
  if (orch.status !== "online") return error("orchestrator is offline", 409);
3577
3521
 
3578
3522
  if (!isRecord(parsed.body)) return error("body required");
3579
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
3523
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3580
3524
  const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
3581
3525
  if (selection instanceof Response) return selection;
3582
3526
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
3583
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
3527
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
3584
3528
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
3585
3529
  }
3586
3530
  const label = cleanString(parsed.body.label, "label", { max: 120 });
3587
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
3531
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3588
3532
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
3589
3533
  const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
3590
3534
  const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
@@ -3607,12 +3551,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3607
3551
  source: "system",
3608
3552
  target: orch.agentId,
3609
3553
  correlationId: requestId,
3610
- params: {
3611
- action: "spawn",
3554
+ params: buildSpawnCommand({
3612
3555
  provider,
3613
- model: selection.model,
3614
- providerModel: selection.providerModel,
3615
- effort: selection.effort,
3556
+ modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
3616
3557
  cwd: cwd || orch.baseDir,
3617
3558
  workspaceMode,
3618
3559
  label,
@@ -3622,7 +3563,6 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3622
3563
  permissionMode: approvalMode,
3623
3564
  profile,
3624
3565
  agentProfile,
3625
- ...workspaceSpawnParams(),
3626
3566
  providerArgs,
3627
3567
  prompt,
3628
3568
  systemPromptAppend,
@@ -3639,7 +3579,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3639
3579
  }),
3640
3580
  requestedBy: "dashboard",
3641
3581
  requestedAt: Date.now(),
3642
- },
3582
+ }),
3643
3583
  });
3644
3584
  emitCommand(command);
3645
3585
  auditEvent({
@@ -3936,7 +3876,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3936
3876
  return json({ available: false, reason: "no isolated worktree" });
3937
3877
  }
3938
3878
  const orch = listOrchestrators().find(
3939
- (candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
3879
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
3940
3880
  );
3941
3881
  if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
3942
3882
  const query = new URLSearchParams({ path: workspace.worktreePath });
@@ -3945,7 +3885,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3945
3885
  for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
3946
3886
  const headers: Record<string, string> = {};
3947
3887
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3948
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3888
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3949
3889
  try {
3950
3890
  const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
3951
3891
  headers,
@@ -3987,11 +3927,11 @@ const getWorkspaceOrphans: Handler = async () => {
3987
3927
  const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
3988
3928
  const headers: Record<string, string> = {};
3989
3929
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3990
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3930
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3991
3931
  const orphans: WorkspaceOrphan[] = [];
3992
3932
 
3993
3933
  for (const repoRoot of repoRoots) {
3994
- const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
3934
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
3995
3935
  if (!orch?.apiUrl) continue;
3996
3936
  let probe: WorkspaceProbe | undefined;
3997
3937
  try {
@@ -4029,7 +3969,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4029
3969
  // Refuse to reclaim a path that still backs a live workspace row.
4030
3970
  const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
4031
3971
  if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
4032
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
3972
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
4033
3973
  if (!orch) return error("no online orchestrator owns this path", 409);
4034
3974
  const command = createCommand({
4035
3975
  type: "workspace.cleanup",
@@ -4055,6 +3995,165 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4055
3995
  }
4056
3996
  };
4057
3997
 
3998
+ // Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
3999
+ // by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
4000
+ // the owner and shape params identically. Queues (no TTL) when the owner is offline;
4001
+ // hard-fails only when no orchestrator owns the path (DELETE is then the escape).
4002
+ function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
4003
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4004
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
4005
+ if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
4006
+ const command = createCommand({
4007
+ type: "workspace.cleanup",
4008
+ source: "system",
4009
+ target: owner.agentId,
4010
+ correlationId: workspace.id,
4011
+ params: {
4012
+ action: "cleanup",
4013
+ workspaceId: workspace.id,
4014
+ repoRoot: workspace.repoRoot,
4015
+ worktreePath: workspace.worktreePath,
4016
+ branch: workspace.branch,
4017
+ requestedBy,
4018
+ requestedAt: Date.now(),
4019
+ deleteBranch: true,
4020
+ queued: owner.status !== "online",
4021
+ },
4022
+ });
4023
+ return { ok: true, command };
4024
+ }
4025
+
4026
+ // Fetch + parse a workspace's live git state from its owning host, or report why
4027
+ // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
4028
+ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
4029
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
4030
+ const orch = listOrchestrators().find(
4031
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
4032
+ );
4033
+ if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
4034
+ const query = new URLSearchParams({ path: workspace.worktreePath });
4035
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
4036
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
4037
+ const headers: Record<string, string> = {};
4038
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
4039
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4040
+ try {
4041
+ const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
4042
+ if (!res.ok) return { unavailable: `host returned ${res.status}` };
4043
+ return { state: await res.json() as WorkspaceGitState };
4044
+ } catch (e) {
4045
+ return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
4046
+ }
4047
+ }
4048
+
4049
+ // Recommend the next action for a workspace from its joined state — the steward's
4050
+ // (and a release agent's) "what should happen here?" without reconstructing it.
4051
+ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
4052
+ const { workspace, ownerOnline, gitState, claim } = input;
4053
+ if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
4054
+ if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
4055
+ if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
4056
+ if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
4057
+ const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
4058
+ const landed = gitState.landed === true;
4059
+ if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
4060
+ if (ahead === 0 || landed) {
4061
+ if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
4062
+ return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
4063
+ }
4064
+ if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
4065
+ return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
4066
+ }
4067
+ if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
4068
+ if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
4069
+ return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
4070
+ }
4071
+
4072
+ // Joined steward briefing for one workspace (#208): row + owner/orchestrator
4073
+ // liveness + live git state + branch mismatch + active claim + recommended action.
4074
+ const getWorkspaceDiagnostics: Handler = async (_req, params) => {
4075
+ const workspace = getWorkspace(params.id!);
4076
+ if (!workspace) return error("workspace not found", 404);
4077
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
4078
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4079
+ const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4080
+ const orchOnline = Boolean(orch) && orch!.status === "online";
4081
+ const fetched = await fetchWorkspaceGitState(workspace);
4082
+ const gitState = "state" in fetched ? fetched.state : undefined;
4083
+ const claim = workspaceActiveClaim(workspace);
4084
+ const liveBranch = gitState?.branch;
4085
+ const diagnostics: WorkspaceDiagnostics = {
4086
+ workspaceId: workspace.id,
4087
+ status: workspace.status,
4088
+ mode: workspace.mode,
4089
+ repoRoot: workspace.repoRoot,
4090
+ worktreePath: workspace.worktreePath,
4091
+ recordedBranch: workspace.branch,
4092
+ liveBranch,
4093
+ baseRef: workspace.baseRef,
4094
+ branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
4095
+ owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
4096
+ orchestrator: { id: orch?.id, online: orchOnline },
4097
+ ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
4098
+ ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
4099
+ recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
4100
+ };
4101
+ return json(diagnostics);
4102
+ };
4103
+
4104
+ // Guarded batch cleanup of stale worktrees (#208 / steward report §3). Defaults to
4105
+ // dry-run and only ever proposes/cleans worktrees that are provably safe: offline
4106
+ // owner, not claimed, clean tree, and (landed-only) work already in base or empty.
4107
+ // Never touches a live owner. Reuses the same cleanup command path as the manual action.
4108
+ const postWorkspaceCleanupStale: Handler = async (req) => {
4109
+ const denied = authorizeRoute(req, { scope: "command:write" });
4110
+ if (denied) return denied;
4111
+ const parsed = await parseBody<unknown>(req);
4112
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4113
+ const body = isRecord(parsed.body) ? parsed.body : {};
4114
+ const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
4115
+ const dryRun = body.dryRun !== false; // safe by default
4116
+ const landedOnly = body.landedOnly !== false;
4117
+ const offlineOwnerOnly = body.offlineOwnerOnly !== false;
4118
+
4119
+ const candidates = listWorkspaces().filter((ws) =>
4120
+ ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
4121
+ );
4122
+
4123
+ const rows: Array<Record<string, unknown>> = [];
4124
+ const cleaned: string[] = [];
4125
+ for (const ws of candidates) {
4126
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
4127
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4128
+ if (ownerOnline) continue; // never clean a live owner's worktree
4129
+ if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
4130
+ if (workspaceActiveClaim(ws)) continue; // respect steward claims
4131
+ const fetched = await fetchWorkspaceGitState(ws);
4132
+ const gitState = "state" in fetched ? fetched.state : undefined;
4133
+ const missing = gitState?.missing === true;
4134
+ const dirtyCount = gitState?.dirtyCount;
4135
+ const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
4136
+ const landed = gitState?.landed === true;
4137
+ // Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
4138
+ const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
4139
+ const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
4140
+ const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
4141
+ if (safe && !dryRun) {
4142
+ const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
4143
+ if (built.ok) {
4144
+ updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
4145
+ emitCommand(built.command);
4146
+ row.commandId = built.command.id;
4147
+ cleaned.push(ws.id);
4148
+ } else {
4149
+ row.cleanupError = built.error;
4150
+ }
4151
+ }
4152
+ rows.push(row);
4153
+ }
4154
+ return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
4155
+ };
4156
+
4058
4157
  const postWorkspaceAction: Handler = async (req, params) => {
4059
4158
  const parsed = await parseBody<unknown>(req);
4060
4159
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4062,7 +4161,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4062
4161
  if (!isRecord(parsed.body)) return error("body required");
4063
4162
  const workspace = getWorkspace(params.id!);
4064
4163
  if (!workspace) return error("workspace not found", 404);
4065
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
4164
+ const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim"] as const);
4066
4165
  if (!action) return error("action required", 400);
4067
4166
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4068
4167
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
@@ -4076,6 +4175,26 @@ const postWorkspaceAction: Handler = async (req, params) => {
4076
4175
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
4077
4176
  if (denied) return denied;
4078
4177
  if (action === "status") return json(workspace);
4178
+ // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
4179
+ // so deterministic landing can't race a steward mid-validation. No status change.
4180
+ if (action === "claim" || action === "release-claim") {
4181
+ const release = action === "release-claim";
4182
+ const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
4183
+ const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
4184
+ if (!updated) return error("workspace not found", 404);
4185
+ auditEvent({
4186
+ clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4187
+ kind: "state",
4188
+ title: release ? "Workspace claim released" : "Workspace claimed",
4189
+ body: detail ?? purpose ?? workspace.worktreePath,
4190
+ meta: workspace.branch ?? workspace.id,
4191
+ icon: "ti-lock",
4192
+ view: "orchestrators",
4193
+ agentId,
4194
+ metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
4195
+ });
4196
+ return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
4197
+ }
4079
4198
  // Base merges go through the shared helper (lease + command + bind), the same
4080
4199
  // path the auto-merge job uses, so both serialize per repo (issue #157).
4081
4200
  if (action === "merge") {
@@ -4123,30 +4242,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
4123
4242
  let command: Command | undefined;
4124
4243
  if (requiresCommand) {
4125
4244
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
4126
- // Cleanup may queue: if the owning orchestrator is offline, the command (no
4127
- // TTL) waits and reconciles when it reconnects. Only hard-fail when no
4128
- // orchestrator owns the path at all — then DELETE is the escape.
4129
- const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
4130
- const onlineOwner = owners.find((candidate) => candidate.status === "online");
4131
- const owner = onlineOwner ?? owners[0];
4132
- if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4133
- command = createCommand({
4134
- type: "workspace.cleanup",
4135
- source: "system",
4136
- target: owner.agentId,
4137
- correlationId: workspace.id,
4138
- params: {
4139
- action: "cleanup",
4140
- workspaceId: workspace.id,
4141
- repoRoot: workspace.repoRoot,
4142
- worktreePath: workspace.worktreePath,
4143
- branch: workspace.branch,
4144
- requestedBy: agentId ?? "dashboard",
4145
- requestedAt: Date.now(),
4146
- deleteBranch: true,
4147
- queued: owner.status !== "online",
4148
- },
4149
- });
4245
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
4246
+ if (!built.ok) return error(built.error, built.status);
4247
+ command = built.command;
4150
4248
  emitCommand(command);
4151
4249
  }
4152
4250
  auditEvent({
@@ -4198,7 +4296,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
4198
4296
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4199
4297
  const headers: Record<string, string> = {};
4200
4298
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4201
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4299
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4202
4300
  try {
4203
4301
  const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
4204
4302
  const contentType = res.headers.get("content-type") ?? "";
@@ -4223,7 +4321,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
4223
4321
  "Content-Type": req.headers.get("content-type") ?? "application/json",
4224
4322
  };
4225
4323
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4226
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4324
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4227
4325
  try {
4228
4326
  const body = await req.text();
4229
4327
  const res = await fetch(proxyUrl, {
@@ -4248,7 +4346,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
4248
4346
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4249
4347
  const headers: Record<string, string> = {};
4250
4348
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4251
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4349
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4252
4350
  try {
4253
4351
  const res = await fetch(proxyUrl, {
4254
4352
  method: "DELETE",
@@ -4269,7 +4367,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
4269
4367
  if (orch.status !== "online") return error("orchestrator is offline", 422);
4270
4368
  const headers: Record<string, string> = { "Content-Type": "application/json" };
4271
4369
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4272
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4370
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4273
4371
  try {
4274
4372
  const res = await fetch(`${orch.apiUrl}${path}`, {
4275
4373
  method,
@@ -4297,8 +4395,8 @@ const getOrchestratorProviders: Handler = async (req, params) => {
4297
4395
  const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
4298
4396
  if (providers) {
4299
4397
  for (const p of providers) {
4300
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
4301
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
4398
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
4399
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
4302
4400
  }
4303
4401
  }
4304
4402
  }
@@ -4483,6 +4581,13 @@ const patchCommand: Handler = async (req, params) => {
4483
4581
  mergeCommandId: command.id,
4484
4582
  mergedAt: Date.now(),
4485
4583
  });
4584
+ // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
4585
+ // Repoint the row so the next merge targets the live branch, not the deleted one.
4586
+ const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4587
+ if (newBranch) {
4588
+ const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4589
+ setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4590
+ }
4486
4591
  }
4487
4592
  } else if (command.status === "failed" && command.correlationId) {
4488
4593
  // Merge couldn't complete — don't leave it stuck in merge_planned.
@@ -4774,7 +4879,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
4774
4879
  // --- Spawn policy routes ---
4775
4880
 
4776
4881
  function spawnRequestId(): string {
4777
- return `sp_${crypto.randomUUID()}`;
4882
+ return generateSpawnRequestId();
4778
4883
  }
4779
4884
 
4780
4885
  function policyStatusPayload(policy: SpawnPolicy) {
@@ -6572,9 +6677,11 @@ const routes: Route[] = [
6572
6677
  route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
6573
6678
  route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
6574
6679
  route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
6680
+ route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
6575
6681
  route("GET", "/api/workspaces/:id", getWorkspaceById),
6576
6682
  route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
6577
6683
  route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
6684
+ route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
6578
6685
  route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
6579
6686
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
6580
6687
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),