agent-relay-orchestrator 0.117.1 → 0.118.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.
|
|
3
|
+
"version": "0.118.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.
|
|
20
|
+
"agent-relay-sdk": "0.2.100",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { errMessage } from "agent-relay-sdk";
|
|
3
|
+
import type { LandGate, LandGateRunResult } from "agent-relay-sdk";
|
|
4
|
+
import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
|
|
5
|
+
|
|
6
|
+
// #902 — execute a repo's configured land gates against the candidate merged tree
|
|
7
|
+
// inside the landing worktree, BEFORE the ref advance in `mergeRebaseFf`. A repo
|
|
8
|
+
// with no `.agent-relay/land-gates.json` loads zero gates, so the caller takes the
|
|
9
|
+
// exact unchanged land path (ironclad opt-in / zero regression). A required gate
|
|
10
|
+
// failing blocks the land (ref NOT advanced); optional gates only warn.
|
|
11
|
+
|
|
12
|
+
/** Per-gate default timeout when the gate didn't declare `timeoutMs`. Land gates
|
|
13
|
+
* are meant to be a FAST high-signal subset, not the full suite — keep the ceiling
|
|
14
|
+
* generous but bounded so a hung gate can't wedge the per-repo merge lease forever. */
|
|
15
|
+
const DEFAULT_GATE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
16
|
+
/** Cap on the full output streamed to the relay artifact (the notification only ever
|
|
17
|
+
* carries the tail). Errors usually surface at the END, so we keep the tail on overflow. */
|
|
18
|
+
const MAX_FULL_OUTPUT_BYTES = 256 * 1024;
|
|
19
|
+
/** Bytes of the tail put in the notification body — enough to show the failure, small
|
|
20
|
+
* enough never to flood relay. */
|
|
21
|
+
const TAIL_BYTES = 4000;
|
|
22
|
+
|
|
23
|
+
function keepTail(text: string, maxBytes: number, label: string): string {
|
|
24
|
+
if (text.length <= maxBytes) return text;
|
|
25
|
+
const dropped = text.length - maxBytes;
|
|
26
|
+
return `…(${dropped} earlier ${label} truncated)…\n${text.slice(text.length - maxBytes)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function combineOutput(stdout: string, stderr: string): string {
|
|
30
|
+
if (stdout && stderr) return `${stdout}\n${stderr}`;
|
|
31
|
+
return stdout || stderr;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Run a single gate and capture its outcome. Never throws — a spawn failure (e.g.
|
|
35
|
+
* the command can't launch) is reported as a non-passing result so the caller can
|
|
36
|
+
* decide block-vs-warn from the gate's `optional` flag. */
|
|
37
|
+
export function runOneLandGate(worktreePath: string, gate: LandGate): LandGateRunResult {
|
|
38
|
+
const cwd = gate.cwd ? resolve(worktreePath, gate.cwd) : worktreePath;
|
|
39
|
+
const timeoutMs = gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const base = { name: gate.name, command: gate.command, optional: gate.optional === true } as const;
|
|
42
|
+
|
|
43
|
+
let proc: ReturnType<typeof Bun.spawnSync>;
|
|
44
|
+
try {
|
|
45
|
+
// A login shell so PATH (bun, node, project bins) resolves like the worker's own
|
|
46
|
+
// environment; `env: process.env` makes runtime env mutations visible to the child.
|
|
47
|
+
proc = Bun.spawnSync(["bash", "-lc", gate.command], {
|
|
48
|
+
cwd,
|
|
49
|
+
env: process.env,
|
|
50
|
+
stdin: "ignore",
|
|
51
|
+
stdout: "pipe",
|
|
52
|
+
stderr: "pipe",
|
|
53
|
+
timeout: timeoutMs,
|
|
54
|
+
});
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const durationMs = Date.now() - started;
|
|
57
|
+
const output = `land gate "${gate.name}" could not be launched: ${errMessage(err)}`;
|
|
58
|
+
return { ...base, passed: false, exitCode: null, timedOut: false, durationMs, outputTail: output, output };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const durationMs = Date.now() - started;
|
|
62
|
+
const stdout = proc.stdout ? proc.stdout.toString() : "";
|
|
63
|
+
const stderr = proc.stderr ? proc.stderr.toString() : "";
|
|
64
|
+
let combined = combineOutput(stdout, stderr);
|
|
65
|
+
const exitCode = typeof proc.exitCode === "number" ? proc.exitCode : null;
|
|
66
|
+
const timedOut = proc.exitedDueToTimeout === true;
|
|
67
|
+
const passed = exitCode === 0;
|
|
68
|
+
if (timedOut) combined = `${combined}\n[land-gate] timed out after ${timeoutMs}ms`.trimStart();
|
|
69
|
+
if (!combined) combined = passed ? "(gate produced no output)" : "(gate produced no output)";
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
passed,
|
|
74
|
+
exitCode,
|
|
75
|
+
timedOut,
|
|
76
|
+
durationMs,
|
|
77
|
+
outputTail: keepTail(combined, TAIL_BYTES, "bytes"),
|
|
78
|
+
output: keepTail(combined, MAX_FULL_OUTPUT_BYTES, "bytes"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LandGatesResult {
|
|
83
|
+
/** Number of gates configured + run (0 ⇒ no config ⇒ caller's land path is unchanged). */
|
|
84
|
+
ran: number;
|
|
85
|
+
/** First REQUIRED gate that failed — its presence means the land MUST be aborted
|
|
86
|
+
* without advancing the ref. */
|
|
87
|
+
failure?: LandGateRunResult;
|
|
88
|
+
/** Optional gates that failed (warn-only); the land still proceeds. */
|
|
89
|
+
warnings: LandGateRunResult[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load + run the repo's configured land gates against `worktreePath`. Returns
|
|
94
|
+
* `ran: 0` with no failure/warnings when the repo declares no gates — the load is the
|
|
95
|
+
* ONLY thing that runs in that case (a missing file → empty list, no subprocess), so
|
|
96
|
+
* the opt-in is byte-clean. Stops at the first failing REQUIRED gate (it blocks the
|
|
97
|
+
* land); optional failures accumulate as warnings and never block.
|
|
98
|
+
*
|
|
99
|
+
* A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure
|
|
100
|
+
* (surfaced as a synthetic required gate) rather than an unhandled throw that would
|
|
101
|
+
* crash the merge command — the worker fixes the config and re-lands.
|
|
102
|
+
*/
|
|
103
|
+
export function runLandGates(worktreePath: string): LandGatesResult {
|
|
104
|
+
const warnings: LandGateRunResult[] = [];
|
|
105
|
+
let gates: LandGate[];
|
|
106
|
+
try {
|
|
107
|
+
gates = loadRepoLandGates(worktreePath);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
|
|
110
|
+
return {
|
|
111
|
+
ran: 1,
|
|
112
|
+
failure: { name: "land-gates-config", command: "(config validation)", optional: false, passed: false, exitCode: null, timedOut: false, durationMs: 0, outputTail: output, output },
|
|
113
|
+
warnings,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (gates.length === 0) return { ran: 0, warnings };
|
|
117
|
+
|
|
118
|
+
for (const gate of gates) {
|
|
119
|
+
const result = runOneLandGate(worktreePath, gate);
|
|
120
|
+
if (result.passed) continue;
|
|
121
|
+
if (result.optional) { warnings.push(result); continue; }
|
|
122
|
+
// First required failure blocks the land — don't run the rest (the worker fixes
|
|
123
|
+
// this gate and re-lands, which re-runs from the top).
|
|
124
|
+
return { ran: gates.length, failure: result, warnings };
|
|
125
|
+
}
|
|
126
|
+
return { ran: gates.length, warnings };
|
|
127
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
3
4
|
import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
|
|
4
5
|
import { git, gitRaw } from "../git";
|
|
5
6
|
import { prMergedState } from "../workspace-pr";
|
|
6
7
|
import { refreshWorkspaceDeps } from "./deps";
|
|
8
|
+
import { type LandGatesResult, runLandGates } from "./land-gates-runner";
|
|
7
9
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
|
|
8
10
|
import { nextBranchName } from "./names";
|
|
9
11
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
@@ -523,9 +525,14 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
|
|
|
523
525
|
// with plumbing: compute the merged tree, commit it with both parents (base first, so
|
|
524
526
|
// `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
|
|
525
527
|
// the old value. Preserves the branch's commit SHAs without a working tree.
|
|
526
|
-
|
|
528
|
+
// Synthesize (but do NOT advance any ref to) the no-ff merge commit of `branchSha` into
|
|
529
|
+
// `baseSha`: compute the merged tree with `merge-tree`, then commit it with both parents
|
|
530
|
+
// (base first, so `--first-parent` reads as base's mainline). This is the exact commit/tree
|
|
531
|
+
// that will become base's new tip — used both to gate the TRUE integrated tree before any ref
|
|
532
|
+
// moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
|
|
533
|
+
// available. Pure object creation: no ref is touched, so a caller can throw it away freely.
|
|
534
|
+
function synthesizeNoFfMerge(
|
|
527
535
|
repoRoot: string,
|
|
528
|
-
base: string,
|
|
529
536
|
baseSha: string,
|
|
530
537
|
branchSha: string,
|
|
531
538
|
message: string,
|
|
@@ -539,10 +546,65 @@ function recordNoFfMerge(
|
|
|
539
546
|
repoRoot,
|
|
540
547
|
);
|
|
541
548
|
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
542
|
-
|
|
543
|
-
|
|
549
|
+
return { ok: true, mergeSha: commit.stdout };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function recordNoFfMerge(
|
|
553
|
+
repoRoot: string,
|
|
554
|
+
base: string,
|
|
555
|
+
baseSha: string,
|
|
556
|
+
branchSha: string,
|
|
557
|
+
message: string,
|
|
558
|
+
): { ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string } {
|
|
559
|
+
const synth = synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
|
|
560
|
+
if (!synth.ok) return synth;
|
|
561
|
+
const update = git(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot);
|
|
544
562
|
if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
|
|
545
|
-
return { ok: true, mergeSha };
|
|
563
|
+
return { ok: true, mergeSha: synth.mergeSha };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Run the repo's configured land gates against the EXACT tree that will become base's new tip,
|
|
568
|
+
* BEFORE any ref advance (#902 BLOCKING 1 / closes #903). The candidate tree depends on `behind`:
|
|
569
|
+
* - behind === 0: the landing worktree HEAD already IS that tree (a clean fast-forward), so gates
|
|
570
|
+
* run in `worktreePath` in place — byte-identical to today's straight-FF path.
|
|
571
|
+
* - behind > 0: base has advanced, so the tree that will land is the no-ff MERGE of
|
|
572
|
+
* `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
|
|
573
|
+
* plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
|
|
574
|
+
* The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
|
|
575
|
+
* can never strand an orphan worktree or leave the synthesized merge half-applied.
|
|
576
|
+
* Returns the gate outcome, or an `abort` describing a merge result the caller must return early
|
|
577
|
+
* (a real merge conflict computing the integrated tree, or a failure materializing it).
|
|
578
|
+
*/
|
|
579
|
+
function runLandGatesOnIntegratedTree(
|
|
580
|
+
repoRoot: string,
|
|
581
|
+
worktreePath: string,
|
|
582
|
+
behind: number,
|
|
583
|
+
integrationBaseSha: string,
|
|
584
|
+
headSha: string,
|
|
585
|
+
mergeMessage: string,
|
|
586
|
+
): { gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } } {
|
|
587
|
+
if (behind === 0) return { gates: runLandGates(worktreePath) };
|
|
588
|
+
|
|
589
|
+
const synth = synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage);
|
|
590
|
+
if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
|
|
591
|
+
|
|
592
|
+
// Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
|
|
593
|
+
// without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
|
|
594
|
+
// creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
|
|
595
|
+
const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
|
|
596
|
+
const tmpWorktree = join(tmpParent, "tree");
|
|
597
|
+
const add = git(["worktree", "add", "--detach", tmpWorktree, synth.mergeSha], repoRoot);
|
|
598
|
+
if (!add.ok) {
|
|
599
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
600
|
+
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
return { gates: runLandGates(tmpWorktree) };
|
|
604
|
+
} finally {
|
|
605
|
+
git(["worktree", "remove", "--force", tmpWorktree], repoRoot);
|
|
606
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
607
|
+
}
|
|
546
608
|
}
|
|
547
609
|
|
|
548
610
|
/**
|
|
@@ -615,6 +677,24 @@ function mergeRebaseFf(
|
|
|
615
677
|
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
616
678
|
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
617
679
|
const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
|
|
680
|
+
|
|
681
|
+
// SHA preservation (#287): never rebase the agent branch — rewriting its commits
|
|
682
|
+
// gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
|
|
683
|
+
// base verbatim). headSha is the preserved landed commit; when base has advanced we
|
|
684
|
+
// tie the branch in with a no-ff merge so the agent's commits keep their identity.
|
|
685
|
+
const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
|
|
686
|
+
// Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
|
|
687
|
+
// an empty/failed read just omits it from the message body.
|
|
688
|
+
const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
|
|
689
|
+
|
|
690
|
+
// #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
|
|
691
|
+
// the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
|
|
692
|
+
// concurrent case (#638): we still fetch fresh origin to compute the real merge, but we defer
|
|
693
|
+
// the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
|
|
694
|
+
// identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
|
|
695
|
+
// sync+advance below, all of it gated.
|
|
696
|
+
let integrationBaseSha = git(["rev-parse", base], repoRoot).stdout;
|
|
697
|
+
let needSync = false;
|
|
618
698
|
if (upstream && remote && pushEnabled) {
|
|
619
699
|
git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
|
|
620
700
|
if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
|
|
@@ -623,21 +703,47 @@ function mergeRebaseFf(
|
|
|
623
703
|
if (!git(["merge-base", "--is-ancestor", base, upstream], worktreePath).ok) {
|
|
624
704
|
return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
|
|
625
705
|
}
|
|
626
|
-
const
|
|
627
|
-
if (!
|
|
628
|
-
|
|
706
|
+
const upstreamSha = git(["rev-parse", "--verify", upstream], worktreePath).stdout;
|
|
707
|
+
if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
|
|
708
|
+
// Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
|
|
709
|
+
integrationBaseSha = upstreamSha;
|
|
710
|
+
needSync = true;
|
|
629
711
|
}
|
|
630
712
|
}
|
|
631
713
|
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
714
|
+
// Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
|
|
715
|
+
const behind = countBehind(worktreePath, integrationBaseSha);
|
|
716
|
+
|
|
717
|
+
// #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
|
|
718
|
+
// that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
|
|
719
|
+
// that tree (clean fast-forward); behind>0 → the synthesized no-ff merge of integrationBaseSha +
|
|
720
|
+
// HEAD, gated in a throwaway worktree (see runLandGatesOnIntegratedTree). A repo with no
|
|
721
|
+
// `.agent-relay/land-gates.json` runs ZERO gates (missing-file → empty list, no subprocess), so
|
|
722
|
+
// behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
|
|
723
|
+
// failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
|
|
724
|
+
// status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
|
|
725
|
+
// the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
|
|
726
|
+
// `gateWarnings` on the successful land below.
|
|
727
|
+
const gateRun = runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
728
|
+
if ("abort" in gateRun) {
|
|
729
|
+
return gateRun.abort.conflict
|
|
730
|
+
? head({ conflict: true, status: "conflict", error: gateRun.abort.error })
|
|
731
|
+
: head({ status: "review_requested", error: gateRun.abort.error });
|
|
732
|
+
}
|
|
733
|
+
const gates = gateRun.gates;
|
|
734
|
+
if (gates.failure) {
|
|
735
|
+
return head({ status: "review_requested", gateFailure: gates.failure, error: `land gate failed: ${gates.failure.name}` });
|
|
736
|
+
}
|
|
737
|
+
const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
|
|
738
|
+
|
|
739
|
+
// Gates passed — only NOW is it safe to touch `refs/heads/<base>`. If origin moved ahead, sync
|
|
740
|
+
// local base up to the fetched upstream first (#638); this is the FIRST mutation of the base ref
|
|
741
|
+
// in the whole land path, so a gate failure above can never leave it advanced (#902 BLOCKING 2).
|
|
742
|
+
if (needSync) {
|
|
743
|
+
const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
|
|
744
|
+
if (!synced.ok) return head({ status: "review_requested", error: synced.error });
|
|
745
|
+
baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
|
|
746
|
+
}
|
|
641
747
|
|
|
642
748
|
// Advance base. `baseTip` is base's new tip after the land: it equals headSha on a
|
|
643
749
|
// clean fast-forward, or the merge commit on a no-ff merge. Operate IN the base worktree
|
|
@@ -716,13 +822,13 @@ function mergeRebaseFf(
|
|
|
716
822
|
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
717
823
|
const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
|
|
718
824
|
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
719
|
-
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, error: undefined });
|
|
825
|
+
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
720
826
|
}
|
|
721
827
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
722
|
-
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, error: undefined });
|
|
828
|
+
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
723
829
|
}
|
|
724
830
|
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
725
831
|
const worktreeRemoved = removed.ok;
|
|
726
832
|
const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
|
|
727
|
-
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, error: undefined });
|
|
833
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
728
834
|
}
|