agent-relay-server 0.36.2 → 0.38.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.
Files changed (95) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-C3mkM6AU.js → activity-ClpDglG8.js} +2 -2
  4. package/public/assets/{activity-C3mkM6AU.js.map → activity-ClpDglG8.js.map} +1 -1
  5. package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
  6. package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
  7. package/public/assets/{agents-CAhQO7JH.js → agents-CHmEJvqV.js} +2 -2
  8. package/public/assets/{agents-CAhQO7JH.js.map → agents-CHmEJvqV.js.map} +1 -1
  9. package/public/assets/{analytics-BwihhhNn.js → analytics-2kTjXIj1.js} +3 -3
  10. package/public/assets/{analytics-BwihhhNn.js.map → analytics-2kTjXIj1.js.map} +1 -1
  11. package/public/assets/{automation-BLXToUiU.js → automation-B5U_g-1P.js} +2 -2
  12. package/public/assets/{automation-BLXToUiU.js.map → automation-B5U_g-1P.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
  14. package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
  15. package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
  16. package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
  17. package/public/assets/chat-zPXWB-03.js +2 -0
  18. package/public/assets/chat-zPXWB-03.js.map +1 -0
  19. package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
  20. package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
  21. package/public/assets/display-ConJ9cJB.js.map +1 -1
  22. package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-tmf8IBfr.js} +2 -2
  23. package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
  24. package/public/assets/index-B1QUkb_O.js +21 -0
  25. package/public/assets/index-B1QUkb_O.js.map +1 -0
  26. package/public/assets/index-Bins8N_5.css +2 -0
  27. package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
  28. package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
  29. package/public/assets/{maintenance-9n_rJCHT.js → maintenance-Tn23oWBF.js} +2 -2
  30. package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-Tn23oWBF.js.map} +1 -1
  31. package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-CasacvJX.js} +2 -2
  32. package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-CasacvJX.js.map} +1 -1
  33. package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
  34. package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
  35. package/public/assets/{memory-BQONtGQS.js → memory-SVCob0fo.js} +2 -2
  36. package/public/assets/{memory-BQONtGQS.js.map → memory-SVCob0fo.js.map} +1 -1
  37. package/public/assets/{messages-DGqpkH72.js → messages-CHK24Uxx.js} +2 -2
  38. package/public/assets/{messages-DGqpkH72.js.map → messages-CHK24Uxx.js.map} +1 -1
  39. package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CQcJb6VE.js} +2 -2
  40. package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
  41. package/public/assets/{overview-DSU_CggA.js → overview-DbyX7k-7.js} +2 -2
  42. package/public/assets/{overview-DSU_CggA.js.map → overview-DbyX7k-7.js.map} +1 -1
  43. package/public/assets/{pairs-DGocNC1U.js → pairs-CaL0_ZfW.js} +2 -2
  44. package/public/assets/{pairs-DGocNC1U.js.map → pairs-CaL0_ZfW.js.map} +1 -1
  45. package/public/assets/{security-BSh0QxOl.js → security-BogsfkbT.js} +2 -2
  46. package/public/assets/{security-BSh0QxOl.js.map → security-BogsfkbT.js.map} +1 -1
  47. package/public/assets/{settings-C03CAJgO.js → settings-BOsnUh5f.js} +2 -2
  48. package/public/assets/{settings-C03CAJgO.js.map → settings-BOsnUh5f.js.map} +1 -1
  49. package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
  50. package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
  51. package/public/assets/{tasks-rKbuUPOk.js → tasks-CCxQovOv.js} +2 -2
  52. package/public/assets/{tasks-rKbuUPOk.js.map → tasks-CCxQovOv.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
  55. package/public/assets/{work-queue-DOsA9s4M.js → work-queue-fM-tu0iP.js} +2 -2
  56. package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-fM-tu0iP.js.map} +1 -1
  57. package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
  58. package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
  59. package/public/index.html +3 -3
  60. package/runner/src/adapter.ts +7 -0
  61. package/scripts/orchestrator-spawn-smoke.ts +65 -33
  62. package/src/agent-ref.ts +28 -1
  63. package/src/automations.ts +17 -2
  64. package/src/bus.ts +52 -41
  65. package/src/cli/index.ts +1 -1
  66. package/src/cli/workspace.ts +36 -3
  67. package/src/compaction-watch.ts +7 -0
  68. package/src/config-store.ts +46 -0
  69. package/src/index.ts +23 -6
  70. package/src/lifecycle-manager.ts +33 -71
  71. package/src/maintenance.ts +6 -1
  72. package/src/mcp.ts +106 -309
  73. package/src/routes/agent-sessions.ts +38 -3
  74. package/src/routes/agents-spawn.ts +43 -174
  75. package/src/routes/commands.ts +7 -19
  76. package/src/routes/messages.ts +24 -87
  77. package/src/routes/workspaces.ts +4 -1
  78. package/src/security.ts +7 -0
  79. package/src/services/auth-context.ts +109 -0
  80. package/src/services/dispatch-command.ts +60 -0
  81. package/src/services/errors.ts +26 -0
  82. package/src/services/managed-running.ts +130 -0
  83. package/src/services/parity-harness.ts +135 -0
  84. package/src/services/register-agent.ts +74 -0
  85. package/src/services/send-message.ts +177 -0
  86. package/src/services/shutdown-agent.ts +234 -0
  87. package/src/services/spawn-agent.ts +284 -0
  88. package/src/workspace-actions.ts +5 -1
  89. package/src/workspace-merge.ts +102 -3
  90. package/src/workspace-phase.ts +15 -1
  91. package/public/assets/chat-8iIPyww9.js +0 -2
  92. package/public/assets/chat-8iIPyww9.js.map +0 -1
  93. package/public/assets/index-3pO43nJo.css +0 -2
  94. package/public/assets/index-CaauKXl9.js +0 -21
  95. package/public/assets/index-CaauKXl9.js.map +0 -1
package/src/agent-ref.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // never silently picks among several — it reports candidates instead.
13
13
 
14
14
  import { STALE_TTL_MS } from "./config";
15
- import type { AgentCard, Message } from "./types";
15
+ import type { AgentCard, Message, TokenConstraints } from "./types";
16
16
 
17
17
  interface ResolveOptions {
18
18
  /** Exclude this agent id from matches (e.g. the requester, when pairing). */
@@ -255,6 +255,33 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
255
255
  return { kind: "not_found", message: notFoundMessage(target, agents) };
256
256
  }
257
257
 
258
+ /**
259
+ * THE caller-identity resolver: the agent id behind a token's signed constraints, for
260
+ * `from`-autofill, whoami, and spawn/shutdown gating (#221/#243/#323). Two cases, in order:
261
+ * - an identity-bearing token (`constraints.agents` with a single id) → that id, no db.
262
+ * - a managed/spawned agent's runner token (no `agents` constraint, but a single
263
+ * `spawnRequestId` or `policy`) → matched back to its registered agent card.
264
+ * Returns undefined for admin/server tokens (no constraints → unrestricted by design) and
265
+ * multi-agent tokens. `getAgents` is LAZY — the live-agent scan runs only when a single
266
+ * spawnRequestId/policy actually needs resolving, so identity-bearing and admin tokens (and
267
+ * the hot registration path) pay no db cost. One home so the #243 "managed agents silently
268
+ * lose implicit identity" drift can't recur across the transport-convergence services.
269
+ */
270
+ export function resolveCallerAgentId(
271
+ constraints: TokenConstraints | undefined,
272
+ getAgents: () => AgentCard[],
273
+ ): string | undefined {
274
+ const agents = constraints?.agents;
275
+ if (agents?.length === 1) return agents[0];
276
+ const spawnRequestId = constraints?.spawnRequestIds?.length === 1 ? constraints.spawnRequestIds[0] : undefined;
277
+ const policyName = constraints?.policies?.length === 1 ? constraints.policies[0] : undefined;
278
+ if (!spawnRequestId && !policyName) return undefined;
279
+ const match = getAgents().find((a) =>
280
+ (spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
281
+ (policyName !== undefined && a.meta?.policyName === policyName));
282
+ return match?.id;
283
+ }
284
+
258
285
  function fanoutReceipt(recipients: string[]): DeliveryReceipt {
259
286
  if (recipients.length === 0) return { delivered: false, expectReply: false, recipients: [], queued: true, reason: "no online members — queued" };
260
287
  return { delivered: true, expectReply: true, recipients };
@@ -487,7 +487,10 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
487
487
  if (automation.targetPolicy.mode === "existing_agent") {
488
488
  const agent = resolveExistingAgent(automation.targetPolicy, orchestrator);
489
489
  if (!agent && automation.targetPolicy.ifNoMatch !== "spawn") throw new ValidationError("no matching managed agent for automation");
490
- if (agent) return createRunTask(automation, run, agent.id, now, { targetMode: "existing_agent" });
490
+ if (agent) {
491
+ const durableTarget = durableExistingAgentTarget(automation.targetPolicy);
492
+ return createRunTask(automation, run, durableTarget, now, { targetMode: "existing_agent", resolvedAgentId: agent.id });
493
+ }
491
494
  const fallbackPolicy: AutomationTargetPolicy = { mode: "on_demand_agent", provider: orchestrator.providers[0] ?? "codex", keepAlive: false };
492
495
  return dispatchOnDemandAutomation(automation, run, orchestrator, fallbackPolicy, now);
493
496
  }
@@ -582,7 +585,11 @@ function createRunTask(
582
585
  updateRun(run.id, {
583
586
  status: meta.targetMode === "on_demand_agent" ? "waiting_agent" : "running",
584
587
  startedAt: now,
585
- targetAgentId: target.startsWith("label:") ? undefined : target,
588
+ targetAgentId: typeof meta.resolvedAgentId === "string"
589
+ ? meta.resolvedAgentId
590
+ : target.startsWith("label:") || target.startsWith("cap:")
591
+ ? undefined
592
+ : target,
586
593
  taskId: result.task.id,
587
594
  messageId: result.message?.id,
588
595
  meta,
@@ -590,6 +597,14 @@ function createRunTask(
590
597
  return { automation, run: getAutomationRun(run.id)!, task: result.task, message: result.message };
591
598
  }
592
599
 
600
+ function durableExistingAgentTarget(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>): string {
601
+ const label = policy.selector.label?.trim();
602
+ if (label) return `label:${label}`;
603
+ const cap = policy.selector.capabilities?.find((item) => item.trim().length > 0)?.trim();
604
+ if (cap) return `cap:${cap}`;
605
+ throw new ValidationError("existing-agent automation target selector must include a label or capability");
606
+ }
607
+
593
608
  function insertRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
594
609
  const id = randomUUID();
595
610
  db().query(`
package/src/bus.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import { createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, upsertAgent, validateAgentSession } from "./db";
2
+ import { ValidationError, createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
3
3
  import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
4
4
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
5
- import { createCommand, getCommand, updateCommand } from "./commands-db";
5
+ import { getCommand, updateCommand } from "./commands-db";
6
6
  import { emitCommandEvent } from "./command-events";
7
7
  import { getLifecycleManager } from "./lifecycle-manager";
8
8
  import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
9
+ import { registerAgent } from "./services/register-agent";
10
+ import { authContextFromBus } from "./services/auth-context";
11
+ import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
12
+ import { ServiceAuthError } from "./services/errors";
13
+ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
9
14
  import { applyCommandToRecipe } from "./recipe-runner";
10
15
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
11
16
  import {
@@ -16,8 +21,8 @@ import {
16
21
  type RegisterFrame,
17
22
  } from "agent-relay-sdk/protocol";
18
23
  import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
19
- import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, resolveSpawnLineage, unauthorized } from "./security";
20
- import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
24
+ import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
25
+ import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, RegisterAgentInput, Task } from "./types";
21
26
 
22
27
  interface BusSocketData {
23
28
  kind: "bus";
@@ -219,19 +224,42 @@ function handleCommandFrame(
219
224
  return;
220
225
  }
221
226
 
222
- if (!busCommandAuthorized(conn, { target, params })) {
223
- sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
227
+ // agent.shutdown converges on the shutdownAgent service (#342/#347): the #221 parent→child
228
+ // gate + canonical payload, identical to HTTP + MCP. The coarse command:write check above
229
+ // stays; the service adds the fine parent→child gate the bus path previously LACKED.
230
+ if (commandType === "agent.shutdown") {
231
+ if (conn.componentAuth && !isComponentAuthorizedFor(conn.componentAuth, { scope: "command:write", resource: commandAuthorizationResource({ target, params }) })) {
232
+ sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
233
+ return;
234
+ }
235
+ try {
236
+ const input = shutdownInputFromBusFrame(target, params, conn.agentId ?? conn.componentId);
237
+ const result = shutdownAgent(input, authContextFromBus(conn.componentAuth));
238
+ sendCommandResult(ws, frameId, "succeeded", { command: result.command });
239
+ } catch (e) {
240
+ if (e instanceof ShutdownAuthError) sendCommandResult(ws, frameId, "rejected", undefined, e.message);
241
+ else if (e instanceof ShutdownTargetError || e instanceof ValidationError) sendCommandResult(ws, frameId, "failed", undefined, e.message);
242
+ else throw e;
243
+ }
224
244
  return;
225
245
  }
226
246
 
227
- const command = createCommand({
228
- type: commandType,
229
- source: conn.agentId ?? conn.componentId,
230
- target,
231
- params,
232
- });
233
- emitCommandEvent(command, "command.requested");
234
- sendCommandResult(ws, frameId, "succeeded", { command });
247
+ // Dispatch through the shared service — it owns authorization (identical to the old
248
+ // busCommandAuthorized gate), createCommand, and the "command.requested" emit. The bus
249
+ // adapter only translates the typed ServiceAuthError into a rejected result frame.
250
+ try {
251
+ const command = dispatchCommand(
252
+ { type: commandType, source: conn.agentId ?? conn.componentId, target, params },
253
+ authContextFromBus(conn.componentAuth),
254
+ );
255
+ sendCommandResult(ws, frameId, "succeeded", { command });
256
+ } catch (e) {
257
+ if (e instanceof ServiceAuthError) {
258
+ sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
259
+ return;
260
+ }
261
+ throw e;
262
+ }
235
263
  }
236
264
 
237
265
  function busCommandAuthorized(
@@ -245,18 +273,6 @@ function busCommandAuthorized(
245
273
  });
246
274
  }
247
275
 
248
- function commandAuthorizationResource(command: Pick<Command, "target" | "params">) {
249
- const params = isRecord(command.params) ? command.params : {};
250
- return {
251
- target: command.target,
252
- agentId: stringValue(params.agentId) ?? command.target,
253
- policyName: stringValue(params.policyName),
254
- orchestratorId: stringValue(params.orchestratorId),
255
- cwd: stringValue(params.cwd),
256
- spawnRequestId: stringValue(params.spawnRequestId),
257
- };
258
- }
259
-
260
276
  function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
261
277
  const payload = frame.payload;
262
278
  const runnerManaged = payload.meta?.runnerManaged === true || typeof payload.meta?.runnerId === "string";
@@ -273,14 +289,16 @@ function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
273
289
  if (payload.agentId) {
274
290
  const label = stringMeta(payload.meta, "label");
275
291
  const context = contextFromMeta(payload.meta);
276
- // Authoritative parent lineage spawned/managed agents register over THIS bus path, not the
277
- // HTTP `postAgent` route, so `spawned_by` must be set here too or it stays NULL (breaking the
278
- // parent→child shutdown gate, the `spawnedBy:` filter, and the live-children quota).
279
- const lineage = resolveSpawnLineage(ws.data.componentAuth?.constraints);
280
- const agent = upsertAgent({
292
+ // Thin transport: build the unified RegisterAgentInput from the wire frame and hand it to
293
+ // the registerAgent service. ALL registration side effects authoritative parent lineage
294
+ // (resolved from the token constraints inside the service, never the wire body), the managed
295
+ // came-up-running reconcile, the status broadcast, the timeline note, the parent-wake (#351),
296
+ // and the audit row — live in src/services/register-agent.ts so this bus path can never drift
297
+ // from the HTTP path again (the #342 `spawned_by`-NULL class of bug).
298
+ const input: RegisterAgentInput = {
281
299
  id: payload.agentId,
282
300
  name: stringMeta(payload.meta, "name") ?? payload.componentId,
283
- kind: payload.role === "integration" ? "provider" : payload.role,
301
+ kind: (payload.role === "integration" ? "provider" : payload.role) as RegisterAgentInput["kind"],
284
302
  ...(label ? { label } : {}),
285
303
  tags: payload.tags,
286
304
  machine: payload.machine,
@@ -290,17 +308,10 @@ function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
290
308
  instanceId: payload.instanceId,
291
309
  ...(providerCapabilities ? { providerCapabilities } : {}),
292
310
  ...(context ? { context } : {}),
293
- ...(lineage ? { spawnedBy: lineage } : {}),
294
311
  meta: payload.meta,
295
- });
312
+ };
313
+ const agent = registerAgent(input, authContextFromBus(ws.data.componentAuth));
296
314
  epoch = agent.epoch;
297
- getLifecycleManager().onAgentRegistered(agent.id, {
298
- policyName: stringMeta(payload.meta, "policyName"),
299
- spawnRequestId: stringMeta(payload.meta, "spawnRequestId"),
300
- tmuxSession: stringMeta(payload.meta, "tmuxSession"),
301
- });
302
- noteAgentTimelineEvent(agent.id, agent.meta?.timelineEvent);
303
- emitAgentStatusEvent(agent.id);
304
315
  }
305
316
 
306
317
  busConnections.set(ws.data.id, {
package/src/cli/index.ts CHANGED
@@ -44,7 +44,7 @@ Usage:
44
44
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
45
45
  agent-relay token <create|list|revoke|verify> [options]
46
46
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
47
- agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
47
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
48
48
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
49
49
  agent-relay message <target> <body> [options]
50
50
  agent-relay get-message <messageId> [--json|--body]
@@ -5,8 +5,11 @@ import { apiRequest } from "./_shared";
5
5
  import { detectAgentId } from "./agent-detect";
6
6
  import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "../workspace-phase";
7
7
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
8
+ import { AUTO_MERGE_POLICIES, resolveLandStrategy } from "../workspace-merge";
9
+ import { getLandingConfig } from "../config-store";
10
+ import type { WorkspaceAutoMergePolicy } from "../types";
8
11
 
9
- export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
12
+ export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
10
13
 
11
14
  // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
12
15
  // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
@@ -98,6 +101,8 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
98
101
 
99
102
  let id = currentWorkspaceId(), idExplicit = false; // idExplicit: --id was passed, not the ambient default (#307)
100
103
  let strategy: string | undefined;
104
+ let autoMerge: WorkspaceAutoMergePolicy | undefined;
105
+ let reviewer: string | undefined;
101
106
  let purpose: string | undefined;
102
107
  let repo: string | undefined;
103
108
  let execute = false;
@@ -109,6 +114,14 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
109
114
  const arg = args[i];
110
115
  if (arg === "--id" && i + 1 < args.length) { id = args[++i]; idExplicit = true; }
111
116
  else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
117
+ else if (arg === "--auto-merge" && i + 1 < args.length) {
118
+ const val = args[++i] as string;
119
+ if (!(AUTO_MERGE_POLICIES as readonly string[]).includes(val)) {
120
+ throw new Error(`--auto-merge must be one of: ${AUTO_MERGE_POLICIES.join(", ")}`);
121
+ }
122
+ autoMerge = val as WorkspaceAutoMergePolicy;
123
+ }
124
+ else if (arg === "--reviewer" && i + 1 < args.length) reviewer = args[++i];
112
125
  else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
113
126
  else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
114
127
  else if (arg === "--execute") execute = true;
@@ -190,11 +203,31 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
190
203
  }
191
204
 
192
205
  const from = await detectAgentId();
206
+
207
+ // For the `land` action, resolve the effective strategy via the three-level chain
208
+ // (explicit > instance config > default) so a bare `land` with no --strategy still
209
+ // picks up the operator's landing.strategy setting. The strategy is passed verbatim
210
+ // to the server; requestWorkspaceMerge is the single resolution point for the default.
211
+ let resolvedStrategy: string | undefined = strategy;
212
+ if (action === "land") {
213
+ const instanceConfig = getLandingConfig();
214
+ resolvedStrategy = resolveLandStrategy({
215
+ explicit: strategy,
216
+ instanceStrategy: instanceConfig.strategy,
217
+ });
218
+ }
219
+
193
220
  const actionBody: Record<string, unknown> =
194
221
  action === "ready" ? { action: "request-review", agentId: from }
195
222
  : action === "claim" ? { action: "claim", agentId: from, purpose }
196
223
  : action === "release" ? { action: "release-claim", agentId: from }
197
- : { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
224
+ : {
225
+ action: "merge",
226
+ agentId: from,
227
+ ...(resolvedStrategy ? { strategy: resolvedStrategy } : {}),
228
+ ...(autoMerge ? { autoMerge } : {}),
229
+ ...(reviewer ? { reviewer } : {}),
230
+ };
198
231
  const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
199
232
  if (json) {
200
233
  console.log(JSON.stringify(result, null, 2));
@@ -210,6 +243,6 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
210
243
  console.log(
211
244
  action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
212
245
  : action === "release" ? `Workspace ${id} claim released.`
213
- : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
246
+ : `Workspace ${id} merge requested (${resolvedStrategy ?? "auto"}${autoMerge ? `, autoMerge: ${autoMerge}` : ""}${reviewer ? `, reviewer: ${reviewer}` : ""}).`,
214
247
  );
215
248
  }
@@ -151,6 +151,13 @@ export function getCompactionWatch(): CompactionWatch {
151
151
  return singleton;
152
152
  }
153
153
 
154
+ /** Test seam: stop and drop the singleton so each parity run starts with no
155
+ * armed watches. */
156
+ export function __resetCompactionWatch(): void {
157
+ singleton?.stop();
158
+ singleton = null;
159
+ }
160
+
154
161
  // Feed an agent's latched timelineEvent (from meta) to the watch. Called from
155
162
  // every path that updates an agent's meta — bus status/register frames and the
156
163
  // HTTP register route — so a real provider hook clears the watch regardless of
@@ -8,6 +8,7 @@ import type {
8
8
  ConfigEntry,
9
9
  ConfigHistoryEntry,
10
10
  InsightsConfig,
11
+ LandingConfig,
11
12
  ManagedAgentState,
12
13
  ManagedAgentStatus,
13
14
  NotificationsConfig,
@@ -16,6 +17,7 @@ import type {
16
17
  SpawnProvider,
17
18
  StewardConfig,
18
19
  WorkspaceConfig,
20
+ WorkspaceLandingPolicy,
19
21
  } from "./types";
20
22
 
21
23
  const CONFIG_HISTORY_LIMIT = 50;
@@ -29,6 +31,9 @@ const NOTIFICATIONS_NAMESPACE = "notifications";
29
31
  const NOTIFICATIONS_KEY = "default";
30
32
  const WORKSPACE_NAMESPACE = "workspace";
31
33
  const WORKSPACE_KEY = "default";
34
+ const LANDING_NAMESPACE = "landing";
35
+ const LANDING_KEY = "default";
36
+ const VALID_LANDING_STRATEGIES = ["direct", "pr"] as const;
32
37
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
33
38
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
34
39
  const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
@@ -239,6 +244,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
239
244
  env: input.env ?? {},
240
245
  providerOptions: input.providerOptions ?? {},
241
246
  ...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
247
+ ...(input.landingStrategy === undefined ? {} : { landingStrategy: input.landingStrategy }),
242
248
  };
243
249
  }
244
250
 
@@ -317,6 +323,9 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
317
323
  maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
318
324
  ? undefined
319
325
  : cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
326
+ landingStrategy: value.landingStrategy === undefined || value.landingStrategy === null
327
+ ? undefined
328
+ : cleanEnum(value.landingStrategy, "landingStrategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
320
329
  });
321
330
  }
322
331
 
@@ -508,6 +517,19 @@ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
508
517
  return { symlinkPaths };
509
518
  }
510
519
 
520
+ const LANDING_CONFIG_DEFAULTS: LandingConfig = {
521
+ strategy: "direct",
522
+ };
523
+
524
+ function validateLandingConfig(value: unknown): LandingConfig {
525
+ if (!isRecord(value)) throw new ValidationError("landing config value must be an object");
526
+ return {
527
+ strategy: value.strategy === undefined || value.strategy === null
528
+ ? LANDING_CONFIG_DEFAULTS.strategy
529
+ : cleanEnum(value.strategy, "strategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
530
+ };
531
+ }
532
+
511
533
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
512
534
  if (value === undefined) throw new ValidationError("value required");
513
535
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
@@ -516,6 +538,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
516
538
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
517
539
  if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
518
540
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
541
+ if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
519
542
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
520
543
  return value;
521
544
  }
@@ -682,6 +705,29 @@ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEn
682
705
  return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
683
706
  }
684
707
 
708
+ /** Instance-wide landing config, merged over defaults (always returns a usable value). */
709
+ export function getLandingConfig(): LandingConfig {
710
+ const entry = getConfig<Partial<LandingConfig>>(LANDING_NAMESPACE, LANDING_KEY);
711
+ if (!entry) return { ...LANDING_CONFIG_DEFAULTS };
712
+ return validateLandingConfig({ ...LANDING_CONFIG_DEFAULTS, ...entry.value });
713
+ }
714
+
715
+ export function getLandingConfigEntry(): ConfigEntry<LandingConfig> {
716
+ const entry = getConfig<LandingConfig>(LANDING_NAMESPACE, LANDING_KEY);
717
+ return entry ?? {
718
+ namespace: LANDING_NAMESPACE,
719
+ key: LANDING_KEY,
720
+ value: { ...LANDING_CONFIG_DEFAULTS },
721
+ version: 0,
722
+ updatedAt: "default",
723
+ updatedBy: "system",
724
+ };
725
+ }
726
+
727
+ export function setLandingConfig(value: unknown, updatedBy?: string): ConfigEntry<LandingConfig> {
728
+ return setConfig(LANDING_NAMESPACE, LANDING_KEY, value as LandingConfig, updatedBy);
729
+ }
730
+
685
731
  /**
686
732
  * Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
687
733
  * Spread into every spawn command's params next to `agentProfile` so any isolated
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:z
15
15
  import {
16
16
  VERSION,
17
17
  } from "./config";
18
+ import { CONTRACT_VERSIONS } from "./contracts";
18
19
  import {
19
20
  applyCors,
20
21
  assertSafeNetworkConfig,
@@ -38,12 +39,28 @@ async function main(): Promise<void> {
38
39
  startServer();
39
40
  }
40
41
 
41
- // On a version change, flag restart-on-update policies so they cycle when idle.
42
- function reconcileRelayVersion(): void {
43
- const stored = getConfig<string>("system", "relay-version")?.value;
44
- if (stored === VERSION) return;
45
- if (stored) getLifecycleManager().onRelayUpdated(VERSION);
46
- setConfig("system", "relay-version", VERSION, "server-startup");
42
+ // Restart-on-update gating (issue #341).
43
+ //
44
+ // A `restartOnUpdate` policy should cycle its managed agent only when the agent's
45
+ // running runner code is now protocol-INCOMPATIBLE with the relay — i.e. on a
46
+ // runner-protocol bump — NOT on every package version change. Gating on the package
47
+ // version churned the always-on Telegram agent ~12×/day (one restart per patch
48
+ // redeploy), dropping in-flight work (surfaced #340). The runner protocol
49
+ // (CONTRACT_VERSIONS.runnerProtocol) only moves on a breaking runner change, so this
50
+ // fires ~never during normal releases.
51
+ //
52
+ // `relay-version` is still tracked for observability/telemetry, but it no longer
53
+ // triggers restarts.
54
+ export function reconcileRelayVersion(): void {
55
+ const storedVersion = getConfig<string>("system", "relay-version")?.value;
56
+ if (storedVersion !== VERSION) setConfig("system", "relay-version", VERSION, "server-startup");
57
+
58
+ const storedProtocol = getConfig<string>("system", "runner-protocol-version")?.value;
59
+ const currentProtocol = String(CONTRACT_VERSIONS.runnerProtocol);
60
+ if (storedProtocol === currentProtocol) return;
61
+ // Skip the very first run (no stored protocol yet) — that's initial bootstrap, not an upgrade.
62
+ if (storedProtocol) getLifecycleManager().onRelayUpdated(VERSION);
63
+ setConfig("system", "runner-protocol-version", currentProtocol, "server-startup");
47
64
  }
48
65
 
49
66
  function startServer(): void {
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "./commands-db";
2
2
  import { isPathWithinBase } from "./utils";
3
- import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
3
+ import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
4
4
  import {
5
5
  getManagedAgentState,
6
6
  listSpawnPolicies,
@@ -8,7 +8,8 @@ import {
8
8
  upsertManagedAgentState,
9
9
  } from "./config-store";
10
10
  import { emitRelayEvent } from "./events";
11
- import { emitMessageDeliveryUpdated } from "./sse";
11
+ import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
12
+ import { authContextFromSystem } from "./services/auth-context";
12
13
  import { emitCommandEvent } from "./command-events";
13
14
  import { buildManagedSpawnParams } from "./managed-policy";
14
15
  import { generateSpawnRequestId } from "./spawn-command";
@@ -75,50 +76,30 @@ export class LifecycleManager {
75
76
  this.spawnAgent(policy, "message-trigger");
76
77
  }
77
78
 
78
- onAgentRegistered(agentId: string, meta: { policyName?: string; spawnRequestId?: string; tmuxSession?: string | null }): void {
79
- if (!meta.policyName || !meta.spawnRequestId) return;
80
- const state = getManagedAgentState(meta.policyName);
81
- if (!state || state.spawnRequestId !== meta.spawnRequestId) return;
82
- const next = updateManagedAgentState(meta.policyName, {
83
- status: "running",
84
- agentId,
85
- tmuxSession: meta.tmuxSession ?? state.tmuxSession,
86
- healthySince: this.now(),
87
- backoffUntil: undefined,
88
- lastError: undefined,
89
- });
90
- if (next) this.emitState(next);
91
- const available = resolveQueuedPolicyMessages(meta.policyName, agentId);
92
- if (available.length) {
93
- emitRelayEvent({
94
- type: "message.available",
95
- source: "server",
96
- subject: `policy:${meta.policyName}`,
97
- data: { policyName: meta.policyName, agentId, messageIds: available.map((message) => message.id), count: available.length },
98
- });
99
- // queued → pending changed delivery_status; refresh the dashboard delivery
100
- // badge now rather than letting it sit stale until the next poll (#265).
101
- for (const message of available) emitMessageDeliveryUpdated(message);
102
- }
103
- }
79
+ // NOTE: the bus registration path used to call `onAgentRegistered` here for the
80
+ // came-up-running transition + queue flush. That logic now lives in the
81
+ // registerAgent service (src/services/register-agent.ts → markManagedAgentRunning),
82
+ // which the bus `handleRegister` calls directly — collapsing the second of the three
83
+ // drifted registration copies (epic #342). Only the orchestrator-report projection
84
+ // below still enters managed-running from the lifecycle manager.
104
85
 
105
86
  onOrchestratorManagedAgentsReported(orchestratorId: string, agents: ManagedAgent[], exitedAgents: ManagedSessionExitDiagnostics[] = []): void {
106
87
  for (const agent of agents) {
107
88
  if (!agent.policyName || !agent.spawnRequestId) continue;
108
89
  const state = getManagedAgentState(agent.policyName);
109
90
  if (!state || state.spawnRequestId !== agent.spawnRequestId) continue;
110
- const next = updateManagedAgentState(agent.policyName, {
111
- status: "running",
112
- agentId: agent.agentId || state.agentId,
113
- tmuxSession: agent.tmuxSession,
114
- workspaceId: agent.workspace?.id,
115
- workspacePath: agent.workspace?.worktreePath,
116
- workspaceBranch: agent.workspace?.branch,
117
- healthySince: state.healthySince ?? this.now(),
118
- backoffUntil: undefined,
119
- lastError: undefined,
120
- });
121
- if (next) this.emitState(next);
91
+ // Same came-up-running core the registration transports use — one home, no drift.
92
+ markManagedAgentRunning(
93
+ {
94
+ policyName: agent.policyName,
95
+ spawnRequestId: agent.spawnRequestId,
96
+ agentId: agent.agentId || state.agentId || "",
97
+ tmuxSession: agent.tmuxSession,
98
+ workspace: agent.workspace,
99
+ },
100
+ authContextFromSystem(),
101
+ { now: this.now },
102
+ );
122
103
 
123
104
  // Liveness reconciliation: the orchestrator just reported this process as
124
105
  // running. If the relay record says offline, the two truths disagree
@@ -511,40 +492,14 @@ export class LifecycleManager {
511
492
  context.utilization > DEFAULT_COMPACT_TARGET;
512
493
  }
513
494
 
495
+ // The managed-state emit + activity-transition record lives in the shared
496
+ // emitManagedState (src/services/managed-running.ts) so the registration service
497
+ // and this lifecycle reconciler emit identically (epic #342). We track the prior
498
+ // status per policy here only to compute the from→to for the transition row.
514
499
  private emitState(state: ManagedAgentState, reason?: string): void {
515
- emitRelayEvent({
516
- type: "policy.state.changed",
517
- source: "lifecycle-manager",
518
- subject: state.policyName,
519
- data: state as unknown as Record<string, unknown>,
520
- });
521
500
  const previous = this.lastStatusByPolicy.get(state.policyName);
522
501
  this.lastStatusByPolicy.set(state.policyName, state.status);
523
- if (previous !== state.status) this.recordTransition(state, previous, reason);
524
- }
525
-
526
- // Persist a managed-state transition so it shows on the activity feed and the
527
- // per-agent timeline — the "what changed, why, and when" record for a policy.
528
- private recordTransition(state: ManagedAgentState, fromState: string | undefined, reason?: string): void {
529
- const why = reason ?? state.lastError ?? undefined;
530
- const seq = ++this.transitionSeq;
531
- createActivityEvent({
532
- clientId: `lifecycle-${state.policyName}-${state.status}-${this.now()}-${seq}`,
533
- kind: "state",
534
- title: `${state.policyName}: ${fromState ?? "—"} → ${state.status}`,
535
- body: why,
536
- icon: "ti-arrows-exchange",
537
- view: "managed-agents",
538
- agentId: state.agentId,
539
- metadata: {
540
- source: "lifecycle-manager",
541
- policyName: state.policyName,
542
- spawnRequestId: state.spawnRequestId,
543
- fromState: fromState ?? null,
544
- toState: state.status,
545
- reason: why ?? null,
546
- },
547
- });
502
+ emitManagedState(state, previous, reason, this.now);
548
503
  }
549
504
 
550
505
  // Surface a disagreement between relay status and real process liveness so the
@@ -624,6 +579,13 @@ export function getLifecycleManager(): LifecycleManager {
624
579
  return singleton;
625
580
  }
626
581
 
582
+ /** Test seam: stop and drop the singleton so each parity run starts with clean
583
+ * in-memory state (the per-policy last-status map, timers). */
584
+ export function __resetLifecycleManager(): void {
585
+ singleton?.stop();
586
+ singleton = null;
587
+ }
588
+
627
589
  function alwaysReloadTags(tags: string[]): string[] {
628
590
  return tags
629
591
  .filter((tag) => tag.startsWith("memory-reload:"))
@@ -37,7 +37,7 @@ import { reconcileLandedWorkspace } from "./branch-landed";
37
37
  import { notifyAgentOffline } from "./agent-lifecycle-events";
38
38
  import { workspaceActiveClaim } from "./workspace-claim";
39
39
  import { reapOrphanedWorktrees } from "./workspace-orphans";
40
- import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
40
+ import { deriveBranchState, DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
41
41
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
42
42
  import { getStewardConfig } from "./config-store";
43
43
  import { ensureRepoSteward } from "./steward";
@@ -716,6 +716,11 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
716
716
  if (!preview || (preview as { available?: false }).available === false) continue;
717
717
  const p = preview as WorkspaceMergePreview;
718
718
  if (p.error || p.missing) continue;
719
+ if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
720
+ patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
721
+ } else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
722
+ patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
723
+ }
719
724
 
720
725
  const ahead = p.unmergedAhead ?? p.ahead ?? 0;
721
726
  const behind = p.behind ?? 0;