agent-relay-server 0.20.0 → 0.22.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/src/db.ts CHANGED
@@ -74,6 +74,7 @@ import type {
74
74
  WorkspaceStatus,
75
75
  } from "./types";
76
76
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "./config";
77
+ import { matchAgents } from "./agent-ref";
77
78
 
78
79
  let db: Database;
79
80
  const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
@@ -210,6 +211,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
210
211
  provider_capabilities TEXT,
211
212
  context_state TEXT,
212
213
  meta TEXT NOT NULL DEFAULT '{}',
214
+ spawned_by TEXT,
213
215
  last_seen INTEGER NOT NULL,
214
216
  created_at INTEGER NOT NULL
215
217
  );
@@ -1047,6 +1049,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
1047
1049
  if (!agentColNames.includes("context_state")) {
1048
1050
  db.run("ALTER TABLE agents ADD COLUMN context_state TEXT");
1049
1051
  }
1052
+ if (!agentColNames.includes("spawned_by")) {
1053
+ db.run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
1054
+ }
1050
1055
  db.run(`
1051
1056
  CREATE TABLE IF NOT EXISTS context_snapshots (
1052
1057
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1245,6 +1250,7 @@ function rowToAgent(row: any): AgentCard {
1245
1250
  providerCapabilities: parseJson<ProviderCapabilities | undefined>(row.provider_capabilities, undefined),
1246
1251
  context: parseJson<ContextState | undefined>(row.context_state, undefined),
1247
1252
  meta: parseJson(row.meta, {}),
1253
+ spawnedBy: row.spawned_by ?? undefined,
1248
1254
  lastSeen: row.last_seen,
1249
1255
  createdAt: row.created_at,
1250
1256
  };
@@ -1747,8 +1753,8 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
1747
1753
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
1748
1754
  const instanceProvided = Boolean(input.instanceId);
1749
1755
  const stmt = db.query(`
1750
- INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, last_seen, created_at)
1751
- VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
1756
+ INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
1757
+ VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
1752
1758
  ON CONFLICT(id) DO UPDATE SET
1753
1759
  name = $name,
1754
1760
  kind = $kind,
@@ -1767,6 +1773,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
1767
1773
  provider_capabilities = coalesce($providerCapabilities, agents.provider_capabilities),
1768
1774
  context_state = coalesce($contextState, agents.context_state),
1769
1775
  meta = $meta,
1776
+ spawned_by = coalesce($spawnedBy, agents.spawned_by),
1770
1777
  last_seen = $now
1771
1778
  `);
1772
1779
 
@@ -1789,6 +1796,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
1789
1796
  $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
1790
1797
  $contextState: input.context ? JSON.stringify(input.context) : null,
1791
1798
  $meta: JSON.stringify(input.meta ?? {}),
1799
+ $spawnedBy: input.spawnedBy ?? null,
1792
1800
  $now: now,
1793
1801
  });
1794
1802
  if (input.context) recordContextSnapshot(input.id, input.context, now);
@@ -1858,6 +1866,106 @@ export function listAgents(filter?: {
1858
1866
  return (db.query(sql).all(...params) as any[]).map(rowToAgent);
1859
1867
  }
1860
1868
 
1869
+ export interface AgentSearchFilter {
1870
+ /** Substring match on label OR name (case-insensitive). */
1871
+ label?: string;
1872
+ /** One or more capabilities (OR within the field), via json_each(capabilities). */
1873
+ capability?: string | string[];
1874
+ /** One or more tags (OR within the field), via json_each(tags). */
1875
+ tag?: string | string[];
1876
+ /** Real model provider — providerCapabilities.model.provider (e.g. "claude", "codex"). */
1877
+ provider?: string | string[];
1878
+ machine?: string | string[];
1879
+ /** provider/orchestrator/channel/etc. */
1880
+ kind?: string | string[];
1881
+ /** Specific status(es), the "running" shorthand (live + ready + fresh), or "all". */
1882
+ status?: string | string[];
1883
+ /** Exact parent agent id — backs the `spawnedBy:` / `spawnedBy:me` fleet filter (#221). */
1884
+ spawnedBy?: string;
1885
+ }
1886
+
1887
+ export interface AgentSearchSort {
1888
+ sortBy?: "lastActive" | "created" | "label";
1889
+ order?: "asc" | "desc";
1890
+ limit?: number;
1891
+ }
1892
+
1893
+ const AGENT_SEARCH_MAX_LIMIT = 200;
1894
+
1895
+ function searchValues(value: string | string[] | undefined): string[] {
1896
+ if (value == null) return [];
1897
+ return (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string" && v.length > 0);
1898
+ }
1899
+
1900
+ // Unified fleet search: all filters optional and AND-combined; array values OR within a
1901
+ // field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
1902
+ // (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
1903
+ // canonical liveness predicate (see db.ts delivery/steward checks + isAgentOnline).
1904
+ export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
1905
+ const clauses: string[] = ["1=1"];
1906
+ const params: any[] = [];
1907
+ const inList = (column: string, values: string[]) => {
1908
+ clauses.push(`${column} IN (${values.map(() => "?").join(",")})`);
1909
+ params.push(...values);
1910
+ };
1911
+
1912
+ if (filter.label) {
1913
+ clauses.push("(label LIKE ? OR name LIKE ?)");
1914
+ params.push(`%${filter.label}%`, `%${filter.label}%`);
1915
+ }
1916
+ const caps = searchValues(filter.capability);
1917
+ if (caps.length) {
1918
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value IN (${caps.map(() => "?").join(",")}))`);
1919
+ params.push(...caps);
1920
+ }
1921
+ const tags = searchValues(filter.tag);
1922
+ if (tags.length) {
1923
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE value IN (${tags.map(() => "?").join(",")}))`);
1924
+ params.push(...tags);
1925
+ }
1926
+ const providers = searchValues(filter.provider);
1927
+ if (providers.length) {
1928
+ clauses.push(`json_extract(provider_capabilities, '$.model.provider') IN (${providers.map(() => "?").join(",")})`);
1929
+ params.push(...providers);
1930
+ }
1931
+ const machines = searchValues(filter.machine);
1932
+ if (machines.length) inList("machine", machines);
1933
+ const kinds = searchValues(filter.kind);
1934
+ if (kinds.length) inList("kind", kinds);
1935
+ if (filter.spawnedBy) {
1936
+ clauses.push("spawned_by = ?");
1937
+ params.push(filter.spawnedBy);
1938
+ }
1939
+
1940
+ const statuses = searchValues(filter.status);
1941
+ if (!statuses.includes("all")) {
1942
+ if (statuses.includes("running")) {
1943
+ clauses.push("status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?");
1944
+ params.push(Date.now() - STALE_TTL_MS);
1945
+ }
1946
+ const explicit = statuses.filter((s) => s !== "running" && s !== "all");
1947
+ if (explicit.length) inList("status", explicit);
1948
+ }
1949
+
1950
+ const sortColumn = sort.sortBy === "created" ? "created_at" : sort.sortBy === "label" ? "label COLLATE NOCASE" : "last_seen";
1951
+ const order = sort.order === "asc" ? "ASC" : "DESC";
1952
+ const limit = Math.max(1, Math.min(sort.limit ?? 50, AGENT_SEARCH_MAX_LIMIT));
1953
+ params.push(limit);
1954
+
1955
+ const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ?`;
1956
+ return (db.query(sql).all(...params) as any[]).map(rowToAgent);
1957
+ }
1958
+
1959
+ // Count of a parent's currently-LIVE children — the spawn quota numerator (#221). "Live"
1960
+ // mirrors the running predicate (not offline/stale, ready, fresh); a child that exits frees
1961
+ // a slot. Uncapped COUNT (unlike searchAgents' LIMIT) so the quota is exact at any fleet size.
1962
+ export function countLiveSpawnedAgents(parentId: string, now: number = Date.now()): number {
1963
+ const row = db.query(
1964
+ "SELECT count(*) AS n FROM agents WHERE spawned_by = ? AND status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?",
1965
+ ).get(parentId, now - STALE_TTL_MS) as { n: number };
1966
+ return row.n;
1967
+ }
1968
+
1861
1969
  export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
1862
1970
  if (!validateAgentSession(id, guard).ok) return false;
1863
1971
  const now = Date.now();
@@ -2350,17 +2458,6 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
2350
2458
  })();
2351
2459
  }
2352
2460
 
2353
- export function findAgentsByCapability(capability: string, onlineOnly = true): AgentCard[] {
2354
- let sql = `SELECT * FROM agents WHERE EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value = ?)`;
2355
- const params: any[] = [capability];
2356
- if (onlineOnly) {
2357
- sql += ` AND status != 'offline' AND last_seen > ?`;
2358
- params.push(Date.now() - STALE_TTL_MS);
2359
- }
2360
- sql += " ORDER BY last_seen DESC";
2361
- return (db.query(sql).all(...params) as any[]).map(rowToAgent);
2362
- }
2363
-
2364
2461
  export function deleteAgent(id: string): { ok: boolean; error?: string } {
2365
2462
  if (id === "user" || id === "system") {
2366
2463
  return { ok: false, error: "built-in agent cannot be deleted" };
@@ -3023,21 +3120,6 @@ function pairPeer(pair: PairSession, agentId: string): string {
3023
3120
  return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
3024
3121
  }
3025
3122
 
3026
- function agentMatchesPairTarget(agent: AgentCard, target: string): boolean {
3027
- if (target.startsWith("id:")) return agent.id === target.slice(3);
3028
- if (target.startsWith("label:")) return agent.label === target.slice(6);
3029
- if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
3030
- if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
3031
- if (target.startsWith("rig:")) return agent.rig === target.slice(4);
3032
- if (target.startsWith("machine:")) return agent.machine === target.slice(8);
3033
- return agent.id === target ||
3034
- agent.label === target ||
3035
- agent.tags.includes(target) ||
3036
- agent.capabilities.includes(target) ||
3037
- agent.rig === target ||
3038
- agent.name === target;
3039
- }
3040
-
3041
3123
  function resolvePairTarget(target: string, requesterId: string): {
3042
3124
  ok: true;
3043
3125
  agent: AgentCard;
@@ -3048,7 +3130,7 @@ function resolvePairTarget(target: string, requesterId: string): {
3048
3130
  matches?: AgentCard[];
3049
3131
  busy?: Array<{ agent: AgentCard; pair: PairSession }>;
3050
3132
  } {
3051
- const matches = listAgents().filter((agent) => agent.id !== requesterId && agentMatchesPairTarget(agent, target));
3133
+ const matches = matchAgents(target, listAgents(), { excludeId: requesterId });
3052
3134
  if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
3053
3135
 
3054
3136
  const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
@@ -34,6 +34,7 @@ import {
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
36
  import { workspaceActiveClaim } from "./workspace-claim";
37
+ import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
37
38
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
38
39
  import { getStewardConfig } from "./config-store";
39
40
  import { ensureRepoSteward } from "./steward";
@@ -86,7 +87,6 @@ const STRANDABLE_STATUSES = new Set<WorkspaceStatus>(["review_requested", "confl
86
87
  // Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
87
88
  // in-flight (cleanup_requested) states are skipped.
88
89
  const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
89
- const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
90
90
  // In-flight merge statuses that should reconcile to `merged` once the host
91
91
  // reports the branch's work has landed in base (squash/cherry-pick, or a merged
92
92
  // PR). Excludes active/ready: an agent still working may have landed an early
@@ -695,7 +695,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
695
695
  // base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
696
696
  // conflict, so a too-optimistic prediction degrades safely to review_requested.
697
697
  // Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
698
- const canLand = p.conflict === false && ahead > 0;
698
+ // A no-op (#230) nothing to land (ahead=0, clean) — is also landable: the host
699
+ // resolves it to a terminal `merged` status, draining the queue instead of waking
700
+ // the steward every sweep for a workspace with no work.
701
+ const canLand = p.noop === true || (p.conflict === false && ahead > 0);
699
702
  if (!canLand) {
700
703
  leftForSteward.push(ws.id);
701
704
  if (stewardEnabled) {
@@ -716,8 +719,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
716
719
  createActivityEvent({
717
720
  clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
718
721
  kind: "state",
719
- title: behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
720
- body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
722
+ title: p.noop ? "Workspace auto-resolving (no work to merge)" : behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
723
+ body: p.noop
724
+ ? `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (nothing to land — resolving to merged)`
725
+ : `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
721
726
  meta: ws.branch ?? ws.id,
722
727
  icon: "ti-git-merge",
723
728
  view: "orchestrators",
@@ -1,4 +1,4 @@
1
- import { getAgentProfile } from "./config-store";
1
+ import { getAgentProfile, spawnGrantForProfile } from "./config-store";
2
2
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
3
3
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
4
4
  import type { SpawnPolicy, WorkspaceMode } from "./types";
@@ -29,6 +29,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
29
29
  const providerArgs = managedPolicyProviderArgs(policy);
30
30
  const prompt = managedPolicyLaunchPrompt(policy);
31
31
  const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
32
+ const grant = spawnGrantForProfile(policy.profile);
32
33
  return buildSpawnCommand({
33
34
  provider: policy.provider,
34
35
  cwd: policy.cwd,
@@ -55,6 +56,8 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
55
56
  policyName: policy.name,
56
57
  spawnRequestId: requestId,
57
58
  createdBy: ctx.createdBy,
59
+ canSpawn: grant.canSpawn,
60
+ maxSpawnedAgents: grant.maxSpawnedAgents,
58
61
  }),
59
62
  requestedBy: ctx.createdBy,
60
63
  requestedAt: ctx.requestedAt ?? Date.now(),