agent-relay-server 0.11.8 → 0.11.9

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,8 +85,6 @@ import {
85
85
  getWorkspace,
86
86
  listWorkspaces,
87
87
  updateWorkspaceStatus,
88
- acquireMergeLease,
89
- setMergeLeaseCommand,
90
88
  releaseMergeLease,
91
89
  listRepoStewards,
92
90
  listMergeLeases,
@@ -107,11 +105,13 @@ import {
107
105
  getAgentProfile,
108
106
  getManagedAgentState,
109
107
  getSpawnPolicy,
108
+ getStewardConfigEntry,
110
109
  listAgentProfiles,
111
110
  listSpawnPolicies,
112
111
  listConfig,
113
112
  setAgentProfile,
114
113
  setConfig,
114
+ setStewardConfig,
115
115
  upsertManagedAgentState,
116
116
  updateManagedAgentState,
117
117
  } from "./config-store";
@@ -140,7 +140,7 @@ import {
140
140
  updateAutomation,
141
141
  type AutomationDispatchResult,
142
142
  } from "./automations";
143
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
143
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMergeStrategy, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
144
144
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
145
145
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
146
146
  import { listHostDirectories } from "./agent-spawn";
@@ -149,6 +149,7 @@ import type { ProviderConfig } from "../runner/src/adapter";
149
149
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
150
150
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
151
151
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
152
+ import { requestWorkspaceMerge } from "./workspace-merge";
152
153
  import {
153
154
  getComponentAuth,
154
155
  getIntegrationAuth,
@@ -187,7 +188,7 @@ import { postMcp } from "./mcp";
187
188
  import { readFileSync } from "node:fs";
188
189
  import { isAbsolute, relative, resolve } from "node:path";
189
190
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
190
- import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
191
+ import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
191
192
  import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
192
193
 
193
194
  type Handler = (
@@ -319,7 +320,8 @@ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability
319
320
  const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
320
321
  const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
321
322
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
322
- const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext"] as const;
323
+ const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume"] as const;
324
+ 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;
323
325
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
324
326
  const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
325
327
  const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
@@ -1248,7 +1250,10 @@ function auditEvent(input: ActivityEventInput): void {
1248
1250
  function auditCommandOutcome(command: Command): void {
1249
1251
  if (command.status !== "succeeded" && command.status !== "failed") return;
1250
1252
  if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
1251
- const action = agentControlActionFromCommandType(command.type);
1253
+ const paramAction = typeof command.params?.action === "string" && (VALID_AGENT_ACTIONS as readonly string[]).includes(command.params.action)
1254
+ ? command.params.action as AgentControlAction
1255
+ : null;
1256
+ const action = paramAction ?? agentControlActionFromCommandType(command.type);
1252
1257
  if (!action) return;
1253
1258
  const agentId = typeof command.params?.agentId === "string" ? command.params.agentId : command.target;
1254
1259
  const succeeded = command.status === "succeeded";
@@ -2143,7 +2148,7 @@ const deleteAgentById: Handler = (_req, params) => {
2143
2148
  type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
2144
2149
 
2145
2150
  function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" {
2146
- if (action === "restart") return "agent.restart";
2151
+ if (action === "restart" || action === "resume") return "agent.restart";
2147
2152
  if (action === "shutdown") return "agent.shutdown";
2148
2153
  if (action === "compact") return "agent.compact";
2149
2154
  if (action === "clearContext") return "agent.clearContext";
@@ -2161,6 +2166,7 @@ function agentControlActionFromCommandType(type: string): AgentControlAction | n
2161
2166
 
2162
2167
  function agentControlActionRequestedTitle(action: AgentControlAction): string {
2163
2168
  if (action === "restart") return "Agent restart requested";
2169
+ if (action === "resume") return "Agent resume requested";
2164
2170
  if (action === "shutdown") return "Agent shutdown requested";
2165
2171
  if (action === "compact") return "Agent compaction requested";
2166
2172
  if (action === "clearContext") return "Agent context clear requested";
@@ -2169,6 +2175,7 @@ function agentControlActionRequestedTitle(action: AgentControlAction): string {
2169
2175
 
2170
2176
  function agentControlActionCompletedTitle(action: AgentControlAction): string {
2171
2177
  if (action === "restart") return "Agent restarted";
2178
+ if (action === "resume") return "Agent resumed";
2172
2179
  if (action === "shutdown") return "Agent shut down";
2173
2180
  if (action === "compact") return "Agent compacted";
2174
2181
  if (action === "clearContext") return "Agent context cleared";
@@ -2179,6 +2186,7 @@ function agentControlActionIcon(action: AgentControlAction): string {
2179
2186
  if (action === "shutdown") return "ti-power";
2180
2187
  if (action === "compact") return "ti-compress";
2181
2188
  if (action === "clearContext") return "ti-eraser";
2189
+ if (action === "resume") return "ti-player-play";
2182
2190
  return "ti-refresh";
2183
2191
  }
2184
2192
 
@@ -2191,6 +2199,7 @@ function agentIsControlEligible(agent: AgentCard): boolean {
2191
2199
 
2192
2200
  function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
2193
2201
  if (!agentIsControlEligible(agent)) return false;
2202
+ if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
2194
2203
  const lifecycle = agent.providerCapabilities?.lifecycle;
2195
2204
  if (lifecycle) {
2196
2205
  if (action === "restart") return lifecycle.restartHard === true;
@@ -2202,6 +2211,10 @@ function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlActi
2202
2211
  return agent.meta?.runnerManaged === true && (action === "restart" || action === "shutdown");
2203
2212
  }
2204
2213
 
2214
+ function agentRuntimeProvider(agent: AgentCard): string | undefined {
2215
+ return metaString(agent.meta, "provider") ?? agent.providerCapabilities?.model?.provider;
2216
+ }
2217
+
2205
2218
  function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
2206
2219
  if (agent.meta?.runnerManaged !== true) return null;
2207
2220
  const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
@@ -2226,11 +2239,16 @@ function restartSpawnParamsForAgent(
2226
2239
  orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
2227
2240
  policyName?: string,
2228
2241
  spawnRequestId?: string,
2242
+ opts: { resumeId?: string } = {},
2229
2243
  ): Record<string, unknown> | undefined {
2230
2244
  if (!orchestrator) return undefined;
2231
2245
  const requestId = spawnRequestId ?? spawnRequestIdForRestart();
2232
2246
  const policy = policyName ? getSpawnPolicy(policyName) : null;
2233
- if (policy) return { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy: "dashboard-restart" };
2247
+ const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
2248
+ if (policy) {
2249
+ const params = { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy };
2250
+ return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2251
+ }
2234
2252
 
2235
2253
  const provider = metaString(agent.meta, "provider");
2236
2254
  if (provider !== "claude" && provider !== "codex") return undefined;
@@ -2259,7 +2277,7 @@ function restartSpawnParamsForAgent(
2259
2277
  };
2260
2278
  }
2261
2279
  }
2262
- return {
2280
+ const params = {
2263
2281
  action: "spawn",
2264
2282
  provider,
2265
2283
  ...resolvedModel,
@@ -2284,17 +2302,57 @@ function restartSpawnParamsForAgent(
2284
2302
  label,
2285
2303
  policyName,
2286
2304
  spawnRequestId: requestId,
2287
- createdBy: "dashboard-restart",
2305
+ createdBy: requestedBy,
2288
2306
  }),
2289
- requestedBy: "dashboard-restart",
2307
+ requestedBy,
2290
2308
  requestedAt: Date.now(),
2291
2309
  };
2310
+ return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2292
2311
  }
2293
2312
 
2294
2313
  function spawnRequestIdForRestart(): string {
2295
2314
  return `sp_${crypto.randomUUID()}`;
2296
2315
  }
2297
2316
 
2317
+ function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
2318
+ return {
2319
+ ...params,
2320
+ providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
2321
+ resumeOfAgentId: agentId,
2322
+ claudeResumeId: resumeId,
2323
+ };
2324
+ }
2325
+
2326
+ function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
2327
+ const cleaned: string[] = [];
2328
+ for (let i = 0; i < args.length; i += 1) {
2329
+ const arg = args[i];
2330
+ if (!arg) continue;
2331
+ if (arg === "--resume") {
2332
+ i += 1;
2333
+ continue;
2334
+ }
2335
+ if (arg.startsWith("--resume=")) continue;
2336
+ cleaned.push(arg);
2337
+ }
2338
+ return [...cleaned, "--resume", resumeId];
2339
+ }
2340
+
2341
+ function recordStringArray(value: unknown): string[] {
2342
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
2343
+ }
2344
+
2345
+ function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
2346
+ for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
2347
+ const metadata = entry.metadata;
2348
+ if (!metadata) continue;
2349
+ const provider = metaString(metadata, "provider");
2350
+ const resumeId = metaString(metadata, "claudeResumeId");
2351
+ if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
2352
+ }
2353
+ return undefined;
2354
+ }
2355
+
2298
2356
  function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
2299
2357
  const alwaysReload = agent.tags
2300
2358
  .filter((tag) => tag.startsWith("memory-reload:"))
@@ -2315,11 +2373,14 @@ const postAgentAction: Handler = async (req, params) => {
2315
2373
  if (!agent) return error("agent not found", 404);
2316
2374
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
2317
2375
 
2318
- const orchestrator = (action === "restart" || action === "shutdown") ? managedControlOrchestrator(agent) : null;
2376
+ const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
2319
2377
  const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
2320
2378
  const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
2321
2379
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
2322
2380
  const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
2381
+ const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
2382
+ if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
2383
+ if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
2323
2384
  const denied = authorizeRoute(req, {
2324
2385
  scope: "agent:write",
2325
2386
  resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
@@ -2338,14 +2399,15 @@ const postAgentAction: Handler = async (req, params) => {
2338
2399
  ...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
2339
2400
  ...(metaPolicyName ? { policyName: metaPolicyName } : {}),
2340
2401
  ...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
2341
- ...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
2402
+ ...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
2342
2403
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
2404
+ ...(resumeId ? { claudeResumeId: resumeId } : {}),
2343
2405
  requestedBy: "dashboard",
2344
2406
  requestedAt: Date.now(),
2345
2407
  },
2346
2408
  });
2347
- if (action === "shutdown" || action === "restart") {
2348
- const lifecycleAction = action === "shutdown" ? "shutting-down" : "restarting";
2409
+ if (action === "shutdown" || action === "restart" || action === "resume") {
2410
+ const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
2349
2411
  markReady(agent.id, false);
2350
2412
  mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
2351
2413
  emitAgentStatus(agent.id);
@@ -2826,6 +2888,40 @@ const postRuntimeTokenRenew: Handler = async (req) => {
2826
2888
  }, 201);
2827
2889
  };
2828
2890
 
2891
+ // Orchestrator-mediated runner-token re-mint. A live runner whose runtime token
2892
+ // has expired (e.g. the relay was unreachable across the renewal window) cannot
2893
+ // self-renew — its dead token can't authenticate. Its orchestrator, which holds
2894
+ // a long-lived credential, proxies the runner's expired token here. We verify the
2895
+ // token is a genuine, non-revoked runner token owned by THIS orchestrator and
2896
+ // mint a fresh one cloning its scope, so the session self-heals without a restart.
2897
+ const postOrchestratorRunnerToken: Handler = async (req, params) => {
2898
+ const orch = getOrchestrator(params.id!);
2899
+ if (!orch) return error("orchestrator not found", 404);
2900
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
2901
+ if (denied) return denied;
2902
+ const parsed = await parseBody<unknown>(req);
2903
+ if (!parsed.ok) return error(parsed.error, parsed.status);
2904
+ if (!isRecord(parsed.body)) return error("body required");
2905
+ const token = cleanString(parsed.body.token, "token", { required: true, max: 8192 })!;
2906
+ const result = reissueRunnerRuntimeToken({
2907
+ expiredToken: token,
2908
+ orchestratorId: orch.id,
2909
+ createdBy: `orchestrator-remint:${orch.id}`,
2910
+ });
2911
+ if ("error" in result) return error(result.error, 403);
2912
+ auditEvent({
2913
+ clientId: "server-runner-token-remint-" + result.record.jti + "-" + Date.now(),
2914
+ kind: "state",
2915
+ title: "Runner token re-minted",
2916
+ body: result.record.sub,
2917
+ meta: result.record.jti,
2918
+ icon: "ti-key",
2919
+ view: "security",
2920
+ metadata: { orchestratorId: orch.id, jti: result.record.jti, sub: result.record.sub, ...authAuditMetadata(req) },
2921
+ });
2922
+ return json(result, 201);
2923
+ };
2924
+
2829
2925
  const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2830
2926
  if (!isRootCredentialRequest(req)) return error("root credential required for runtime token exchange", 403);
2831
2927
  const parsed = await parseBody<unknown>(req);
@@ -3940,7 +4036,6 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3940
4036
  const postWorkspaceAction: Handler = async (req, params) => {
3941
4037
  const parsed = await parseBody<unknown>(req);
3942
4038
  if (!parsed.ok) return error(parsed.error, parsed.status);
3943
- let mergeLeaseRepo: string | undefined;
3944
4039
  try {
3945
4040
  if (!isRecord(parsed.body)) return error("body required");
3946
4041
  const workspace = getWorkspace(params.id!);
@@ -3959,17 +4054,32 @@ const postWorkspaceAction: Handler = async (req, params) => {
3959
4054
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3960
4055
  if (denied) return denied;
3961
4056
  if (action === "status") return json(workspace);
3962
- // Serialize base merges per repo: acquire the merge lease BEFORE mutating
3963
- // status so a losing request leaves the workspace untouched (issue #157).
4057
+ // Base merges go through the shared helper (lease + command + bind), the same
4058
+ // path the auto-merge job uses, so both serialize per repo (issue #157).
3964
4059
  if (action === "merge") {
3965
- const lease = acquireMergeLease(workspace.repoRoot, workspace.id, agentId ?? "dashboard");
3966
- if (!lease.ok) {
3967
- return error(
3968
- `a merge is already in progress for ${workspace.repoRoot} (workspace ${lease.lease.workspaceId}); retry after it settles`,
3969
- 409,
3970
- );
3971
- }
3972
- mergeLeaseRepo = workspace.repoRoot;
4060
+ const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4061
+ const result = requestWorkspaceMerge(workspace, {
4062
+ requestedBy: agentId ?? "dashboard",
4063
+ strategy,
4064
+ deleteBranch: parsed.body.deleteBranch !== false,
4065
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4066
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4067
+ metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
4068
+ });
4069
+ if (!result.ok) return error(result.error, result.status);
4070
+ emitCommand(result.command);
4071
+ auditEvent({
4072
+ clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
4073
+ kind: "state",
4074
+ title: "Workspace merge",
4075
+ body: detail ?? workspace.worktreePath,
4076
+ meta: workspace.branch ?? workspace.id,
4077
+ icon: "ti-git-merge",
4078
+ view: "orchestrators",
4079
+ agentId,
4080
+ metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
4081
+ });
4082
+ return json({ workspace: result.workspace, command: result.command }, 202);
3973
4083
  }
3974
4084
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
3975
4085
  status: undefined,
@@ -3977,7 +4087,6 @@ const postWorkspaceAction: Handler = async (req, params) => {
3977
4087
  "conflict-found": "conflict",
3978
4088
  "request-review": "review_requested",
3979
4089
  "merge-plan": "merge_planned",
3980
- merge: "merge_planned",
3981
4090
  abandon: "abandoned",
3982
4091
  cleanup: "cleanup_requested",
3983
4092
  };
@@ -3991,58 +4100,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
3991
4100
  if (!updated) return error("workspace not found", 404);
3992
4101
  let command: Command | undefined;
3993
4102
  if (requiresCommand) {
3994
- // All orchestrators whose baseDir contains the workspace; prefer an online one.
4103
+ // Only `cleanup` reaches here `merge` returned early via the shared helper.
4104
+ // Cleanup may queue: if the owning orchestrator is offline, the command (no
4105
+ // TTL) waits and reconciles when it reconnects. Only hard-fail when no
4106
+ // orchestrator owns the path at all — then DELETE is the escape.
3995
4107
  const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3996
4108
  const onlineOwner = owners.find((candidate) => candidate.status === "online");
3997
- const baseParams = {
3998
- workspaceId: workspace.id,
3999
- repoRoot: workspace.repoRoot,
4000
- worktreePath: workspace.worktreePath,
4001
- branch: workspace.branch,
4002
- requestedBy: agentId ?? "dashboard",
4003
- requestedAt: Date.now(),
4004
- };
4005
- if (action === "merge") {
4006
- // Merge needs a live host: rebasing against a stale base later is unsafe.
4007
- if (!onlineOwner) {
4008
- releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
4009
- mergeLeaseRepo = undefined;
4010
- return error("no online orchestrator available for workspace merge", 409);
4011
- }
4012
- const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto");
4013
- command = createCommand({
4014
- type: "workspace.merge",
4015
- source: "system",
4016
- target: onlineOwner.agentId,
4017
- correlationId: workspace.id,
4018
- params: {
4019
- action: "merge",
4020
- ...baseParams,
4021
- baseRef: workspace.baseRef,
4022
- baseSha: workspace.baseSha,
4023
- strategy,
4024
- deleteBranch: parsed.body.deleteBranch !== false,
4025
- prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4026
- prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4027
- },
4028
- });
4029
- } else {
4030
- // Cleanup may queue: if the owning orchestrator is offline, the command
4031
- // (no TTL) waits and reconciles when it reconnects. Only hard-fail when
4032
- // no orchestrator owns the path at all — then DELETE is the escape.
4033
- const owner = onlineOwner ?? owners[0];
4034
- if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4035
- command = createCommand({
4036
- type: "workspace.cleanup",
4037
- source: "system",
4038
- target: owner.agentId,
4039
- correlationId: workspace.id,
4040
- params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
4041
- });
4042
- }
4043
- // Bind the lease to the dispatched merge command so it's released by id
4044
- // when the command settles (postCommandResult).
4045
- if (action === "merge" && mergeLeaseRepo) setMergeLeaseCommand(mergeLeaseRepo, command.id);
4109
+ const owner = onlineOwner ?? owners[0];
4110
+ if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4111
+ command = createCommand({
4112
+ type: "workspace.cleanup",
4113
+ source: "system",
4114
+ target: owner.agentId,
4115
+ correlationId: workspace.id,
4116
+ params: {
4117
+ action: "cleanup",
4118
+ workspaceId: workspace.id,
4119
+ repoRoot: workspace.repoRoot,
4120
+ worktreePath: workspace.worktreePath,
4121
+ branch: workspace.branch,
4122
+ requestedBy: agentId ?? "dashboard",
4123
+ requestedAt: Date.now(),
4124
+ deleteBranch: true,
4125
+ queued: owner.status !== "online",
4126
+ },
4127
+ });
4046
4128
  emitCommand(command);
4047
4129
  }
4048
4130
  auditEvent({
@@ -4051,16 +4133,13 @@ const postWorkspaceAction: Handler = async (req, params) => {
4051
4133
  title: `Workspace ${action}`,
4052
4134
  body: detail ?? workspace.worktreePath,
4053
4135
  meta: workspace.branch ?? workspace.id,
4054
- icon: action === "cleanup" ? "ti-trash" : action === "merge" ? "ti-git-merge" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4136
+ icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4055
4137
  view: "orchestrators",
4056
4138
  agentId,
4057
4139
  metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
4058
4140
  });
4059
4141
  return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
4060
4142
  } catch (e) {
4061
- // A merge that acquired the lease but failed before dispatch must release it,
4062
- // or the repo stays blocked until the TTL expires.
4063
- if (mergeLeaseRepo) releaseMergeLease({ repoRoot: mergeLeaseRepo });
4064
4143
  if (e instanceof ValidationError) return error(e.message, 400);
4065
4144
  throw e;
4066
4145
  }
@@ -4397,16 +4476,35 @@ const patchCommand: Handler = async (req, params) => {
4397
4476
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4398
4477
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4399
4478
  const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4479
+ const removed = command.result.removed === true;
4400
4480
  if (workspaceId && resultStatus) {
4401
4481
  // Only act on workspaces the agent left in a live state; never overwrite
4402
4482
  // a status a human/agent has since moved on (merge_planned, abandoned, …).
4403
4483
  const current = getWorkspace(workspaceId);
4404
4484
  if (current && (current.status === "active" || current.status === "ready")) {
4405
- updateWorkspaceStatus(workspaceId, resultStatus, {
4406
- reconcileResult: command.result,
4407
- reconcileCommandId: command.id,
4408
- reconciledAt: Date.now(),
4409
- });
4485
+ if (removed) {
4486
+ // The owner exited and the worktree had no work — the orchestrator
4487
+ // already deleted it on disk. Drop the DB row immediately instead of
4488
+ // parking it at `cleaned` for 24h: a no-change session is pure junk
4489
+ // from the user's view, so it should leave the Workspaces panel now.
4490
+ deleteWorkspace(workspaceId);
4491
+ auditEvent({
4492
+ clientId: `workspace-reconcile-removed-${workspaceId}-${Date.now()}`,
4493
+ kind: "state",
4494
+ title: "Workspace removed (no changes)",
4495
+ body: current.worktreePath,
4496
+ meta: current.branch ?? workspaceId,
4497
+ icon: "ti-trash",
4498
+ view: "orchestrators",
4499
+ metadata: { reconcileCommandId: command.id, workspaceId, repoRoot: current.repoRoot },
4500
+ });
4501
+ } else {
4502
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4503
+ reconcileResult: command.result,
4504
+ reconcileCommandId: command.id,
4505
+ reconciledAt: Date.now(),
4506
+ });
4507
+ }
4410
4508
  }
4411
4509
  }
4412
4510
  }
@@ -4469,6 +4567,27 @@ const deleteAgentProfileRoute: Handler = (req, params) => {
4469
4567
  }
4470
4568
  };
4471
4569
 
4570
+ // --- Steward config (global, provider-independent — issue #167) ---
4571
+
4572
+ const getStewardConfigRoute: Handler = () => json(getStewardConfigEntry());
4573
+
4574
+ const putStewardConfigRoute: Handler = async (req) => {
4575
+ const parsed = await parseBody<unknown>(req);
4576
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4577
+ try {
4578
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4579
+ ? parsed.body.value
4580
+ : parsed.body;
4581
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4582
+ const entry = setStewardConfig(value, updatedBy);
4583
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4584
+ return json(entry, entry.version === 1 ? 201 : 200);
4585
+ } catch (e) {
4586
+ if (e instanceof ValidationError) return error(e.message, 400);
4587
+ throw e;
4588
+ }
4589
+ };
4590
+
4472
4591
  // --- Config routes ---
4473
4592
 
4474
4593
  function normalizeConfigPathParam(raw: string | undefined, field: string): string {
@@ -6276,6 +6395,7 @@ const routes: Route[] = [
6276
6395
  route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
6277
6396
  route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
6278
6397
  route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
6398
+ route("POST", "/api/orchestrators/:id/runner-token", postOrchestratorRunnerToken),
6279
6399
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
6280
6400
  route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
6281
6401
  route("POST", "/api/orchestrators/:id/directories", postOrchestratorCreateDirectory),
@@ -6334,6 +6454,8 @@ const routes: Route[] = [
6334
6454
  route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
6335
6455
  route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
6336
6456
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6457
+ route("GET", "/api/steward-config", getStewardConfigRoute),
6458
+ route("PUT", "/api/steward-config", putStewardConfigRoute),
6337
6459
  route("GET", "/api/config/:namespace", getConfigNamespace),
6338
6460
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
6339
6461
  route("GET", "/api/config/:namespace/:key", getConfigKey),
@@ -1,4 +1,5 @@
1
- import { createToken } from "./token-db";
1
+ import { createToken, revokeToken } from "./token-db";
2
+ import { verifyComponentTokenAllowExpired } from "./security";
2
3
  import type { TokenRecord } from "./types";
3
4
 
4
5
  interface RuntimeTokenResult {
@@ -6,6 +7,48 @@ interface RuntimeTokenResult {
6
7
  record: TokenRecord;
7
8
  }
8
9
 
10
+ // How long after expiry a runner token may still be re-minted. Bounds replay of
11
+ // a long-dead token to a sane window — a live session re-mints well within this.
12
+ const REMINT_MAX_EXPIRED_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
13
+
14
+ // Orchestrator-mediated re-mint: given a runner's current (possibly expired) token
15
+ // and the calling orchestrator's id, verify the token belongs to a runner of THIS
16
+ // orchestrator and issue a fresh provider-agent token cloning its scope. The old
17
+ // token is revoked. The caller MUST already be authenticated/authorized as the
18
+ // orchestrator — this only establishes that the presented token is a genuine,
19
+ // non-revoked runner token owned by that orchestrator. See approach #1 in the
20
+ // runner self-heal design: the relay stays strict, the orchestrator's standing
21
+ // privilege is the authorization, the signed token is the identity.
22
+ export function reissueRunnerRuntimeToken(input: {
23
+ expiredToken: string;
24
+ orchestratorId: string;
25
+ createdBy?: string;
26
+ }): RuntimeTokenResult | { error: string } {
27
+ const payload = verifyComponentTokenAllowExpired(input.expiredToken);
28
+ if (!payload) return { error: "invalid or revoked runner token" };
29
+ if (payload.role !== "provider" || !payload.sub.startsWith("runner:")) {
30
+ return { error: "not a runner token" };
31
+ }
32
+ const orchestrators = payload.constraints?.orchestrators;
33
+ if (!Array.isArray(orchestrators) || !orchestrators.includes(input.orchestratorId)) {
34
+ return { error: "runner token not owned by this orchestrator" };
35
+ }
36
+ if (payload.exp !== undefined) {
37
+ const ageSeconds = Math.floor(Date.now() / 1000) - payload.exp;
38
+ if (ageSeconds > REMINT_MAX_EXPIRED_AGE_SECONDS) return { error: "runner token expired too long ago" };
39
+ }
40
+ const reissued = createToken({
41
+ profileId: "provider-agent",
42
+ sub: payload.sub,
43
+ role: "provider",
44
+ scope: payload.scope,
45
+ constraints: payload.constraints,
46
+ createdBy: input.createdBy ?? `remint:${payload.jti ?? "unknown"}`,
47
+ });
48
+ if (payload.jti) revokeToken(payload.jti);
49
+ return reissued;
50
+ }
51
+
9
52
  export function issueOrchestratorRuntimeToken(input: {
10
53
  orchestratorId: string;
11
54
  baseDir: string;
package/src/security.ts CHANGED
@@ -294,6 +294,23 @@ export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date
294
294
  return payload;
295
295
  }
296
296
 
297
+ // Verify a component token's signature and revocation WITHOUT enforcing expiry.
298
+ // Used only for orchestrator-mediated runner-token re-minting: an expired runner
299
+ // token is unforgeable proof of identity (it is HMAC-signed by this relay), even
300
+ // though it can no longer authenticate a request. The caller (an authenticated
301
+ // orchestrator) supplies the authorization; this just establishes which runner.
302
+ // Revoked tokens are still rejected — revocation is a hard kill.
303
+ export function verifyComponentTokenAllowExpired(token: string): ComponentToken | null {
304
+ const parts = token.split(".");
305
+ if (parts.length !== 3) return null;
306
+ const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
307
+ if (!safeEqual(signature, hmac(`${headerRaw}.${payloadRaw}`))) return null;
308
+ const payload = parseBase64urlJson(payloadRaw);
309
+ if (!isComponentToken(payload)) return null;
310
+ if (payload.jti && isTokenRevoked(payload.jti)) return null;
311
+ return payload;
312
+ }
313
+
297
314
  export function forbidden(req: Request): Response {
298
315
  return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
299
316
  }