agent-relay-server 0.118.5 → 0.119.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 +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +55 -0
- package/src/commands-db.ts +57 -2
- package/src/services/task-context-hydration.ts +7 -2
- package/src/spawn-targets.ts +21 -2
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.
|
|
5
|
+
"version": "0.119.0",
|
|
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.
|
|
3
|
+
"version": "0.119.0",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"CONTRIBUTING.md"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"agent-relay-channels-host": "0.
|
|
39
|
+
"agent-relay-channels-host": "0.119.0",
|
|
40
40
|
"agent-relay-providers": "0.104.1",
|
|
41
|
-
"agent-relay-sdk": "0.2.
|
|
41
|
+
"agent-relay-sdk": "0.2.102",
|
|
42
42
|
"ajv": "^8.20.0"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
@@ -848,6 +848,61 @@ import { promisify } from "util";
|
|
|
848
848
|
var execFileAsync = promisify(execFile);
|
|
849
849
|
// sdk/src/teams-config.ts
|
|
850
850
|
var TEAM_IDLE_THRESHOLD_MINUTES_MAX = 24 * 60;
|
|
851
|
+
// sdk/src/admission/semaphore.ts
|
|
852
|
+
class Semaphore {
|
|
853
|
+
#limit;
|
|
854
|
+
#inFlight = 0;
|
|
855
|
+
#waiters = [];
|
|
856
|
+
constructor(limit) {
|
|
857
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
858
|
+
throw new RangeError(`Semaphore limit must be an integer >= 1, got ${limit}`);
|
|
859
|
+
}
|
|
860
|
+
this.#limit = limit;
|
|
861
|
+
}
|
|
862
|
+
get limit() {
|
|
863
|
+
return this.#limit;
|
|
864
|
+
}
|
|
865
|
+
get inFlight() {
|
|
866
|
+
return this.#inFlight;
|
|
867
|
+
}
|
|
868
|
+
get waiting() {
|
|
869
|
+
return this.#waiters.length;
|
|
870
|
+
}
|
|
871
|
+
get available() {
|
|
872
|
+
return this.#limit - this.#inFlight;
|
|
873
|
+
}
|
|
874
|
+
tryAcquire() {
|
|
875
|
+
if (this.#inFlight < this.#limit) {
|
|
876
|
+
this.#inFlight++;
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
acquire() {
|
|
882
|
+
if (this.tryAcquire())
|
|
883
|
+
return Promise.resolve();
|
|
884
|
+
return new Promise((resolve) => {
|
|
885
|
+
this.#waiters.push(resolve);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
release() {
|
|
889
|
+
const next = this.#waiters.shift();
|
|
890
|
+
if (next) {
|
|
891
|
+
next();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (this.#inFlight > 0)
|
|
895
|
+
this.#inFlight--;
|
|
896
|
+
}
|
|
897
|
+
async run(fn) {
|
|
898
|
+
await this.acquire();
|
|
899
|
+
try {
|
|
900
|
+
return await fn();
|
|
901
|
+
} finally {
|
|
902
|
+
this.release();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
851
906
|
// runner/src/config.ts
|
|
852
907
|
function messageBodyMaxCharsFromEnv() {
|
|
853
908
|
return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
|
package/src/commands-db.ts
CHANGED
|
@@ -27,6 +27,36 @@ interface CommandFilters {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const ACTIVE_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
|
|
30
|
+
const TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "failed", "canceled", "timed_out"];
|
|
31
|
+
|
|
32
|
+
// #930 — an `agent.spawn` command carries a hard TTL from CREATION (see
|
|
33
|
+
// defaultExpiresAt). The #880 concurrency cap introduces REAL queuing, so a
|
|
34
|
+
// legitimately-queued spawn can sit `accepted`/`running` in the orchestrator past
|
|
35
|
+
// that create-time TTL and be swept to `timed_out` while the orchestrator still
|
|
36
|
+
// intends to run it. Refresh the TTL from dispatch-intent (claim → accepted, or
|
|
37
|
+
// slot-acquire → running) so the window measures from when the host committed to
|
|
38
|
+
// the spawn, not from enqueue. Scoped to `agent.spawn`; every other command type
|
|
39
|
+
// keeps create-time TTL semantics.
|
|
40
|
+
const SPAWN_TTL_MS = 180_000;
|
|
41
|
+
const SPAWN_DISPATCH_STATUSES: CommandStatus[] = ["accepted", "running"];
|
|
42
|
+
|
|
43
|
+
function isTerminalStatus(status: CommandStatus): boolean {
|
|
44
|
+
return TERMINAL_STATUSES.includes(status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// #930 — the two terminal→different transitions that ARE legitimate (verified by
|
|
48
|
+
// checking every updateCommand caller). Everything else out of a terminal state is
|
|
49
|
+
// state churn to be refused (esp. a backgrounded spawn's late `succeeded` racing the
|
|
50
|
+
// TTL sweep: `timed_out`→`succeeded`).
|
|
51
|
+
// 1. The spawn-registration watchdog corrects a premature spawn `succeeded` to
|
|
52
|
+
// `failed` when the agent never registered (src/services/spawn-registration-watchdog.ts).
|
|
53
|
+
// 2. The upgrade reconcile flips a timed-out upgrade back to `succeeded` when the
|
|
54
|
+
// orchestrator eventually reports the desired version (src/routes/orchestrator.ts).
|
|
55
|
+
function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
|
|
56
|
+
if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
|
|
57
|
+
if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
30
60
|
|
|
31
61
|
export function createCommand(input: CreateCommandInput): Command {
|
|
32
62
|
const now = Date.now();
|
|
@@ -97,16 +127,41 @@ export function updateCommand(id: string, input: UpdateCommandInput): Command |
|
|
|
97
127
|
const existing = getCommand(id);
|
|
98
128
|
if (!existing) return null;
|
|
99
129
|
const nextStatus = input.status ?? existing.status;
|
|
130
|
+
// #930 — terminal-state guard. A command already in a terminal state must not be
|
|
131
|
+
// silently transitioned to a DIFFERENT status. The concrete hazard: the backgrounded
|
|
132
|
+
// spawn dispatch (#880 cap) can call updateCommand("succeeded") AFTER expireCommands
|
|
133
|
+
// has already swept the row to `timed_out`, silently flipping timed_out→succeeded —
|
|
134
|
+
// state churn plus double-spawn risk if the requestor already reacted to the timeout.
|
|
135
|
+
// Idempotent terminal→same-terminal is fine; the only terminal→different transitions
|
|
136
|
+
// allowed are the checked-caller corrections in isLegitimateTerminalCorrection. Any
|
|
137
|
+
// other is an observable no-op (returns the row unchanged, logs) rather than a write.
|
|
138
|
+
if (
|
|
139
|
+
input.status &&
|
|
140
|
+
input.status !== existing.status &&
|
|
141
|
+
isTerminalStatus(existing.status) &&
|
|
142
|
+
!isLegitimateTerminalCorrection(existing.type, existing.status, input.status)
|
|
143
|
+
) {
|
|
144
|
+
console.error(`[commands-db] refused terminal transition ${existing.status}→${input.status} for ${existing.type} command ${id}`);
|
|
145
|
+
return existing;
|
|
146
|
+
}
|
|
100
147
|
const now = Date.now();
|
|
148
|
+
// #930 — refresh the spawn TTL from dispatch-intent when the command is claimed or
|
|
149
|
+
// dispatched (see SPAWN_DISPATCH_STATUSES). Other command types keep their existing
|
|
150
|
+
// expiry untouched.
|
|
151
|
+
const nextExpiresAt =
|
|
152
|
+
existing.type === "agent.spawn" && input.status && SPAWN_DISPATCH_STATUSES.includes(input.status)
|
|
153
|
+
? now + SPAWN_TTL_MS
|
|
154
|
+
: existing.expiresAt ?? null;
|
|
101
155
|
getDb().query(`
|
|
102
156
|
UPDATE commands
|
|
103
|
-
SET status = ?, result = ?, error = ?, updated_at = ?
|
|
157
|
+
SET status = ?, result = ?, error = ?, updated_at = ?, expires_at = ?
|
|
104
158
|
WHERE id = ?
|
|
105
159
|
`).run(
|
|
106
160
|
nextStatus,
|
|
107
161
|
input.result !== undefined ? JSON.stringify(input.result) : existing.result ? JSON.stringify(existing.result) : null,
|
|
108
162
|
input.error ?? existing.error ?? null,
|
|
109
163
|
now,
|
|
164
|
+
nextExpiresAt,
|
|
110
165
|
id,
|
|
111
166
|
);
|
|
112
167
|
const updated = getCommand(id);
|
|
@@ -131,7 +186,7 @@ export function expireCommands(now: number = Date.now()): Command[] {
|
|
|
131
186
|
}
|
|
132
187
|
|
|
133
188
|
function defaultExpiresAt(type: string, now: number): number | undefined {
|
|
134
|
-
if (type === "agent.spawn") return now +
|
|
189
|
+
if (type === "agent.spawn") return now + SPAWN_TTL_MS;
|
|
135
190
|
if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.reconnect" || type === "agent.kill" || type === "agent.compact" || type === "agent.clearContext" || type === "agent.injectContext") return now + 30_000;
|
|
136
191
|
return undefined;
|
|
137
192
|
}
|
|
@@ -338,7 +338,9 @@ function joinBlocks(...parts: Array<string | undefined>): string | undefined {
|
|
|
338
338
|
* single instruction source. Caller system append is preserved unless it already embeds the
|
|
339
339
|
* hydrated brief, which avoids delivering the same brief through both provider channels.
|
|
340
340
|
* The steer used as a first-message kick is sentinel-stripped so it cannot forge the data fence
|
|
341
|
-
* in the (instruction-bearing) systemPromptAppend brief.
|
|
341
|
+
* in the (instruction-bearing) systemPromptAppend brief. #927 — for the append-system-prompt
|
|
342
|
+
* channel a spawn with no steer falls back to submitting the hydrated brief as turn 1, so the
|
|
343
|
+
* worker is never left with an empty prompt (which left task-bound Claude spawns idling).
|
|
342
344
|
*/
|
|
343
345
|
export function projectTaskHydration(
|
|
344
346
|
provider: SpawnProvider | string,
|
|
@@ -352,7 +354,10 @@ export function projectTaskHydration(
|
|
|
352
354
|
if (taskHydrationChannel(provider) === "append-system-prompt") {
|
|
353
355
|
return {
|
|
354
356
|
systemPromptAppend: joinBlocks(context, callerAppend),
|
|
355
|
-
|
|
357
|
+
// #927 — always submit a turn-1 message. Steer drives it when present (the brief lives in
|
|
358
|
+
// systemPromptAppend as durable context); with NO steer, fall back to submitting the brief
|
|
359
|
+
// itself so the spawn isn't left with an empty prompt and idling forever.
|
|
360
|
+
prompt: joinBlocks(steer ? stripSentinels(steer) : context),
|
|
356
361
|
};
|
|
357
362
|
}
|
|
358
363
|
return {
|
package/src/spawn-targets.ts
CHANGED
|
@@ -113,6 +113,26 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
|
|
|
113
113
|
recommendedAction: "No quota burn projection available; spawn normally.",
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
// #926 — the orchestrator's REPORTED catalog is built from the static SDK manifest only
|
|
117
|
+
// (orchestrator/src/provider-probe.ts uses providerCatalogList(), which has no DB access), while
|
|
118
|
+
// spawn VALIDATION resolves against effectiveProviderCatalogList() (static catalog + DB
|
|
119
|
+
// provider_model_overrides — see resolveSpawnModelParams in spawn-command.ts). A model enabled
|
|
120
|
+
// only via a DB override (e.g. a newly added `sonnet-5`) is therefore spawnable but invisible to
|
|
121
|
+
// this matrix even after an orchestrator restart, since the orchestrator's snapshot never reads
|
|
122
|
+
// the DB. Merge by alias, with the DB-matrix (global) entry winning on collision, so every model
|
|
123
|
+
// spawn validation would accept is discoverable here too.
|
|
124
|
+
function mergeCatalogModels(reportedCat: SpawnCatalogish | undefined, globalCat: SpawnCatalogish | undefined): SpawnCatalogish | undefined {
|
|
125
|
+
if (!reportedCat && !globalCat) return undefined;
|
|
126
|
+
const byAlias = new Map<string, SpawnCatalogish["models"][number]>();
|
|
127
|
+
for (const m of reportedCat?.models ?? []) byAlias.set(m.alias, m);
|
|
128
|
+
for (const m of globalCat?.models ?? []) byAlias.set(m.alias, m);
|
|
129
|
+
return {
|
|
130
|
+
provider: (reportedCat ?? globalCat)!.provider,
|
|
131
|
+
defaultModel: reportedCat?.defaultModel ?? globalCat?.defaultModel,
|
|
132
|
+
models: [...byAlias.values()],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
116
136
|
function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider, SpawnQuotaAdvisory>): Array<Record<string, unknown>> {
|
|
117
137
|
const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
|
|
118
138
|
const global = effectiveProviderCatalogList();
|
|
@@ -129,8 +149,7 @@ function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider,
|
|
|
129
149
|
const unavailableAliasesFor = (provider: SpawnProvider): Set<string> =>
|
|
130
150
|
new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.unavailable).map((m) => m.alias) ?? []);
|
|
131
151
|
return o.providers.map((provider) => {
|
|
132
|
-
const cat
|
|
133
|
-
reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
|
|
152
|
+
const cat = mergeCatalogModels(reported.find((s) => s.provider === provider), global.find((e) => e.provider === provider));
|
|
134
153
|
const disabled = disabledAliasesFor(provider);
|
|
135
154
|
const unavailable = unavailableAliasesFor(provider);
|
|
136
155
|
const quotaAdvisory = quotaAdvisories.get(provider) ?? DEFAULT_QUOTA_ADVISORY;
|