agent-relay-server 0.65.2 → 0.67.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 (110) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-CG_eCKDI.js +3 -0
  4. package/public/assets/MessageBubble-CG_eCKDI.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-DrHNFbg6.js → PlanGraphPanel-Ba9qh-if.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DrHNFbg6.js.map → PlanGraphPanel-Ba9qh-if.js.map} +1 -1
  7. package/public/assets/{activity-CECdsDpd.js → activity-8eZ4FhxO.js} +2 -2
  8. package/public/assets/{activity-CECdsDpd.js.map → activity-8eZ4FhxO.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CHitqihm.js → agent-profiles-1Nx8ROA_.js} +2 -2
  10. package/public/assets/{agent-profiles-CHitqihm.js.map → agent-profiles-1Nx8ROA_.js.map} +1 -1
  11. package/public/assets/{agents-GG-aJj6Z.js → agents-C8fM2VwM.js} +2 -2
  12. package/public/assets/{agents-GG-aJj6Z.js.map → agents-C8fM2VwM.js.map} +1 -1
  13. package/public/assets/{analytics-y0Yf26cD.js → analytics-D6ffu1ji.js} +2 -2
  14. package/public/assets/{analytics-y0Yf26cD.js.map → analytics-D6ffu1ji.js.map} +1 -1
  15. package/public/assets/{automation-CmzftGBn.js → automation-BVmzNi6H.js} +2 -2
  16. package/public/assets/{automation-CmzftGBn.js.map → automation-BVmzNi6H.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-Biam17QK.js → branch-state-badge-DrwCnoKR.js} +2 -2
  18. package/public/assets/{branch-state-badge-Biam17QK.js.map → branch-state-badge-DrwCnoKR.js.map} +1 -1
  19. package/public/assets/{channels-CmAXppQu.js → channels-BuqbbyaO.js} +2 -2
  20. package/public/assets/{channels-CmAXppQu.js.map → channels-BuqbbyaO.js.map} +1 -1
  21. package/public/assets/chat-DCnEGe-x.js +2 -0
  22. package/public/assets/chat-DCnEGe-x.js.map +1 -0
  23. package/public/assets/{connectors-bZ0YsSqg.js → connectors-_LpoKQNb.js} +2 -2
  24. package/public/assets/{connectors-bZ0YsSqg.js.map → connectors-_LpoKQNb.js.map} +1 -1
  25. package/public/assets/formatted-body-impl-C7ftpr0y.js +3 -0
  26. package/public/assets/formatted-body-impl-C7ftpr0y.js.map +1 -0
  27. package/public/assets/index-eCpWfoCA.css +2 -0
  28. package/public/assets/{index-BSWui_-2.js → index-ksV0Snw6.js} +10 -10
  29. package/public/assets/index-ksV0Snw6.js.map +1 -0
  30. package/public/assets/{insights-cC2lVGUs.js → insights-BjRehGVP.js} +2 -2
  31. package/public/assets/{insights-cC2lVGUs.js.map → insights-BjRehGVP.js.map} +1 -1
  32. package/public/assets/{integrations--k7_eYP2.js → integrations-DCyxAv8c.js} +2 -2
  33. package/public/assets/{integrations--k7_eYP2.js.map → integrations-DCyxAv8c.js.map} +1 -1
  34. package/public/assets/{maintenance-B6wOij_l.js → maintenance-CKiNcUMS.js} +2 -2
  35. package/public/assets/{maintenance-B6wOij_l.js.map → maintenance-CKiNcUMS.js.map} +1 -1
  36. package/public/assets/{managed-agents-CDzLSKp1.js → managed-agents-Os_T-7w7.js} +2 -2
  37. package/public/assets/{managed-agents-CDzLSKp1.js.map → managed-agents-Os_T-7w7.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-D3Y4uUgk.js → markdown-preview-impl-grkpBldu.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-D3Y4uUgk.js.map → markdown-preview-impl-grkpBldu.js.map} +1 -1
  40. package/public/assets/{memory-DYzw5EXn.js → memory-YiYlqWUZ.js} +2 -2
  41. package/public/assets/{memory-DYzw5EXn.js.map → memory-YiYlqWUZ.js.map} +1 -1
  42. package/public/assets/{messages-ATbJw7jy.js → messages-CVDtrrub.js} +2 -2
  43. package/public/assets/messages-CVDtrrub.js.map +1 -0
  44. package/public/assets/orchestrators-Dzup-DDB.js +2 -0
  45. package/public/assets/{orchestrators-Dtr4zNWI.js.map → orchestrators-Dzup-DDB.js.map} +1 -1
  46. package/public/assets/{overview-B071A7FM.js → overview-DZltfAFn.js} +2 -2
  47. package/public/assets/{overview-B071A7FM.js.map → overview-DZltfAFn.js.map} +1 -1
  48. package/public/assets/{pairs-DgiNK9mA.js → pairs-Q2CmriBh.js} +2 -2
  49. package/public/assets/{pairs-DgiNK9mA.js.map → pairs-Q2CmriBh.js.map} +1 -1
  50. package/public/assets/{projects-DhQUdwcb.js → projects-DtJmrhI0.js} +2 -2
  51. package/public/assets/projects-DtJmrhI0.js.map +1 -0
  52. package/public/assets/{security-COHkbWIm.js → security-Bur19Sp4.js} +2 -2
  53. package/public/assets/{security-COHkbWIm.js.map → security-Bur19Sp4.js.map} +1 -1
  54. package/public/assets/{select-CKPIes_u.js → select-CuzWJuBz.js} +2 -2
  55. package/public/assets/{select-CKPIes_u.js.map → select-CuzWJuBz.js.map} +1 -1
  56. package/public/assets/settings-CEMCJ31_.js +4 -0
  57. package/public/assets/settings-CEMCJ31_.js.map +1 -0
  58. package/public/assets/store-BeKcuJ2q.js +9 -0
  59. package/public/assets/store-BeKcuJ2q.js.map +1 -0
  60. package/public/assets/{tasks-DdVsKCUN.js → tasks-DjwAusKz.js} +2 -2
  61. package/public/assets/{tasks-DdVsKCUN.js.map → tasks-DjwAusKz.js.map} +1 -1
  62. package/public/assets/{teams-BLQeR-qJ.js → teams-CFCnB12u.js} +2 -2
  63. package/public/assets/{teams-BLQeR-qJ.js.map → teams-CFCnB12u.js.map} +1 -1
  64. package/public/assets/{terminal-viewer-impl-eFq9PBWV.js → terminal-viewer-impl-CWu5RTp2.js} +2 -2
  65. package/public/assets/{terminal-viewer-impl-eFq9PBWV.js.map → terminal-viewer-impl-CWu5RTp2.js.map} +1 -1
  66. package/public/assets/{work-queue-OFw-Hwir.js → work-queue-TZQ5Gz0t.js} +2 -2
  67. package/public/assets/{work-queue-OFw-Hwir.js.map → work-queue-TZQ5Gz0t.js.map} +1 -1
  68. package/public/assets/{workspaces-zum4lO_O.js → workspaces-Bu4uVUcu.js} +2 -2
  69. package/public/assets/{workspaces-zum4lO_O.js.map → workspaces-Bu4uVUcu.js.map} +1 -1
  70. package/public/index.html +3 -3
  71. package/src/agent-issue-repo.ts +29 -0
  72. package/src/branch-landed.ts +69 -2
  73. package/src/cli/index.ts +6 -4
  74. package/src/config.ts +1 -0
  75. package/src/context-advisory.ts +93 -15
  76. package/src/context-threshold-config.ts +14 -2
  77. package/src/db/migrations.ts +24 -0
  78. package/src/db/projects.ts +34 -6
  79. package/src/db/schema.ts +1 -0
  80. package/src/mcp/tools-workspace.ts +3 -3
  81. package/src/mcp-project-tools.ts +2 -0
  82. package/src/notification-router.ts +2 -0
  83. package/src/provider-catalog-store.ts +35 -0
  84. package/src/routes/agents.ts +3 -2
  85. package/src/routes/commands.ts +6 -1
  86. package/src/routes/provider-config.ts +27 -0
  87. package/src/services/git-remote.ts +46 -0
  88. package/src/services/issue-lifecycle.ts +5 -36
  89. package/src/services/project.ts +23 -3
  90. package/src/services/register-agent.ts +7 -1
  91. package/src/services/spawn-agent.ts +7 -9
  92. package/src/services/spawn-registration-watchdog.ts +34 -12
  93. package/src/settings/registry.ts +2 -0
  94. package/src/sse.ts +9 -4
  95. package/src/workspace-phase.ts +17 -9
  96. package/public/assets/MessageBubble-DLF9fJLb.js +0 -2
  97. package/public/assets/MessageBubble-DLF9fJLb.js.map +0 -1
  98. package/public/assets/chat-C-FI9bLO.js +0 -2
  99. package/public/assets/chat-C-FI9bLO.js.map +0 -1
  100. package/public/assets/formatted-body-impl-D--5zVSd.js +0 -3
  101. package/public/assets/formatted-body-impl-D--5zVSd.js.map +0 -1
  102. package/public/assets/index-BSWui_-2.js.map +0 -1
  103. package/public/assets/index-B_On3Osd.css +0 -2
  104. package/public/assets/messages-ATbJw7jy.js.map +0 -1
  105. package/public/assets/orchestrators-Dtr4zNWI.js +0 -2
  106. package/public/assets/projects-DhQUdwcb.js.map +0 -1
  107. package/public/assets/settings-B4SN1TpB.js +0 -4
  108. package/public/assets/settings-B4SN1TpB.js.map +0 -1
  109. package/public/assets/store-DnHOGovN.js +0 -9
  110. package/public/assets/store-DnHOGovN.js.map +0 -1
package/src/config.ts CHANGED
@@ -51,6 +51,7 @@ export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min wit
51
51
  // registered with Relay. Keep this above the runner's own 60s registration timeout so the runner's
52
52
  // direct failure path wins first; this is the relay-side dead-man switch.
53
53
  export const SPAWN_REGISTRATION_DEADLINE_MS = envPositiveInt("AGENT_RELAY_SPAWN_REGISTRATION_DEADLINE_MS", 120_000);
54
+ export const SPAWN_REGISTRATION_NOTIFY_MAX_AGE_MS = envPositiveInt("AGENT_RELAY_SPAWN_REGISTRATION_NOTIFY_MAX_AGE_MS", 15 * 60_000);
54
55
  // #451 — default TTL for fire-and-forget FAN-OUT messages (broadcast/tag/cap/label/team) that the
55
56
  // sender didn't bound. A stale fan-out push must not wake an agent that matches the target hours
56
57
  // later (the cold-session-woken-by-old-coordination-thread bug). Direct DMs, reply-obligations, and
@@ -1,7 +1,11 @@
1
- import { getNotificationsConfig } from "./config-store";
1
+ import { deriveContextThresholdFromWindowTokens, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
2
+ import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
3
+ import { getConfig, getNotificationsConfig } from "./config-store";
4
+ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
2
5
  import { getDb } from "./db/connection.ts";
3
6
  import { emitRelayEvent } from "./events";
4
7
  import { routeNotification } from "./notification-router";
8
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
5
9
  import type { AgentCard, Command, ContextState } from "./types";
6
10
 
7
11
  interface ContextAdvisoryInput {
@@ -10,7 +14,17 @@ interface ContextAdvisoryInput {
10
14
  percent: number;
11
15
  windowTokens: number;
12
16
  band: number;
17
+ stage: ContextAdvisoryStage;
18
+ selfCompactPercent: number;
19
+ warnPercent: number;
13
20
  hardBackstopPercent: number;
21
+ thresholdSource: string;
22
+ }
23
+
24
+ type ContextAdvisoryStage = "warn" | "self-compact";
25
+
26
+ interface EffectiveContextThreshold extends ResolvedProviderContextThreshold {
27
+ source: "model" | "global" | "window";
14
28
  }
15
29
 
16
30
  /**
@@ -25,7 +39,11 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
25
39
  percent: input.percent,
26
40
  windowTokens: input.windowTokens,
27
41
  band: input.band,
42
+ stage: input.stage,
43
+ selfCompactPercent: input.selfCompactPercent,
44
+ warnPercent: input.warnPercent,
28
45
  hardBackstopPercent: input.hardBackstopPercent,
46
+ thresholdSource: input.thresholdSource,
29
47
  };
30
48
 
31
49
  emitRelayEvent({
@@ -45,19 +63,29 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
45
63
  scope: { agentId: input.agentId },
46
64
  recipients: [input.agentId],
47
65
  message: {
48
- subject: "Context threshold reached",
49
- body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
66
+ subject: input.stage === "warn" ? "Context threshold warning" : "Context self-compact threshold reached",
67
+ body: contextAdvisoryBody(input),
50
68
  payload,
51
69
  replyExpected: false,
52
70
  },
53
71
  });
54
72
  }
55
73
 
56
- function contextAdvisoryBand(utilization: number, advisoryUtilization: number, bandSize: number): number | null {
57
- if (!Number.isFinite(utilization) || utilization < advisoryUtilization) return null;
58
- const percent = Math.min(100, Math.max(0, utilization * 100));
59
- const step = Math.max(1, bandSize * 100);
60
- return Math.min(100, Math.floor(percent / step) * step);
74
+ function contextAdvisoryBody(input: ContextAdvisoryInput): string {
75
+ const usage = `${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band)`;
76
+ const threshold = `resolved self-compact threshold ${input.selfCompactPercent}% (${input.thresholdSource})`;
77
+ if (input.stage === "warn") {
78
+ return `Context warning: ${usage}. You are approaching the ${threshold}; if the current task is at a clean seam, prepare to call relay_compact_and_resume before continuing.`;
79
+ }
80
+ return `Context advisory: ${usage}. You are over the ${threshold}; call relay_compact_and_resume at the next clean seam between tasks or waves, not mid-edit. The ${input.hardBackstopPercent}% hard backstop may request compaction if usage keeps climbing.`;
81
+ }
82
+
83
+ function contextAdvisoryBand(utilization: number, threshold: EffectiveContextThreshold): { band: number; stage: ContextAdvisoryStage } | null {
84
+ if (!Number.isFinite(utilization) || utilization < threshold.warnUtilization) return null;
85
+ if (utilization >= threshold.selfCompactUtilization) {
86
+ return { band: utilizationPercent(threshold.selfCompactUtilization), stage: "self-compact" };
87
+ }
88
+ return { band: utilizationPercent(threshold.warnUtilization), stage: "warn" };
61
89
  }
62
90
 
63
91
  export function handleContextPressure(input: {
@@ -71,9 +99,9 @@ export function handleContextPressure(input: {
71
99
  if (!context || agent.status === "busy") return false;
72
100
  if (context.lifecycleState === "compacting" || context.lifecycleState === "hibernating") return false;
73
101
  if (typeof context.utilization !== "number" || !Number.isFinite(context.utilization)) return false;
74
- const threshold = getNotificationsConfig().contextThreshold;
75
- const band = contextAdvisoryBand(context.utilization, threshold.advisoryUtilization, threshold.bandSize);
76
- if (band === null) {
102
+ const threshold = effectiveContextThreshold(agent);
103
+ const advisory = contextAdvisoryBand(context.utilization, threshold);
104
+ if (advisory === null) {
77
105
  if (context.lastContextAdvisoryBand !== undefined || context.lastContextAdvisoryAt !== undefined) {
78
106
  persistContextState(agent.id, clearContextAdvisory(context));
79
107
  }
@@ -83,7 +111,7 @@ export function handleContextPressure(input: {
83
111
  input.compact("context-hard-backstop");
84
112
  return true;
85
113
  }
86
- if (context.lastContextAdvisoryBand === band) return false;
114
+ if (context.lastContextAdvisoryBand === advisory.band) return false;
87
115
  const tokens = finiteNumber(context.tokensUsed);
88
116
  const windowTokens = finiteNumber(context.tokensMax);
89
117
  if (tokens === undefined || windowTokens === undefined) return false;
@@ -92,13 +120,55 @@ export function handleContextPressure(input: {
92
120
  tokens,
93
121
  percent: Math.round(context.utilization * 100),
94
122
  windowTokens,
95
- band,
96
- hardBackstopPercent: Math.round(threshold.hardBackstopUtilization * 100),
123
+ band: advisory.band,
124
+ stage: advisory.stage,
125
+ warnPercent: utilizationPercent(threshold.warnUtilization),
126
+ selfCompactPercent: utilizationPercent(threshold.selfCompactUtilization),
127
+ hardBackstopPercent: utilizationPercent(threshold.hardBackstopUtilization),
128
+ thresholdSource: threshold.source,
97
129
  });
98
- persistContextState(agent.id, { ...context, lastContextAdvisoryBand: band, lastContextAdvisoryAt: now() });
130
+ persistContextState(agent.id, { ...context, lastContextAdvisoryBand: advisory.band, lastContextAdvisoryAt: now() });
99
131
  return true;
100
132
  }
101
133
 
134
+ function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold {
135
+ const model = findAgentCatalogModel(agent);
136
+ const windowTokens = model?.limits?.contextWindowTokens?.value
137
+ ?? finiteNumber(agent.providerCapabilities?.context?.windowTokens)
138
+ ?? finiteNumber(agent.context?.tokensMax);
139
+ if (model?.contextThreshold) {
140
+ return { ...resolveProviderContextThreshold(model), source: "model" };
141
+ }
142
+ const global = explicitContextThresholdConfig();
143
+ if (global) return global;
144
+ return { ...deriveContextThresholdFromWindowTokens(windowTokens), source: "window" };
145
+ }
146
+
147
+ function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined {
148
+ const entry = getConfig("notifications", "default");
149
+ if (!entry || !isRecord(entry.value) || !isRecord(entry.value.contextThreshold)) return undefined;
150
+ const global = validateContextThresholdConfig({ ...DEFAULT_CONTEXT_THRESHOLD_CONFIG, ...entry.value.contextThreshold });
151
+ const selfCompactUtilization = global.selfCompactUtilization ?? global.advisoryUtilization;
152
+ return {
153
+ selfCompactUtilization,
154
+ warnUtilization: global.warnUtilization ?? Math.max(0, roundUtilization(selfCompactUtilization - 0.1)),
155
+ hardBackstopUtilization: global.hardBackstopUtilization,
156
+ source: "global",
157
+ };
158
+ }
159
+
160
+ function findAgentCatalogModel(agent: AgentCard): ProviderModelCatalogEntry | undefined {
161
+ const runtime = agent.providerCapabilities?.model;
162
+ const provider = runtime?.provider;
163
+ if (!provider || !(SPAWN_PROVIDERS as readonly string[]).includes(provider)) return undefined;
164
+ const catalog = effectiveProviderCatalogList().find((entry) => entry.provider === provider);
165
+ if (!catalog) return undefined;
166
+ const ids = [runtime.alias, runtime.id, runtime.providerModel].filter((value): value is string => Boolean(value));
167
+ const lowerIds = new Set(ids.map((id) => id.toLowerCase()));
168
+ return catalog.models.find((model) => ids.includes(model.alias) || ids.includes(model.providerModel))
169
+ ?? catalog.models.find((model) => lowerIds.has(model.alias.toLowerCase()) || lowerIds.has(model.providerModel.toLowerCase()));
170
+ }
171
+
102
172
  function clearContextAdvisory(context: ContextState): ContextState {
103
173
  const { lastContextAdvisoryBand: _band, lastContextAdvisoryAt: _at, ...rest } = context;
104
174
  return rest;
@@ -112,6 +182,14 @@ function finiteNumber(value: unknown): number | undefined {
112
182
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
113
183
  }
114
184
 
185
+ function utilizationPercent(value: number): number {
186
+ return Math.round(value * 100);
187
+ }
188
+
189
+ function roundUtilization(value: number): number {
190
+ return Math.round(value * 1000) / 1000;
191
+ }
192
+
115
193
  function formatTokens(value: number): string {
116
194
  return Math.round(value).toLocaleString("en-US");
117
195
  }
@@ -14,13 +14,25 @@ export function validateContextThresholdConfig(value: unknown): NotificationsCon
14
14
  const bandSize = cleanFraction(input.bandSize, "contextThreshold.bandSize", DEFAULT_CONTEXT_THRESHOLD_CONFIG.bandSize);
15
15
  if (bandSize <= 0) throw new ValidationError("contextThreshold.bandSize must be greater than 0");
16
16
  const advisoryUtilization = cleanFraction(input.advisoryUtilization, "contextThreshold.advisoryUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.advisoryUtilization);
17
+ const selfCompactUtilization = input.selfCompactUtilization === undefined || input.selfCompactUtilization === null
18
+ ? undefined
19
+ : cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization", advisoryUtilization);
20
+ const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
21
+ ? undefined
22
+ : cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization", Math.max(0, (selfCompactUtilization ?? advisoryUtilization) - 0.1));
17
23
  const hardBackstopUtilization = cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.hardBackstopUtilization);
18
- if (hardBackstopUtilization <= advisoryUtilization) {
19
- throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.advisoryUtilization");
24
+ const compactUtilization = selfCompactUtilization ?? advisoryUtilization;
25
+ if (warnUtilization !== undefined && warnUtilization >= compactUtilization) {
26
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
27
+ }
28
+ if (hardBackstopUtilization <= compactUtilization) {
29
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
20
30
  }
21
31
  return {
22
32
  enabled: input.enabled === undefined ? DEFAULT_CONTEXT_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "contextThreshold.enabled"),
23
33
  advisoryUtilization,
34
+ ...(selfCompactUtilization !== undefined ? { selfCompactUtilization } : {}),
35
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
24
36
  hardBackstopUtilization,
25
37
  bandSize,
26
38
  };
@@ -17,6 +17,8 @@ import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
18
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
19
19
  import { normalizeReactionEmoji } from "./mappers.ts";
20
+ import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
21
+ import { repoFromGitRemote } from "../services/git-remote.ts";
20
22
  import type {
21
23
  AgentCard,
22
24
  ActivityEvent,
@@ -108,6 +110,21 @@ function normalizeExistingMessageReactions(): void {
108
110
  migrate(rows);
109
111
  }
110
112
 
113
+ function backfillProjectIssueRepos(): void {
114
+ const repaired = new Set<string>();
115
+ for (const row of projectRootsMissingIssueRepo()) {
116
+ if (repaired.has(row.projectId)) continue;
117
+ try {
118
+ const repo = repoFromGitRemote(row.rootPath);
119
+ if (!repo) continue;
120
+ setProjectIssueRepo(row.projectId, repo, { onlyIfNull: true });
121
+ repaired.add(row.projectId);
122
+ } catch {
123
+ // Best-effort startup repair: a missing root or unavailable git must not block Relay.
124
+ }
125
+ }
126
+ }
127
+
111
128
 
112
129
  // Incremental schema migrations + one-shot backfills, applied after the base DDL
113
130
  // in initDb(). Ordering matters: each block guards on the current column/table
@@ -453,6 +470,13 @@ export function applyMigrations(): void {
453
470
  getDb().run("ALTER TABLE provider_model_overrides ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
454
471
  }
455
472
 
473
+ // Migration: projects.issue_repo (#533) — per-project `owner/repo` for #NNN linkify/land-close.
474
+ const projectCols = (getDb().query("PRAGMA table_info(projects)").all() as any[]).map((c: any) => c.name);
475
+ if (!projectCols.includes("issue_repo")) {
476
+ getDb().run("ALTER TABLE projects ADD COLUMN issue_repo TEXT");
477
+ }
478
+ backfillProjectIssueRepos();
479
+
456
480
  // Migration: orchestrators.api_url
457
481
  const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
458
482
  const orchColNames = orchCols.map((c: any) => c.name);
@@ -14,6 +14,7 @@ interface ProjectRow {
14
14
  title: string | null;
15
15
  description: string | null;
16
16
  tenant_id: string | null;
17
+ issue_repo: string | null;
17
18
  status: Project["status"];
18
19
  created_at: number;
19
20
  updated_at: number;
@@ -36,6 +37,7 @@ function rowToProject(row: ProjectRow): Project {
36
37
  ...(row.title ? { title: row.title } : {}),
37
38
  ...(row.description ? { description: row.description } : {}),
38
39
  ...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
40
+ ...(row.issue_repo ? { issueRepo: row.issue_repo } : {}),
39
41
  status: row.status,
40
42
  createdAt: row.created_at,
41
43
  updatedAt: row.updated_at,
@@ -76,30 +78,44 @@ export function rootsForProject(id: string): ProjectRoot[] {
76
78
  return rows.map(rowToRoot);
77
79
  }
78
80
 
81
+ export function projectRootsMissingIssueRepo(): Array<{ projectId: string; rootPath: string }> {
82
+ return getDb().query(`
83
+ SELECT p.id AS projectId, pr.root_path AS rootPath
84
+ FROM projects p
85
+ JOIN project_roots pr ON pr.project_id = p.id
86
+ WHERE p.issue_repo IS NULL OR trim(p.issue_repo) = ''
87
+ ORDER BY p.created_at ASC, p.id ASC, pr.root_path ASC
88
+ `).all() as Array<{ projectId: string; rootPath: string }>;
89
+ }
90
+
79
91
  interface EnsureProjectInput {
80
92
  slug: string;
81
93
  title?: string;
82
94
  description?: string;
83
95
  tenantId?: string;
96
+ /** Explicit `owner/repo` override (#533); when set it wins over the git-remote-derived
97
+ * default. A bare derived value is filled separately via setProjectIssueRepo(onlyIfNull). */
98
+ issueRepo?: string;
84
99
  }
85
100
 
86
101
  /** Get-or-create a project by slug. The create-on-first-use analog of ensureTeam;
87
- * updates title/description/tenant on an existing project only when provided. */
102
+ * updates title/description/tenant/issueRepo on an existing project only when provided. */
88
103
  export function ensureProject(input: EnsureProjectInput): Project {
89
104
  const slug = input.slug.trim();
90
105
  if (!slug) throw new ValidationError("slug required");
91
106
  const existing = getProjectBySlug(slug);
92
107
  const now = Date.now();
93
108
  if (existing) {
94
- if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined) {
109
+ if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined || input.issueRepo !== undefined) {
95
110
  getDb().query(`
96
111
  UPDATE projects
97
- SET title = ?, description = ?, tenant_id = ?, updated_at = ?
112
+ SET title = ?, description = ?, tenant_id = ?, issue_repo = ?, updated_at = ?
98
113
  WHERE id = ?
99
114
  `).run(
100
115
  input.title ?? existing.title ?? null,
101
116
  input.description ?? existing.description ?? null,
102
117
  input.tenantId ?? existing.tenantId ?? null,
118
+ input.issueRepo ?? existing.issueRepo ?? null,
103
119
  now,
104
120
  existing.id,
105
121
  );
@@ -109,12 +125,24 @@ export function ensureProject(input: EnsureProjectInput): Project {
109
125
  }
110
126
  const id = projectId();
111
127
  getDb().query(`
112
- INSERT INTO projects (id, slug, title, description, tenant_id, status, created_at, updated_at)
113
- VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
114
- `).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, now, now);
128
+ INSERT INTO projects (id, slug, title, description, tenant_id, issue_repo, status, created_at, updated_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)
130
+ `).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, input.issueRepo ?? null, now, now);
115
131
  return getProject(id)!;
116
132
  }
117
133
 
134
+ /** Set (or clear, with null) a project's `issueRepo` (#533). With `onlyIfNull`, a value is
135
+ * written only when none is set yet — the mechanism for the git-remote AUTO-DERIVE to fill
136
+ * a fresh project without ever clobbering an explicit override or a prior derivation. */
137
+ export function setProjectIssueRepo(id: string, issueRepo: string | null, opts: { onlyIfNull?: boolean } = {}): Project | null {
138
+ const project = getProject(id);
139
+ if (!project) return null;
140
+ if (opts.onlyIfNull && project.issueRepo) return project;
141
+ const value = issueRepo?.trim() || null;
142
+ getDb().query("UPDATE projects SET issue_repo = ?, updated_at = ? WHERE id = ?").run(value, Date.now(), id);
143
+ return getProject(id);
144
+ }
145
+
118
146
  /** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
119
147
  export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
120
148
  const project = getProject(id);
package/src/db/schema.ts CHANGED
@@ -207,6 +207,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
207
207
  title TEXT,
208
208
  description TEXT,
209
209
  tenant_id TEXT,
210
+ issue_repo TEXT,
210
211
  status TEXT NOT NULL DEFAULT 'active',
211
212
  created_at INTEGER NOT NULL,
212
213
  updated_at INTEGER NOT NULL
@@ -13,14 +13,14 @@ import { optionalBoolean, optionalPositiveInt, optionalString } from "./validati
13
13
  export const WORKSPACE_TOOLS: ToolDefinition[] = [
14
14
  {
15
15
  name: "relay_workspace_status",
16
- description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to your own workspace; pass workspaceId to target another.",
16
+ description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_workspace_ready unless explicitly debugging. Normal branch workers mark ready, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
17
17
  requiredScopes: ["agents:read", "agent:read", "agent:write"],
18
18
  alwaysLoad: true,
19
19
  inputSchema: {
20
20
  type: "object",
21
21
  properties: {
22
22
  workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
23
- wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout." },
23
+ wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_workspace_ready in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
24
24
  timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
25
25
  },
26
26
  additionalProperties: false,
@@ -28,7 +28,7 @@ export const WORKSPACE_TOOLS: ToolDefinition[] = [
28
28
  },
29
29
  {
30
30
  name: "relay_workspace_ready",
31
- description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this, poll relay_workspace_status with wait:true until it lands and you get a fresh rebased branch to continue on. Defaults to your own workspace.",
31
+ description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this tool returns, stop your turn and go idle; Relay wakes you later with landed-success when the merge lands and your branch refreshes, or landed-failure if landing fatally fails. Defaults to your own workspace.",
32
32
  requiredScopes: ["agent:write"],
33
33
  alwaysLoad: true,
34
34
  inputSchema: {
@@ -62,6 +62,7 @@ export const PROJECT_TOOLS = [
62
62
  title: { type: "string" },
63
63
  description: { type: "string" },
64
64
  tenantId: { type: "string" },
65
+ issueRepo: { type: "string", description: "Explicit owner/repo for #NNN linkify; overrides the git-remote-derived default (#533)." },
65
66
  },
66
67
  required: ["slug", "rootPath"],
67
68
  additionalProperties: false,
@@ -273,6 +274,7 @@ export function relayProjectTool(auth: McpAuthLike, name: string, args: Record<s
273
274
  title: cleanString(args.title, "title", { max: 240 }),
274
275
  description: cleanString(args.description, "description", { max: 16_000 }),
275
276
  tenantId: cleanString(args.tenantId, "tenantId", { max: 200 }),
277
+ issueRepo: cleanString(args.issueRepo, "issueRepo", { max: 200 }),
276
278
  });
277
279
  return { project, roots };
278
280
  }
@@ -17,6 +17,7 @@ import type { Message, MessageKind } from "./types";
17
17
  * it, so emitters are converted exactly once and the rule engine has its inputs the day it lands.
18
18
  */
19
19
  export type NotificationType =
20
+ | "landed-failure"
20
21
  | "branch.landed"
21
22
  | "agent.ready"
22
23
  | "agent.exited"
@@ -99,6 +100,7 @@ const MINUTES = 60 * 1000;
99
100
  * Emitters opt INTO a shorter TTL per-call for the bystander/informational cases.
100
101
  */
101
102
  export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
103
+ "landed-failure": null,
102
104
  "branch.landed": null,
103
105
  "agent.ready": null,
104
106
  "agent.exited": null,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  providerCatalogList,
3
3
  type ProviderCatalogEntry,
4
+ type ProviderContextThreshold,
4
5
  type ProviderModelCatalogEntry,
5
6
  } from "agent-relay-sdk/provider-catalog";
6
7
  import { SPAWN_PROVIDERS } from "agent-relay-sdk";
@@ -145,6 +146,9 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
145
146
  if (!input.entry.providerModel?.trim()) throw new ValidationError("provider model is required");
146
147
  if (!Array.isArray(input.entry.efforts)) throw new ValidationError("model efforts must be an array");
147
148
  const entry = cloneModelEntry({ ...input.entry, alias });
149
+ const contextThreshold = normalizeContextThreshold(entry.contextThreshold);
150
+ if (contextThreshold) entry.contextThreshold = contextThreshold;
151
+ else delete entry.contextThreshold;
148
152
  delete entry.disabled;
149
153
  return {
150
154
  ...input,
@@ -183,3 +187,34 @@ function cloneProviderEntry(entry: ProviderCatalogEntry): ProviderCatalogEntry {
183
187
  function cloneModelEntry(entry: ProviderModelCatalogEntry): ProviderModelCatalogEntry {
184
188
  return JSON.parse(JSON.stringify(entry)) as ProviderModelCatalogEntry;
185
189
  }
190
+
191
+ function normalizeContextThreshold(value: unknown): ProviderContextThreshold | undefined {
192
+ if (value === undefined || value === null) return undefined;
193
+ if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("contextThreshold must be an object");
194
+ const input = value as Record<string, unknown>;
195
+ const selfCompactUtilization = cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
196
+ const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
197
+ ? undefined
198
+ : cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization");
199
+ const hardBackstopUtilization = input.hardBackstopUtilization === undefined || input.hardBackstopUtilization === null
200
+ ? undefined
201
+ : cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
202
+ if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
203
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
204
+ }
205
+ if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
206
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
207
+ }
208
+ return {
209
+ selfCompactUtilization,
210
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
211
+ ...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
212
+ };
213
+ }
214
+
215
+ function cleanFraction(value: unknown, field: string): number {
216
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
217
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
218
+ }
219
+ return value;
220
+ }
@@ -2,6 +2,7 @@
2
2
  import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
3
3
  import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
+ import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
5
6
  import { cleanStringArray } from "../validation";
6
7
  import { clearActiveMemories, memoryBroker } from "../memory-service";
7
8
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
@@ -44,7 +45,7 @@ export const getAgents: Handler = (req) => {
44
45
  const tag = url.searchParams.get("tag") ?? undefined;
45
46
  const machine = url.searchParams.get("machine") ?? undefined;
46
47
  const status = url.searchParams.get("status") ?? undefined;
47
- return json(attachBranchStates(listAgents({ tag, machine, status })));
48
+ return json(attachIssueRepos(attachBranchStates(listAgents({ tag, machine, status }))));
48
49
  };
49
50
 
50
51
  export const findAgents: Handler = (req) => {
@@ -57,7 +58,7 @@ export const findAgents: Handler = (req) => {
57
58
 
58
59
  export const getAgentById: Handler = (_req, params) => {
59
60
  const agent = getAgent(params.id!);
60
- return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
61
+ return agent ? json(attachIssueRepo(attachBranchState(agent))) : error("agent not found", 404);
61
62
  };
62
63
 
63
64
  export const getAgentReplyObligations: Handler = (_req, params) => {
@@ -12,7 +12,7 @@ import { isRecord } from "agent-relay-sdk";
12
12
  import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
14
  import { ServiceAuthError } from "../services/errors";
15
- import { notifyBranchLanded } from "../branch-landed";
15
+ import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
16
  import { seedProviderConfigs } from "../provider-config-store";
17
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
18
18
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
@@ -266,6 +266,11 @@ export const patchCommand: Handler = async (req, params) => {
266
266
  mergeCommandId: command.id,
267
267
  mergeError: command.error ?? "merge failed",
268
268
  });
269
+ notifyBranchLandFailed({
270
+ workspace: current,
271
+ commandId: command.id,
272
+ reason: command.error ?? "merge failed",
273
+ });
269
274
  }
270
275
  }
271
276
  }
@@ -139,16 +139,43 @@ function cleanProviderModelEntry(alias: string, body: Record<string, unknown>):
139
139
  efforts,
140
140
  ...(defaultEffort ? { defaultEffort } : {}),
141
141
  ...(isRecord(source.limits) ? { limits: source.limits as ProviderModelCatalogEntry["limits"] } : {}),
142
+ ...(isRecord(source.contextThreshold) ? { contextThreshold: cleanProviderContextThreshold(source.contextThreshold) } : {}),
142
143
  ...(isRecord(source.capabilities) ? { capabilities: source.capabilities as unknown as ProviderModelCatalogEntry["capabilities"] } : {}),
143
144
  };
144
145
  }
145
146
 
147
+ function cleanProviderContextThreshold(source: Record<string, unknown>): NonNullable<ProviderModelCatalogEntry["contextThreshold"]> {
148
+ const selfCompactUtilization = optionalFraction(source.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
149
+ if (selfCompactUtilization === undefined) throw new ValidationError("contextThreshold.selfCompactUtilization required");
150
+ const warnUtilization = optionalFraction(source.warnUtilization, "contextThreshold.warnUtilization");
151
+ const hardBackstopUtilization = optionalFraction(source.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
152
+ if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
153
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
154
+ }
155
+ if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
156
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
157
+ }
158
+ return {
159
+ selfCompactUtilization,
160
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
161
+ ...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
162
+ };
163
+ }
164
+
146
165
  function optionalBoolean(value: unknown, field: string): boolean | undefined {
147
166
  if (value === undefined || value === null) return undefined;
148
167
  if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
149
168
  return value;
150
169
  }
151
170
 
171
+ function optionalFraction(value: unknown, field: string): number | undefined {
172
+ if (value === undefined || value === null) return undefined;
173
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
174
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
175
+ }
176
+ return value;
177
+ }
178
+
152
179
  export const deleteCommandById: Handler = (_req, params) => {
153
180
  if (!deleteCommand(params.id!)) return error("command not found or cannot be canceled", 404);
154
181
  const command = getCommand(params.id!);
@@ -0,0 +1,46 @@
1
+ // Shared git-remote → `owner/repo` resolution (#533). The SINGLE home for deriving a
2
+ // GitHub `owner/repo` from a repo root's `origin` remote. Used by two callers that must
3
+ // not drift: issue-lifecycle land-close (which repo a bare `#NNN` in a commit subject
4
+ // closes) AND project auto-seed (a project's `issueRepo` for chat #NNN linkify). Promoted
5
+ // out of issue-lifecycle.ts where it was private — see the DRY rule in CLAUDE.md.
6
+
7
+ /**
8
+ * `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
9
+ * `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
10
+ * optional. Pure — exported for tests.
11
+ */
12
+ export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
13
+ if (!url) return undefined;
14
+ const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
15
+ return match?.[1];
16
+ }
17
+
18
+ let remoteRepoCache: Map<string, string | undefined> | undefined;
19
+
20
+ /**
21
+ * `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
22
+ * subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
23
+ * git repo, has no `origin`, or the URL doesn't parse.
24
+ */
25
+ export function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
26
+ if (!repoRoot) return undefined;
27
+ if (!remoteRepoCache) remoteRepoCache = new Map();
28
+ if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
29
+ let repo: string | undefined;
30
+ try {
31
+ const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
32
+ stdin: "ignore",
33
+ stdout: "pipe",
34
+ stderr: "ignore",
35
+ });
36
+ if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
37
+ } catch {
38
+ repo = undefined;
39
+ }
40
+ remoteRepoCache.set(repoRoot, repo);
41
+ return repo;
42
+ }
43
+
44
+ export function __resetRemoteRepoCacheForTests(): void {
45
+ remoteRepoCache = undefined;
46
+ }