agent-relay-server 0.8.0 → 0.9.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/package.json +1 -1
- package/public/index.html +1 -1
- package/src/config.ts +1 -0
- package/src/db.ts +126 -6
- package/src/index.ts +8 -2
- package/src/routes.ts +12 -3
- package/src/sse.ts +6 -0
- package/src/types.ts +6 -1
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -305,7 +305,7 @@
|
|
|
305
305
|
</div>
|
|
306
306
|
|
|
307
307
|
<!-- Main content -->
|
|
308
|
-
<main class="ar-main flex-grow-1 overflow-
|
|
308
|
+
<main class="ar-main flex-grow-1" style="overflow-y:scroll;overflow-x:hidden">
|
|
309
309
|
<div class="container-xl py-3">
|
|
310
310
|
<template x-if="authNeeded">
|
|
311
311
|
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3">
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min wit
|
|
|
22
22
|
export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
|
|
23
23
|
export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
|
|
24
24
|
export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
|
|
25
|
+
export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_MS", STALE_TTL_MS * 3); // pool binding lease
|
|
25
26
|
|
|
26
27
|
// Max body size for any POST/PATCH request (64 KiB).
|
|
27
28
|
export const MAX_BODY_BYTES = 64 * 1024;
|
package/src/db.ts
CHANGED
|
@@ -41,7 +41,7 @@ import type {
|
|
|
41
41
|
InboxThreadState,
|
|
42
42
|
TaskStatusInput,
|
|
43
43
|
} from "./types";
|
|
44
|
-
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
|
|
44
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS } from "./config";
|
|
45
45
|
|
|
46
46
|
let db: Database;
|
|
47
47
|
|
|
@@ -307,6 +307,14 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
307
307
|
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
308
308
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
309
309
|
|
|
310
|
+
const bindingColNames = (db.prepare("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
|
|
311
|
+
if (!bindingColNames.includes("pool_selector")) {
|
|
312
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
|
|
313
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
|
|
314
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_epoch INTEGER");
|
|
315
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_claim_expires_at INTEGER");
|
|
316
|
+
}
|
|
317
|
+
|
|
310
318
|
if (!colNames.includes("kind")) {
|
|
311
319
|
db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
|
|
312
320
|
}
|
|
@@ -608,7 +616,7 @@ function rowToChannelBinding(row: any): ChannelBinding {
|
|
|
608
616
|
const target = row.target_type === "broadcast"
|
|
609
617
|
? { type: "broadcast" } as ChannelRouteTarget
|
|
610
618
|
: { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
|
|
611
|
-
|
|
619
|
+
const binding: ChannelBinding = {
|
|
612
620
|
id: row.id,
|
|
613
621
|
channelId: row.channel_id,
|
|
614
622
|
conversationId: row.conversation_id ?? undefined,
|
|
@@ -618,6 +626,13 @@ function rowToChannelBinding(row: any): ChannelBinding {
|
|
|
618
626
|
createdAt: row.created_at,
|
|
619
627
|
updatedAt: row.updated_at,
|
|
620
628
|
};
|
|
629
|
+
if (row.pool_selector) {
|
|
630
|
+
binding.poolSelector = row.pool_selector;
|
|
631
|
+
binding.poolAgentId = row.pool_agent_id ?? undefined;
|
|
632
|
+
binding.poolAgentEpoch = row.pool_agent_epoch ?? undefined;
|
|
633
|
+
binding.poolClaimExpiresAt = row.pool_claim_expires_at ?? undefined;
|
|
634
|
+
}
|
|
635
|
+
return binding;
|
|
621
636
|
}
|
|
622
637
|
|
|
623
638
|
function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
@@ -627,6 +642,7 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
|
627
642
|
if (target.type === "capability") return `cap:${target.id}`;
|
|
628
643
|
if (target.type === "broadcast") return "broadcast";
|
|
629
644
|
if (target.type === "orchestrator") return `orchestrator:${target.id}`;
|
|
645
|
+
if (target.type === "pool") return `pool:${target.id}`;
|
|
630
646
|
return "";
|
|
631
647
|
}
|
|
632
648
|
|
|
@@ -641,6 +657,7 @@ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
|
|
|
641
657
|
const agent = getAgent(target.id);
|
|
642
658
|
return agent ? [agent] : [];
|
|
643
659
|
}
|
|
660
|
+
if (target.type === "pool") return poolSelectorMatches(target.id, candidates);
|
|
644
661
|
if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
|
|
645
662
|
if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
|
|
646
663
|
if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
|
|
@@ -648,6 +665,14 @@ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
|
|
|
648
665
|
return [];
|
|
649
666
|
}
|
|
650
667
|
|
|
668
|
+
function poolSelectorMatches(selector: string, candidates: AgentCard[]): AgentCard[] {
|
|
669
|
+
if (selector.startsWith("label:")) return candidates.filter((a) => a.label === selector.slice("label:".length));
|
|
670
|
+
if (selector.startsWith("tag:")) return candidates.filter((a) => a.tags.includes(selector.slice("tag:".length)));
|
|
671
|
+
if (selector.startsWith("cap:")) return candidates.filter((a) => a.capabilities.includes(selector.slice("cap:".length)));
|
|
672
|
+
const agent = getAgent(selector);
|
|
673
|
+
return agent ? [agent] : [];
|
|
674
|
+
}
|
|
675
|
+
|
|
651
676
|
function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
|
|
652
677
|
return {
|
|
653
678
|
id: agent.id,
|
|
@@ -675,6 +700,25 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
|
|
|
675
700
|
const matches = channelTargetMatches(target);
|
|
676
701
|
const snapshots = matches.map(channelTargetMatchSnapshot);
|
|
677
702
|
|
|
703
|
+
if (target.type === "pool") {
|
|
704
|
+
const healthyEligibles = matches.filter((a) => isHealthyChannelTarget(a, now));
|
|
705
|
+
if (!binding.poolAgentId) {
|
|
706
|
+
if (matches.length === 0) return { status: "error", detail: `Pool ${targetLabel}: no eligible agents`, target, matches: [] };
|
|
707
|
+
return { status: "error", detail: `Pool ${targetLabel}: ${healthyEligibles.length} eligible but slot unclaimed`, target, matches: snapshots };
|
|
708
|
+
}
|
|
709
|
+
const holder = getAgent(binding.poolAgentId);
|
|
710
|
+
if (!holder || holder.status === "offline") {
|
|
711
|
+
return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is offline`, target, matches: snapshots };
|
|
712
|
+
}
|
|
713
|
+
if (!holder.ready) {
|
|
714
|
+
return { status: "warning", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} online but not ready`, target, matches: snapshots };
|
|
715
|
+
}
|
|
716
|
+
if (binding.poolAgentEpoch !== undefined && holder.epoch !== binding.poolAgentEpoch) {
|
|
717
|
+
return { status: "warning", detail: `Pool ${targetLabel}: holder epoch changed (stale claim)`, target, matches: snapshots };
|
|
718
|
+
}
|
|
719
|
+
return { status: "ok", detail: `Pool ${targetLabel}: claimed by ${binding.poolAgentId}`, target, matches: snapshots };
|
|
720
|
+
}
|
|
721
|
+
|
|
678
722
|
if (target.type === "agent" || target.type === "orchestrator") {
|
|
679
723
|
const agent = matches[0];
|
|
680
724
|
if (!agent) {
|
|
@@ -729,6 +773,10 @@ function rowToChannelSummary(row: any): ChannelSummary {
|
|
|
729
773
|
priority: row.binding_priority,
|
|
730
774
|
created_at: row.binding_created_at,
|
|
731
775
|
updated_at: row.binding_updated_at,
|
|
776
|
+
pool_selector: row.binding_pool_selector,
|
|
777
|
+
pool_agent_id: row.binding_pool_agent_id,
|
|
778
|
+
pool_agent_epoch: row.binding_pool_agent_epoch,
|
|
779
|
+
pool_claim_expires_at: row.binding_pool_claim_expires_at,
|
|
732
780
|
}) : undefined;
|
|
733
781
|
|
|
734
782
|
return {
|
|
@@ -817,6 +865,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
817
865
|
|
|
818
866
|
const agent = getAgent(input.id)!;
|
|
819
867
|
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
868
|
+
evaluatePoolBindings();
|
|
820
869
|
return agent;
|
|
821
870
|
}
|
|
822
871
|
|
|
@@ -949,6 +998,7 @@ function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"]
|
|
|
949
998
|
|
|
950
999
|
function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
|
|
951
1000
|
if (!target) return undefined;
|
|
1001
|
+
if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
|
|
952
1002
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
953
1003
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
954
1004
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
@@ -1002,7 +1052,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
1002
1052
|
upsertChannelBinding({
|
|
1003
1053
|
channelId,
|
|
1004
1054
|
target: defaultTarget,
|
|
1005
|
-
mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
|
|
1055
|
+
mode: (defaultTarget.type === "agent" || defaultTarget.type === "pool") ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
|
|
1006
1056
|
});
|
|
1007
1057
|
}
|
|
1008
1058
|
}
|
|
@@ -1024,7 +1074,11 @@ export function listChannels(): ChannelSummary[] {
|
|
|
1024
1074
|
b.mode AS binding_mode,
|
|
1025
1075
|
b.priority AS binding_priority,
|
|
1026
1076
|
b.created_at AS binding_created_at,
|
|
1027
|
-
b.updated_at AS binding_updated_at
|
|
1077
|
+
b.updated_at AS binding_updated_at,
|
|
1078
|
+
b.pool_selector AS binding_pool_selector,
|
|
1079
|
+
b.pool_agent_id AS binding_pool_agent_id,
|
|
1080
|
+
b.pool_agent_epoch AS binding_pool_agent_epoch,
|
|
1081
|
+
b.pool_claim_expires_at AS binding_pool_claim_expires_at
|
|
1028
1082
|
FROM channels c
|
|
1029
1083
|
JOIN agents a ON a.id = c.agent_id
|
|
1030
1084
|
LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
|
|
@@ -1057,17 +1111,20 @@ export function upsertChannelBinding(input: {
|
|
|
1057
1111
|
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
1058
1112
|
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
1059
1113
|
const mode = input.mode ?? "exclusive";
|
|
1114
|
+
const isPool = input.target.type === "pool";
|
|
1115
|
+
const poolSelector = isPool ? targetId : null;
|
|
1060
1116
|
const now = Date.now();
|
|
1061
1117
|
db.transaction(() => {
|
|
1062
1118
|
if (mode === "exclusive") {
|
|
1063
1119
|
db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
1064
1120
|
}
|
|
1065
1121
|
db.prepare(`
|
|
1066
|
-
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
1067
|
-
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
1122
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
|
|
1123
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
|
|
1068
1124
|
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
1069
1125
|
mode = $mode,
|
|
1070
1126
|
priority = $priority,
|
|
1127
|
+
pool_selector = $poolSelector,
|
|
1071
1128
|
updated_at = $now
|
|
1072
1129
|
`).run({
|
|
1073
1130
|
$id: id,
|
|
@@ -1078,6 +1135,7 @@ export function upsertChannelBinding(input: {
|
|
|
1078
1135
|
$targetId: targetId,
|
|
1079
1136
|
$mode: mode,
|
|
1080
1137
|
$priority: input.priority ?? 0,
|
|
1138
|
+
$poolSelector: poolSelector,
|
|
1081
1139
|
$now: now,
|
|
1082
1140
|
});
|
|
1083
1141
|
})();
|
|
@@ -1086,6 +1144,67 @@ export function upsertChannelBinding(input: {
|
|
|
1086
1144
|
return rowToChannelBinding(row);
|
|
1087
1145
|
}
|
|
1088
1146
|
|
|
1147
|
+
export interface PoolBindingChange {
|
|
1148
|
+
bindingId: string;
|
|
1149
|
+
channelId: string;
|
|
1150
|
+
previousAgentId: string | null;
|
|
1151
|
+
newAgentId: string | null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
|
|
1155
|
+
const rows = db.prepare("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
|
|
1156
|
+
const changes: PoolBindingChange[] = [];
|
|
1157
|
+
|
|
1158
|
+
for (const row of rows) {
|
|
1159
|
+
const bindingId = row.id as string;
|
|
1160
|
+
const channelId = row.channel_id as string;
|
|
1161
|
+
const selector = row.pool_selector ?? row.target_id;
|
|
1162
|
+
const currentAgentId: string | null = row.pool_agent_id;
|
|
1163
|
+
const currentEpoch: number | null = row.pool_agent_epoch;
|
|
1164
|
+
|
|
1165
|
+
let holderValid = false;
|
|
1166
|
+
if (currentAgentId) {
|
|
1167
|
+
const holder = getAgent(currentAgentId);
|
|
1168
|
+
if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS) {
|
|
1169
|
+
if (currentEpoch === null || holder.epoch === currentEpoch) {
|
|
1170
|
+
db.prepare("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
|
|
1171
|
+
.run(now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
1172
|
+
holderValid = true;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (!holderValid && currentAgentId) {
|
|
1178
|
+
db.prepare("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
|
|
1179
|
+
.run(bindingId);
|
|
1180
|
+
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (!holderValid) {
|
|
1184
|
+
const candidates = listAgents().filter((a) =>
|
|
1185
|
+
a.id !== "user" && a.id !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel"
|
|
1186
|
+
);
|
|
1187
|
+
const eligible = poolSelectorMatches(selector, candidates)
|
|
1188
|
+
.filter((a) => a.status !== "offline" && a.ready && a.lastSeen > now - STALE_TTL_MS)
|
|
1189
|
+
.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
1190
|
+
|
|
1191
|
+
if (eligible.length > 0) {
|
|
1192
|
+
const picked = eligible[0]!;
|
|
1193
|
+
db.prepare("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
|
|
1194
|
+
.run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
1195
|
+
const lastChange = changes[changes.length - 1];
|
|
1196
|
+
if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
|
|
1197
|
+
lastChange.newAgentId = picked.id;
|
|
1198
|
+
} else {
|
|
1199
|
+
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: picked.id });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return changes;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1089
1208
|
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
1090
1209
|
const rows = conversationId
|
|
1091
1210
|
? db.prepare(`
|
|
@@ -2503,6 +2622,7 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
2503
2622
|
machine: input.hostname,
|
|
2504
2623
|
capabilities: ["orchestrator", "spawn"],
|
|
2505
2624
|
status: "online",
|
|
2625
|
+
ready: true,
|
|
2506
2626
|
meta: {
|
|
2507
2627
|
orchestratorId: input.id,
|
|
2508
2628
|
builtin: true,
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims } from "./db";
|
|
2
|
+
import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims, evaluatePoolBindings } from "./db";
|
|
3
3
|
import { matchRoute } from "./routes";
|
|
4
|
-
import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitTaskChanged } from "./sse";
|
|
4
|
+
import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitTaskChanged, emitPoolBindingChanged } from "./sse";
|
|
5
5
|
import { resolve, sep } from "path";
|
|
6
6
|
import {
|
|
7
7
|
REAP_INTERVAL_MS,
|
|
@@ -57,6 +57,12 @@ function startServer(): void {
|
|
|
57
57
|
console.log(`pruned ${pruned.length} offline agent(s)`);
|
|
58
58
|
for (const id of pruned) emitAgentRemoved(id);
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
const poolChanges = evaluatePoolBindings();
|
|
62
|
+
for (const change of poolChanges) {
|
|
63
|
+
console.log(`pool binding ${change.bindingId}: ${change.previousAgentId ?? "none"} → ${change.newAgentId ?? "none"}`);
|
|
64
|
+
emitPoolBindingChanged(change.bindingId, change.channelId, change.previousAgentId, change.newAgentId);
|
|
65
|
+
}
|
|
60
66
|
}, REAP_INTERVAL_MS);
|
|
61
67
|
|
|
62
68
|
// Daily message prune
|
package/src/routes.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
orchestratorHeartbeat,
|
|
61
61
|
updateManagedAgents,
|
|
62
62
|
deleteOrchestrator,
|
|
63
|
+
evaluatePoolBindings,
|
|
63
64
|
ValidationError,
|
|
64
65
|
} from "./db";
|
|
65
66
|
import {
|
|
@@ -90,6 +91,7 @@ import {
|
|
|
90
91
|
emitChannelActivity,
|
|
91
92
|
emitOrchestratorStatus,
|
|
92
93
|
emitOrchestratorRemoved,
|
|
94
|
+
emitPoolBindingChanged,
|
|
93
95
|
} from "./sse";
|
|
94
96
|
|
|
95
97
|
type Handler = (
|
|
@@ -175,7 +177,7 @@ function parseQueryInt(
|
|
|
175
177
|
|
|
176
178
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
177
179
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
178
|
-
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator"] as const;
|
|
180
|
+
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool"] as const;
|
|
179
181
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
|
|
180
182
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
|
|
181
183
|
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
@@ -404,6 +406,7 @@ function normalizeChannelBindingInput(body: unknown): {
|
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
function routeTargetFromAddress(target: string): ChannelRouteTarget {
|
|
409
|
+
if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
|
|
407
410
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
408
411
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
409
412
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
@@ -412,8 +415,12 @@ function routeTargetFromAddress(target: string): ChannelRouteTarget {
|
|
|
412
415
|
return { type: "agent", id: target };
|
|
413
416
|
}
|
|
414
417
|
|
|
415
|
-
function messageTargetForChannelTarget(target: ChannelRouteTarget): string {
|
|
418
|
+
function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: ChannelBinding): string {
|
|
416
419
|
if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
420
|
+
if (target.type === "pool") {
|
|
421
|
+
if (!binding?.poolAgentId) throw new ValidationError("pool slot is unclaimed — no eligible agent available");
|
|
422
|
+
return binding.poolAgentId;
|
|
423
|
+
}
|
|
417
424
|
if (target.type === "label") return `label:${target.id}`;
|
|
418
425
|
if (target.type === "tag") return `tag:${target.id}`;
|
|
419
426
|
if (target.type === "capability") return `cap:${target.id}`;
|
|
@@ -1762,7 +1769,7 @@ const postChannelEvent: Handler = async (req, params) => {
|
|
|
1762
1769
|
if (!bindings.length) return error("channel has no binding", 409);
|
|
1763
1770
|
const results = bindings.map((binding) => sendMessageWithResult({
|
|
1764
1771
|
from: channel.agentId,
|
|
1765
|
-
to: messageTargetForChannelTarget(binding.target),
|
|
1772
|
+
to: messageTargetForChannelTarget(binding.target, binding),
|
|
1766
1773
|
kind: "channel.event",
|
|
1767
1774
|
channel: channel.id,
|
|
1768
1775
|
body: input.body,
|
|
@@ -2123,6 +2130,8 @@ const postSystemReap: Handler = () => {
|
|
|
2123
2130
|
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
2124
2131
|
for (const id of reapedAgentIds) emitAgentStatus(id);
|
|
2125
2132
|
for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
|
|
2133
|
+
const poolChanges = evaluatePoolBindings();
|
|
2134
|
+
for (const change of poolChanges) emitPoolBindingChanged(change.bindingId, change.channelId, change.previousAgentId, change.newAgentId);
|
|
2126
2135
|
auditEvent({
|
|
2127
2136
|
clientId: "server-system-reap-" + Date.now(),
|
|
2128
2137
|
kind: "state",
|
package/src/sse.ts
CHANGED
|
@@ -155,3 +155,9 @@ export function emitOrchestratorRemoved(orchestratorId: string) {
|
|
|
155
155
|
send(conn, "orchestrator.removed", { id: orchestratorId });
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
export function emitPoolBindingChanged(bindingId: string, channelId: string, previousAgentId: string | null, newAgentId: string | null) {
|
|
160
|
+
for (const conn of connections.values()) {
|
|
161
|
+
send(conn, "channel.pool.changed", { bindingId, channelId, previousAgentId, newAgentId, at: Date.now() });
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -275,7 +275,8 @@ export type ChannelRouteTarget =
|
|
|
275
275
|
| { type: "tag"; id: string }
|
|
276
276
|
| { type: "capability"; id: string }
|
|
277
277
|
| { type: "broadcast" }
|
|
278
|
-
| { type: "orchestrator"; id: string }
|
|
278
|
+
| { type: "orchestrator"; id: string }
|
|
279
|
+
| { type: "pool"; id: string };
|
|
279
280
|
|
|
280
281
|
export type ChannelBindingMode = "exclusive" | "claimable" | "broadcast";
|
|
281
282
|
|
|
@@ -288,6 +289,10 @@ export interface ChannelBinding {
|
|
|
288
289
|
priority: number;
|
|
289
290
|
createdAt: number;
|
|
290
291
|
updatedAt: number;
|
|
292
|
+
poolSelector?: string;
|
|
293
|
+
poolAgentId?: string;
|
|
294
|
+
poolAgentEpoch?: number;
|
|
295
|
+
poolClaimExpiresAt?: number;
|
|
291
296
|
}
|
|
292
297
|
|
|
293
298
|
export interface ChannelTargetHealth {
|