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.
@@ -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 { notifySystemMessage } from "./notify";
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
- notifySystemMessage(target, {
64
- subject: `Review requested: ${workspace.branch ?? workspace.id}`,
65
- body: [
66
- `Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
67
- `Repo: ${workspace.repoRoot}`,
68
- workspace.branch ? `Branch: ${workspace.branch}` : undefined,
69
- workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
70
- compareRef ? `Compare: ${compareRef}` : undefined,
71
- "Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
72
- ].filter(Boolean).join("\n"),
73
- payload: {
74
- kind: "workspace.review-request",
75
- workspaceId: workspace.id,
76
- repoRoot: workspace.repoRoot,
77
- worktreePath: workspace.worktreePath,
78
- branch: workspace.branch,
79
- baseRef: workspace.baseRef,
80
- prUrl,
81
- prNumber,
82
- compareRef,
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
- notifySystemMessage(target, {
176
- subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
177
- body: [
178
- `The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
179
- `Workspace \`${workspace.id}\` remains in review_requested.`,
180
- preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
181
- ].filter(Boolean).join("\n"),
182
- payload: {
183
- kind: "workspace.review-changes-requested",
184
- workspaceId: workspace.id,
185
- repoRoot: workspace.repoRoot,
186
- worktreePath: workspace.worktreePath,
187
- branch: workspace.branch,
188
- baseRef: workspace.baseRef,
189
- prUrl,
190
- prNumber,
191
- reviewDecision: preview.reviewDecision,
192
- statusCheckRollup: preview.statusCheckRollup,
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
  }
@@ -11,7 +11,7 @@ import {
11
11
  ValidationError,
12
12
  } from "./db";
13
13
  import { emitRelayEvent } from "./events";
14
- import { notifySystemMessage } from "./notify";
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
- notifySystemMessage(agent.spawnedBy, {
204
- subject: "Self-resume budget exhausted",
205
- body: `Spawned agent \`${agent.id}\` stopped cleanly: ${reason}.`,
206
- payload,
207
- replyExpected: false,
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 { notifySystemMessage } from "../notify";
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
- notifySystemMessage(target, {
52
- subject: "Bound plan node blocked",
53
- body: `Agent \`${agentId}\` exited${reason ? ` (${reason})` : ""}; blocked plan node(s) ${blockedNodes.map((node) => `\`${node.id}\``).join(", ")} in \`${plan.id}\`.`,
54
- payload: { kind: "plan.bound_agent_exited", planId: plan.id, agentId, nodeIds: blockedNodes.map((node) => node.id), reason },
55
- replyExpected: false,
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 { notifySystemMessage } from "../notify";
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
- notifySystemMessage(agent.id, {
56
- subject: "Usage limit reset resume",
57
- body: RESUME_BODY,
58
- payload: {
59
- kind: "agent.rate_limit_resume",
60
- agentId: agent.id,
61
- resetAt: state?.resetAt,
62
- errorType: state?.errorType,
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 { notifySystemMessage } from "../notify";
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
- notifySystemMessage(parent, {
51
- subject: "Transient worker held pending land",
52
- 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.`,
53
- payload,
54
- replyExpected: false,
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
- const command = getCommand(commandId);
72
- if (command && ACTIVE_COMMAND_STATUSES.has(command.status)) return true;
73
- if (command?.status === "succeeded" && command.result?.relayMerged === true) return true;
74
- }
75
- return workspace.metadata.relayMerged === true && !commandId;
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 };