agent-relay-server 0.62.1 → 0.62.3
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/display-DzNvOu1j.js.map +1 -1
- package/scripts/orchestrator-spawn-smoke.ts +87 -67
- package/src/branch-landed.ts +7 -1
- package/src/db/delivery.ts +68 -18
- package/src/db/message-reads.ts +131 -9
- package/src/db/messages.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/routes/commands.ts +3 -0
- package/src/services/issue-lifecycle.ts +100 -22
|
@@ -20,6 +20,8 @@ type Agent = {
|
|
|
20
20
|
createdAt?: number;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
type ApprovalMode = "open" | "guarded" | "read-only";
|
|
24
|
+
|
|
23
25
|
const args = process.argv.slice(2);
|
|
24
26
|
let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
25
27
|
let orchestratorId = process.env.AGENT_RELAY_SMOKE_ORCHESTRATOR || "";
|
|
@@ -92,6 +94,26 @@ function findSpawnedAgent(agents: Agent[], provider: string, label: string, star
|
|
|
92
94
|
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0] || null;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
function approvalModesForProvider(provider: string): ApprovalMode[] {
|
|
98
|
+
if (provider === "claude") return ["open", "guarded", "read-only"];
|
|
99
|
+
return ["guarded"];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function promptForCase(provider: string, approvalMode: ApprovalMode, label: string, nonce: string): { prompt?: string; nonceLabel?: string } {
|
|
103
|
+
if (provider !== "claude") return {};
|
|
104
|
+
if (approvalMode === "guarded") return {};
|
|
105
|
+
if (approvalMode === "read-only") {
|
|
106
|
+
return {
|
|
107
|
+
prompt: `Agent Relay read-only spawn smoke. As your VERY FIRST action, use the Bash tool to run exactly: agent-relay /message label:${label} "${nonce}". Then wait for shutdown — do nothing else.`,
|
|
108
|
+
nonceLabel: "Bash relay CLI",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
prompt: `Agent Relay spawn smoke. As your VERY FIRST action, use the agent-relay MCP tool relay_send_message to send to "label:${label}" with body exactly "${nonce}". Then wait for shutdown — do nothing else.`,
|
|
113
|
+
nonceLabel: "relay MCP tool",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
95
117
|
const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
|
|
96
118
|
// Default selection prefers the orchestrator on the same host as the relay (the
|
|
97
119
|
// gate runs there): a local spawn is faster and avoids cross-host teardown
|
|
@@ -123,79 +145,77 @@ for (const provider of providers) {
|
|
|
123
145
|
continue;
|
|
124
146
|
}
|
|
125
147
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// call would block on a dashboard approval that never comes here. Autonomous workers spawn
|
|
144
|
-
// open for the same reason. (Approval mode is orthogonal to prompt delivery, which is the
|
|
145
|
-
// regression under test.)
|
|
146
|
-
approvalMode: promptProvider ? "open" : "guarded",
|
|
147
|
-
...(promptProvider ? { prompt } : {}),
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
let agent: Agent | undefined;
|
|
151
|
-
let managed: { tmuxSession: string } | undefined;
|
|
152
|
-
try {
|
|
153
|
-
agent = await waitFor(`waiting for ${provider} agent registration`, async () => {
|
|
154
|
-
const agents = await api<Agent[]>("GET", "/agents");
|
|
155
|
-
return findSpawnedAgent(agents, provider, label, startedAt);
|
|
148
|
+
for (const approvalMode of approvalModesForProvider(provider)) {
|
|
149
|
+
const label = `smoke-${provider}-${approvalMode}-${Date.now()}`;
|
|
150
|
+
const startedAt = Date.now();
|
|
151
|
+
// #352: for providers seeded with an initial prompt, assert the spawn prompt was actually
|
|
152
|
+
// DELIVERED and the first turn RAN — by making the prompt's first action send a nonce message
|
|
153
|
+
// and waiting for that nonce to land in the relay feed. Registration alone happens via the
|
|
154
|
+
// plugin regardless of prompt delivery, so it never catches silently dropped spawn prompts.
|
|
155
|
+
const nonce = `SMOKE-NONCE-${provider}-${approvalMode}-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`;
|
|
156
|
+
const promptCase = promptForCase(provider, approvalMode, label, nonce);
|
|
157
|
+
console.log(`spawn ${provider} ${approvalMode} via ${orchestrator.id}`);
|
|
158
|
+
await api("POST", `/agents/spawn`, {
|
|
159
|
+
provider,
|
|
160
|
+
orchestratorId: orchestrator.id,
|
|
161
|
+
cwd,
|
|
162
|
+
label,
|
|
163
|
+
approvalMode,
|
|
164
|
+
...(promptCase.prompt ? { prompt: promptCase.prompt } : {}),
|
|
156
165
|
});
|
|
157
|
-
console.log(`registered ${provider}: ${agent.id}`);
|
|
158
166
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
let agent: Agent | undefined;
|
|
168
|
+
let managed: { tmuxSession: string } | undefined;
|
|
169
|
+
try {
|
|
170
|
+
agent = await waitFor(`waiting for ${provider} ${approvalMode} agent registration`, async () => {
|
|
171
|
+
const agents = await api<Agent[]>("GET", "/agents");
|
|
172
|
+
return findSpawnedAgent(agents, provider, label, startedAt);
|
|
173
|
+
});
|
|
174
|
+
console.log(`registered ${provider} ${approvalMode}: ${agent.id}`);
|
|
163
175
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
|
|
168
|
-
return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
|
|
176
|
+
managed = await waitFor(`waiting for ${provider} ${approvalMode} managed session`, async () => {
|
|
177
|
+
const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
|
|
178
|
+
return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
|
|
169
179
|
});
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}).catch(() => {});
|
|
192
|
-
await waitFor(`waiting for ${provider} managed session cleanup`, async () => {
|
|
193
|
-
const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
|
|
194
|
-
const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
|
|
195
|
-
return stillManaged ? null : true;
|
|
180
|
+
|
|
181
|
+
// Hard-assert prompt-seeded cases ran and reached relay. Guarded Claude remains
|
|
182
|
+
// registration-only here: forcing a Bash/MCP first turn would intentionally block on
|
|
183
|
+
// the known unattended approval path.
|
|
184
|
+
if (promptCase.nonceLabel) {
|
|
185
|
+
await waitFor(`waiting for ${provider} ${approvalMode} spawn-prompt first turn (${promptCase.nonceLabel})`, async () => {
|
|
186
|
+
const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
|
|
187
|
+
return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
|
|
188
|
+
});
|
|
189
|
+
console.log(`spawn-prompt delivered + first turn called relay ${provider} ${approvalMode}: ${nonce}`);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
// Always tear down — a failed assertion (e.g. a re-introduced prompt-drop) must NOT leak a
|
|
193
|
+
// managed agent on the host running the gate. Best-effort: teardown errors don't mask the
|
|
194
|
+
// original failure.
|
|
195
|
+
if (agent) {
|
|
196
|
+
const id = agent.id;
|
|
197
|
+
await api("POST", `/agents/${encodeURIComponent(id)}/actions`, { action: "shutdown" }).catch(() => {});
|
|
198
|
+
await waitFor(`waiting for ${provider} ${approvalMode} agent cleanup`, async () => {
|
|
199
|
+
const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(id)}`);
|
|
200
|
+
return !current || (current.status === "offline" && current.ready === false) ? true : null;
|
|
196
201
|
}).catch(() => {});
|
|
202
|
+
await apiOptional("DELETE", `/agents/${encodeURIComponent(id)}`).catch(() => {});
|
|
203
|
+
if (managed) {
|
|
204
|
+
const tmuxSession = managed.tmuxSession;
|
|
205
|
+
await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
|
|
206
|
+
action: "shutdown",
|
|
207
|
+
agentId: id,
|
|
208
|
+
tmuxSession,
|
|
209
|
+
reason: "smoke-cleanup",
|
|
210
|
+
}).catch(() => {});
|
|
211
|
+
await waitFor(`waiting for ${provider} ${approvalMode} managed session cleanup`, async () => {
|
|
212
|
+
const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
|
|
213
|
+
const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
|
|
214
|
+
return stillManaged ? null : true;
|
|
215
|
+
}).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
console.log(`shutdown ${provider} ${approvalMode}: ${label}`);
|
|
197
218
|
}
|
|
198
|
-
console.log(`shutdown ${provider}: ${label}`);
|
|
199
219
|
}
|
|
200
220
|
}
|
|
201
221
|
}
|
package/src/branch-landed.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface BranchLandedInput {
|
|
|
34
34
|
* `undefined` from older orchestrators that don't report it → message stays generic.
|
|
35
35
|
*/
|
|
36
36
|
pushed?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The land came in via a pull request — the subject's trailing `(#NNN)` is the PR
|
|
39
|
+
* number, not an issue. Forwarded to the issue-lifecycle hook so it doesn't mistake
|
|
40
|
+
* the PR number for an issue to close (#482).
|
|
41
|
+
*/
|
|
42
|
+
prLand?: boolean;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
@@ -172,7 +178,7 @@ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: Workspace
|
|
|
172
178
|
metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, fromStatus: ws.status, ...(sha ? { landedSha: sha } : {}) },
|
|
173
179
|
});
|
|
174
180
|
try {
|
|
175
|
-
notifyBranchLanded({ workspace: ws, mergedSha: sha, pushed: true });
|
|
181
|
+
notifyBranchLanded({ workspace: ws, mergedSha: sha, pushed: true, prLand: via === "pr" });
|
|
176
182
|
} catch {
|
|
177
183
|
// Notification is best-effort; the merged status + activity event still stand.
|
|
178
184
|
}
|
package/src/db/delivery.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk"
|
|
|
4
4
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
|
|
5
5
|
import { parseJson } from "../utils";
|
|
6
6
|
import { isLiveIsolatedWorkspace } from "../workspace-phase";
|
|
7
|
+
import { parseChannelRouteTarget } from "../channel-target";
|
|
7
8
|
import {
|
|
8
9
|
CONTRACT_REQUIREMENTS,
|
|
9
10
|
contractCompatibility,
|
|
@@ -156,27 +157,76 @@ export function legacyChannelTargets(agent: AgentCard | null | undefined): strin
|
|
|
156
157
|
return [...aliases];
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
160
|
+
type BroadcastDeliveryContext = Pick<SendMessageInput, "from" | "channel">;
|
|
161
|
+
|
|
162
|
+
function addTargetParticipants(target: string, scope: Set<string>, candidates: AgentCard[]): void {
|
|
163
|
+
const parsed = parseChannelRouteTarget(target);
|
|
164
|
+
switch (parsed.type) {
|
|
165
|
+
case "agent":
|
|
166
|
+
case "orchestrator":
|
|
167
|
+
if (candidates.some((agent) => agent.id === parsed.id)) scope.add(parsed.id);
|
|
168
|
+
return;
|
|
169
|
+
case "tag":
|
|
170
|
+
for (const agent of candidates) if (agent.tags.includes(parsed.id)) scope.add(agent.id);
|
|
171
|
+
return;
|
|
172
|
+
case "capability":
|
|
173
|
+
for (const agent of candidates) if (agent.capabilities.includes(parsed.id)) scope.add(agent.id);
|
|
174
|
+
return;
|
|
175
|
+
case "label":
|
|
176
|
+
for (const agent of candidates) if (agent.label === parsed.id) scope.add(agent.id);
|
|
177
|
+
return;
|
|
178
|
+
case "team":
|
|
179
|
+
for (const agent of candidates) if (agent.teamId === parsed.id) scope.add(agent.id);
|
|
180
|
+
return;
|
|
168
181
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function channelParticipantAgentIds(channel: string, candidates: AgentCard[], senderId?: string): Set<string> {
|
|
185
|
+
const candidateIds = new Set(candidates.map((agent) => agent.id));
|
|
186
|
+
const scope = new Set<string>();
|
|
187
|
+
if (senderId && candidateIds.has(senderId)) scope.add(senderId);
|
|
188
|
+
const rows = getDb().query(`
|
|
189
|
+
SELECT from_agent, to_target, resolved_to_agent
|
|
190
|
+
FROM messages
|
|
191
|
+
WHERE channel = ?
|
|
192
|
+
`).all(channel) as Array<{ from_agent: string; to_target: string; resolved_to_agent?: string | null }>;
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
if (candidateIds.has(row.from_agent)) scope.add(row.from_agent);
|
|
195
|
+
if (row.resolved_to_agent && candidateIds.has(row.resolved_to_agent)) scope.add(row.resolved_to_agent);
|
|
196
|
+
addTargetParticipants(row.to_target, scope, candidates);
|
|
172
197
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
198
|
+
return scope;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function scopedBroadcastCandidates(candidates: AgentCard[], context?: BroadcastDeliveryContext): AgentCard[] {
|
|
202
|
+
if (context?.channel) {
|
|
203
|
+
const scope = channelParticipantAgentIds(context.channel, candidates, context.from);
|
|
204
|
+
return candidates.filter((agent) => scope.has(agent.id));
|
|
176
205
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
206
|
+
const sender = context?.from ? getAgent(context.from) : null;
|
|
207
|
+
if (sender?.teamId) return candidates.filter((agent) => agent.teamId === sender.teamId);
|
|
208
|
+
return candidates;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function matchingDeliveryAgents(target: string, context?: BroadcastDeliveryContext): AgentCard[] {
|
|
212
|
+
if (!target) return [];
|
|
213
|
+
const candidates = listAgents().filter(isDeliveryAgent);
|
|
214
|
+
const parsed = parseChannelRouteTarget(target);
|
|
215
|
+
switch (parsed.type) {
|
|
216
|
+
case "broadcast":
|
|
217
|
+
return scopedBroadcastCandidates(candidates, context);
|
|
218
|
+
case "agent": {
|
|
219
|
+
const direct = getAgent(parsed.id);
|
|
220
|
+
return direct && isDeliveryAgent(direct) ? [direct] : [];
|
|
221
|
+
}
|
|
222
|
+
case "tag":
|
|
223
|
+
return candidates.filter((agent) => agent.tags.includes(parsed.id));
|
|
224
|
+
case "capability":
|
|
225
|
+
return candidates.filter((agent) => agent.capabilities.includes(parsed.id));
|
|
226
|
+
case "label":
|
|
227
|
+
return candidates.filter((agent) => agent.label === parsed.id);
|
|
228
|
+
case "team":
|
|
229
|
+
return candidates.filter((agent) => agent.teamId === parsed.id);
|
|
180
230
|
}
|
|
181
231
|
return [];
|
|
182
232
|
}
|
package/src/db/message-reads.ts
CHANGED
|
@@ -159,6 +159,124 @@ export function listAgentChatHistory(query: {
|
|
|
159
159
|
return ascending ? messages : messages.reverse();
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
function agentParticipationClauses(
|
|
163
|
+
alias: string,
|
|
164
|
+
agent: AgentCard,
|
|
165
|
+
legacyTargets: string[],
|
|
166
|
+
params: any[],
|
|
167
|
+
): string[] {
|
|
168
|
+
const clauses = [
|
|
169
|
+
`${alias}.from_agent = ?`,
|
|
170
|
+
`${alias}.to_target = ?`,
|
|
171
|
+
`${alias}.resolved_to_agent = ?`,
|
|
172
|
+
];
|
|
173
|
+
params.push(agent.id, agent.id, agent.id);
|
|
174
|
+
for (const tag of agent.tags) {
|
|
175
|
+
clauses.push(`${alias}.to_target = ?`);
|
|
176
|
+
params.push(`tag:${tag}`);
|
|
177
|
+
}
|
|
178
|
+
for (const cap of agent.capabilities) {
|
|
179
|
+
clauses.push(`${alias}.to_target = ?`);
|
|
180
|
+
params.push(`cap:${cap}`);
|
|
181
|
+
}
|
|
182
|
+
if (agent.label) {
|
|
183
|
+
clauses.push(`${alias}.to_target = ?`);
|
|
184
|
+
params.push(`label:${agent.label}`);
|
|
185
|
+
}
|
|
186
|
+
if (agent.teamId) {
|
|
187
|
+
clauses.push(`${alias}.to_target = ?`);
|
|
188
|
+
params.push(`team:${agent.teamId}`);
|
|
189
|
+
}
|
|
190
|
+
for (const target of legacyTargets) {
|
|
191
|
+
clauses.push(`${alias}.to_target = ?`);
|
|
192
|
+
params.push(target);
|
|
193
|
+
}
|
|
194
|
+
return clauses;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function scopedBroadcastClause(agent: AgentCard | null | undefined, legacyTargets: string[], params: any[]): string {
|
|
198
|
+
const senderGlobal = "NOT EXISTS (SELECT 1 FROM agents sender WHERE sender.id = m.from_agent AND sender.team_id IS NOT NULL)";
|
|
199
|
+
const noChannel = agent?.teamId
|
|
200
|
+
? `(m.channel IS NULL AND (${senderGlobal} OR EXISTS (SELECT 1 FROM agents sender WHERE sender.id = m.from_agent AND sender.team_id = ?)))`
|
|
201
|
+
: `(m.channel IS NULL AND ${senderGlobal})`;
|
|
202
|
+
if (agent?.teamId) params.push(agent.teamId);
|
|
203
|
+
if (!agent) return noChannel;
|
|
204
|
+
|
|
205
|
+
const participantParams: any[] = [];
|
|
206
|
+
const participantClauses = agentParticipationClauses("cp", agent, legacyTargets, participantParams);
|
|
207
|
+
const channel = `(m.channel IS NOT NULL AND (m.from_agent = ? OR EXISTS (
|
|
208
|
+
SELECT 1 FROM messages cp
|
|
209
|
+
WHERE cp.channel = m.channel
|
|
210
|
+
AND cp.id <> m.id
|
|
211
|
+
AND (${participantClauses.join(" OR ")})
|
|
212
|
+
)))`;
|
|
213
|
+
params.push(agent.id, ...participantParams);
|
|
214
|
+
return `(${noChannel} OR ${channel})`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function freshForSessionClause(agent: AgentCard | null | undefined, params: any[]): string {
|
|
218
|
+
// Same-ms registration+send can happen in tests and on fast local loops; >= preserves genuine
|
|
219
|
+
// post-registration messages while still excluding historical fan-out backlog for fresh sessions.
|
|
220
|
+
params.push(agent?.createdAt ?? Date.now());
|
|
221
|
+
return "(m.claimable = 1 OR m.created_at >= ?)";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function freshFanoutTargetClause(target: string, agent: AgentCard | null | undefined, params: any[]): string {
|
|
225
|
+
params.push(target);
|
|
226
|
+
return `(m.to_target = ? AND ${freshForSessionClause(agent, params)})`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Emojis that count as an explicit acknowledgement of a message thread (#437).
|
|
231
|
+
* A reaction with one of these suppresses the reply-reminder on subsequent deliveries.
|
|
232
|
+
* Policy: thumbs-up (👍) and check-mark (✅) classes only — passing reactions like ❤️
|
|
233
|
+
* or 👀 are not settling and must never cause false suppression.
|
|
234
|
+
*/
|
|
235
|
+
export const ACK_REACTION_EMOJIS: readonly string[] = ["👍", "✅"];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Annotates messages with `replyExpected: false` when this agent has already settled them
|
|
239
|
+
* via a direct/thread reply OR an ack-class reaction. Used at poll time so the runner's
|
|
240
|
+
* delivery formatting naturally drops them from the reply-reminder anchor (#437).
|
|
241
|
+
*
|
|
242
|
+
* A single batch SQL query covers all replyable messages — no per-message round-trips.
|
|
243
|
+
*/
|
|
244
|
+
function annotateAnsweredMessages(messages: Message[], agentId: string): Message[] {
|
|
245
|
+
const replyableIds = messages
|
|
246
|
+
.filter((m) => m.replyExpected !== false && Number.isSafeInteger(m.id) && m.id > 0)
|
|
247
|
+
.map((m) => m.id);
|
|
248
|
+
if (replyableIds.length === 0) return messages;
|
|
249
|
+
|
|
250
|
+
const idPlaceholders = replyableIds.map(() => "?").join(", ");
|
|
251
|
+
const ackPlaceholders = ACK_REACTION_EMOJIS.map(() => "?").join(", ");
|
|
252
|
+
|
|
253
|
+
const answeredRows = getDb().query(`
|
|
254
|
+
SELECT m.id FROM messages m
|
|
255
|
+
WHERE m.id IN (${idPlaceholders})
|
|
256
|
+
AND (
|
|
257
|
+
EXISTS (
|
|
258
|
+
SELECT 1 FROM messages r
|
|
259
|
+
WHERE r.from_agent = ?
|
|
260
|
+
AND (
|
|
261
|
+
r.reply_to = m.id
|
|
262
|
+
OR (m.thread_id IS NOT NULL AND r.thread_id = m.thread_id AND r.id > m.id)
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
OR EXISTS (
|
|
266
|
+
SELECT 1 FROM message_reactions mr
|
|
267
|
+
WHERE mr.message_id = m.id
|
|
268
|
+
AND mr.actor_id = ?
|
|
269
|
+
AND mr.emoji IN (${ackPlaceholders})
|
|
270
|
+
AND mr.created_at >= m.created_at
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
`).all(...replyableIds, agentId, agentId, ...ACK_REACTION_EMOJIS) as Array<{ id: number }>;
|
|
274
|
+
|
|
275
|
+
if (answeredRows.length === 0) return messages;
|
|
276
|
+
const answeredIds = new Set(answeredRows.map((r) => r.id));
|
|
277
|
+
return messages.map((m) => (answeredIds.has(m.id) ? { ...m, replyExpected: false as const } : m));
|
|
278
|
+
}
|
|
279
|
+
|
|
162
280
|
export function pollMessages(query: PollQuery): Message[] {
|
|
163
281
|
const agent = getAgent(query.for);
|
|
164
282
|
const agentTags = agent?.tags ?? [];
|
|
@@ -170,24 +288,28 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
170
288
|
const conditions: string[] = [];
|
|
171
289
|
const params: any[] = [];
|
|
172
290
|
|
|
173
|
-
// Build target matching: direct + broadcast + tag + capability + label.
|
|
291
|
+
// Build target matching: direct + scoped broadcast + tag + capability + label.
|
|
174
292
|
// Channel agents also accept legacy bare provider targets (for example
|
|
175
293
|
// "telegram") so older clients keep working after canonical IDs became
|
|
176
294
|
// provider:account ("telegram:default").
|
|
177
|
-
const targetClauses = ["to_target = ?", "resolved_to_agent = ?"
|
|
295
|
+
const targetClauses = ["to_target = ?", "resolved_to_agent = ?"];
|
|
178
296
|
params.push(query.for, query.for);
|
|
297
|
+
{
|
|
298
|
+
const broadcastParams: any[] = [];
|
|
299
|
+
const broadcastScope = scopedBroadcastClause(agent, agentLegacyChannelTargets, broadcastParams);
|
|
300
|
+
const fresh = freshForSessionClause(agent, broadcastParams);
|
|
301
|
+
targetClauses.push(`(m.to_target = 'broadcast' AND ${broadcastScope} AND ${fresh})`);
|
|
302
|
+
params.push(...broadcastParams);
|
|
303
|
+
}
|
|
179
304
|
|
|
180
305
|
for (const tag of agentTags) {
|
|
181
|
-
targetClauses.push(
|
|
182
|
-
params.push(`tag:${tag}`);
|
|
306
|
+
targetClauses.push(freshFanoutTargetClause(`tag:${tag}`, agent, params));
|
|
183
307
|
}
|
|
184
308
|
for (const cap of agentCaps) {
|
|
185
|
-
targetClauses.push(
|
|
186
|
-
params.push(`cap:${cap}`);
|
|
309
|
+
targetClauses.push(freshFanoutTargetClause(`cap:${cap}`, agent, params));
|
|
187
310
|
}
|
|
188
311
|
if (agentLabel) {
|
|
189
|
-
targetClauses.push(
|
|
190
|
-
params.push(`label:${agentLabel}`);
|
|
312
|
+
targetClauses.push(freshFanoutTargetClause(`label:${agentLabel}`, agent, params));
|
|
191
313
|
}
|
|
192
314
|
if (agentTeamId) {
|
|
193
315
|
targetClauses.push("to_target = ?");
|
|
@@ -244,7 +366,7 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
244
366
|
params.push(limit);
|
|
245
367
|
|
|
246
368
|
const rows = timedQuery("pollMessages", () => getDb().query(sql).all(...params) as any[]);
|
|
247
|
-
return rows.map(rowToMessage);
|
|
369
|
+
return annotateAnsweredMessages(rows.map(rowToMessage), query.for);
|
|
248
370
|
}
|
|
249
371
|
|
|
250
372
|
export function messageRequiresReply(message: Message): boolean {
|
package/src/db/messages.ts
CHANGED
|
@@ -265,13 +265,13 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
// #451 — moderate default TTL for
|
|
269
|
-
// stale broadcast/tag/cap/label/team
|
|
268
|
+
// #451/#461 — moderate default TTL for non-claimable FAN-OUT messages the sender didn't bound,
|
|
269
|
+
// so stale broadcast/tag/cap/label/team chat cannot wake a late-joining agent hours later. Exempt
|
|
270
270
|
// (never auto-expire, preserving store-ahead #234): direct DMs and policy targets (single
|
|
271
|
-
// recipient),
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
if (ttlUnspecified &&
|
|
271
|
+
// recipient), claimable work, and any message that already carried an explicit TTL — including an
|
|
272
|
+
// explicit `null` from routeNotification, whose per-type decision (e.g. a store-ahead
|
|
273
|
+
// `plan.bound_agent_exited` to `team:`) must win here.
|
|
274
|
+
if (ttlUnspecified && !claimable && isFanoutTarget(input.to)) {
|
|
275
275
|
maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
|
|
276
276
|
}
|
|
277
277
|
|
|
@@ -480,4 +480,3 @@ export function getMessage(id: number): Message | null {
|
|
|
480
480
|
const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
|
|
481
481
|
return row ? rowToMessage(row) : null;
|
|
482
482
|
}
|
|
483
|
-
|
package/src/db/schema.ts
CHANGED
|
@@ -677,7 +677,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
677
677
|
);
|
|
678
678
|
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
679
679
|
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
680
|
-
CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at);
|
|
680
|
+
CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at);
|
|
681
|
+
-- issue_ref_id/issue_action indexes are created in applyMigrations() AFTER the ALTER — never inline here (#483: inline index over a not-yet-migrated column crashes initDb on existing DBs).
|
|
681
682
|
|
|
682
683
|
CREATE TABLE IF NOT EXISTS channels (
|
|
683
684
|
id TEXT PRIMARY KEY,
|
package/src/routes/commands.ts
CHANGED
|
@@ -250,6 +250,9 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
250
250
|
subject: prLanded
|
|
251
251
|
? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
|
|
252
252
|
: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
253
|
+
// PR-land subjects carry a trailing `(#PR)` GitHub stamps on the squash — not an
|
|
254
|
+
// issue. Tell the lifecycle hook so it won't close the PR number as an issue (#482).
|
|
255
|
+
prLand: Boolean(prLanded),
|
|
253
256
|
newBranch,
|
|
254
257
|
pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
255
258
|
});
|