agent-relay-server 0.10.20 → 0.10.22
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 +459 -70
- package/package.json +2 -2
- package/public/index.html +1200 -472
- package/scripts/orchestrator-spawn-smoke.ts +26 -2
- package/src/db.ts +97 -21
- package/src/lifecycle-manager.ts +56 -1
- package/src/maintenance.ts +117 -0
- package/src/routes.ts +415 -27
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
|
|
2
4
|
type Orchestrator = {
|
|
3
5
|
id: string;
|
|
4
6
|
status: string;
|
|
@@ -20,7 +22,11 @@ type Agent = {
|
|
|
20
22
|
const args = process.argv.slice(2);
|
|
21
23
|
let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
22
24
|
let orchestratorId = process.env.AGENT_RELAY_SMOKE_ORCHESTRATOR || "";
|
|
23
|
-
|
|
25
|
+
// Default cwd is resolved from the selected orchestrator's baseDir below, not
|
|
26
|
+
// the local process.cwd() — the chosen orchestrator may be on a different host
|
|
27
|
+
// (e.g. macOS macmini with /Users/admin/projects) where a local Linux path
|
|
28
|
+
// fails cwd containment.
|
|
29
|
+
let cwd = process.env.AGENT_RELAY_SMOKE_CWD || "";
|
|
24
30
|
let timeoutMs = Number(process.env.AGENT_RELAY_SMOKE_TIMEOUT_MS || 90_000);
|
|
25
31
|
let providers = (process.env.AGENT_RELAY_SMOKE_PROVIDERS || "codex,claude").split(",").filter(Boolean);
|
|
26
32
|
|
|
@@ -86,12 +92,30 @@ function findSpawnedAgent(agents: Agent[], provider: string, label: string, star
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
|
|
95
|
+
// Default selection prefers the orchestrator on the same host as the relay (the
|
|
96
|
+
// gate runs there): a local spawn is faster and avoids cross-host teardown
|
|
97
|
+
// flakiness. Fall back to any online orchestrator. An explicit --orchestrator
|
|
98
|
+
// always wins — pair it with --cwd (or rely on the baseDir default below) to
|
|
99
|
+
// target a remote host such as a macOS macmini.
|
|
100
|
+
const onlineOrchestrators = orchestrators.filter((orch) => orch.status === "online");
|
|
101
|
+
const localOrchestrator = onlineOrchestrators.find((orch) => orch.id === hostname());
|
|
89
102
|
const orchestrator = orchestratorId
|
|
90
103
|
? orchestrators.find((orch) => orch.id === orchestratorId)
|
|
91
|
-
:
|
|
104
|
+
: localOrchestrator ?? onlineOrchestrators[0];
|
|
92
105
|
if (!orchestrator) throw new Error(orchestratorId ? `orchestrator not found: ${orchestratorId}` : "no online orchestrator found");
|
|
93
106
|
if (orchestrator.status !== "online") throw new Error(`orchestrator ${orchestrator.id} is ${orchestrator.status}`);
|
|
94
107
|
|
|
108
|
+
// Resolve the spawn cwd against the chosen orchestrator's own base directory so
|
|
109
|
+
// the smoke works regardless of which host runs it. An explicit --cwd / env
|
|
110
|
+
// override still wins (and is subject to the orchestrator's containment check).
|
|
111
|
+
if (!cwd) {
|
|
112
|
+
if (!orchestrator.baseDir) {
|
|
113
|
+
throw new Error(`orchestrator ${orchestrator.id} did not report a baseDir; pass --cwd or set AGENT_RELAY_SMOKE_CWD`);
|
|
114
|
+
}
|
|
115
|
+
cwd = orchestrator.baseDir;
|
|
116
|
+
}
|
|
117
|
+
console.log(`using cwd ${cwd} on ${orchestrator.id} (base ${orchestrator.baseDir})`);
|
|
118
|
+
|
|
95
119
|
for (const provider of providers) {
|
|
96
120
|
if (!orchestrator.providers.includes(provider)) {
|
|
97
121
|
console.log(`skip ${provider}: orchestrator ${orchestrator.id} does not support it`);
|
package/src/db.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type {
|
|
|
41
41
|
OrchestratorHealth,
|
|
42
42
|
OrchestratorRuntimeInput,
|
|
43
43
|
OrchestratorStatus,
|
|
44
|
+
OrchestratorUpgradeState,
|
|
44
45
|
PairActionInput,
|
|
45
46
|
PairMessageInput,
|
|
46
47
|
PairSession,
|
|
@@ -4084,7 +4085,7 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
4084
4085
|
}
|
|
4085
4086
|
|
|
4086
4087
|
function messageRequiresReply(message: Message): boolean {
|
|
4087
|
-
if (message.kind === "system" || message.kind === "control") return false;
|
|
4088
|
+
if (message.kind === "system" || message.kind === "control" || message.kind === "session") return false;
|
|
4088
4089
|
if (message.from === "user") return true;
|
|
4089
4090
|
if (message.kind === "task" || message.kind === "channel.event") return true;
|
|
4090
4091
|
return Boolean(message.payload?.source);
|
|
@@ -4132,7 +4133,7 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
|
|
|
4132
4133
|
...(message.channel ? { channel: message.channel } : {}),
|
|
4133
4134
|
bodyPreview: message.body.length > 240 ? `${message.body.slice(0, 240)}\n[truncated]` : message.body,
|
|
4134
4135
|
createdAt: message.createdAt,
|
|
4135
|
-
replyCommand: `agent-relay /reply ${message.id} --stdin <
|
|
4136
|
+
replyCommand: `agent-relay /reply ${message.id} --stdin < .agent-relay/sessions/${agentId}/tmp/reply.md`,
|
|
4136
4137
|
};
|
|
4137
4138
|
}
|
|
4138
4139
|
|
|
@@ -4652,6 +4653,11 @@ function rowToOrchestrator(row: any): Orchestrator {
|
|
|
4652
4653
|
const providerStatus = Array.isArray(meta.providerStatus) ? meta.providerStatus as Orchestrator["providerStatus"] : undefined;
|
|
4653
4654
|
const providerCatalog = Array.isArray(meta.providerCatalog) ? meta.providerCatalog as Orchestrator["providerCatalog"] : undefined;
|
|
4654
4655
|
const compatibility = contractCompatibility(contracts, { orchestratorProtocol: CONTRACT_REQUIREMENTS.orchestratorProtocol });
|
|
4656
|
+
const supervisorRaw = stringValue(meta.supervisor);
|
|
4657
|
+
const supervisor = supervisorRaw === "systemd" || supervisorRaw === "process" || supervisorRaw === "unknown" ? supervisorRaw : undefined;
|
|
4658
|
+
const selfUnit = stringValue(meta.selfUnit);
|
|
4659
|
+
const runtimePrefix = stringValue(meta.runtimePrefix);
|
|
4660
|
+
const upgrade = parseOrchestratorUpgrade(meta.upgrade);
|
|
4655
4661
|
return {
|
|
4656
4662
|
id: row.id,
|
|
4657
4663
|
hostname: row.hostname,
|
|
@@ -4671,6 +4677,10 @@ function rowToOrchestrator(row: any): Orchestrator {
|
|
|
4671
4677
|
...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
|
|
4672
4678
|
...(gitSha ? { gitSha } : {}),
|
|
4673
4679
|
health: orchestratorHealth(version, compatibility),
|
|
4680
|
+
...(supervisor ? { supervisor } : {}),
|
|
4681
|
+
...(selfUnit ? { selfUnit } : {}),
|
|
4682
|
+
...(runtimePrefix ? { runtimePrefix } : {}),
|
|
4683
|
+
...(upgrade ? { upgrade } : {}),
|
|
4674
4684
|
meta,
|
|
4675
4685
|
managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
|
|
4676
4686
|
lastSeen: row.last_seen,
|
|
@@ -4678,6 +4688,39 @@ function rowToOrchestrator(row: any): Orchestrator {
|
|
|
4678
4688
|
};
|
|
4679
4689
|
}
|
|
4680
4690
|
|
|
4691
|
+
function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
|
|
4692
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
4693
|
+
const v = value as Record<string, unknown>;
|
|
4694
|
+
const desiredVersion = stringValue(v.desiredVersion);
|
|
4695
|
+
const status = v.status;
|
|
4696
|
+
if (!desiredVersion || (status !== "pending" && status !== "succeeded" && status !== "failed")) return undefined;
|
|
4697
|
+
return {
|
|
4698
|
+
desiredVersion,
|
|
4699
|
+
status,
|
|
4700
|
+
...(stringValue(v.commandId) ? { commandId: stringValue(v.commandId) } : {}),
|
|
4701
|
+
...(Array.isArray(v.providers) ? { providers: v.providers.filter((p): p is string => typeof p === "string") } : {}),
|
|
4702
|
+
...(stringValue(v.fromVersion) ? { fromVersion: stringValue(v.fromVersion) } : {}),
|
|
4703
|
+
...(stringValue(v.requestedBy) ? { requestedBy: stringValue(v.requestedBy) } : {}),
|
|
4704
|
+
requestedAt: typeof v.requestedAt === "number" ? v.requestedAt : 0,
|
|
4705
|
+
...(typeof v.settledAt === "number" ? { settledAt: v.settledAt } : {}),
|
|
4706
|
+
...(stringValue(v.error) ? { error: stringValue(v.error) } : {}),
|
|
4707
|
+
};
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
/**
|
|
4711
|
+
* Set or clear the orchestrator's in-flight/last upgrade state. Stored in the
|
|
4712
|
+
* meta blob (no schema change). Pass null to clear.
|
|
4713
|
+
*/
|
|
4714
|
+
export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgradeState | null): Orchestrator | null {
|
|
4715
|
+
const row = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
4716
|
+
if (!row) return null;
|
|
4717
|
+
const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
|
|
4718
|
+
if (state) meta.upgrade = state;
|
|
4719
|
+
else delete meta.upgrade;
|
|
4720
|
+
db.prepare("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
|
|
4721
|
+
return getOrchestrator(id);
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4681
4724
|
function rowToWorkspace(row: any): WorkspaceRecord {
|
|
4682
4725
|
return {
|
|
4683
4726
|
id: row.id,
|
|
@@ -4759,6 +4802,13 @@ function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: Orch
|
|
|
4759
4802
|
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
4760
4803
|
const now = Date.now();
|
|
4761
4804
|
const agentId = `orchestrator-${input.id}`;
|
|
4805
|
+
// Carry forward server-managed meta the orchestrator never reports (upgrade
|
|
4806
|
+
// state) — registration meta would otherwise drop it on the very re-register
|
|
4807
|
+
// that follows a self-upgrade restart, breaking version reconciliation.
|
|
4808
|
+
const existingRow = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
|
|
4809
|
+
const existingMeta = existingRow ? parseJson<Record<string, unknown>>(existingRow.meta ?? "{}", {}) : {};
|
|
4810
|
+
const mergedMeta = mergeOrchestratorRuntimeMeta(input.meta ?? {}, input);
|
|
4811
|
+
if (existingMeta.upgrade !== undefined && mergedMeta.upgrade === undefined) mergedMeta.upgrade = existingMeta.upgrade;
|
|
4762
4812
|
const stmt = db.prepare(`
|
|
4763
4813
|
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
|
|
4764
4814
|
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
|
|
@@ -4780,7 +4830,7 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
4780
4830
|
$baseDir: input.baseDir,
|
|
4781
4831
|
$apiUrl: input.apiUrl ?? null,
|
|
4782
4832
|
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
4783
|
-
$meta: JSON.stringify(
|
|
4833
|
+
$meta: JSON.stringify(mergedMeta),
|
|
4784
4834
|
$now: now,
|
|
4785
4835
|
});
|
|
4786
4836
|
|
|
@@ -4865,26 +4915,48 @@ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchest
|
|
|
4865
4915
|
|
|
4866
4916
|
function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord | null {
|
|
4867
4917
|
const workspace = agent.workspace;
|
|
4868
|
-
if (!workspace
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4918
|
+
if (!workspace) return null;
|
|
4919
|
+
if (workspace.mode === "isolated" && workspace.id && workspace.repoRoot && workspace.worktreePath) {
|
|
4920
|
+
return upsertWorkspace({
|
|
4921
|
+
id: workspace.id,
|
|
4922
|
+
repoRoot: workspace.repoRoot,
|
|
4923
|
+
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
4924
|
+
worktreePath: workspace.worktreePath,
|
|
4925
|
+
branch: workspace.branch,
|
|
4926
|
+
baseRef: workspace.baseRef,
|
|
4927
|
+
baseSha: workspace.baseSha,
|
|
4928
|
+
mode: workspace.mode,
|
|
4929
|
+
requestedMode: workspace.requestedMode,
|
|
4930
|
+
status: workspace.status ?? "active",
|
|
4931
|
+
ownerAgentId: agent.agentId || undefined,
|
|
4932
|
+
ownerPolicyName: agent.policyName,
|
|
4933
|
+
ownerAutomationRunId: agent.automationRunId,
|
|
4934
|
+
metadata: { provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
4935
|
+
});
|
|
4936
|
+
}
|
|
4937
|
+
// Shared-mode occupancy: one row per agent sharing a git repo, with no
|
|
4938
|
+
// worktree. Records true repo occupancy in the Workspaces view (informational
|
|
4939
|
+
// only — never a cleanup/merge target). Skip non-git launches.
|
|
4940
|
+
const sharedRepoRoot = workspace.mode === "shared" ? (workspace.repoRoot ?? workspace.probe?.repoRoot) : undefined;
|
|
4941
|
+
if (sharedRepoRoot && agent.agentId) {
|
|
4942
|
+
return upsertWorkspace({
|
|
4943
|
+
id: `shared-${agent.agentId}`,
|
|
4944
|
+
repoRoot: sharedRepoRoot,
|
|
4945
|
+
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
4946
|
+
worktreePath: "",
|
|
4947
|
+
mode: "shared",
|
|
4948
|
+
requestedMode: workspace.requestedMode,
|
|
4949
|
+
status: "active",
|
|
4950
|
+
ownerAgentId: agent.agentId,
|
|
4951
|
+
ownerPolicyName: agent.policyName,
|
|
4952
|
+
ownerAutomationRunId: agent.automationRunId,
|
|
4953
|
+
metadata: { occupancy: true, provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4956
|
+
return null;
|
|
4885
4957
|
}
|
|
4886
4958
|
|
|
4887
|
-
function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
|
|
4959
|
+
export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
|
|
4888
4960
|
const now = Date.now();
|
|
4889
4961
|
db.prepare(`
|
|
4890
4962
|
INSERT INTO workspaces (id, repo_root, source_cwd, worktree_path, branch, base_ref, base_sha, mode, requested_mode, status, owner_agent_id, owner_policy_name, owner_automation_run_id, steward_agent_id, metadata, created_at, updated_at, ready_at, cleaned_at)
|
|
@@ -4947,6 +5019,10 @@ export function listWorkspaces(filter: { repoRoot?: string; ownerAgentId?: strin
|
|
|
4947
5019
|
return (db.prepare(sql).all(...params) as any[]).map(rowToWorkspace);
|
|
4948
5020
|
}
|
|
4949
5021
|
|
|
5022
|
+
export function deleteWorkspace(id: string): boolean {
|
|
5023
|
+
return db.prepare("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5024
|
+
}
|
|
5025
|
+
|
|
4950
5026
|
export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
|
|
4951
5027
|
const existing = getWorkspace(id);
|
|
4952
5028
|
if (!existing) return null;
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
1
2
|
import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
|
|
2
3
|
import { createCommand } from "./commands-db";
|
|
3
|
-
import { createActivityEvent, getAgent, getDb, getOrchestrator, resolveQueuedPolicyMessages } from "./db";
|
|
4
|
+
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
|
|
4
5
|
import {
|
|
5
6
|
getManagedAgentState,
|
|
6
7
|
listSpawnPolicies,
|
|
@@ -162,6 +163,9 @@ export class LifecycleManager {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
onAgentDisappeared(agentId: string): void {
|
|
166
|
+
// Reconcile the agent's worktrees before policy handling — this runs for
|
|
167
|
+
// every agent (managed or not), so it must not depend on a policy match.
|
|
168
|
+
this.reconcileWorkspacesForAgent(agentId);
|
|
165
169
|
const policy = this.loadPolicies().find((item) => getManagedAgentState(item.name)?.agentId === agentId);
|
|
166
170
|
if (!policy) return;
|
|
167
171
|
const state = getManagedAgentState(policy.name);
|
|
@@ -570,6 +574,57 @@ export class LifecycleManager {
|
|
|
570
574
|
data: { command },
|
|
571
575
|
});
|
|
572
576
|
}
|
|
577
|
+
|
|
578
|
+
// When an agent disappears, its isolated worktrees would otherwise sit
|
|
579
|
+
// `active` on disk forever. Dispatch a workspace.reconcile command to the
|
|
580
|
+
// owning orchestrator, which probes the worktree and either removes it (no
|
|
581
|
+
// work) or leaves it intact (the relay then flags it for review). Only live
|
|
582
|
+
// states are touched; if no online orchestrator owns the path we leave the
|
|
583
|
+
// row as-is so nothing is lost — it can be reconciled later.
|
|
584
|
+
private reconcileWorkspacesForAgent(agentId: string): void {
|
|
585
|
+
const owned = listWorkspaces({ ownerAgentId: agentId });
|
|
586
|
+
// Shared-mode rows are pure occupancy markers with nothing on disk — drop
|
|
587
|
+
// them outright when the occupant leaves; no orchestrator round-trip.
|
|
588
|
+
for (const ws of owned) {
|
|
589
|
+
if (ws.mode === "shared" && ws.status !== "cleaned") deleteWorkspace(ws.id);
|
|
590
|
+
}
|
|
591
|
+
const candidates = owned.filter(
|
|
592
|
+
(ws) => ws.mode === "isolated" && (ws.status === "active" || ws.status === "ready") && Boolean(ws.worktreePath),
|
|
593
|
+
);
|
|
594
|
+
if (!candidates.length) return;
|
|
595
|
+
const orchestrators = listOrchestrators();
|
|
596
|
+
for (const ws of candidates) {
|
|
597
|
+
const orch = orchestrators.find(
|
|
598
|
+
(candidate) => candidate.status === "online" && pathWithinBaseDir(ws.sourceCwd, candidate.baseDir),
|
|
599
|
+
);
|
|
600
|
+
if (!orch) continue;
|
|
601
|
+
const command = createCommand({
|
|
602
|
+
type: "workspace.reconcile",
|
|
603
|
+
source: "system",
|
|
604
|
+
target: orch.agentId,
|
|
605
|
+
correlationId: ws.id,
|
|
606
|
+
params: {
|
|
607
|
+
action: "reconcile",
|
|
608
|
+
workspaceId: ws.id,
|
|
609
|
+
repoRoot: ws.repoRoot,
|
|
610
|
+
worktreePath: ws.worktreePath,
|
|
611
|
+
branch: ws.branch,
|
|
612
|
+
baseRef: ws.baseRef,
|
|
613
|
+
baseSha: ws.baseSha,
|
|
614
|
+
reason: "owner-disappeared",
|
|
615
|
+
requestedBy: "lifecycle-manager",
|
|
616
|
+
requestedAt: this.now(),
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
this.emitCommand(command);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function pathWithinBaseDir(path: string | undefined, baseDir: string | undefined): boolean {
|
|
625
|
+
if (!path || !baseDir) return false;
|
|
626
|
+
const rel = relative(resolve(baseDir), resolve(path));
|
|
627
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
573
628
|
}
|
|
574
629
|
|
|
575
630
|
let singleton: LifecycleManager | null = null;
|
package/src/maintenance.ts
CHANGED
|
@@ -4,19 +4,25 @@ import { expireStaleBusAgents } from "./bus";
|
|
|
4
4
|
import { pruneOutbox } from "./bus-outbox";
|
|
5
5
|
import { expireCommands } from "./commands-db";
|
|
6
6
|
import { DAY_MS, OFFLINE_PRUNE_MS, REAP_INTERVAL_MS, STALE_TTL_MS } from "./config";
|
|
7
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
7
8
|
import {
|
|
8
9
|
createActivityEvent,
|
|
9
10
|
evaluatePoolBindings,
|
|
10
11
|
expireQueuedMessages,
|
|
11
12
|
getDb,
|
|
13
|
+
listOrchestrators,
|
|
14
|
+
listWorkspaces,
|
|
12
15
|
pruneOfflineAgents,
|
|
13
16
|
pruneOldMessages,
|
|
14
17
|
reapStaleAgents,
|
|
15
18
|
reapStaleOrchestrators,
|
|
16
19
|
releaseExpiredClaims,
|
|
17
20
|
releaseOrphanedTasks,
|
|
21
|
+
sendMessage,
|
|
18
22
|
sweepArtifacts,
|
|
23
|
+
updateWorkspaceStatus,
|
|
19
24
|
} from "./db";
|
|
25
|
+
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
20
26
|
import { emitRelayEvent } from "./events";
|
|
21
27
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
22
28
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
@@ -25,6 +31,7 @@ import {
|
|
|
25
31
|
emitAgentStatus,
|
|
26
32
|
emitMessageClaimReleased,
|
|
27
33
|
emitMessageExpired,
|
|
34
|
+
emitNewMessage,
|
|
28
35
|
emitOrchestratorStatus,
|
|
29
36
|
emitPoolBindingChanged,
|
|
30
37
|
emitTaskChanged,
|
|
@@ -36,6 +43,10 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
36
43
|
const SCHEDULER_TICK_MS = 10_000;
|
|
37
44
|
const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
|
|
38
45
|
const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
|
|
46
|
+
const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 2 * 60 * 1000;
|
|
47
|
+
// Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
|
|
48
|
+
// in-flight (cleanup_requested) states are skipped.
|
|
49
|
+
const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
39
50
|
|
|
40
51
|
interface MaintenanceJobDefinition {
|
|
41
52
|
id: string;
|
|
@@ -268,8 +279,114 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
268
279
|
return { prunedTokenJtis };
|
|
269
280
|
},
|
|
270
281
|
},
|
|
282
|
+
{
|
|
283
|
+
id: "workspace-conflict-scan",
|
|
284
|
+
title: "Workspace conflict scan",
|
|
285
|
+
description: "Probe active worktrees for divergence and auto-flag conflicts before merge time.",
|
|
286
|
+
intervalMs: CONFLICT_SCAN_INTERVAL_MS,
|
|
287
|
+
runOnStart: false,
|
|
288
|
+
timeoutMs: 60 * 1000,
|
|
289
|
+
handler: scanWorkspaceConflicts,
|
|
290
|
+
},
|
|
271
291
|
];
|
|
272
292
|
|
|
293
|
+
function workspacePathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
294
|
+
if (!path || !baseDir) return false;
|
|
295
|
+
const rel = relative(resolve(baseDir), resolve(path));
|
|
296
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord): Promise<WorkspaceMergePreview | { available: false } | null> {
|
|
300
|
+
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
301
|
+
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
302
|
+
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
303
|
+
const headers: Record<string, string> = {};
|
|
304
|
+
const token = process.env.AGENT_RELAY_TOKEN;
|
|
305
|
+
if (token) headers["X-Agent-Relay-Token"] = token;
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch(`${apiUrl}/api/workspace/merge-preview?${query.toString()}`, { headers, signal: AbortSignal.timeout(8_000) });
|
|
308
|
+
if (!res.ok) return null;
|
|
309
|
+
return await res.json() as WorkspaceMergePreview;
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Iterate active worktrees and ask the owning host whether each can still merge
|
|
316
|
+
// cleanly. Auto-flag `conflict` when a clean merge is no longer possible, and
|
|
317
|
+
// auto-clear conflicts we set ourselves once they resolve (restoring the prior
|
|
318
|
+
// status). Human-set conflicts are never cleared.
|
|
319
|
+
async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
320
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
321
|
+
if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
|
|
322
|
+
|
|
323
|
+
const candidates = listWorkspaces().filter(
|
|
324
|
+
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
|
|
325
|
+
);
|
|
326
|
+
const flagged: string[] = [];
|
|
327
|
+
const cleared: string[] = [];
|
|
328
|
+
const notifiedStewards: string[] = [];
|
|
329
|
+
|
|
330
|
+
for (const ws of candidates) {
|
|
331
|
+
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
332
|
+
if (!orch?.apiUrl) continue;
|
|
333
|
+
const preview = await fetchHostMergePreview(orch.apiUrl, ws);
|
|
334
|
+
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
335
|
+
const p = preview as WorkspaceMergePreview;
|
|
336
|
+
if (p.error || p.missing || p.conflict === undefined) continue;
|
|
337
|
+
|
|
338
|
+
const meta = ws.metadata as Record<string, unknown>;
|
|
339
|
+
if (p.conflict === true && ws.status !== "conflict") {
|
|
340
|
+
updateWorkspaceStatus(ws.id, "conflict", {
|
|
341
|
+
autoConflict: true,
|
|
342
|
+
preConflictStatus: ws.status,
|
|
343
|
+
conflictAhead: p.ahead,
|
|
344
|
+
conflictBehind: p.behind,
|
|
345
|
+
conflictBaseRef: p.baseRef,
|
|
346
|
+
conflictDetectedAt: Date.now(),
|
|
347
|
+
});
|
|
348
|
+
flagged.push(ws.id);
|
|
349
|
+
createActivityEvent({
|
|
350
|
+
clientId: "server-workspace-" + ws.id + "-conflict-" + Date.now(),
|
|
351
|
+
kind: "state",
|
|
352
|
+
title: "Merge conflict detected",
|
|
353
|
+
body: `${ws.branch ?? ws.id} can no longer merge cleanly into ${p.baseRef ?? "base"}`,
|
|
354
|
+
meta: ws.branch ?? ws.id,
|
|
355
|
+
icon: "ti-alert-triangle",
|
|
356
|
+
view: "orchestrators",
|
|
357
|
+
metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, ahead: p.ahead, behind: p.behind },
|
|
358
|
+
});
|
|
359
|
+
// The steward is the repo's coordination point — ping it so a conflict
|
|
360
|
+
// gets resolved instead of silently rotting until merge time. Once-per-
|
|
361
|
+
// onset (we only enter this branch on the active→conflict transition).
|
|
362
|
+
if (ws.stewardAgentId) {
|
|
363
|
+
try {
|
|
364
|
+
const msg = sendMessage({
|
|
365
|
+
from: "system",
|
|
366
|
+
to: ws.stewardAgentId,
|
|
367
|
+
kind: "system",
|
|
368
|
+
subject: "Workspace merge conflict",
|
|
369
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
|
|
370
|
+
payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
|
|
371
|
+
});
|
|
372
|
+
emitNewMessage(msg);
|
|
373
|
+
notifiedStewards.push(ws.stewardAgentId);
|
|
374
|
+
} catch {
|
|
375
|
+
// Steward unregistered/stale — the activity event still records it.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} else if (p.conflict === false && ws.status === "conflict" && meta.autoConflict === true) {
|
|
379
|
+
const prior = typeof meta.preConflictStatus === "string" && meta.preConflictStatus !== "conflict"
|
|
380
|
+
? meta.preConflictStatus as WorkspaceStatus
|
|
381
|
+
: "active";
|
|
382
|
+
updateWorkspaceStatus(ws.id, prior, { autoConflict: false, conflictClearedAt: Date.now() });
|
|
383
|
+
cleared.push(ws.id);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { scanned: candidates.length, flagged, cleared, notifiedStewards };
|
|
388
|
+
}
|
|
389
|
+
|
|
273
390
|
let timer: Timer | null = null;
|
|
274
391
|
|
|
275
392
|
export function startMaintenanceScheduler(): void {
|