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.
@@ -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
- let cwd = process.env.AGENT_RELAY_SMOKE_CWD || process.cwd();
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
- : orchestrators.find((orch) => orch.status === "online");
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 < response.md`,
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(mergeOrchestratorRuntimeMeta(input.meta ?? {}, input)),
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?.id || workspace.mode !== "isolated" || !workspace.repoRoot || !workspace.worktreePath) return null;
4869
- return upsertWorkspace({
4870
- id: workspace.id,
4871
- repoRoot: workspace.repoRoot,
4872
- sourceCwd: workspace.sourceCwd ?? agent.cwd,
4873
- worktreePath: workspace.worktreePath,
4874
- branch: workspace.branch,
4875
- baseRef: workspace.baseRef,
4876
- baseSha: workspace.baseSha,
4877
- mode: workspace.mode,
4878
- requestedMode: workspace.requestedMode,
4879
- status: workspace.status ?? "active",
4880
- ownerAgentId: agent.agentId || undefined,
4881
- ownerPolicyName: agent.policyName,
4882
- ownerAutomationRunId: agent.automationRunId,
4883
- metadata: { provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
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;
@@ -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;
@@ -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 {