agent-relay-orchestrator 0.127.9 → 0.127.10

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.127.9",
3
+ "version": "0.127.10",
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.4",
20
- "agent-relay-sdk": "0.2.122",
20
+ "agent-relay-sdk": "0.2.123",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -433,6 +433,9 @@ export function createControlHandler(
433
433
  prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
434
434
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
435
435
  autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
436
+ gateLevel: command.params.gateLevel === "subset" || command.params.gateLevel === "none" ? command.params.gateLevel : undefined,
437
+ gateReason: typeof command.params.gateReason === "string" ? command.params.gateReason : undefined,
438
+ gateRequestedBy: typeof command.params.gateRequestedBy === "string" ? command.params.gateRequestedBy : undefined,
436
439
  prLanded: isRecord(command.params.prLanded)
437
440
  ? {
438
441
  sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
@@ -3,16 +3,14 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { errMessage } from "agent-relay-sdk";
5
5
  import type { LandGate } from "agent-relay-sdk";
6
+ import type { WorkspaceLandGateLevel } from "agent-relay-sdk";
7
+ import { cleanLandGatesConfig, landGatesTimeoutBudgetMs, loadRepoLandGates } from "agent-relay-sdk/land-gates";
6
8
  import { git } from "../git";
7
9
  import { runLandGates, type LandGatesResult } from "./land-gates-runner";
8
10
  import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
9
11
 
10
12
  const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const;
11
- const FULL_CI_LAND_GATE: LandGate = {
12
- name: "ci-land-full",
13
- command: "AGENT_RELAY_CI_LAND_FULL=1 bun run ci:land",
14
- timeoutMs: 30 * 60 * 1000,
15
- };
13
+ const LAND_GATES_FILE = ".agent-relay/land-gates.json";
16
14
  const DEPENDENCY_LINK_PATHS = [
17
15
  "node_modules",
18
16
  "dashboard/node_modules",
@@ -48,17 +46,23 @@ export async function synthesizeNoFfMerge(
48
46
  return { ok: true, mergeSha: commit.stdout };
49
47
  }
50
48
 
51
- async function baseDeclaresCiLand(repoRoot: string, baseSha: string, signal?: AbortSignal): Promise<boolean> {
52
- const pkg = await git(["show", `${baseSha}:package.json`], repoRoot, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge inspect base package scripts", signal });
53
- if (!pkg.ok || !pkg.stdout) return false;
49
+ async function resolveRequiredLandGates(repoRoot: string, baseSha: string, signal?: AbortSignal): Promise<LandGate[]> {
50
+ const config = await git(["show", `${baseSha}:${LAND_GATES_FILE}`], repoRoot, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge inspect base land-gates config", signal });
51
+ if (!config.ok || !config.stdout) return [];
54
52
  try {
55
- const parsed = JSON.parse(pkg.stdout) as { scripts?: Record<string, unknown> };
56
- return typeof parsed.scripts?.["ci:land"] === "string";
57
- } catch {
58
- return false;
53
+ return cleanLandGatesConfig(JSON.parse(config.stdout), repoRoot);
54
+ } catch (err) {
55
+ throw new Error(`invalid ${LAND_GATES_FILE} at ${baseSha}: ${errMessage(err)}`);
59
56
  }
60
57
  }
61
58
 
59
+ function requiredGatesForLevel(requiredGates: LandGate[], gateLevel: WorkspaceLandGateLevel): LandGate[] {
60
+ if (gateLevel === "full") return requiredGates;
61
+ // subset = candidate-tree repo gates only. The base-required list is omitted;
62
+ // runLandGates still reads the candidate checkout config below.
63
+ return [];
64
+ }
65
+
62
66
  function linkDependencyDirs(sourceWorktree: string, gateWorktree: string): void {
63
67
  for (const rel of DEPENDENCY_LINK_PATHS) {
64
68
  const from = join(sourceWorktree, rel);
@@ -90,11 +94,24 @@ export async function runLandGatesOnIntegratedTree(
90
94
  integrationBaseSha: string,
91
95
  headSha: string,
92
96
  mergeMessage: string,
97
+ gateLevel: WorkspaceLandGateLevel = "full",
93
98
  signal?: AbortSignal,
94
99
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
95
- console.error("[orchestrator] workspace.merge gate-start worktree=" + worktreePath + " behind=" + behind);
100
+ console.error("[orchestrator] workspace.merge gate-start worktree=" + worktreePath + " behind=" + behind + " level=" + gateLevel);
96
101
 
97
- const requiredGates: LandGate[] = await baseDeclaresCiLand(repoRoot, integrationBaseSha, signal) ? [FULL_CI_LAND_GATE] : [];
102
+ if (gateLevel === "none") {
103
+ console.error("[orchestrator] workspace.merge gate-skip level=none worktree=" + worktreePath);
104
+ return { gates: { ran: 0, warnings: [] } };
105
+ }
106
+
107
+ let baseRequiredGates: LandGate[];
108
+ try {
109
+ baseRequiredGates = await resolveRequiredLandGates(repoRoot, integrationBaseSha, signal);
110
+ } catch (err) {
111
+ return { abort: { error: errMessage(err) } };
112
+ }
113
+ const requiredGates = requiredGatesForLevel(baseRequiredGates, gateLevel);
114
+ const loadCandidateRepoGates = gateLevel === "subset";
98
115
  let gateRef = headSha;
99
116
  if (behind > 0) {
100
117
  let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
@@ -135,7 +152,22 @@ export async function runLandGatesOnIntegratedTree(
135
152
 
136
153
  try {
137
154
  linkDependencyDirs(worktreePath, tmpWorktree);
138
- return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree, requiredGates), { signal }) };
155
+ let candidateRepoGates: LandGate[] | undefined;
156
+ if (loadCandidateRepoGates) {
157
+ try {
158
+ candidateRepoGates = loadRepoLandGates(tmpWorktree);
159
+ } catch {
160
+ candidateRepoGates = undefined;
161
+ }
162
+ }
163
+ const gateTimeoutBudgetMs = landGatesTimeoutBudgetMs([...requiredGates, ...(candidateRepoGates ?? [])]);
164
+ return {
165
+ gates: await withMergePhaseTimeout(
166
+ "gates",
167
+ () => runLandGates(tmpWorktree, requiredGates, { loadRepoGates: loadCandidateRepoGates, repoGates: candidateRepoGates }),
168
+ { signal, minimumMs: gateTimeoutBudgetMs },
169
+ ),
170
+ };
139
171
  } finally {
140
172
  try {
141
173
  await withMergePhaseTimeout(
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { errMessage } from "agent-relay-sdk";
3
3
  import type { LandGate, LandGateRunResult } from "agent-relay-sdk";
4
- import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
4
+ import { DEFAULT_LAND_GATE_TIMEOUT_MS, loadRepoLandGates } from "agent-relay-sdk/land-gates";
5
5
 
6
6
  // #902/#1199 — execute mandatory boundary gates and any repo-configured land gates
7
7
  // against the candidate merged tree BEFORE the ref advance in `mergeRebaseFf`.
@@ -10,7 +10,7 @@ import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
10
10
  /** Per-gate default timeout when the gate didn't declare `timeoutMs`. Keep the
11
11
  * ceiling generous but bounded so a hung gate can't wedge the per-repo merge
12
12
  * lease forever. Full-suite boundary gates should declare their own timeout. */
13
- const DEFAULT_GATE_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const DEFAULT_GATE_TIMEOUT_MS = DEFAULT_LAND_GATE_TIMEOUT_MS;
14
14
  const TIMEOUT_KILL_GRACE_MS = 1_000;
15
15
  const OUTPUT_CANCEL_GRACE_MS = 50;
16
16
  /** Cap on the full output streamed to the relay artifact (the notification only ever
@@ -187,8 +187,8 @@ export interface LandGatesResult {
187
187
  }
188
188
 
189
189
  /**
190
- * Load + run required boundary gates plus the repo's configured land gates against `worktreePath`.
191
- * Returns `ran: 0` with no failure/warnings only when neither the boundary nor the repo
190
+ * Run required boundary gates plus, when requested, repo-configured land gates against
191
+ * `worktreePath`. Returns `ran: 0` with no failure/warnings only when neither set
192
192
  * declares gates. Stops at the first failing REQUIRED gate (it blocks the land);
193
193
  * optional failures accumulate as warnings and never block.
194
194
  *
@@ -196,11 +196,12 @@ export interface LandGatesResult {
196
196
  * (surfaced as a synthetic required gate) rather than an unhandled throw that would
197
197
  * crash the merge command — the worker fixes the config and re-lands.
198
198
  */
199
- export async function runLandGates(worktreePath: string, requiredGates: LandGate[] = []): Promise<LandGatesResult> {
199
+ export async function runLandGates(worktreePath: string, requiredGates: LandGate[] = [], options: { loadRepoGates?: boolean; repoGates?: LandGate[] } = {}): Promise<LandGatesResult> {
200
200
  const warnings: LandGateRunResult[] = [];
201
201
  let gates: LandGate[];
202
202
  try {
203
- gates = [...requiredGates, ...loadRepoLandGates(worktreePath)];
203
+ const repoGates = options.repoGates ?? (options.loadRepoGates === false ? [] : loadRepoLandGates(worktreePath));
204
+ gates = [...requiredGates, ...repoGates];
204
205
  } catch (err) {
205
206
  const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
206
207
  return {
@@ -19,11 +19,12 @@ function positiveEnvMs(name: string): number | undefined {
19
19
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
20
20
  }
21
21
 
22
- export function mergePhaseTimeoutMs(phase: MergePhase): number {
22
+ export function mergePhaseTimeoutMs(phase: MergePhase, minimumMs = 0): number {
23
23
  const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`;
24
- return positiveEnvMs(key)
24
+ const configured = positiveEnvMs(key)
25
25
  ?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS")
26
26
  ?? MERGE_PHASE_TIMEOUTS_MS[phase];
27
+ return Math.max(configured, minimumMs);
27
28
  }
28
29
 
29
30
  function abortError(signal: AbortSignal): Error {
@@ -40,9 +41,9 @@ export function throwIfMergeAborted(signal?: AbortSignal): void {
40
41
  export async function withMergePhaseTimeout<T>(
41
42
  phase: MergePhase,
42
43
  run: (signal: AbortSignal) => Promise<T>,
43
- options: { signal?: AbortSignal } = {},
44
+ options: { signal?: AbortSignal; minimumMs?: number } = {},
44
45
  ): Promise<T> {
45
- const timeoutMs = mergePhaseTimeoutMs(phase);
46
+ const timeoutMs = mergePhaseTimeoutMs(phase, options.minimumMs);
46
47
  const controller = new AbortController();
47
48
  let timeout: ReturnType<typeof setTimeout> | undefined;
48
49
  const abortFromParent = () => controller.abort(abortError(options.signal!));
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
3
+ import { errMessage, type BaseWorktreeSyncResult, type WorkspaceLandGateLevel, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
4
4
  import { git, gitRaw } from "../git";
5
5
  import { execProcess } from "../process";
6
6
  import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
@@ -825,6 +825,7 @@ async function mergeRebaseFf(
825
825
  signal?: AbortSignal,
826
826
  ): Promise<WorkspaceMergeResult> {
827
827
  throwIfMergeAborted(signal);
828
+ const gateLevel: WorkspaceLandGateLevel = input.gateLevel === "subset" || input.gateLevel === "none" ? input.gateLevel : "full";
828
829
  const base = preview.baseRef;
829
830
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
830
831
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
@@ -833,16 +834,11 @@ async function mergeRebaseFf(
833
834
  // upstream (e.g. main -> origin/main) and we'll push, fetch it and check whether
834
835
  // origin has moved ahead of local base.
835
836
  //
836
- // Origin-ahead is the COMMON case under concurrency (#638): a sibling lane lands
837
- // and advances origin/<base> while this lane still sits on a stale local base.
838
- // Refusing here floods the steward on every normal multi-lane batch. Instead:
839
- // - If local base carries nothing origin lacks (it's a strict ancestor of the
840
- // fetched upstream), fast-forward local base to the upstream and land onto the
841
- // FRESH origin (the no-ff merge below ties the branch onto the advanced base).
842
- // This is a clean recovery — no steward escalation.
843
- // - Refuse ONLY on genuine divergence: local base has commits not on origin, so
844
- // a sync would rewrite/discard published-or-local history. A real failure must
845
- // still set `error` so the relay's no-progress backoff (#638) can engage.
837
+ // Origin-ahead is common under concurrency: a sibling lane lands and advances
838
+ // origin/<base> while this lane still sits on a stale local base. Direct land
839
+ // must not silently synthesize a no-ff merge or PR fallback in that upstream-stale
840
+ // state (#1184). Local-only base advancement still uses the existing no-ff path:
841
+ // land gates, timeout cancellation, and abort recovery all exercise that machinery.
846
842
  // Tracks whether the DIRTY base checkout caught up to the advanced HEAD across BOTH heal
847
843
  // points (the upstream sync below and the final land). A mixed state from either is surfaced
848
844
  // loudly rather than swallowed as a log warning (#824).
@@ -855,8 +851,8 @@ async function mergeRebaseFf(
855
851
 
856
852
  // SHA preservation (#287): never rebase the agent branch — rewriting its commits
857
853
  // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
858
- // base verbatim). headSha is the preserved landed commit; when base has advanced we
859
- // tie the branch in with a no-ff merge so the agent's commits keep their identity.
854
+ // base verbatim). headSha is the preserved landed commit; local no-ff lands tie the
855
+ // branch in with a merge commit so the agent's commits keep their identity.
860
856
  const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates", signal);
861
857
  const headSha = headResult.stdout;
862
858
  if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
@@ -865,8 +861,7 @@ async function mergeRebaseFf(
865
861
  const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject", signal)).stdout || undefined;
866
862
 
867
863
  // Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is
868
- // the common concurrent case (#638): we still fetch fresh origin to compute the real merge,
869
- // but defer the local-base sync until the final base-ref mutation block below.
864
+ // the #1184 fail-fast case; local-base no-ff is left to the existing gate/ref machinery.
870
865
  const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`, signal);
871
866
  let integrationBaseSha = integrationBaseResult.stdout;
872
867
  if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
@@ -883,25 +878,46 @@ async function mergeRebaseFf(
883
878
  return head({ status: "review_requested", error: errMessage(err) });
884
879
  }
885
880
  if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`, signal)).ok) {
886
- // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
887
- // of upstream); otherwise it's genuine divergence refuse without mutating.
881
+ // Origin moved ahead. If local base is not cleanly behind, it's genuine divergence
882
+ // and we refuse without mutating. If it is cleanly behind, compute against upstream;
883
+ // the behind check below decides whether direct land can still fast-forward.
888
884
  if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`, signal)).ok) {
889
885
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
890
886
  }
891
887
  const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`, signal)).stdout;
892
888
  if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
893
- // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
889
+ // Consider fresh origin the integration base, but DON'T advance local base yet.
894
890
  integrationBaseSha = upstreamSha;
895
891
  needSync = true;
896
892
  }
897
893
  }
898
894
 
899
- // Behind relative to the TRUE integration base (origin-ahead behind>0 a real no-ff merge).
895
+ // Behind relative to fresh upstream means direct land is not a fast-forward of the
896
+ // instance's true integration base. Fail before gates/ref mutation so callers see
897
+ // the reason and can rebase or choose PR explicitly. Local-only behind remains the
898
+ // pre-existing no-ff path to preserve gate, timeout, and abort-recovery behavior.
900
899
  const behindResult = await countBehind(worktreePath, integrationBaseSha, signal);
901
900
  if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
902
901
  const behind = behindResult.behind;
902
+ if (behind > 0 && needSync) {
903
+ const branchName = branch ?? "workspace branch";
904
+ const baseName = base ?? "base";
905
+ const advanced = integrationBaseSha ? ` (base advanced to ${integrationBaseSha.slice(0, 12)})` : "";
906
+ return head({
907
+ status: "review_requested",
908
+ error: `cannot direct-land: ${branchName} is not a fast-forward of ${baseName}${advanced}; rebase and retry, or land with --strategy pr`,
909
+ });
910
+ }
903
911
 
904
- const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject), signal);
912
+ const gateSkippedField = gateLevel !== "full" ? {
913
+ gateSkipped: {
914
+ level: gateLevel,
915
+ reason: input.gateReason ?? "",
916
+ by: input.gateRequestedBy ?? "unknown",
917
+ commit: headSha,
918
+ },
919
+ } : {};
920
+ const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject), gateLevel, signal);
905
921
  throwIfMergeAborted(signal);
906
922
  if ("abort" in gateRun) {
907
923
  return gateRun.abort.conflict
@@ -1062,14 +1078,14 @@ async function mergeRebaseFf(
1062
1078
  const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
1063
1079
  throwIfMergeAborted(signal);
1064
1080
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
1065
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
1081
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, ...gateSkippedField, error: undefined });
1066
1082
  }
1067
1083
  // Recycle failed — keep the existing branch. Still landed, still active.
1068
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
1084
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, ...gateSkippedField, error: undefined });
1069
1085
  }
1070
1086
  throwIfMergeAborted(signal);
1071
1087
  const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove landed worktree", signal);
1072
1088
  const worktreeRemoved = removed.ok;
1073
1089
  const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal, input.id) : { branchDeleted: false };
1074
- return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
1090
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateWarningsField, ...gateSkippedField, error: undefined });
1075
1091
  }
@@ -1,5 +1,5 @@
1
1
  import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
2
- import type { WorkspaceMergeResult } from "agent-relay-sdk";
2
+ import type { WorkspaceLandGateLevel, WorkspaceMergeResult } from "agent-relay-sdk";
3
3
 
4
4
  /** Attach to or branch off an existing managed worktree instead of creating a fresh one (#635). */
5
5
  export interface ResumeWorkspaceTarget {
@@ -59,6 +59,11 @@ export interface WorkspaceMergeInput {
59
59
  * - "on-approval": open PR, do NOT arm; reviewer pipeline arms it later.
60
60
  * - "manual": open PR, do NOT arm (today's legacy behavior). */
61
61
  autoMerge?: "on-green" | "on-approval" | "manual";
62
+ /** Land-gate level. Omitted/"full" runs the base-declared required gates. */
63
+ gateLevel?: WorkspaceLandGateLevel;
64
+ /** Required by Relay before subset/none reaches the host; carried for audit output. */
65
+ gateReason?: string;
66
+ gateRequestedBy?: string;
62
67
  /**
63
68
  * Set when the relay observed this branch's PR already merged on the remote and is
64
69
  * dispatching a land-and-continue RECYCLE (#423). Carries the relay's ground truth (the