agent-relay-server 0.120.5 → 0.121.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 (124) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-BVff6PSi.js → MessageBubble-BaPGFJxq.js} +2 -2
  4. package/public/assets/{MessageBubble-BVff6PSi.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js → PlanGraphPanel-BXCVfmln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
  7. package/public/assets/{activity-Bk9LEUr4.js → activity-D_shwAZt.js} +2 -2
  8. package/public/assets/{activity-Bk9LEUr4.js.map → activity-D_shwAZt.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DSVDoQ_N.js → agent-profiles-BrfVz5IV.js} +2 -2
  10. package/public/assets/{agent-profiles-DSVDoQ_N.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
  11. package/public/assets/agents-B5w-Z9Ic.js +2 -0
  12. package/public/assets/{agents-8rQuJqii.js.map → agents-B5w-Z9Ic.js.map} +1 -1
  13. package/public/assets/{analytics-DifGnk7q.js → analytics-Bm3D12UN.js} +2 -2
  14. package/public/assets/{analytics-DifGnk7q.js.map → analytics-Bm3D12UN.js.map} +1 -1
  15. package/public/assets/{automation-CMxQAf3l.js → automation-DOTL8BFs.js} +2 -2
  16. package/public/assets/{automation-CMxQAf3l.js.map → automation-DOTL8BFs.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-BlqeJZ5-.js} +2 -2
  18. package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-BlqeJZ5-.js.map} +1 -1
  19. package/public/assets/{channels-BLkdt2XR.js → channels-BoiKfK6Q.js} +2 -2
  20. package/public/assets/{channels-BLkdt2XR.js.map → channels-BoiKfK6Q.js.map} +1 -1
  21. package/public/assets/chat-PeU3iOaa.js +2 -0
  22. package/public/assets/{chat-jlXywb9G.js.map → chat-PeU3iOaa.js.map} +1 -1
  23. package/public/assets/{connectors-C0n12Je8.js → connectors-BvV8Hee2.js} +2 -2
  24. package/public/assets/{connectors-C0n12Je8.js.map → connectors-BvV8Hee2.js.map} +1 -1
  25. package/public/assets/{formatted-body-CzehtPtx.js → formatted-body-C1FgzlT1.js} +3 -3
  26. package/public/assets/{formatted-body-CzehtPtx.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js → formatted-body-impl-BTpU1xH4.js} +2 -2
  28. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
  29. package/public/assets/index-DZEahmyc.js +25 -0
  30. package/public/assets/index-DZEahmyc.js.map +1 -0
  31. package/public/assets/{insights-3H6KttBi.js → insights-BqmgSpQm.js} +2 -2
  32. package/public/assets/{insights-3H6KttBi.js.map → insights-BqmgSpQm.js.map} +1 -1
  33. package/public/assets/{integrations-v14ehskT.js → integrations-CuT92zQi.js} +2 -2
  34. package/public/assets/{integrations-v14ehskT.js.map → integrations-CuT92zQi.js.map} +1 -1
  35. package/public/assets/{maintenance-BANYcBcn.js → maintenance-lM7FeXVC.js} +2 -2
  36. package/public/assets/{maintenance-BANYcBcn.js.map → maintenance-lM7FeXVC.js.map} +1 -1
  37. package/public/assets/{managed-agents-DCFMWKqt.js → managed-agents-DaPNv3yY.js} +3 -3
  38. package/public/assets/{managed-agents-DCFMWKqt.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CfaR-GRX.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CfaR-GRX.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
  41. package/public/assets/{memory-BmkUEyd5.js → memory-B1v_zD-M.js} +2 -2
  42. package/public/assets/{memory-BmkUEyd5.js.map → memory-B1v_zD-M.js.map} +1 -1
  43. package/public/assets/{messages-BQ_fVnSA.js → messages-BNOx3yK6.js} +2 -2
  44. package/public/assets/{messages-BQ_fVnSA.js.map → messages-BNOx3yK6.js.map} +1 -1
  45. package/public/assets/{orchestrators-DC7y9vQD.js → orchestrators-DWFy4asl.js} +2 -2
  46. package/public/assets/{orchestrators-DC7y9vQD.js.map → orchestrators-DWFy4asl.js.map} +1 -1
  47. package/public/assets/{overview-6lzywFHq.js → overview-C_12cFPM.js} +2 -2
  48. package/public/assets/{overview-6lzywFHq.js.map → overview-C_12cFPM.js.map} +1 -1
  49. package/public/assets/{pairs-CRrRIaHI.js → pairs-DIc7gX83.js} +2 -2
  50. package/public/assets/{pairs-CRrRIaHI.js.map → pairs-DIc7gX83.js.map} +1 -1
  51. package/public/assets/{projects-CZJW35C7.js → projects-DPjvJUzT.js} +2 -2
  52. package/public/assets/{projects-CZJW35C7.js.map → projects-DPjvJUzT.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DvlnMq8H.js → prompt-settings-CtumNgG8.js} +2 -2
  54. package/public/assets/{prompt-settings-DvlnMq8H.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
  55. package/public/assets/{registry-3TWg4zbE.js → registry-DzUhgMq9.js} +2 -2
  56. package/public/assets/{registry-3TWg4zbE.js.map → registry-DzUhgMq9.js.map} +1 -1
  57. package/public/assets/{security-A2VYKHeI.js → security-BJ3_u0uY.js} +2 -2
  58. package/public/assets/{security-A2VYKHeI.js.map → security-BJ3_u0uY.js.map} +1 -1
  59. package/public/assets/{select-_gK5Ojjc.js → select-AWvlctyQ.js} +2 -2
  60. package/public/assets/{select-_gK5Ojjc.js.map → select-AWvlctyQ.js.map} +1 -1
  61. package/public/assets/{settings-BZ26ewcu.js → settings-5ofqi-bN.js} +4 -4
  62. package/public/assets/settings-5ofqi-bN.js.map +1 -0
  63. package/public/assets/store-D5et8685.js +9 -0
  64. package/public/assets/store-D5et8685.js.map +1 -0
  65. package/public/assets/{tasks-DlokSl_P.js → tasks-BEE9bBf7.js} +2 -2
  66. package/public/assets/{tasks-DlokSl_P.js.map → tasks-BEE9bBf7.js.map} +1 -1
  67. package/public/assets/{teams-t7M1HMgS.js → teams-C27H_Gwo.js} +2 -2
  68. package/public/assets/{teams-t7M1HMgS.js.map → teams-C27H_Gwo.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js → terminal-viewer-impl-WD2TboA7.js} +22 -22
  70. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js.map → terminal-viewer-impl-WD2TboA7.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-V8LR-3Gi.js → work-queue-CtmPK2hK.js} +2 -2
  73. package/public/assets/{work-queue-V8LR-3Gi.js.map → work-queue-CtmPK2hK.js.map} +1 -1
  74. package/public/assets/{workspaces-DI317fMk.js → workspaces-B26EKv_H.js} +2 -2
  75. package/public/assets/{workspaces-DI317fMk.js.map → workspaces-B26EKv_H.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/agent-card-projection.ts +39 -9
  79. package/src/bus-outbox.ts +30 -0
  80. package/src/bus.ts +57 -1
  81. package/src/commands-db.ts +14 -0
  82. package/src/config-store.ts +2 -0
  83. package/src/config.ts +4 -0
  84. package/src/db/connection.ts +3 -1
  85. package/src/db/delivery.ts +85 -3
  86. package/src/db/index.ts +1 -0
  87. package/src/db/maintenance-worker.ts +91 -0
  88. package/src/db/messages.ts +19 -4
  89. package/src/db/migrations.ts +7 -0
  90. package/src/db/retention.ts +130 -0
  91. package/src/db/schema.ts +5 -0
  92. package/src/db/stats.ts +32 -8
  93. package/src/db/task-entity.ts +7 -8
  94. package/src/event-loop-lag.ts +90 -0
  95. package/src/lifecycle-manager.ts +11 -12
  96. package/src/maintenance/jobs.ts +23 -7
  97. package/src/maintenance/workspaces.ts +28 -47
  98. package/src/managed-policy-escalation.ts +47 -0
  99. package/src/mcp-task-tools.ts +4 -5
  100. package/src/retention-config-store.ts +32 -0
  101. package/src/retention-config.ts +58 -0
  102. package/src/routes/commands.ts +8 -245
  103. package/src/routes/messages.ts +13 -1
  104. package/src/routes/tasks.ts +3 -5
  105. package/src/routing-policy-store.ts +11 -1
  106. package/src/server.ts +2 -0
  107. package/src/services/issue-lifecycle.ts +23 -8
  108. package/src/services/send-message.ts +63 -21
  109. package/src/services/spawn-registration-watchdog.ts +74 -9
  110. package/src/services/task-entity.ts +54 -2
  111. package/src/settings/registry.ts +11 -0
  112. package/src/sse.ts +54 -9
  113. package/src/workspace-auto-merge.ts +94 -34
  114. package/src/workspace-command-settle.ts +227 -0
  115. package/src/workspace-escalation.ts +45 -0
  116. package/src/workspace-merge-progress.ts +16 -0
  117. package/src/workspace-orphans.ts +91 -3
  118. package/public/assets/agents-8rQuJqii.js +0 -2
  119. package/public/assets/chat-jlXywb9G.js +0 -2
  120. package/public/assets/index-4g3PbGVN.js +0 -25
  121. package/public/assets/index-4g3PbGVN.js.map +0 -1
  122. package/public/assets/settings-BZ26ewcu.js.map +0 -1
  123. package/public/assets/store-nXGxgz2Q.js +0 -9
  124. package/public/assets/store-nXGxgz2Q.js.map +0 -1
@@ -88,6 +88,7 @@ export const TASK_TOOLS = [
88
88
  teamId: { type: "string" },
89
89
  projectId: { type: "string" },
90
90
  repo: { type: "string", description: "owner/repo for the task's project/repository" },
91
+ branch: { type: "string", description: "optional branch ref to seed on the task" },
91
92
  dispatch: DISPATCH_SCHEMA,
92
93
  issueRefs: { type: "array", items: { type: "string" }, description: "#n, owner/repo#n, GitHub URL, or issue_<id>" },
93
94
  defaultRepo: { type: "string", description: "owner/repo for bare #n issue refs" },
@@ -140,14 +141,12 @@ export function relayTaskTool(auth: McpAuthLike, name: string, args: Record<stri
140
141
  teamId: cleanString(args.teamId, "teamId", { max: 200 }),
141
142
  projectId: cleanString(args.projectId, "projectId", { max: 120 }),
142
143
  repo: cleanString(args.repo, "repo", { max: 500 }),
144
+ branch: cleanString(args.branch, "branch", { max: 500 }),
143
145
  dispatch: args.dispatch as TaskDispatch | undefined,
146
+ issueRefs: Array.isArray(args.issueRefs) ? args.issueRefs.filter((ref): ref is string => typeof ref === "string") : undefined,
147
+ defaultRepo: cleanString(args.defaultRepo, "defaultRepo", { max: 500 }),
144
148
  createdBy: actor,
145
149
  });
146
- if (Array.isArray(args.issueRefs)) {
147
- for (const ref of args.issueRefs) {
148
- if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(args.defaultRepo, "defaultRepo", { max: 500 }), actor });
149
- }
150
- }
151
150
  if (Array.isArray(args.dependsOn)) {
152
151
  for (const dep of args.dependsOn) addTaskEntityDependency(created.id, taskIdArg(dep), actor);
153
152
  }
@@ -0,0 +1,32 @@
1
+ import { getConfig, setConfig } from "./config-store";
2
+ import {
3
+ RETENTION_CONFIG_DEFAULTS,
4
+ RETENTION_KEY,
5
+ RETENTION_NAMESPACE,
6
+ validateRetentionConfig,
7
+ type RetentionConfig,
8
+ } from "./retention-config";
9
+ import type { ConfigEntry } from "./types";
10
+
11
+ /** Instance-wide retention config, merged over defaults (always returns a usable value). */
12
+ export function getRetentionConfig(): RetentionConfig {
13
+ const entry = getConfig<Partial<RetentionConfig>>(RETENTION_NAMESPACE, RETENTION_KEY);
14
+ if (!entry) return { ...RETENTION_CONFIG_DEFAULTS };
15
+ return validateRetentionConfig({ ...RETENTION_CONFIG_DEFAULTS, ...entry.value });
16
+ }
17
+
18
+ export function getRetentionConfigEntry(): ConfigEntry<RetentionConfig> {
19
+ const entry = getConfig<RetentionConfig>(RETENTION_NAMESPACE, RETENTION_KEY);
20
+ return entry ?? {
21
+ namespace: RETENTION_NAMESPACE,
22
+ key: RETENTION_KEY,
23
+ value: { ...RETENTION_CONFIG_DEFAULTS },
24
+ version: 0,
25
+ updatedAt: "default",
26
+ updatedBy: "system",
27
+ };
28
+ }
29
+
30
+ export function setRetentionConfig(value: unknown, updatedBy?: string): ConfigEntry<RetentionConfig> {
31
+ return setConfig(RETENTION_NAMESPACE, RETENTION_KEY, value as RetentionConfig, updatedBy);
32
+ }
@@ -0,0 +1,58 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db/connection.ts";
3
+
4
+ export const RETENTION_NAMESPACE = "retention";
5
+ export const RETENTION_KEY = "default";
6
+
7
+ export interface RetentionConfig {
8
+ messageRetentionDays: number;
9
+ activityEventRetentionDays: number;
10
+ activityEventMaxRows: number;
11
+ spawnCommandTerminalRetentionDays: number;
12
+ pruneBatchSize: number;
13
+ }
14
+
15
+ export const RETENTION_CONFIG_DEFAULTS: RetentionConfig = {
16
+ messageRetentionDays: 30,
17
+ activityEventRetentionDays: 14,
18
+ activityEventMaxRows: 100_000,
19
+ spawnCommandTerminalRetentionDays: 14,
20
+ pruneBatchSize: 10_000,
21
+ };
22
+
23
+ export const RETENTION_CONFIG_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ messageRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
27
+ activityEventRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
28
+ activityEventMaxRows: { type: "integer", minimum: 1, maximum: 10_000_000 },
29
+ spawnCommandTerminalRetentionDays: { type: "integer", minimum: 1, maximum: 3650 },
30
+ pruneBatchSize: { type: "integer", minimum: 1, maximum: 50_000 },
31
+ },
32
+ additionalProperties: false,
33
+ } as const;
34
+
35
+ function cleanInteger(value: unknown, field: string, fallback: number, min: number, max: number): number {
36
+ if (value === undefined || value === null) return fallback;
37
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
38
+ throw new ValidationError(`${field} must be an integer between ${min} and ${max}`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ export function validateRetentionConfig(value: unknown): RetentionConfig {
44
+ if (!isRecord(value)) throw new ValidationError("retention config value must be an object");
45
+ return {
46
+ messageRetentionDays: cleanInteger(value.messageRetentionDays, "messageRetentionDays", RETENTION_CONFIG_DEFAULTS.messageRetentionDays, 1, 3650),
47
+ activityEventRetentionDays: cleanInteger(value.activityEventRetentionDays, "activityEventRetentionDays", RETENTION_CONFIG_DEFAULTS.activityEventRetentionDays, 1, 3650),
48
+ activityEventMaxRows: cleanInteger(value.activityEventMaxRows, "activityEventMaxRows", RETENTION_CONFIG_DEFAULTS.activityEventMaxRows, 1, 10_000_000),
49
+ spawnCommandTerminalRetentionDays: cleanInteger(
50
+ value.spawnCommandTerminalRetentionDays,
51
+ "spawnCommandTerminalRetentionDays",
52
+ RETENTION_CONFIG_DEFAULTS.spawnCommandTerminalRetentionDays,
53
+ 1,
54
+ 3650,
55
+ ),
56
+ pruneBatchSize: cleanInteger(value.pruneBatchSize, "pruneBatchSize", RETENTION_CONFIG_DEFAULTS.pruneBatchSize, 1, 50_000),
57
+ };
58
+ }
@@ -1,76 +1,28 @@
1
1
  // Auto-split from routes.ts (#299). Domain: commands.
2
2
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authorizeRoute, emitCommand, error, json, parseBody, parseQueryInt, type AgentControlAction, type Handler } from "./_shared";
3
3
  import { VALID_WORKSPACE_STATUSES, cleanParams, cleanString, optionalEnum } from "../validation";
4
- import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, releaseMergeLease, setOrchestratorUpgradeState, setWorkspaceBranch, updateWorkspaceStatus } from "../db";
4
+ import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, setOrchestratorUpgradeState, updateWorkspaceStatus } from "../db";
5
5
  import { applyCommandToRecipe } from "../recipe-runner";
6
- import { claimMetadataPatch } from "../workspace-claim";
7
6
  import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
8
7
  import { getCommand, listCommands, updateCommand } from "../commands-db";
9
8
  import { emitOrchestratorStatus } from "../sse";
10
9
  import { getCompactionWatch } from "../compaction-watch";
11
- import { isRecord, sanitizeFsName, type LandGateRunResult } from "agent-relay-sdk";
12
- import { storeTextArtifact } from "../artifact-text";
10
+ import { isRecord } from "agent-relay-sdk";
13
11
  import { authContextFromRequest } from "../services/auth-context";
14
12
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
15
13
  import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "../services/scheduler-command";
16
- import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
17
14
  import { ServiceAuthError } from "../services/errors";
18
- import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
19
15
  import { applyIdleRefreshResult } from "../maintenance/workspaces";
20
16
  import { seedProviderConfigs } from "../provider-config-store";
21
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
22
- import { handleReviewerMergeResult } from "../reviewer-pipeline";
23
- import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
18
+ import { scheduleRepoMerge } from "../workspace-auto-merge";
24
19
  import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
25
20
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
26
21
  import { commandTargetsWorkspaceOwner } from "../workspace-host";
22
+ import { settleWorkspaceCommand } from "../workspace-command-settle";
27
23
 
28
24
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
29
25
 
30
- // #902 — normalize a land-gate run result that round-tripped through the command bus
31
- // (JSON, loosely typed) back into a LandGateRunResult. Returns undefined when the value
32
- // isn't a gate result, so the caller falls back to the generic land-failed path.
33
- function normalizeGateRunResult(value: unknown): LandGateRunResult | undefined {
34
- if (!isRecord(value)) return undefined;
35
- const name = typeof value.name === "string" ? value.name : undefined;
36
- const command = typeof value.command === "string" ? value.command : "";
37
- if (!name) return undefined;
38
- return {
39
- name,
40
- command,
41
- optional: value.optional === true,
42
- passed: value.passed === true,
43
- exitCode: typeof value.exitCode === "number" ? value.exitCode : null,
44
- timedOut: value.timedOut === true,
45
- durationMs: typeof value.durationMs === "number" ? value.durationMs : 0,
46
- outputTail: typeof value.outputTail === "string" ? value.outputTail : "",
47
- output: typeof value.output === "string" ? value.output : (typeof value.outputTail === "string" ? value.outputTail : ""),
48
- };
49
- }
50
-
51
- function normalizeGateWarnings(value: unknown): LandGateRunResult[] {
52
- if (!Array.isArray(value)) return [];
53
- return value.map(normalizeGateRunResult).filter((g): g is LandGateRunResult => Boolean(g));
54
- }
55
-
56
- // Upload a gate's full output to a relay artifact; returns its id, or undefined on any
57
- // failure (artifact upload is best-effort — the tail still rides in the notification).
58
- async function uploadGateOutputArtifact(workspaceId: string, gate: LandGateRunResult): Promise<string | undefined> {
59
- try {
60
- const safeName = sanitizeFsName(gate.name, { replacement: "-", maxLen: 60, trimEdge: true, fallback: "gate" });
61
- const stored = await storeTextArtifact({
62
- text: gate.output || gate.outputTail || "(no output)",
63
- filename: `land-gate-${safeName}-${workspaceId}.log`,
64
- createdBy: "server",
65
- metadata: { kind: "land-gate-output", workspaceId, gateName: gate.name, gateCommand: gate.command, exitCode: gate.exitCode, timedOut: gate.timedOut },
66
- });
67
- return stored.id;
68
- } catch (error) {
69
- console.warn(`land-gate artifact upload failed for ${workspaceId} gate "${gate.name}": ${String(error)}`);
70
- return undefined;
71
- }
72
- }
73
-
74
26
  function auditCommandOutcome(command: Command): void {
75
27
  if (command.status !== "succeeded" && command.status !== "failed") return;
76
28
  if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
@@ -232,204 +184,15 @@ export const patchCommand: Handler = async (req, params) => {
232
184
  if (command.type === "scheduler.command") {
233
185
  await settleScheduledCommandTimer(command);
234
186
  }
235
- if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
236
- const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
237
- if (workspaceId) {
238
- const workspace = getWorkspace(workspaceId);
239
- if (workspace && commandTargetsWorkspaceOwner(command, workspace)) {
240
- updateWorkspaceStatus(workspaceId, "cleaned", {
241
- cleanupResult: command.result,
242
- cleanupCommandId: command.id,
243
- });
244
- }
245
- }
187
+ const workspaceSettle = settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
188
+ if (workspaceSettle.ignoredWrongOwner) {
189
+ auditCommandOutcome(command);
190
+ return json(command);
246
191
  }
247
192
  if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
248
193
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
249
194
  if (workspaceId) applyIdleRefreshResult(workspaceId, command.result);
250
195
  }
251
- if (command.type === "workspace.merge") {
252
- // Merge settled (either way) — free the per-repo merge lease so the next
253
- // base merge can proceed (issue #157).
254
- if (command.status === "succeeded" || command.status === "failed") {
255
- releaseMergeLease({ commandId: command.id });
256
- // #237: the lease just freed — drain the next ready/review_requested row for
257
- // this repo right now instead of waiting for the backstop sweep. This is the
258
- // on-settle link of the sequential per-repo drain chain (no-op if nothing else
259
- // is queued, or if AUTO_MERGE is disabled).
260
- const settledRepoRoot = typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined;
261
- scheduleRepoMerge(settledRepoRoot);
262
- }
263
- if (command.status === "succeeded" && isRecord(command.result)) {
264
- if (handleReviewerMergeResult(command, command.result)) {
265
- emitCommand(command);
266
- auditCommandOutcome(command);
267
- return json(command);
268
- }
269
- const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
270
- const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
271
- if (workspaceId && resultStatus) {
272
- // Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
273
- // branch name + author (#239 branch.landed push) come from this pre-mutation state.
274
- const landedWorkspace = getWorkspace(workspaceId);
275
- if (landedWorkspace && !commandTargetsWorkspaceOwner(command, landedWorkspace)) {
276
- auditCommandOutcome(command);
277
- return json(command);
278
- }
279
- const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
280
- const normalizedStatus = command.result.merged === true
281
- && !newBranch
282
- && ["review_requested", "merge_planned", "conflict"].includes(resultStatus)
283
- ? "merged"
284
- : resultStatus;
285
- updateWorkspaceStatus(workspaceId, normalizedStatus, {
286
- mergeResult: command.result,
287
- mergeCommandId: command.id,
288
- mergedAt: Date.now(),
289
- // A merge that settles `succeeded` made genuine progress (land / recycle /
290
- // noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
291
- // is attempted normally next time (#638).
292
- ...CLEAR_MERGE_NO_PROGRESS,
293
- mergeError: undefined,
294
- ...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
295
- // The land consumes the steward's claim (#208 steward contract / vent
296
- // #62): a successful land must release it, or `steward inspect` keeps
297
- // showing a stale claim on the recycled workspace and blocks the next
298
- // automation. Clearing a null claim (auto-merge path) is a harmless no-op.
299
- ...claimMetadataPatch(true),
300
- });
301
- // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
302
- // Repoint the row so the next merge targets the live branch, not the deleted one.
303
- const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
304
- // base_sha tracks the tip the recycled workspace forks from. On a no-ff land
305
- // (#287) that's the merge commit (result.baseSha), not the preserved landed
306
- // commit (mergedSha); fall back to mergedSha for older orchestrators.
307
- const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
308
- if (newBranch) {
309
- setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
310
- }
311
- // #239 — push the author a "your branch landed" notice (no polling). Fires on a
312
- // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
313
- // #423 — a PR-land RECYCLE is a no-op merge (`merged:false`) but a land DID happen
314
- // on the remote: the relay carries that ground truth in `params.prLanded`, so the
315
- // notice fires from it with the recycled `newBranch` — full rebase-ff parity.
316
- const prLanded = isRecord(command.params.prLanded) ? command.params.prLanded : undefined;
317
- // #824 — the land advanced a DIRTY base by ref plumbing and the shared checkout was
318
- // left in a MIXED state (HEAD moved, some landed working files didn't). The land
319
- // succeeded, but surface the strand LOUDLY so it's reconciled before anything reads/
320
- // builds/publishes from the stale checkout — never silently as before.
321
- const baseWorktreeSync = isRecord(command.result.baseWorktreeSync) ? command.result.baseWorktreeSync : undefined;
322
- if (baseWorktreeSync && baseWorktreeSync.reconciled === false && landedWorkspace) {
323
- notifyBaseWorktreeStrand({
324
- workspace: landedWorkspace,
325
- sync: {
326
- reconciled: false,
327
- unsyncedPaths: Array.isArray(baseWorktreeSync.unsyncedPaths) ? baseWorktreeSync.unsyncedPaths.filter((p): p is string => typeof p === "string") : undefined,
328
- preservedWipPaths: Array.isArray(baseWorktreeSync.preservedWipPaths) ? baseWorktreeSync.preservedWipPaths.filter((p): p is string => typeof p === "string") : undefined,
329
- message: typeof baseWorktreeSync.message === "string" ? baseWorktreeSync.message : undefined,
330
- },
331
- });
332
- }
333
- if ((command.result.merged === true || prLanded) && landedWorkspace) {
334
- notifyBranchLanded({
335
- workspace: landedWorkspace,
336
- mergedSha: prLanded ? cleanString(prLanded.sha, "params.prLanded.sha", { max: 64 }) ?? mergedSha : mergedSha,
337
- subject: prLanded
338
- ? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
339
- : cleanString(command.result.subject, "result.subject", { max: 200 }),
340
- // PR-land subjects carry a trailing `(#PR)` GitHub stamps on the squash — not an
341
- // issue. Tell the lifecycle hook so it won't close the PR number as an issue (#482).
342
- prLand: Boolean(prLanded),
343
- newBranch,
344
- pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
345
- });
346
- }
347
- // #902 — OPTIONAL land gates that failed on a SUCCESSFUL land (merged:true, ref
348
- // advanced). Warn the worker (tail + combined-output artifact) without blocking.
349
- // Best-effort + isolated so a warn-notify throw can't undo the completed land.
350
- const gateWarnings = command.result.merged === true ? normalizeGateWarnings(command.result.gateWarnings) : [];
351
- if (gateWarnings.length && landedWorkspace) {
352
- const warnWorkspace = landedWorkspace;
353
- void (async () => {
354
- const combined = gateWarnings.map((g) => `### ${g.name} (${g.command}) — ${g.timedOut ? "timed out" : g.exitCode === null ? "could not run" : `exited ${g.exitCode}`}\n${g.output || g.outputTail || "(no output)"}`).join("\n\n");
355
- let artifactId: string | undefined;
356
- try {
357
- artifactId = (await storeTextArtifact({ text: combined, filename: `land-gate-warnings-${warnWorkspace.id}.log`, createdBy: "server", metadata: { kind: "land-gate-warnings", workspaceId: warnWorkspace.id, gateNames: gateWarnings.map((g) => g.name) } })).id;
358
- } catch (error) {
359
- console.warn(`land-gate-warnings artifact upload failed for ${warnWorkspace.id}: ${String(error)}`);
360
- }
361
- try {
362
- notifyLandGateWarnings({ workspace: warnWorkspace, warnings: gateWarnings, artifactId });
363
- } catch (error) {
364
- console.warn(`land-gate-warnings notify threw for ${warnWorkspace.id}: ${String(error)}`);
365
- }
366
- })();
367
- }
368
- }
369
- } else if (command.status === "failed" && command.correlationId) {
370
- // Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
371
- // merge now settles `failed` (orchestrator #638) carrying the result; honor the
372
- // orchestrator's intended target status when it gave a valid one so a real
373
- // `conflict` routes to the steward (a non-landable status the auto-merge job
374
- // skips, which itself stops the loop) rather than bouncing back to the landable
375
- // `review_requested`. Surface the error and count the no-progress attempt so the
376
- // auto-merge job backs off after N instead of busy-looping (#638).
377
- const current = getWorkspace(command.correlationId);
378
- if (current && current.status === "merge_planned") {
379
- if (!commandTargetsWorkspaceOwner(command, current)) {
380
- auditCommandOutcome(command);
381
- return json(command);
382
- }
383
- const failedResultStatus = isRecord(command.result)
384
- ? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
385
- : undefined;
386
- const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
387
- ? failedResultStatus
388
- : "review_requested";
389
- updateWorkspaceStatus(command.correlationId, nextStatus, {
390
- mergeCommandId: command.id,
391
- mergeError: command.error ?? "merge failed",
392
- ...noteMergeNoProgress(current.metadata),
393
- });
394
- if (nextStatus === "conflict") {
395
- emitWorkspaceTaskSemanticNotifications({
396
- workspace: current,
397
- transition: "land-conflict",
398
- actor: "workspace-merge",
399
- occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
400
- outcome: {
401
- reason: command.error ?? "merge failed",
402
- commandId: command.id,
403
- status: nextStatus,
404
- },
405
- });
406
- }
407
- const gateFailure = isRecord(command.result) ? normalizeGateRunResult(command.result.gateFailure) : undefined;
408
- if (gateFailure) {
409
- // #902 — a REQUIRED land gate failed: the orchestrator aborted WITHOUT advancing
410
- // the ref and settled status "review_requested" (not "conflict", so the steward
411
- // path above is never taken). Send the richer worker+coordinator notice (gate name
412
- // + output tail + full-output artifact) INSTEAD of the generic land-failed wake.
413
- // Best-effort + isolated: the land already (didn't) happen; a notify/upload throw
414
- // must not break command settling.
415
- void (async () => {
416
- const artifactId = await uploadGateOutputArtifact(current.id, gateFailure);
417
- try {
418
- notifyLandGateFailed({ workspace: current, gate: gateFailure, artifactId });
419
- } catch (error) {
420
- console.warn(`land-gate-failed notify threw for ${current.id}: ${String(error)}`);
421
- }
422
- })();
423
- } else {
424
- notifyBranchLandFailed({
425
- workspace: current,
426
- commandId: command.id,
427
- reason: command.error ?? "merge failed",
428
- });
429
- }
430
- }
431
- }
432
- }
433
196
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
434
197
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
435
198
  const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
@@ -7,7 +7,7 @@ import { emitMessageClaimed, emitMessageDeleted, emitMessageDeliveryUpdated, emi
7
7
  import { errMessage, isMechanicalMessageKind, isRecord } from "agent-relay-sdk";
8
8
  import { injectMemoryForMessageDelivery } from "../memory-service";
9
9
  import { authContextFromRequest } from "../services/auth-context";
10
- import { sendMessageService } from "../services/send-message";
10
+ import { sendMessagesBatchService, sendMessageService } from "../services/send-message";
11
11
  import { AmbiguousTargetError, ServiceAuthError } from "../services/errors";
12
12
  import { type AgentSessionGuard, type Message, type SendMessageInput } from "../types";
13
13
 
@@ -73,6 +73,7 @@ function isDirectTarget(to: string): boolean {
73
73
  }
74
74
 
75
75
  const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
76
+ const MAX_MESSAGE_BATCH = 100;
76
77
 
77
78
  export const getQueuedMessagesRoute: Handler = (req) => {
78
79
  const url = new URL(req.url);
@@ -149,6 +150,17 @@ export const postMessage: Handler = async (req) => {
149
150
  const parsed = await parseBody<unknown>(req);
150
151
  if (!parsed.ok) return error(parsed.error, parsed.status);
151
152
  try {
153
+ if (isRecord(parsed.body) && Array.isArray(parsed.body.messages)) {
154
+ if (parsed.body.messages.length === 0 || parsed.body.messages.length > MAX_MESSAGE_BATCH) {
155
+ throw new ValidationError(`messages must contain 1-${MAX_MESSAGE_BATCH} items`);
156
+ }
157
+ const inputs = parsed.body.messages.map((message) => normalizeMessageInput(message));
158
+ if (inputs.some((input) => input.kind !== "session")) {
159
+ throw new ValidationError("batch messages only supports kind=session");
160
+ }
161
+ const results = sendMessagesBatchService(inputs, authContextFromRequest(req));
162
+ return json({ messages: results.map((result) => result.message) }, 201);
163
+ }
152
164
  const input = normalizeMessageInput(parsed.body);
153
165
  if (!input.idempotencyKey) {
154
166
  input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
@@ -130,14 +130,12 @@ export const postTaskCreate: Handler = async (req) => {
130
130
  teamId: cleanString(body.teamId, "teamId", { max: 200 }),
131
131
  projectId: cleanString(body.projectId, "projectId", { max: 120 }),
132
132
  repo: cleanString(body.repo, "repo", { max: 500 }),
133
+ branch: cleanString(body.branch, "branch", { max: 500 }),
133
134
  dispatch: body.dispatch as TaskDispatch | undefined,
135
+ issueRefs: Array.isArray(body.issueRefs) ? body.issueRefs.filter((ref): ref is string => typeof ref === "string") : undefined,
136
+ defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }),
134
137
  createdBy: actorOf(req, body.createdBy),
135
138
  });
136
- if (Array.isArray(body.issueRefs)) {
137
- for (const ref of body.issueRefs) {
138
- if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }), actor: actorOf(req, body.createdBy) });
139
- }
140
- }
141
139
  if (Array.isArray(body.dependsOn)) {
142
140
  for (const dep of body.dependsOn) {
143
141
  const depId = parseId(String(dep));
@@ -278,7 +278,7 @@ function normalizeProviderQuotaConfig(raw: unknown, provider: string): ProviderQ
278
278
  if (!isRecord(raw)) throw new ValidationError(`providerQuota.${provider} must be an object`);
279
279
  const cfg: ProviderQuotaRoutingConfig = {};
280
280
  if (raw.hardBackstopUtilization !== undefined) {
281
- cfg.hardBackstopUtilization = cleanFraction(raw.hardBackstopUtilization, `providerQuota.${provider}.hardBackstopUtilization`);
281
+ cfg.hardBackstopUtilization = cleanHardBackstopFraction(raw.hardBackstopUtilization);
282
282
  }
283
283
  if (raw.burnPenaltyUtilizationFloor !== undefined) {
284
284
  cfg.burnPenaltyUtilizationFloor = cleanFraction(raw.burnPenaltyUtilizationFloor, `providerQuota.${provider}.burnPenaltyUtilizationFloor`);
@@ -293,6 +293,16 @@ function cleanQuotaFraction(value: unknown, field: string): number {
293
293
  return value;
294
294
  }
295
295
 
296
+ function cleanHardBackstopFraction(value: unknown): number {
297
+ // hardBackstopUtilization=0 is an intentional contract: disable the hard backstop
298
+ // for that provider. Other invalid values normalize to the default instead.
299
+ if (value === 0) return 0;
300
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
301
+ return 0.99;
302
+ }
303
+ return value;
304
+ }
305
+
296
306
  function cleanFraction(value: unknown, field: string): number {
297
307
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
298
308
  throw new ValidationError(`${field} must be a number between 0 and 1`);
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import { installNotificationSubscriptionRule } from "./notification-subscription
13
13
  import { installQuotaAdvisoryHandler } from "./quota-advisory";
14
14
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { startRelayScheduler } from "./services/scheduler";
16
+ import { startEventLoopLagSampler } from "./event-loop-lag";
16
17
  import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
17
18
  import { onWorkspaceCreate } from "./db";
18
19
  import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
@@ -80,6 +81,7 @@ export function startServer(): void {
80
81
  getCompactionWatch().start();
81
82
  reconcileRelayVersion();
82
83
  startConnectorStatusPoller();
84
+ startEventLoopLagSampler();
83
85
  startMaintenanceScheduler();
84
86
  startRelayScheduler();
85
87
 
@@ -1,5 +1,5 @@
1
1
  import { errMessage } from "agent-relay-sdk";
2
- import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch } from "../db";
2
+ import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch, deliverableTaskIdsByOwnerAgent, deliverableTaskIdsBySpawnRequestId } from "../db";
3
3
  import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
4
4
  import {
5
5
  getSuccessfulIssueLifecycleAction,
@@ -142,17 +142,28 @@ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
142
142
  }
143
143
  }
144
144
  }
145
- // #834 task-aware resolution: a deliverable Task owns its branch, so the landed branch
146
- // resolves to its linked issue(s). This is the resolution PATH; lifecycle automation that
147
- // advances the Task itself on land is #831.
148
- if (workspace.branch) {
149
- for (const taskId of deliverableTaskIdsByBranch(workspace.branch)) {
150
- refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
151
- }
145
+ for (const taskId of associatedTaskIds(workspace)) {
146
+ refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
152
147
  }
153
148
  return issueRefMap(refs);
154
149
  }
155
150
 
151
+ function associatedTaskIds(workspace: LifecycleWorkspace): number[] {
152
+ const ids = new Set<number>();
153
+ const ownerId = workspace.ownerAgentId?.trim();
154
+ if (ownerId) {
155
+ for (const id of deliverableTaskIdsByOwnerAgent(ownerId)) ids.add(id);
156
+ const spawnRequestId = getAgent(ownerId)?.meta?.spawnRequestId;
157
+ if (typeof spawnRequestId === "string" && spawnRequestId.trim()) {
158
+ for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
159
+ }
160
+ }
161
+ if (workspace.branch) {
162
+ for (const id of deliverableTaskIdsByBranch(workspace.branch)) ids.add(id);
163
+ }
164
+ return [...ids].sort((a, b) => a - b);
165
+ }
166
+
156
167
  const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\r]+)/gi;
157
168
  const ISSUE_REF_AT_START_RE = /^(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
158
169
  const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
@@ -400,6 +411,10 @@ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInpu
400
411
  }> {
401
412
  if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
402
413
  const resolved = await resolveIssueRefsForLand(input);
414
+ const taskIds = associatedTaskIds(input.workspace);
415
+ if (resolved.refs.length === 0 && taskIds.length > 0) {
416
+ logFailure(opts, `issue lifecycle found landed task(s) without linked issue refs: tasks=${taskIds.join(",")} workspace=${input.workspace.id} branch=${input.workspace.branch ?? "unknown"}`);
417
+ }
403
418
  const closed: CloseIssueLifecycleResult[] = [];
404
419
  for (const ref of resolved.refs) {
405
420
  closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));