agent-relay-orchestrator 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.118.5",
3
+ "version": "0.119.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "agent-relay-providers": "0.104.1",
20
- "agent-relay-sdk": "0.2.101",
20
+ "agent-relay-sdk": "0.2.102",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { errMessage, isRecord, normalizeAgentLifecycle, normalizeWorkspaceMode } from "agent-relay-sdk";
1
+ import { errMessage, isRecord, normalizeAgentLifecycle, normalizeWorkspaceMode, Semaphore } from "agent-relay-sdk";
2
2
  import { getAllManifests, getManifest } from "agent-relay-providers";
3
3
  import type { OrchestratorConfig } from "./config";
4
4
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
5
5
  import { handleSelfUpgrade } from "./self-upgrade";
6
6
  import { readLocalProviderConfigs } from "./provider-config-migration";
7
- import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
7
+ import { findSessionRecord, readRunnerInfo, spawnAgent, stopSession, type SpawnOptions } from "./spawn";
8
8
  import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
9
9
  import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
10
10
  import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
@@ -24,22 +24,167 @@ interface ControlHandler {
24
24
  handleCommand(command: RelayCommand): Promise<boolean>;
25
25
  getManagedAgents(): ManagedAgentReport[];
26
26
  setManagedAgents(agents: ManagedAgentReport[]): void;
27
+ // #930/#880 — spawn commands dispatch in the background (claimed synchronously,
28
+ // then run past registration under the concurrency cap). These expose that
29
+ // in-flight background work so callers/tests can observe and settle it.
30
+ spawnsInFlight(): number;
31
+ settleSpawns(): Promise<void>;
32
+ }
33
+
34
+ const DEFAULT_MAX_CONCURRENT_SPAWNS = 2;
35
+
36
+ // #930/#880 — per-host spawn concurrency cap. Bursts of spawns racing worktree
37
+ // materialization + port churn during the registration window are what surface
38
+ // as `EADDRINUSE`/"operation was aborted" on the loser (#880). Bound how many
39
+ // spawns materialize at once. Parsed once at handler construction; invalid or
40
+ // out-of-range values fall back to the safe default rather than throwing.
41
+ export function resolveMaxConcurrentSpawns(env: NodeJS.ProcessEnv = process.env): number {
42
+ const raw = env.AGENT_RELAY_MAX_CONCURRENT_SPAWNS;
43
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_CONCURRENT_SPAWNS;
44
+ const parsed = Number(raw);
45
+ if (!Number.isInteger(parsed) || parsed < 1) return DEFAULT_MAX_CONCURRENT_SPAWNS;
46
+ return parsed;
47
+ }
48
+
49
+ // #930/#880 — how long a spawn slot is held while we wait for the child to
50
+ // register (bind its local control/ws server — the contended resource). This is
51
+ // a fail-safe upper bound only: a child that never registers must not wedge a
52
+ // slot forever, so we release after this deadline even if it hasn't appeared.
53
+ // The happy path releases as soon as the child's runner-info file materializes,
54
+ // well under this ceiling.
55
+ const DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS = 30_000;
56
+ const SPAWN_REGISTRATION_POLL_MS = 100;
57
+
58
+ export function resolveSpawnRegistrationTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
59
+ const raw = env.AGENT_RELAY_SPAWN_REGISTRATION_TIMEOUT_MS;
60
+ if (raw === undefined || raw.trim() === "") return DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS;
61
+ const parsed = Number(raw);
62
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS;
63
+ return parsed;
64
+ }
65
+
66
+ /** Awaits a spawn slot until the child registers, or resolves false at the fail-safe deadline. */
67
+ export type WaitForRegistration = (agent: ManagedAgentReport, timeoutMs: number) => Promise<boolean>;
68
+
69
+ // #930/#880 — the orchestrator observes a child registering through its runner
70
+ // info file: the runner writes `controlUrl` immediately after binding its local
71
+ // control/MCP server (runner-core writeRunnerInfoFile, right after startMcpProxy),
72
+ // which is exactly #880's contended ws-bind window. So the file's presence is the
73
+ // host-local signal that the child is past that window; holding the slot until it
74
+ // appears is what bounds how many children materialize→bind at once.
75
+ async function waitForManagedRegistration(agent: ManagedAgentReport, timeoutMs: number): Promise<boolean> {
76
+ const session = agent.sessionName ?? agent.tmuxSession;
77
+ if (!session) return false;
78
+ const deadline = Date.now() + timeoutMs;
79
+ for (;;) {
80
+ const record = findSessionRecord({ tmuxSession: session, agentId: agent.agentId });
81
+ if (record && readRunnerInfo(record)) return true;
82
+ if (Date.now() >= deadline) return false;
83
+ await Bun.sleep(SPAWN_REGISTRATION_POLL_MS);
84
+ }
85
+ }
86
+
87
+ interface ControlHandlerDeps {
88
+ // Injectable spawn dispatch — defaults to the real spawnAgent. Only tests
89
+ // override it, to gate dispatch and observe the concurrency bound directly.
90
+ spawnAgent?: typeof spawnAgent;
91
+ // Override the cap directly, bypassing the env parse (tests).
92
+ maxConcurrentSpawns?: number;
93
+ // Injectable registration wait — defaults to polling the child's runner info
94
+ // file. Tests override it to gate the registration window explicitly.
95
+ waitForRegistration?: WaitForRegistration;
96
+ // Override the fail-safe registration timeout, bypassing the env parse (tests).
97
+ registrationTimeoutMs?: number;
27
98
  }
28
99
 
29
100
  export function createControlHandler(
30
101
  config: OrchestratorConfig,
31
102
  relay: RelayClient,
103
+ deps: ControlHandlerDeps = {},
32
104
  ): ControlHandler {
33
105
  let managedAgents: ManagedAgentReport[] = [];
106
+ const dispatchSpawn = deps.spawnAgent ?? spawnAgent;
107
+ // In-memory counter is correct here: a slot only needs to live within this
108
+ // orchestrator process, and a crash resets in-flight spawns anyway (#881 Q1).
109
+ const spawnSlots = new Semaphore(deps.maxConcurrentSpawns ?? resolveMaxConcurrentSpawns());
110
+ const waitForRegistration = deps.waitForRegistration ?? waitForManagedRegistration;
111
+ const registrationTimeoutMs = deps.registrationTimeoutMs ?? resolveSpawnRegistrationTimeoutMs();
112
+ // Backgrounded spawn tasks: each is claimed synchronously (status → accepted)
113
+ // then runs to completion off the poll tick, holding a slot across the child's
114
+ // registration window. Tracked so tests/shutdown can observe and settle them.
115
+ const backgroundSpawns = new Set<Promise<void>>();
34
116
 
35
- async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
117
+ async function handleSpawn(ctrl: Record<string, any>): Promise<ManagedAgentReport> {
36
118
  const opts = spawnOptionsFromControl(ctrl, config);
37
- const agent = await spawnAgent(opts, config);
119
+ const agent = await dispatchSpawn(opts, config);
38
120
  managedAgents.push(agent);
39
121
  console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
122
+ return agent;
123
+ }
124
+
125
+ // #930/#880 — dispatch a spawn command concurrently, up to the per-host cap.
126
+ //
127
+ // The command is CLAIMED (status → accepted) synchronously here, BEFORE we
128
+ // return, so the next pollCommands() tick can never re-return this still-pending
129
+ // row and double-dispatch it. The slot itself is taken in the BACKGROUND via
130
+ // acquire() (strict FIFO — no starvation), so the poll tick is never blocked
131
+ // waiting for a free slot, preserving the #864 single-flight of the tick.
132
+ //
133
+ // The background task holds its slot across the child's registration window
134
+ // (#880's contended ws-bind resource) and frees it in `finally` on EVERY path:
135
+ // registration success, spawn failure, or the fail-safe registration timeout.
136
+ async function dispatchSpawnCommand(command: RelayCommand): Promise<boolean> {
137
+ await relay.updateCommand(command.id, "accepted");
138
+ // #930 — guard the backgrounded task against unhandled rejection. runSpawnToRegistration
139
+ // settles its own errors, but a relay blip in the ≤30s in-flight window can reject the
140
+ // status write in its catch block too; without a terminal `.catch` here that escapes as
141
+ // an unhandledRejection (log spam, or a crash if the runtime exits on it). Track the
142
+ // GUARDED promise so settleSpawns() awaits a promise that never rejects.
143
+ const task = runSpawnToRegistration(command).catch((error) => {
144
+ console.error(`[orchestrator] backgrounded spawn task error (swallowed): ${errMessage(error)}`);
145
+ });
146
+ backgroundSpawns.add(task);
147
+ void task.finally(() => backgroundSpawns.delete(task));
40
148
  return true;
41
149
  }
42
150
 
151
+ async function runSpawnToRegistration(command: RelayCommand): Promise<void> {
152
+ // Blocks FIFO until a slot frees — this is what bounds concurrent spawns to N.
153
+ await spawnSlots.acquire();
154
+ try {
155
+ await relay.updateCommand(command.id, "running");
156
+ const agent = await handleSpawn(command.params);
157
+ const registered = await waitForRegistration(agent, registrationTimeoutMs);
158
+ if (!registered) {
159
+ // Not a spawn failure — the process launched; it just didn't register in
160
+ // time. Release the slot (fail-safe) so a slow/never-registering child
161
+ // can't wedge capacity, but still settle the command as succeeded.
162
+ console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
163
+ }
164
+ await relay.updateCommand(command.id, "succeeded", { managedAgents });
165
+ await relay.updateManagedAgents(managedAgents);
166
+ } catch (error) {
167
+ console.error(`[orchestrator] spawn dispatch failed: ${errMessage(error)}`);
168
+ // #930 — the `failed` status write can itself reject during a relay blip. Wrap it so
169
+ // a failed status write can't re-throw out of the backgrounded task (belt-and-suspenders
170
+ // with the `.catch` in dispatchSpawnCommand). The command may strand non-terminal until
171
+ // the TTL sweep / watchdog reconciles it, but the task settles cleanly regardless.
172
+ try {
173
+ await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
174
+ } catch (statusError) {
175
+ console.error(`[orchestrator] failed to mark spawn command failed: ${errMessage(statusError)}`);
176
+ }
177
+ } finally {
178
+ spawnSlots.release();
179
+ }
180
+ }
181
+
182
+ async function settleSpawns(): Promise<void> {
183
+ while (backgroundSpawns.size > 0) {
184
+ await Promise.all([...backgroundSpawns]);
185
+ }
186
+ }
187
+
43
188
  async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
44
189
  const current = managedAgentShutdownTarget(managedAgents, ctrl);
45
190
  const session = current?.sessionName ?? current?.tmuxSession;
@@ -86,14 +231,18 @@ export function createControlHandler(
86
231
  }
87
232
 
88
233
  async function handleCommand(command: RelayCommand): Promise<boolean> {
234
+ // #930/#880 — spawns dispatch concurrently in the background under the
235
+ // per-host cap (see dispatchSpawnCommand). Every other command is handled
236
+ // inline, synchronously with the poll tick, exactly as before.
237
+ if (command.type === "agent.spawn") return dispatchSpawnCommand(command);
238
+ return handleNonSpawnCommand(command);
239
+ }
240
+
241
+ async function handleNonSpawnCommand(command: RelayCommand): Promise<boolean> {
89
242
  await relay.updateCommand(command.id, "accepted");
90
243
  await relay.updateCommand(command.id, "running");
91
244
  try {
92
- if (command.type === "agent.spawn") {
93
- const handled = await handleSpawn(command.params);
94
- if (!handled) throw new Error("spawn failed");
95
- await relay.updateCommand(command.id, "succeeded", { managedAgents });
96
- } else if (command.type === "agent.shutdown" || command.type === "agent.restart") {
245
+ if (command.type === "agent.shutdown" || command.type === "agent.restart") {
97
246
  const result = await handleShutdown(command.params, command.type === "agent.restart");
98
247
  await relay.updateCommand(command.id, "succeeded", result);
99
248
  } else if (command.type === "workspace.cleanup") {
@@ -250,7 +399,7 @@ export function createControlHandler(
250
399
  managedAgents = agents;
251
400
  }
252
401
 
253
- return { handleCommand, getManagedAgents, setManagedAgents };
402
+ return { handleCommand, getManagedAgents, setManagedAgents, spawnsInFlight: () => backgroundSpawns.size, settleSpawns };
254
403
  }
255
404
 
256
405
  export function managedAgentShutdownTarget(agents: ManagedAgentReport[], ctrl: Record<string, any>): ManagedAgentReport | undefined {
@@ -1,12 +1,11 @@
1
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
1
+ import { existsSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
3
  import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
5
4
  import { git, gitRaw } from "../git";
6
5
  import { execProcess } from "../process";
7
6
  import { prMergedState } from "../workspace-pr";
8
7
  import { refreshWorkspaceDeps } from "./deps";
9
- import { type LandGatesResult, runLandGates } from "./land-gates-runner";
8
+ import { type LandGatesResult } from "./land-gates-runner";
10
9
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
11
10
  import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
12
11
  import { nextBranchName } from "./names";
@@ -584,18 +583,16 @@ async function recordNoFfMerge(
584
583
  return { ok: true, mergeSha: synth.mergeSha };
585
584
  }
586
585
 
586
+ function decommissionedLandGatesResult(): LandGatesResult {
587
+ return { ran: 0, warnings: [] };
588
+ }
589
+
587
590
  /**
588
- * Run the repo's configured land gates against the EXACT tree that will become base's new tip,
589
- * BEFORE any ref advance (#902 BLOCKING 1 / closes #903). The candidate tree depends on `behind`:
590
- * - behind === 0: the landing worktree HEAD already IS that tree (a clean fast-forward), so gates
591
- * run in `worktreePath` in place byte-identical to today's straight-FF path.
592
- * - behind > 0: base has advanced, so the tree that will land is the no-ff MERGE of
593
- * `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
594
- * plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
595
- * The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
596
- * can never strand an orphan worktree or leave the synthesized merge half-applied.
597
- * Returns the gate outcome, or an `abort` describing a merge result the caller must return early
598
- * (a real merge conflict computing the integrated tree, or a failure materializing it).
591
+ * Land gates are decommissioned (#923). Release-time gates in `release.ts` are the
592
+ * quality boundary; workspace landing must never launch repo-configured gate subprocesses.
593
+ * Keep this function as the single merge-path kill switch so populated
594
+ * `.agent-relay/land-gates.json` files are treated as always-empty without changing the
595
+ * rest of the rebase/fetch/no-ff landing flow.
599
596
  */
600
597
  async function runLandGatesOnIntegratedTree(
601
598
  repoRoot: string,
@@ -605,51 +602,12 @@ async function runLandGatesOnIntegratedTree(
605
602
  headSha: string,
606
603
  mergeMessage: string,
607
604
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
608
- console.error(`[orchestrator] workspace.merge gate-start worktree=${worktreePath} behind=${behind}`);
609
- if (behind === 0) return { gates: await withMergePhaseTimeout("gates", () => runLandGates(worktreePath)) };
610
-
611
- let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
612
- try {
613
- synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
614
- } catch (err) {
615
- return { abort: { error: errMessage(err) } };
616
- }
617
- if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
618
-
619
- // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
620
- // without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
621
- // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
622
- const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
623
- const tmpWorktree = join(tmpParent, "tree");
624
- let add: Awaited<ReturnType<typeof git>>;
625
- try {
626
- add = await withMergePhaseTimeout("worktree-add", () => git(
627
- ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
628
- repoRoot,
629
- { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
630
- ));
631
- } catch (err) {
632
- rmSync(tmpParent, { recursive: true, force: true });
633
- return { abort: { error: errMessage(err) } };
634
- }
635
- if (!add.ok) {
636
- rmSync(tmpParent, { recursive: true, force: true });
637
- return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
638
- }
639
- try {
640
- return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
641
- } finally {
642
- try {
643
- await withMergePhaseTimeout("cleanup", () => git(
644
- ["worktree", "remove", "--force", tmpWorktree],
645
- repoRoot,
646
- { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
647
- ));
648
- } catch (err) {
649
- console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
650
- }
651
- rmSync(tmpParent, { recursive: true, force: true });
652
- }
605
+ void repoRoot;
606
+ void integrationBaseSha;
607
+ void headSha;
608
+ void mergeMessage;
609
+ console.error(`[orchestrator] workspace.merge gate-skip worktree=${worktreePath} behind=${behind} reason=land-gates-decommissioned`);
610
+ return { gates: decommissionedLandGatesResult() };
653
611
  }
654
612
 
655
613
  /**
@@ -735,12 +693,9 @@ async function mergeRebaseFf(
735
693
  // an empty/failed read just omits it from the message body.
736
694
  const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
737
695
 
738
- // #902 BLOCKING 2 NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
739
- // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
740
- // concurrent case (#638): we still fetch fresh origin to compute the real merge, but we defer
741
- // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
742
- // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
743
- // sync+advance below, all of it gated.
696
+ // Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is
697
+ // the common concurrent case (#638): we still fetch fresh origin to compute the real merge,
698
+ // but defer the local-base sync until the final base-ref mutation block below.
744
699
  const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`);
745
700
  let integrationBaseSha = integrationBaseResult.stdout;
746
701
  if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
@@ -775,16 +730,10 @@ async function mergeRebaseFf(
775
730
  if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
776
731
  const behind = behindResult.behind;
777
732
 
778
- // #902 BLOCKING 1 / closes #903 run the repo's configured land gates against the EXACT tree
779
- // that will become base's new tip, BEFORE advancing the ref. behind===0 the worktree HEAD IS
780
- // that tree (clean fast-forward); behind>0 the synthesized no-ff merge of integrationBaseSha +
781
- // HEAD, gated in a throwaway worktree (see runLandGatesOnIntegratedTree). A repo with no
782
- // `.agent-relay/land-gates.json` runs ZERO gates (missing-file → empty list, no subprocess), so
783
- // behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
784
- // failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
785
- // status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
786
- // the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
787
- // `gateWarnings` on the successful land below.
733
+ // #923 land gates are decommissioned. This call is now a hard kill switch that always
734
+ // returns an empty gate set, even when `.agent-relay/land-gates.json` is populated. Keep it
735
+ // before base-ref mutation so the rest of the historical land ordering stays intact, but no
736
+ // gate subprocess or detached gate worktree can be launched at land time.
788
737
  const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
789
738
  if ("abort" in gateRun) {
790
739
  return gateRun.abort.conflict
@@ -797,9 +746,9 @@ async function mergeRebaseFf(
797
746
  }
798
747
  const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
799
748
 
800
- // Gates passed — only NOW is it safe to touch `refs/heads/<base>`. If origin moved ahead, sync
801
- // local base up to the fetched upstream first (#638); this is the FIRST mutation of the base ref
802
- // in the whole land path, so a gate failure above can never leave it advanced (#902 BLOCKING 2).
749
+ // Only NOW touch `refs/heads/<base>`. If origin moved ahead, sync local base up to the fetched
750
+ // upstream first (#638); this preserves the pre-existing land ordering while gate execution
751
+ // remains permanently disabled.
803
752
  if (needSync) {
804
753
  const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
805
754
  if (!synced.ok) return head({ status: "review_requested", error: synced.error });