agent-relay-server 0.59.0 → 0.60.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/display-CketawEF.js.map +1 -1
- package/src/agent-lifecycle-events.ts +41 -21
- package/src/branch-landed.ts +46 -35
- package/src/channel-target.ts +10 -0
- package/src/config.ts +5 -0
- package/src/context-advisory.ts +13 -6
- package/src/db/message-reads.ts +6 -0
- package/src/db/messages.ts +16 -1
- package/src/db/migrations.ts +15 -0
- package/src/db/notification-subscriptions.ts +198 -0
- package/src/db/schema.ts +11 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +32 -13
- package/src/mcp-notification-subscription-tools.ts +111 -0
- package/src/mcp.ts +3 -0
- package/src/notification-router.ts +168 -0
- package/src/notification-rules.ts +186 -0
- package/src/notify.ts +9 -0
- package/src/reviewer-pipeline.ts +54 -40
- package/src/self-resume.ts +11 -6
- package/src/services/plan-live-bindings.ts +11 -6
- package/src/services/rate-limit-resume.ts +17 -10
- package/src/services/transient-agent-reaper.ts +11 -6
- package/src/token-db.ts +3 -3
- package/src/workspace-pr-completion.ts +15 -8
package/src/reviewer-pipeline.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { planSend } from "./agent-ref";
|
|
|
3
3
|
import { createCommand } from "./commands-db";
|
|
4
4
|
import { getSpawnPolicy } from "./config-store";
|
|
5
5
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
6
|
-
import {
|
|
6
|
+
import { routeNotification } from "./notification-router";
|
|
7
7
|
import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
|
|
8
8
|
import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
9
9
|
|
|
@@ -60,26 +60,33 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
|
|
|
60
60
|
});
|
|
61
61
|
if (!updated) return false;
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
63
|
+
// Obligation (ttl null by type default): a review request must reach the reviewer even if they're
|
|
64
|
+
// offline a while — it's not a bystander notice.
|
|
65
|
+
routeNotification({
|
|
66
|
+
type: "workspace.review_request",
|
|
67
|
+
scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot },
|
|
68
|
+
recipients: [target],
|
|
69
|
+
message: {
|
|
70
|
+
subject: `Review requested: ${workspace.branch ?? workspace.id}`,
|
|
71
|
+
body: [
|
|
72
|
+
`Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
|
|
73
|
+
`Repo: ${workspace.repoRoot}`,
|
|
74
|
+
workspace.branch ? `Branch: ${workspace.branch}` : undefined,
|
|
75
|
+
workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
|
|
76
|
+
compareRef ? `Compare: ${compareRef}` : undefined,
|
|
77
|
+
"Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
|
|
78
|
+
].filter(Boolean).join("\n"),
|
|
79
|
+
payload: {
|
|
80
|
+
kind: "workspace.review-request",
|
|
81
|
+
workspaceId: workspace.id,
|
|
82
|
+
repoRoot: workspace.repoRoot,
|
|
83
|
+
worktreePath: workspace.worktreePath,
|
|
84
|
+
branch: workspace.branch,
|
|
85
|
+
baseRef: workspace.baseRef,
|
|
86
|
+
prUrl,
|
|
87
|
+
prNumber,
|
|
88
|
+
compareRef,
|
|
89
|
+
},
|
|
83
90
|
},
|
|
84
91
|
});
|
|
85
92
|
if (policyName) getLifecycleManager().onMessageForPolicy(policyName);
|
|
@@ -172,26 +179,33 @@ function notifyReviewChangesRequested(
|
|
|
172
179
|
): string | undefined {
|
|
173
180
|
const target = workspace.ownerAgentId;
|
|
174
181
|
if (!target) return undefined;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
182
|
+
// Durable (ttl null): the owner must learn changes were requested on THEIR workspace even after
|
|
183
|
+
// being offline — store-ahead, not a freshness-gated bystander notice.
|
|
184
|
+
routeNotification({
|
|
185
|
+
type: "workspace.review_changes_requested",
|
|
186
|
+
scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, author: target },
|
|
187
|
+
recipients: [target],
|
|
188
|
+
message: {
|
|
189
|
+
subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
|
|
190
|
+
body: [
|
|
191
|
+
`The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
|
|
192
|
+
`Workspace \`${workspace.id}\` remains in review_requested.`,
|
|
193
|
+
preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
|
|
194
|
+
].filter(Boolean).join("\n"),
|
|
195
|
+
payload: {
|
|
196
|
+
kind: "workspace.review-changes-requested",
|
|
197
|
+
workspaceId: workspace.id,
|
|
198
|
+
repoRoot: workspace.repoRoot,
|
|
199
|
+
worktreePath: workspace.worktreePath,
|
|
200
|
+
branch: workspace.branch,
|
|
201
|
+
baseRef: workspace.baseRef,
|
|
202
|
+
prUrl,
|
|
203
|
+
prNumber,
|
|
204
|
+
reviewDecision: preview.reviewDecision,
|
|
205
|
+
statusCheckRollup: preview.statusCheckRollup,
|
|
206
|
+
},
|
|
207
|
+
replyExpected: false,
|
|
193
208
|
},
|
|
194
|
-
replyExpected: false,
|
|
195
209
|
});
|
|
196
210
|
return target;
|
|
197
211
|
}
|
package/src/self-resume.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
ValidationError,
|
|
12
12
|
} from "./db";
|
|
13
13
|
import { emitRelayEvent } from "./events";
|
|
14
|
-
import {
|
|
14
|
+
import { routeNotification } from "./notification-router";
|
|
15
15
|
import type { AgentCard, Command, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
16
16
|
|
|
17
17
|
export interface CompactAndResumeInput {
|
|
@@ -200,11 +200,16 @@ function notifySelfResumeBudgetExhausted(agent: AgentCard, envelope: Continuatio
|
|
|
200
200
|
};
|
|
201
201
|
emitRelayEvent({ type: "agent.resume_budget_exhausted", source: "server", subject: agent.id, data: payload });
|
|
202
202
|
if (!agent.spawnedBy) return;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
routeNotification({
|
|
204
|
+
type: "agent.resume_budget_exhausted",
|
|
205
|
+
scope: { agentId: agent.id, parent: agent.spawnedBy },
|
|
206
|
+
recipients: [agent.spawnedBy],
|
|
207
|
+
message: {
|
|
208
|
+
subject: "Self-resume budget exhausted",
|
|
209
|
+
body: `Spawned agent \`${agent.id}\` stopped cleanly: ${reason}.`,
|
|
210
|
+
payload,
|
|
211
|
+
replyExpected: false,
|
|
212
|
+
},
|
|
208
213
|
});
|
|
209
214
|
}
|
|
210
215
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getTeam } from "../db";
|
|
2
2
|
import { subscribeRelayEvents, type RelayEvent } from "../events";
|
|
3
|
-
import {
|
|
3
|
+
import { routeNotification } from "../notification-router";
|
|
4
4
|
import { updateBoundPlanNodesForAgent, updateBoundPlanNodesFromAgent } from "./plan-graph";
|
|
5
5
|
|
|
6
6
|
let unsubscribe: (() => void) | undefined;
|
|
@@ -48,11 +48,16 @@ function notifyBoundExit(event: RelayEvent, updatedPlans: ReturnType<typeof upda
|
|
|
48
48
|
const target = team?.coordinatorId ?? `team:${plan.teamId}`;
|
|
49
49
|
const blockedNodes = plan.nodes.filter((node) => node.statusSource === "bound" && node.ownerRef?.agentId === agentId && node.status === "blocked");
|
|
50
50
|
if (blockedNodes.length === 0) continue;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
routeNotification({
|
|
52
|
+
type: "plan.bound_agent_exited",
|
|
53
|
+
scope: { agentId, planId: plan.id, teamId: plan.teamId },
|
|
54
|
+
recipients: [target],
|
|
55
|
+
message: {
|
|
56
|
+
subject: "Bound plan node blocked",
|
|
57
|
+
body: `Agent \`${agentId}\` exited${reason ? ` (${reason})` : ""}; blocked plan node(s) ${blockedNodes.map((node) => `\`${node.id}\``).join(", ")} in \`${plan.id}\`.`,
|
|
58
|
+
payload: { kind: "plan.bound_agent_exited", planId: plan.id, agentId, nodeIds: blockedNodes.map((node) => node.id), reason },
|
|
59
|
+
replyExpected: false,
|
|
60
|
+
},
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
63
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createActivityEvent, listAgents } from "../db";
|
|
2
|
-
import {
|
|
2
|
+
import { routeNotification } from "../notification-router";
|
|
3
3
|
import { agentProviderState, isRateLimitHold, rateLimitResumeAt } from "../provider-state";
|
|
4
4
|
|
|
5
5
|
// #286 Phase 2: auto-resume agents held on a usage/rate-limit stall once their
|
|
@@ -52,16 +52,23 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
|
|
|
52
52
|
resumeTracker.set(agent.id, now);
|
|
53
53
|
|
|
54
54
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
// Short default TTL (per-type): a "limit reset, resume" push is pointless once stale — the
|
|
56
|
+
// window has moved on. One recipient per iteration keeps the per-agent rollback below intact.
|
|
57
|
+
routeNotification({
|
|
58
|
+
type: "agent.rate_limit_resume",
|
|
59
|
+
scope: { agentId: agent.id },
|
|
60
|
+
recipients: [agent.id],
|
|
61
|
+
message: {
|
|
62
|
+
subject: "Usage limit reset — resume",
|
|
63
|
+
body: RESUME_BODY,
|
|
64
|
+
payload: {
|
|
65
|
+
kind: "agent.rate_limit_resume",
|
|
66
|
+
agentId: agent.id,
|
|
67
|
+
resetAt: state?.resetAt,
|
|
68
|
+
errorType: state?.errorType,
|
|
69
|
+
},
|
|
70
|
+
replyExpected: false,
|
|
63
71
|
},
|
|
64
|
-
replyExpected: false,
|
|
65
72
|
});
|
|
66
73
|
} catch {
|
|
67
74
|
// A stale/unreachable target must never break the sweep; it store-aheads
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCommand } from "../commands-db";
|
|
2
2
|
import { emitRelayEvent } from "../events";
|
|
3
3
|
import { createActivityEvent, getAgent, listAgents, listWorkspaces } from "../db";
|
|
4
|
-
import {
|
|
4
|
+
import { routeNotification } from "../notification-router";
|
|
5
5
|
import { isProviderBlocked } from "../provider-state";
|
|
6
6
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
7
7
|
|
|
@@ -47,11 +47,16 @@ function notifyTransientHeld(agentId: string, workspaceId: string | undefined, s
|
|
|
47
47
|
metadata: { source: "server", maintenanceJobId: "transient-agent-reaper", ...payload },
|
|
48
48
|
});
|
|
49
49
|
if (parent) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
routeNotification({
|
|
51
|
+
type: "agent.transient_land_hold",
|
|
52
|
+
scope: { agentId, parent, workspaceId },
|
|
53
|
+
recipients: [parent],
|
|
54
|
+
message: {
|
|
55
|
+
subject: "Transient worker held pending land",
|
|
56
|
+
body: `Transient agent \`${agentId}\` is idle but still unreaped because workspace \`${workspaceId ?? "unknown"}\` is \`${status ?? "unknown"}\` after ${Math.round(waitMs / 60_000)} min. Its work is being preserved; resolve the land before cleanup.`,
|
|
57
|
+
payload,
|
|
58
|
+
replyExpected: false,
|
|
59
|
+
},
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
}
|
package/src/token-db.ts
CHANGED
|
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
57
57
|
name: "Provider Agent",
|
|
58
58
|
description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
|
|
59
59
|
role: "provider",
|
|
60
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "insights:write"],
|
|
60
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
|
|
61
61
|
ttlSeconds: 24 * 60 * 60,
|
|
62
62
|
builtIn: true,
|
|
63
63
|
createdBy: "system",
|
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Provider Child Agent",
|
|
68
68
|
description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
|
|
69
69
|
role: "provider",
|
|
70
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "insights:write"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
|
|
71
71
|
constraints: { canDelegate: false },
|
|
72
72
|
ttlSeconds: 2 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
78
78
|
name: "Provider Interactive Agent",
|
|
79
79
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
80
80
|
role: "provider",
|
|
81
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "insights:write"],
|
|
81
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
|
|
82
82
|
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
83
83
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
84
84
|
builtIn: true,
|
|
@@ -65,14 +65,21 @@ const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
|
65
65
|
const PASSING_STATES = new Set(["SUCCESS"]);
|
|
66
66
|
const READY_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
|
|
67
67
|
|
|
68
|
-
function relayPrMergeAlreadyHandled(workspace: WorkspaceRecord): boolean {
|
|
68
|
+
function relayPrMergeAlreadyHandled(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): boolean {
|
|
69
69
|
const commandId = text(workspace.metadata.relayMergeCommandId);
|
|
70
|
-
if (commandId)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
if (!commandId) return false;
|
|
71
|
+
const command = getCommand(commandId);
|
|
72
|
+
if (!command) return false;
|
|
73
|
+
// PR-scope the idempotency guard (#453). A land-and-continue recycle (#423) repoints the
|
|
74
|
+
// workspace onto a fresh branch but leaves the PRIOR cycle's relayMerge* metadata on the row,
|
|
75
|
+
// so without this a succeeded merge for an already-closed PR would block every SUBSEQUENT PR
|
|
76
|
+
// on the recycled workspace forever (green PR, never merged, no steward). Only treat the merge
|
|
77
|
+
// as already-handled when the recorded command targets the SAME PR we're now considering.
|
|
78
|
+
const recordedPr = isRecord(command.params) && typeof command.params.prNumber === "number" ? command.params.prNumber : undefined;
|
|
79
|
+
const currentPr = preview.prNumber ?? (typeof workspace.metadata.prNumber === "number" ? workspace.metadata.prNumber : undefined);
|
|
80
|
+
if (typeof recordedPr === "number" && typeof currentPr === "number" && recordedPr !== currentPr) return false;
|
|
81
|
+
if (ACTIVE_COMMAND_STATUSES.has(command.status)) return true;
|
|
82
|
+
return command.status === "succeeded" && command.result?.relayMerged === true;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
function nativeAutoMergeArmed(workspace: WorkspaceRecord): boolean {
|
|
@@ -99,7 +106,7 @@ function prReadyForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMerg
|
|
|
99
106
|
if (preview.prMerged === true || preview.prState === "merged") return { ok: false, reason: "already-merged" };
|
|
100
107
|
if (!preview.prNumber && !preview.prUrl) return { ok: false, reason: "missing-pr" };
|
|
101
108
|
if (nativeAutoMergeArmed(workspace)) return { ok: false, reason: "native-auto-merge-armed" };
|
|
102
|
-
if (relayPrMergeAlreadyHandled(workspace)) return { ok: false, reason: "relay-merge-already-dispatched" };
|
|
109
|
+
if (relayPrMergeAlreadyHandled(workspace, preview)) return { ok: false, reason: "relay-merge-already-dispatched" };
|
|
103
110
|
|
|
104
111
|
const policy = prAutoMergePolicy(workspace, preview);
|
|
105
112
|
if (policy === "manual") return { ok: false, reason: "manual", policy };
|