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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-CG_eCKDI.js +3 -0
- package/public/assets/MessageBubble-CG_eCKDI.js.map +1 -0
- package/public/assets/{PlanGraphPanel-DrHNFbg6.js → PlanGraphPanel-Ba9qh-if.js} +2 -2
- package/public/assets/{PlanGraphPanel-DrHNFbg6.js.map → PlanGraphPanel-Ba9qh-if.js.map} +1 -1
- package/public/assets/{activity-CECdsDpd.js → activity-8eZ4FhxO.js} +2 -2
- package/public/assets/{activity-CECdsDpd.js.map → activity-8eZ4FhxO.js.map} +1 -1
- package/public/assets/{agent-profiles-CHitqihm.js → agent-profiles-1Nx8ROA_.js} +2 -2
- package/public/assets/{agent-profiles-CHitqihm.js.map → agent-profiles-1Nx8ROA_.js.map} +1 -1
- package/public/assets/{agents-GG-aJj6Z.js → agents-C8fM2VwM.js} +2 -2
- package/public/assets/{agents-GG-aJj6Z.js.map → agents-C8fM2VwM.js.map} +1 -1
- package/public/assets/{analytics-y0Yf26cD.js → analytics-D6ffu1ji.js} +2 -2
- package/public/assets/{analytics-y0Yf26cD.js.map → analytics-D6ffu1ji.js.map} +1 -1
- package/public/assets/{automation-CmzftGBn.js → automation-BVmzNi6H.js} +2 -2
- package/public/assets/{automation-CmzftGBn.js.map → automation-BVmzNi6H.js.map} +1 -1
- package/public/assets/{branch-state-badge-Biam17QK.js → branch-state-badge-DrwCnoKR.js} +2 -2
- package/public/assets/{branch-state-badge-Biam17QK.js.map → branch-state-badge-DrwCnoKR.js.map} +1 -1
- package/public/assets/{channels-CmAXppQu.js → channels-BuqbbyaO.js} +2 -2
- package/public/assets/{channels-CmAXppQu.js.map → channels-BuqbbyaO.js.map} +1 -1
- package/public/assets/chat-DCnEGe-x.js +2 -0
- package/public/assets/chat-DCnEGe-x.js.map +1 -0
- package/public/assets/{connectors-bZ0YsSqg.js → connectors-_LpoKQNb.js} +2 -2
- package/public/assets/{connectors-bZ0YsSqg.js.map → connectors-_LpoKQNb.js.map} +1 -1
- package/public/assets/formatted-body-impl-C7ftpr0y.js +3 -0
- package/public/assets/formatted-body-impl-C7ftpr0y.js.map +1 -0
- package/public/assets/index-eCpWfoCA.css +2 -0
- package/public/assets/{index-BSWui_-2.js → index-ksV0Snw6.js} +10 -10
- package/public/assets/index-ksV0Snw6.js.map +1 -0
- package/public/assets/{insights-cC2lVGUs.js → insights-BjRehGVP.js} +2 -2
- package/public/assets/{insights-cC2lVGUs.js.map → insights-BjRehGVP.js.map} +1 -1
- package/public/assets/{integrations--k7_eYP2.js → integrations-DCyxAv8c.js} +2 -2
- package/public/assets/{integrations--k7_eYP2.js.map → integrations-DCyxAv8c.js.map} +1 -1
- package/public/assets/{maintenance-B6wOij_l.js → maintenance-CKiNcUMS.js} +2 -2
- package/public/assets/{maintenance-B6wOij_l.js.map → maintenance-CKiNcUMS.js.map} +1 -1
- package/public/assets/{managed-agents-CDzLSKp1.js → managed-agents-Os_T-7w7.js} +2 -2
- package/public/assets/{managed-agents-CDzLSKp1.js.map → managed-agents-Os_T-7w7.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-D3Y4uUgk.js → markdown-preview-impl-grkpBldu.js} +2 -2
- package/public/assets/{markdown-preview-impl-D3Y4uUgk.js.map → markdown-preview-impl-grkpBldu.js.map} +1 -1
- package/public/assets/{memory-DYzw5EXn.js → memory-YiYlqWUZ.js} +2 -2
- package/public/assets/{memory-DYzw5EXn.js.map → memory-YiYlqWUZ.js.map} +1 -1
- package/public/assets/{messages-ATbJw7jy.js → messages-CVDtrrub.js} +2 -2
- package/public/assets/messages-CVDtrrub.js.map +1 -0
- package/public/assets/orchestrators-Dzup-DDB.js +2 -0
- package/public/assets/{orchestrators-Dtr4zNWI.js.map → orchestrators-Dzup-DDB.js.map} +1 -1
- package/public/assets/{overview-B071A7FM.js → overview-DZltfAFn.js} +2 -2
- package/public/assets/{overview-B071A7FM.js.map → overview-DZltfAFn.js.map} +1 -1
- package/public/assets/{pairs-DgiNK9mA.js → pairs-Q2CmriBh.js} +2 -2
- package/public/assets/{pairs-DgiNK9mA.js.map → pairs-Q2CmriBh.js.map} +1 -1
- package/public/assets/{projects-DhQUdwcb.js → projects-DtJmrhI0.js} +2 -2
- package/public/assets/projects-DtJmrhI0.js.map +1 -0
- package/public/assets/{security-COHkbWIm.js → security-Bur19Sp4.js} +2 -2
- package/public/assets/{security-COHkbWIm.js.map → security-Bur19Sp4.js.map} +1 -1
- package/public/assets/{select-CKPIes_u.js → select-CuzWJuBz.js} +2 -2
- package/public/assets/{select-CKPIes_u.js.map → select-CuzWJuBz.js.map} +1 -1
- package/public/assets/settings-CEMCJ31_.js +4 -0
- package/public/assets/settings-CEMCJ31_.js.map +1 -0
- package/public/assets/store-BeKcuJ2q.js +9 -0
- package/public/assets/store-BeKcuJ2q.js.map +1 -0
- package/public/assets/{tasks-DdVsKCUN.js → tasks-DjwAusKz.js} +2 -2
- package/public/assets/{tasks-DdVsKCUN.js.map → tasks-DjwAusKz.js.map} +1 -1
- package/public/assets/{teams-BLQeR-qJ.js → teams-CFCnB12u.js} +2 -2
- package/public/assets/{teams-BLQeR-qJ.js.map → teams-CFCnB12u.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-eFq9PBWV.js → terminal-viewer-impl-CWu5RTp2.js} +2 -2
- package/public/assets/{terminal-viewer-impl-eFq9PBWV.js.map → terminal-viewer-impl-CWu5RTp2.js.map} +1 -1
- package/public/assets/{work-queue-OFw-Hwir.js → work-queue-TZQ5Gz0t.js} +2 -2
- package/public/assets/{work-queue-OFw-Hwir.js.map → work-queue-TZQ5Gz0t.js.map} +1 -1
- package/public/assets/{workspaces-zum4lO_O.js → workspaces-Bu4uVUcu.js} +2 -2
- package/public/assets/{workspaces-zum4lO_O.js.map → workspaces-Bu4uVUcu.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/agent-issue-repo.ts +29 -0
- package/src/branch-landed.ts +69 -2
- package/src/cli/index.ts +6 -4
- package/src/config.ts +1 -0
- package/src/context-advisory.ts +93 -15
- package/src/context-threshold-config.ts +14 -2
- package/src/db/migrations.ts +24 -0
- package/src/db/projects.ts +34 -6
- package/src/db/schema.ts +1 -0
- package/src/mcp/tools-workspace.ts +3 -3
- package/src/mcp-project-tools.ts +2 -0
- package/src/notification-router.ts +2 -0
- package/src/provider-catalog-store.ts +35 -0
- package/src/routes/agents.ts +3 -2
- package/src/routes/commands.ts +6 -1
- package/src/routes/provider-config.ts +27 -0
- package/src/services/git-remote.ts +46 -0
- package/src/services/issue-lifecycle.ts +5 -36
- package/src/services/project.ts +23 -3
- package/src/services/register-agent.ts +7 -1
- package/src/services/spawn-agent.ts +7 -9
- package/src/services/spawn-registration-watchdog.ts +34 -12
- package/src/settings/registry.ts +2 -0
- package/src/sse.ts +9 -4
- package/src/workspace-phase.ts +17 -9
- package/public/assets/MessageBubble-DLF9fJLb.js +0 -2
- package/public/assets/MessageBubble-DLF9fJLb.js.map +0 -1
- package/public/assets/chat-C-FI9bLO.js +0 -2
- package/public/assets/chat-C-FI9bLO.js.map +0 -1
- package/public/assets/formatted-body-impl-D--5zVSd.js +0 -3
- package/public/assets/formatted-body-impl-D--5zVSd.js.map +0 -1
- package/public/assets/index-BSWui_-2.js.map +0 -1
- package/public/assets/index-B_On3Osd.css +0 -2
- package/public/assets/messages-ATbJw7jy.js.map +0 -1
- package/public/assets/orchestrators-Dtr4zNWI.js +0 -2
- package/public/assets/projects-DhQUdwcb.js.map +0 -1
- package/public/assets/settings-B4SN1TpB.js +0 -4
- package/public/assets/settings-B4SN1TpB.js.map +0 -1
- package/public/assets/store-DnHOGovN.js +0 -9
- 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
|
package/src/context-advisory.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
};
|
package/src/db/migrations.ts
CHANGED
|
@@ -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);
|
package/src/db/projects.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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: {
|
package/src/mcp-project-tools.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -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) => {
|
package/src/routes/commands.ts
CHANGED
|
@@ -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
|
+
}
|