agent-relay-server 0.106.0 → 0.108.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.
Files changed (149) hide show
  1. package/docs/openapi.json +503 -1
  2. package/package.json +6 -5
  3. package/public/assets/{MessageBubble-CPYQQLDI.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-CPYQQLDI.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-B7xZt5DI.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-B7xZt5DI.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-UKQrQ8_0.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-UKQrQ8_0.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-Zr_5nNEJ.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-Zr_5nNEJ.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-FdrfmJsj.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-FdrfmJsj.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-VximqXQ9.js → analytics-B9USQLl5.js} +18 -18
  14. package/public/assets/{analytics-VximqXQ9.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-DlEcJvgu.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-DlEcJvgu.js.map → automation-XRePs1dT.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
  18. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
  19. package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
  20. package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
  21. package/public/assets/{chat-D1bunuI-.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-D1bunuI-.js.map → chat-D_0uHgB6.js.map} +1 -1
  23. package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
  24. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
  25. package/public/assets/display-1J__Ti1K.js +3 -0
  26. package/public/assets/display-1J__Ti1K.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-4v9SkUD1.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-4v9SkUD1.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
  29. package/public/assets/index-DlBSuzd2.js +23 -0
  30. package/public/assets/index-DlBSuzd2.js.map +1 -0
  31. package/public/assets/{insights-CntgHcTp.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-CntgHcTp.js.map → insights-BzNoYvDe.js.map} +1 -1
  33. package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
  34. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
  35. package/public/assets/{maintenance-Dw7frQtu.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-Dw7frQtu.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-Bj--bFmZ.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-Bj--bFmZ.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-DGLCocU8.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-DGLCocU8.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-yj2MIxUA.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-yj2MIxUA.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-BT5mL7iz.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-BT5mL7iz.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-BVDAhrPk.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-BVDAhrPk.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-DlqJIqYK.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-DlqJIqYK.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-DQ4tNCo3.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-DQ4tNCo3.js.map → pairs-ChyeBs0Z.js.map} +1 -1
  51. package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
  52. package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DlsqOP0C.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-DlsqOP0C.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-Cw2fdWrn.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-Cw2fdWrn.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-ucr7IT1V.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-ucr7IT1V.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-D8UWCtYZ.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-D8UWCtYZ.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/settings-DGgwr9WH.js +6 -0
  62. package/public/assets/settings-DGgwr9WH.js.map +1 -0
  63. package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
  64. package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
  65. package/public/assets/{tasks-jRUvxUd5.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-jRUvxUd5.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-Dn0yggD-.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-Dn0yggD-.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-fLly20IP.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-fLly20IP.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
  71. package/public/assets/types-BiJFuTiL.js +2 -0
  72. package/public/assets/types-BiJFuTiL.js.map +1 -0
  73. package/public/assets/{user-avatar-BWLUHt2e.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-BWLUHt2e.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-BAnNY0xE.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-BAnNY0xE.js.map → work-queue-BH2-mARc.js.map} +1 -1
  77. package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
  78. package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
  79. package/public/index.html +4 -4
  80. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  81. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  82. package/src/branch-landed.ts +93 -4
  83. package/src/config-store.ts +12 -1
  84. package/src/db/agents.ts +3 -2
  85. package/src/db/continuations.ts +13 -3
  86. package/src/db/index.ts +3 -0
  87. package/src/db/issue-lifecycle.ts +12 -0
  88. package/src/db/mappers.ts +17 -0
  89. package/src/db/messages.ts +9 -4
  90. package/src/db/migrations.ts +91 -1
  91. package/src/db/notification-subscriptions.ts +1 -1
  92. package/src/db/prompt-templates.ts +19 -0
  93. package/src/db/provider-quotas.ts +6 -0
  94. package/src/db/schema.ts +67 -1
  95. package/src/db/stats.ts +3 -2
  96. package/src/db/task-comment-through.ts +112 -0
  97. package/src/db/task-core.ts +14 -1
  98. package/src/db/task-entity.ts +629 -0
  99. package/src/db/task-maintenance.ts +4 -3
  100. package/src/db/task-memory.ts +180 -0
  101. package/src/db/tasks.ts +24 -10
  102. package/src/issue-tracker/adapter.ts +15 -0
  103. package/src/issue-tracker/github.ts +40 -2
  104. package/src/issue-tracker/index.ts +1 -0
  105. package/src/mcp/index.ts +12 -1
  106. package/src/mcp/tools-spawn.ts +4 -0
  107. package/src/mcp-dispatch-tools.ts +164 -0
  108. package/src/mcp-notification-subscription-tools.ts +62 -4
  109. package/src/mcp-plan-tools.ts +30 -3
  110. package/src/mcp-release-tools.ts +33 -0
  111. package/src/mcp-task-memory-tools.ts +132 -0
  112. package/src/mcp-task-tools.ts +232 -0
  113. package/src/mcp-task-weave-tools.ts +66 -0
  114. package/src/notification-router.ts +24 -2
  115. package/src/notification-rules.ts +4 -0
  116. package/src/notification-types.ts +25 -0
  117. package/src/prompt-resolver.ts +66 -3
  118. package/src/routes/agents-spawn.ts +10 -0
  119. package/src/routes/commands.ts +17 -1
  120. package/src/routes/index.ts +16 -2
  121. package/src/routes/plans.ts +10 -2
  122. package/src/routes/provider-quotas.ts +13 -1
  123. package/src/routes/tasks.ts +276 -2
  124. package/src/routing-policy-store.ts +147 -2
  125. package/src/security.ts +2 -0
  126. package/src/self-resume.ts +13 -2
  127. package/src/services/issue-lifecycle.ts +9 -1
  128. package/src/services/plan-graph.ts +107 -9
  129. package/src/services/release-readiness.ts +142 -0
  130. package/src/services/send-message.ts +31 -0
  131. package/src/services/spawn-agent.ts +81 -4
  132. package/src/services/task-context-hydration.ts +361 -0
  133. package/src/services/task-entity.ts +149 -0
  134. package/src/services/task-issue-weave.ts +558 -0
  135. package/src/services/task-lifecycle.ts +465 -0
  136. package/src/services/task-plan-view.ts +178 -0
  137. package/src/services/task-semantic-events.ts +137 -0
  138. package/src/services/transient-agent-reaper.ts +71 -23
  139. package/src/spawn-targets.ts +27 -2
  140. package/src/task-dispatcher.ts +461 -0
  141. package/src/upgrade-install-verify.ts +20 -1
  142. package/public/assets/display-BWi8DQCp.js +0 -3
  143. package/public/assets/display-BWi8DQCp.js.map +0 -1
  144. package/public/assets/index-Vmhw442d.js +0 -23
  145. package/public/assets/index-Vmhw442d.js.map +0 -1
  146. package/public/assets/settings-Dvf_ps1B.js +0 -6
  147. package/public/assets/settings-Dvf_ps1B.js.map +0 -1
  148. package/public/assets/types-DorDDvEb.js +0 -2
  149. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -0,0 +1,137 @@
1
+ import { getAgent } from "../db";
2
+ import { emitRelayEvent } from "../events";
3
+ import type { NotificationScope, NotificationType } from "../notification-types";
4
+ import { routeNotification } from "../notification-router";
5
+ import { resolveAudience } from "../notification-rules";
6
+ import type { Task, TaskBlocker, TaskStatus } from "../types";
7
+
8
+ export type TaskSemanticTransition = "created" | "started" | "blocked" | "needs-decision" | "landed" | "closed";
9
+
10
+ const TASK_NOTIFICATION_TYPE: Record<TaskSemanticTransition, NotificationType> = {
11
+ created: "task.created",
12
+ started: "task.started",
13
+ blocked: "task.blocked",
14
+ "needs-decision": "task.needs_decision",
15
+ landed: "task.landed",
16
+ closed: "task.closed",
17
+ };
18
+
19
+ export function taskTransitionForStatus(status: TaskStatus, blockers: TaskBlocker[] | undefined): TaskSemanticTransition | null {
20
+ if (status === "claimed" || status === "in_progress") return "started";
21
+ if (status === "blocked") {
22
+ return blockers?.some((blocker) => blocker.type === "decision") ? "needs-decision" : "blocked";
23
+ }
24
+ if (status === "done" || status === "failed" || status === "canceled") return "closed";
25
+ return null;
26
+ }
27
+
28
+ export interface TaskSemanticNotificationInput {
29
+ task: Task;
30
+ transition: TaskSemanticTransition;
31
+ actor?: string;
32
+ recipients?: string[];
33
+ workspaceId?: string;
34
+ branch?: string;
35
+ sha?: string;
36
+ landKey?: string;
37
+ occurrenceKey?: string;
38
+ outcome?: Record<string, unknown>;
39
+ }
40
+
41
+ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInput): void {
42
+ const type = TASK_NOTIFICATION_TYPE[input.transition];
43
+ const scope = taskNotificationScope(input);
44
+ const semanticEvent = `task.${input.transition}`;
45
+ const eventData = {
46
+ taskId: input.task.id,
47
+ transition: input.transition,
48
+ semanticEvent,
49
+ title: input.task.title,
50
+ status: input.task.status,
51
+ deliverableStage: input.task.deliverableStage,
52
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
53
+ ...(input.branch ? { branch: input.branch } : {}),
54
+ ...(input.sha ? { sha: input.sha } : {}),
55
+ ...(input.landKey ? { landKey: input.landKey } : {}),
56
+ ...(input.outcome ? { outcome: input.outcome } : {}),
57
+ };
58
+ emitRelayEvent({
59
+ type,
60
+ source: input.actor ?? "server",
61
+ subject: String(input.task.id),
62
+ data: eventData as Record<string, unknown>,
63
+ });
64
+
65
+ const subscriptionAudience = resolveAudience(type, scope).map((member) => ({
66
+ agentId: member.agentId,
67
+ matchedRule: member.matchedRule,
68
+ }));
69
+ const explicit = (input.recipients ?? [])
70
+ .map((agentId) => agentId.trim())
71
+ .filter(Boolean)
72
+ .map((agentId) => ({ agentId, matchedRule: "task-stakeholder" }));
73
+
74
+ const recipients = [...subscriptionAudience, ...explicit];
75
+ if (recipients.length === 0) return;
76
+
77
+ routeNotification({
78
+ type,
79
+ scope,
80
+ recipients,
81
+ ttlMs: null,
82
+ message: {
83
+ subject: taskNotificationSubject(input),
84
+ body: taskNotificationBody(input),
85
+ payload: {
86
+ kind: "task.transition",
87
+ notification: "task-transition",
88
+ taskId: input.task.id,
89
+ transition: input.transition,
90
+ semanticEvent,
91
+ ...(input.outcome ? { outcome: input.outcome } : {}),
92
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
93
+ ...(input.branch ? { branch: input.branch } : {}),
94
+ ...(input.sha ? { sha: input.sha } : {}),
95
+ },
96
+ idempotencyKey: taskNotificationIdempotencyKey(input),
97
+ replyExpected: false,
98
+ },
99
+ });
100
+ }
101
+
102
+ function taskNotificationIdempotencyKey(input: TaskSemanticNotificationInput): string | undefined {
103
+ if (input.landKey) return `task:${input.task.id}:transition:${input.transition}:${input.landKey}`;
104
+ if (input.occurrenceKey) return `task:${input.task.id}:transition:${input.transition}:occurrence:${input.occurrenceKey}`;
105
+ return undefined;
106
+ }
107
+
108
+ function taskNotificationScope(input: TaskSemanticNotificationInput): NotificationScope {
109
+ const owner = input.task.ownerAgentId;
110
+ const ownerAgent = owner ? getAgent(owner) : undefined;
111
+ const teamId = input.task.teamId ?? ownerAgent?.teamId;
112
+ return {
113
+ taskId: String(input.task.id),
114
+ transition: input.transition,
115
+ ...(input.landKey ? { landKey: input.landKey } : {}),
116
+ ...(owner ? { author: owner, agentId: owner } : {}),
117
+ ...(input.task.createdBy ? { parent: input.task.createdBy } : {}),
118
+ ...(teamId ? { teamId } : {}),
119
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
120
+ };
121
+ }
122
+
123
+ function taskNotificationSubject(input: TaskSemanticNotificationInput): string {
124
+ return `Task #${input.task.id} ${input.transition}`;
125
+ }
126
+
127
+ function taskNotificationBody(input: TaskSemanticNotificationInput): string {
128
+ if (input.transition === "landed") {
129
+ const bits = [`Task #${input.task.id} landed: ${input.task.title}`];
130
+ if (input.sha) bits.push(`sha ${input.sha.slice(0, 12)}`);
131
+ const gatesPassed = input.outcome?.gatesPassed;
132
+ if (typeof gatesPassed === "boolean") bits.push(gatesPassed ? "gates passed" : "gates not verified");
133
+ if (input.outcome?.statusAdvanced === true) bits.push("closed");
134
+ return bits.join("; ");
135
+ }
136
+ return `Task #${input.task.id} ${input.transition}: ${input.task.title}`;
137
+ }
@@ -4,7 +4,52 @@ import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTermin
4
4
  import { routeNotification } from "../notification-router";
5
5
  import { isProviderBlocked } from "../provider-state";
6
6
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
7
- import type { WorkspaceRecord } from "../types";
7
+ import type { AgentCard, Command, WorkspaceRecord } from "../types";
8
+
9
+ // The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
10
+ // command + its events/terminal-marker/activity. Extracted so the idle-grace scan below AND
11
+ // the #831 on-land reap signal emit the byte-identical disposal (one shutdown shape, not two).
12
+ // Pure emission: the CALLER owns the decision (idle+grace vs landed+gates) and any dedup.
13
+ export interface TransientShutdownOptions {
14
+ /** Disposal cause stamped on the command, agent.exited event, and terminal marker. */
15
+ reason: string;
16
+ /** Audit attribution (the command's requestedBy + the activity row's maintenanceJobId). */
17
+ requestedBy: string;
18
+ workspaceId?: string;
19
+ workspaceStatus?: string;
20
+ activityTitle: string;
21
+ activityBody: string;
22
+ /** Why the land was considered complete (idle-scan reason or "deliverable-landed"). */
23
+ landReason?: string;
24
+ now?: number;
25
+ }
26
+
27
+ export function requestTransientAgentShutdown(agent: AgentCard, opts: TransientShutdownOptions): Command {
28
+ const now = opts.now ?? Date.now();
29
+ const command = createCommand({
30
+ type: "agent.shutdown",
31
+ source: "system",
32
+ target: agent.id,
33
+ params: { action: "shutdown", agentId: agent.id, graceful: true, timeoutMs: 10_000, reason: opts.reason, requestedBy: opts.requestedBy, requestedAt: now },
34
+ });
35
+ emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
36
+ emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: opts.reason, workspaceId: opts.workspaceId, workspaceStatus: opts.workspaceStatus } });
37
+ // #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
38
+ // completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
39
+ recordTerminalEvent({ agentId: agent.id, reason: opts.reason, kind: "agent.exited", epoch: agent.epoch, createdAt: now });
40
+ createActivityEvent({
41
+ clientId: `transient-agent-reaped-${agent.id}-${now}`,
42
+ kind: "state",
43
+ title: opts.activityTitle,
44
+ body: opts.activityBody,
45
+ meta: agent.id,
46
+ icon: "ti-check",
47
+ view: "agents",
48
+ agentId: agent.id,
49
+ metadata: { source: "server", maintenanceJobId: opts.requestedBy, commandId: command.id, reason: opts.reason, workspaceId: opts.workspaceId, landReason: opts.landReason },
50
+ });
51
+ return command;
52
+ }
8
53
 
9
54
  const envMsOrDefault = (name: string, fallback: number): number => {
10
55
  const v = Number(process.env[name]);
@@ -37,6 +82,19 @@ function defaultCommitsAhead(ws: WorkspaceRecord): number | null {
37
82
  }
38
83
  }
39
84
 
85
+ // #800 — A transient agent is protected from reaping ONLY by one of three evidence-based states:
86
+ // it is not idle (still working), it is not ready (mid-startup/teardown), or it carries a REAL
87
+ // provider block. `isProviderBlocked` reads the agent's own `meta.providerState`, which is set
88
+ // exclusively from hard evidence — an HTTP 429 / StopFailure hook, the scraped CLI usage-limit
89
+ // modal, an approval gate, or a model-unavailable hold (see runner rate-limit.ts / control-server).
90
+ // It is NEVER derived from a quota-utilization number. The reaper deliberately does not read quota
91
+ // utilization at all, so a provider merely sitting at high 7d/5h utilization can neither cause nor
92
+ // prevent a reap. This keeps the false "high-util ⇒ blocked ⇒ reaped" chain (#800) impossible by
93
+ // construction: utilization is not an input to this decision.
94
+ function reapProtected(agent: ReturnType<typeof listAgents>[number]): boolean {
95
+ return agent.status !== "idle" || !agent.ready || isProviderBlocked(agent);
96
+ }
97
+
40
98
  let _commitsAheadFn: (ws: WorkspaceRecord) => number | null = defaultCommitsAhead;
41
99
 
42
100
  export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null): void {
@@ -109,7 +167,9 @@ export function reapTransientAgents(): Record<string, unknown> {
109
167
  // A held agent (rate-limit hold #286, or any provider-blocked state) reads as
110
168
  // idle+ready but is NOT done — reaping it would kill work that's just waiting
111
169
  // for the limit to reset. Treat it as not-idle; the clock restarts on resume.
112
- if (agent.status !== "idle" || !agent.ready || isProviderBlocked(agent)) { transientTracker.delete(agent.id); continue; }
170
+ // #800 `reapProtected` is the single home for that decision: it honors only
171
+ // evidence-based block states, never a high quota-utilization number.
172
+ if (reapProtected(agent)) { transientTracker.delete(agent.id); continue; }
113
173
  const entry = transientTracker.get(agent.id) ?? { firstIdleAt: now };
114
174
  transientTracker.set(agent.id, entry);
115
175
  const idleForMs = now - entry.firstIdleAt;
@@ -127,27 +187,15 @@ export function reapTransientAgents(): Record<string, unknown> {
127
187
  }
128
188
  if (entry.lastReapAt) continue;
129
189
  entry.lastReapAt = now;
130
- const command = createCommand({
131
- type: "agent.shutdown",
132
- source: "system",
133
- target: agent.id,
134
- params: { action: "shutdown", agentId: agent.id, graceful: true, timeoutMs: 10_000, reason: "transient-complete", requestedBy: "transient-agent-reaper", requestedAt: now },
135
- });
136
- emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
137
- emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: "transient-complete", workspaceId: land.workspaceId, workspaceStatus: land.status } });
138
- // #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
139
- // completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
140
- recordTerminalEvent({ agentId: agent.id, reason: "transient-complete", kind: "agent.exited", epoch: agent.epoch, createdAt: now });
141
- createActivityEvent({
142
- clientId: `transient-agent-reaped-${agent.id}-${now}`,
143
- kind: "state",
144
- title: "Transient agent complete",
145
- body: "Agent idle grace elapsed and branch work is land-complete; shutdown requested",
146
- meta: agent.id,
147
- icon: "ti-check",
148
- view: "agents",
149
- agentId: agent.id,
150
- metadata: { source: "server", maintenanceJobId: "transient-agent-reaper", commandId: command.id, reason: "transient-complete", workspaceId: land.workspaceId, landReason: land.reason },
190
+ requestTransientAgentShutdown(agent, {
191
+ reason: "transient-complete",
192
+ requestedBy: "transient-agent-reaper",
193
+ workspaceId: land.workspaceId,
194
+ workspaceStatus: land.status,
195
+ activityTitle: "Transient agent complete",
196
+ activityBody: "Agent idle grace elapsed and branch work is land-complete; shutdown requested",
197
+ landReason: land.reason,
198
+ now,
151
199
  });
152
200
  reaped.push(agent.id);
153
201
  }
@@ -206,6 +206,29 @@ function quotaAdvisoryFor(
206
206
  };
207
207
  }
208
208
 
209
+ // #839 — compact per-provider quota band view for the task dispatcher. Projects the same
210
+ // advisory the spawn matrix surfaces (band / prefer / blockedNow) down to the fields the
211
+ // dispatcher needs to protect scarce pools and load-shift onto abundant ones. Returns one
212
+ // entry per provider that has quota records; providers without records are simply absent
213
+ // (the dispatcher then treats them as band-unknown = "normal").
214
+ export interface ProviderQuotaBand {
215
+ provider: SpawnProvider;
216
+ band: QuotaBand;
217
+ prefer: boolean;
218
+ blockedNow: boolean;
219
+ recommendedAction: string;
220
+ }
221
+
222
+ export function providerQuotaBands(now = Date.now()): ProviderQuotaBand[] {
223
+ return [...providerQuotaAdvisories(now).entries()].map(([provider, advisory]) => ({
224
+ provider,
225
+ band: advisory.quotaPenaltyBand,
226
+ prefer: advisory.prefer,
227
+ blockedNow: advisory.blockedNow,
228
+ recommendedAction: advisory.recommendedAction,
229
+ }));
230
+ }
231
+
209
232
  function quotaWindowsFor(record: ProviderQuotaRecord, now: number, providerCfg?: ProviderQuotaRoutingConfig): SpawnQuotaBurnWindow[] {
210
233
  const quotaByName = new Map((record.quota?.windows ?? []).map((window) => [window.name, window]));
211
234
  return (record.burn?.windows ?? [])
@@ -381,9 +404,11 @@ export function quotaBandFromSignal(
381
404
  function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
382
405
  if (input.blockedNow) {
383
406
  const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
407
+ // #827 — "at capacity" (util ≥ hardBackstop) is a routing-avoid advisory, not an evidence-based
408
+ // rate-limit hold. Use distinct wording so operators don't confuse it with an HTTP 429 block.
384
409
  return hasSevenDayBlock
385
- ? "Quota is blocked now; switch provider or escalate unless this provider was explicitly requested."
386
- : "5h quota is blocked now; wait for reset or use another provider unless the job is urgent.";
410
+ ? "Quota at capacity (routing-avoid); switch provider or wait for reset unless this provider was explicitly requested."
411
+ : "5h quota at capacity (routing-avoid); wait for reset or use another provider unless the job is urgent.";
387
412
  }
388
413
  return windowRecommendedAction(input.quotaPenaltyBand, input.slack);
389
414
  }