agent-relay-server 0.118.4 → 0.118.5
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/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/notification-rules.ts +1 -0
- package/src/notification-settings.ts +1 -1
- package/src/notification-types.ts +2 -0
- package/src/services/task-entity.ts +43 -2
- package/src/services/transient-agent-reaper.ts +26 -8
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.118.
|
|
5
|
+
"version": "0.118.5",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.118.
|
|
3
|
+
"version": "0.118.5",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"CONTRIBUTING.md"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"agent-relay-channels-host": "0.118.
|
|
39
|
+
"agent-relay-channels-host": "0.118.5",
|
|
40
40
|
"agent-relay-providers": "0.104.1",
|
|
41
41
|
"agent-relay-sdk": "0.2.101",
|
|
42
42
|
"ajv": "^8.20.0"
|
|
@@ -255,6 +255,7 @@ const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
|
|
|
255
255
|
"agent.exited",
|
|
256
256
|
"agent.spawn_failed",
|
|
257
257
|
"agent.spawn_recovered",
|
|
258
|
+
"agent.transient_empty_reap",
|
|
258
259
|
] as const satisfies readonly NotificationType[];
|
|
259
260
|
|
|
260
261
|
for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
|
|
@@ -129,7 +129,7 @@ export function notificationTypeEnabled(config: NotificationsConfig, type: Notif
|
|
|
129
129
|
function notificationToggleForType(type: NotificationType): NotificationToggleKey | null {
|
|
130
130
|
if (type === "branch.landed" || type === "landed-failure") return "branchLanded";
|
|
131
131
|
if (type === "agent.ready") return "agentReady";
|
|
132
|
-
if (type === "agent.exited") return "agentExited";
|
|
132
|
+
if (type === "agent.exited" || type === "agent.transient_empty_reap") return "agentExited";
|
|
133
133
|
if (type === "agent.spawn_failed") return "agentSpawnFailed";
|
|
134
134
|
return null;
|
|
135
135
|
}
|
|
@@ -22,6 +22,7 @@ export type NotificationType =
|
|
|
22
22
|
| "agent.exited"
|
|
23
23
|
| "agent.spawn_failed"
|
|
24
24
|
| "agent.spawn_recovered"
|
|
25
|
+
| "agent.transient_empty_reap"
|
|
25
26
|
| "agent.context_threshold"
|
|
26
27
|
| "agent.stuck_busy"
|
|
27
28
|
| "agent.quota_threshold"
|
|
@@ -120,6 +121,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
120
121
|
"agent.exited": null,
|
|
121
122
|
"agent.spawn_failed": null,
|
|
122
123
|
"agent.spawn_recovered": null,
|
|
124
|
+
"agent.transient_empty_reap": null,
|
|
123
125
|
"agent.context_threshold": 15 * MINUTES,
|
|
124
126
|
"agent.stuck_busy": null,
|
|
125
127
|
"agent.quota_threshold": 15 * MINUTES,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createDeliverableTask,
|
|
10
10
|
deliverableTaskIdsByOwnerAgent,
|
|
11
11
|
deliverableTaskIdsBySpawnRequestId,
|
|
12
|
+
getAgent,
|
|
12
13
|
getTaskDetail,
|
|
13
14
|
listDeliverableTaskSummaries,
|
|
14
15
|
listDeliverableTasks,
|
|
@@ -34,6 +35,8 @@ import {
|
|
|
34
35
|
import { emitTaskChanged } from "../sse";
|
|
35
36
|
import type { Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem } from "../types";
|
|
36
37
|
import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
|
|
38
|
+
import { emitRelayEvent } from "../events";
|
|
39
|
+
import { routeNotification } from "../notification-router";
|
|
37
40
|
import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
|
|
38
41
|
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
39
42
|
|
|
@@ -187,11 +190,11 @@ export function taskEntityHistory(id: number): TaskEvent[] {
|
|
|
187
190
|
return listTaskEvents(id);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
// #889 — finalize bound tasks for
|
|
193
|
+
// #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
|
|
191
194
|
// Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
|
|
192
195
|
// don't leave their auto-scaffolded Task stuck in_progress forever.
|
|
193
196
|
// Never overrides an already-terminal status. Idempotent (reapedAt guard inside markTaskAgentReaped).
|
|
194
|
-
export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string } = {}): number[] {
|
|
197
|
+
export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string; producedWork?: boolean; evidence?: string } = {}): number[] {
|
|
195
198
|
const ids = new Set<number>();
|
|
196
199
|
for (const id of deliverableTaskIdsByOwnerAgent(agentId)) ids.add(id);
|
|
197
200
|
if (opts.spawnRequestId) {
|
|
@@ -203,6 +206,15 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
|
|
|
203
206
|
if (!task) continue;
|
|
204
207
|
if (task.status === "done" || task.status === "failed" || task.status === "canceled") continue;
|
|
205
208
|
if (!isAgentExitDoneWhen(task.doneWhen)) continue;
|
|
209
|
+
if (opts.producedWork === false) {
|
|
210
|
+
try {
|
|
211
|
+
setTaskEntityStatus(taskId, { status: "open", note: "worker reaped without producing work; task reopened for retry", actor: "task-lifecycle" });
|
|
212
|
+
notifyEmptyTransientReap(agentId, task, opts);
|
|
213
|
+
} catch {
|
|
214
|
+
// best-effort; failures must not surface into the reaper scan
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
206
218
|
try {
|
|
207
219
|
if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
|
|
208
220
|
setTaskEntityStatus(taskId, { status: "done", note: "doneWhen=agent-exit satisfied by worker reap", actor: "task-lifecycle" });
|
|
@@ -213,3 +225,32 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
|
|
|
213
225
|
}
|
|
214
226
|
return finalized;
|
|
215
227
|
}
|
|
228
|
+
|
|
229
|
+
function notifyEmptyTransientReap(agentId: string, task: TaskDetail, opts: { spawnRequestId?: string; reason?: string; evidence?: string }): void {
|
|
230
|
+
const agent = getAgent(agentId);
|
|
231
|
+
const parent = agent?.spawnedBy;
|
|
232
|
+
const recipients = [...new Set([parent, task.createdBy].filter((value): value is string => Boolean(value && value.trim())))];
|
|
233
|
+
const payload = {
|
|
234
|
+
kind: "agent.transient_empty_reap",
|
|
235
|
+
agentId,
|
|
236
|
+
parent,
|
|
237
|
+
taskId: task.id,
|
|
238
|
+
taskTitle: task.title,
|
|
239
|
+
spawnRequestId: opts.spawnRequestId,
|
|
240
|
+
reason: opts.reason ?? "agent-exit",
|
|
241
|
+
evidence: opts.evidence ?? "no-produced-work",
|
|
242
|
+
};
|
|
243
|
+
emitRelayEvent({ type: "agent.transient_empty_reap", source: "task-lifecycle", subject: agentId, data: payload });
|
|
244
|
+
routeNotification({
|
|
245
|
+
type: "agent.transient_empty_reap",
|
|
246
|
+
scope: { agentId, parent, taskId: String(task.id) },
|
|
247
|
+
recipients,
|
|
248
|
+
message: {
|
|
249
|
+
subject: "Worker reaped without producing work",
|
|
250
|
+
body: `Worker \`${agentId}\` was reaped without producing work for Task #${task.id} (${task.title}). The task was reopened for retry instead of being marked done.`,
|
|
251
|
+
payload,
|
|
252
|
+
idempotencyKey: `task:${task.id}:empty-reap:${agentId}:${opts.reason ?? "agent-exit"}`,
|
|
253
|
+
replyExpected: false,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -105,24 +105,39 @@ export function resetCommitsAheadForTests(): void {
|
|
|
105
105
|
_commitsAheadFn = defaultCommitsAhead;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function
|
|
108
|
+
function completedProviderTurns(agent: AgentCard): number {
|
|
109
|
+
const value = agent.meta?.completedProviderTurns;
|
|
110
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
|
|
114
|
+
if (!ws) return false;
|
|
115
|
+
if (ws.status === "merged") return true;
|
|
116
|
+
return typeof ws.metadata.mergedAt === "number";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function transientLandComplete(agent: AgentCard): { complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string } {
|
|
120
|
+
const agentId = agent.id;
|
|
109
121
|
const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
|
|
110
|
-
if (!isolated.length) return { complete: true, reason: "no-isolated-workspace" };
|
|
122
|
+
if (!isolated.length) return { complete: true, producedWork: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
|
|
111
123
|
const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
112
|
-
if (!live)
|
|
124
|
+
if (!live) {
|
|
125
|
+
const latest = isolated[0];
|
|
126
|
+
return { complete: true, producedWork: workspaceProducedWork(latest), workspaceId: latest?.id, status: latest?.status, reason: "terminal-workspace" };
|
|
127
|
+
}
|
|
113
128
|
const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
|
|
114
129
|
const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
|
|
115
130
|
if (live.status === "active" && mergedAt !== undefined && mergedAt > (landReportedAt ?? 0)) {
|
|
116
|
-
return { complete: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
|
|
131
|
+
return { complete: true, producedWork: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
|
|
117
132
|
}
|
|
118
133
|
// Fast-path (#629): a zero-commit branch has nothing to preserve — reap immediately
|
|
119
134
|
// instead of holding it pending a land that will never happen. Only fires when the
|
|
120
135
|
// branch is provably empty (count === 0); null (unknown/git error) falls through to
|
|
121
136
|
// the safe #614 hold-pending bias below.
|
|
122
137
|
if (live.status === "active" && _commitsAheadFn(live) === 0) {
|
|
123
|
-
return { complete: true, workspaceId: live.id, status: live.status, reason: "empty-branch" };
|
|
138
|
+
return { complete: true, producedWork: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
|
|
124
139
|
}
|
|
125
|
-
return { complete: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
140
|
+
return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
function notifyTransientHeld(agentId: string, workspaceId: string | undefined, status: string | undefined, waitMs: number): void {
|
|
@@ -171,11 +186,14 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
171
186
|
// #800 — `reapProtected` is the single home for that decision: it honors only
|
|
172
187
|
// evidence-based block states, never a high quota-utilization number.
|
|
173
188
|
if (reapProtected(agent)) { transientTracker.delete(agent.id); continue; }
|
|
189
|
+
// A just-registered transient worker can briefly report idle/ready before its first provider
|
|
190
|
+
// turn starts. Do not start the idle-reap clock until the runner has observed a completed turn.
|
|
191
|
+
if (completedProviderTurns(agent) === 0) { transientTracker.delete(agent.id); waiting.push(agent.id); continue; }
|
|
174
192
|
const entry = transientTracker.get(agent.id) ?? { firstIdleAt: now };
|
|
175
193
|
transientTracker.set(agent.id, entry);
|
|
176
194
|
const idleForMs = now - entry.firstIdleAt;
|
|
177
195
|
if (idleForMs < grace) { waiting.push(agent.id); continue; }
|
|
178
|
-
const land = transientLandComplete(agent
|
|
196
|
+
const land = transientLandComplete(agent);
|
|
179
197
|
if (!land.complete) {
|
|
180
198
|
if (idleForMs >= grace + maxLandWait) {
|
|
181
199
|
held.push(agent.id);
|
|
@@ -201,7 +219,7 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
201
219
|
// #889 — finalize any bound task whose doneWhen is "agent-exit" (auto-scaffolded ephemeral
|
|
202
220
|
// tasks that produce no deliverable and would otherwise hang in_progress forever).
|
|
203
221
|
const spawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
|
|
204
|
-
finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete" });
|
|
222
|
+
finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete", producedWork: land.producedWork, evidence: land.reason });
|
|
205
223
|
reaped.push(agent.id);
|
|
206
224
|
}
|
|
207
225
|
for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
|