agent-relay-orchestrator 0.118.3 → 0.118.4
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 +1 -1
- package/src/git.ts +10 -8
- package/src/process.ts +90 -9
- package/src/workspace-probe/merge-timeouts.ts +41 -0
- package/src/workspace-probe/merge.ts +47 -7
package/package.json
CHANGED
package/src/git.ts
CHANGED
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
// `git -C` invocation lives in one place.
|
|
4
4
|
|
|
5
5
|
import { execProcess } from "./process";
|
|
6
|
+
import type { ExecResult } from "./process";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "timedOut">;
|
|
9
|
+
|
|
10
|
+
interface GitOptions {
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
timeoutLabel?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
|
|
14
|
-
export async function git(args: string[], cwd: string): Promise<GitResult> {
|
|
15
|
-
return await execProcess(["git", "-C", cwd, ...args]);
|
|
16
|
+
export async function git(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
|
|
17
|
+
return await execProcess(["git", "-C", cwd, ...args], options);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
|
|
19
|
-
export async function gitRaw(args: string[], cwd: string): Promise<GitResult> {
|
|
20
|
-
return await execProcess(["git", "-C", cwd, ...args], { trimStdout: false });
|
|
21
|
+
export async function gitRaw(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
|
|
22
|
+
return await execProcess(["git", "-C", cwd, ...args], { ...options, trimStdout: false });
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
|
package/src/process.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface ExecResult {
|
|
|
3
3
|
exitCode: number | null;
|
|
4
4
|
stdout: string;
|
|
5
5
|
stderr: string;
|
|
6
|
+
timedOut?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
interface ExecOptions {
|
|
@@ -12,11 +13,56 @@ interface ExecOptions {
|
|
|
12
13
|
stderr?: "pipe" | "ignore";
|
|
13
14
|
trimStdout?: boolean;
|
|
14
15
|
trimStderr?: boolean;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
timeoutLabel?: string;
|
|
18
|
+
streamDrainGraceMs?: number;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
|
|
22
|
+
|
|
23
|
+
interface StreamCapture {
|
|
24
|
+
done: Promise<void>;
|
|
25
|
+
text(): string;
|
|
26
|
+
cancel(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureStream(stream: ReadableStream<Uint8Array> | undefined): StreamCapture {
|
|
30
|
+
if (!stream) return { done: Promise.resolve(), text: () => "", cancel: () => {} };
|
|
31
|
+
const reader = stream.getReader();
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
let output = "";
|
|
34
|
+
let canceled = false;
|
|
35
|
+
const done = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
while (!canceled) {
|
|
38
|
+
const chunk = await reader.read();
|
|
39
|
+
if (chunk.done) break;
|
|
40
|
+
output += decoder.decode(chunk.value, { stream: true });
|
|
41
|
+
}
|
|
42
|
+
output += decoder.decode();
|
|
43
|
+
} catch {
|
|
44
|
+
// Intentional cancellation on process timeout or a stuck post-exit pipe.
|
|
45
|
+
} finally {
|
|
46
|
+
try { reader.releaseLock(); } catch {}
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return {
|
|
50
|
+
done,
|
|
51
|
+
text: () => output,
|
|
52
|
+
cancel: () => {
|
|
53
|
+
canceled = true;
|
|
54
|
+
void reader.cancel().catch(() => {});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sleep(ms: number): Promise<void> {
|
|
60
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function timeoutMessage(cmd: string[], options: ExecOptions): string {
|
|
64
|
+
const label = options.timeoutLabel ?? cmd.join(" ");
|
|
65
|
+
return `${label} timed out after ${options.timeoutMs}ms`;
|
|
20
66
|
}
|
|
21
67
|
|
|
22
68
|
export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
|
|
@@ -27,15 +73,50 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
|
|
|
27
73
|
stdout: options.stdout ?? "pipe",
|
|
28
74
|
stderr: options.stderr ?? "pipe",
|
|
29
75
|
});
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
|
|
77
|
+
const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
let killTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
let timeoutResolve: ((value: null) => void) | undefined;
|
|
82
|
+
const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
|
|
83
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
84
|
+
timeout = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
try { proc.kill("SIGTERM"); } catch {}
|
|
87
|
+
killTimeout = setTimeout(() => {
|
|
88
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
89
|
+
}, 1_000);
|
|
90
|
+
killTimeout.unref?.();
|
|
91
|
+
timeoutResolve?.(null);
|
|
92
|
+
}, options.timeoutMs);
|
|
93
|
+
timeout.unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
|
|
97
|
+
if (timeout) clearTimeout(timeout);
|
|
98
|
+
if (!timedOut && killTimeout) clearTimeout(killTimeout);
|
|
99
|
+
if (timedOut) {
|
|
100
|
+
stdoutCapture.cancel();
|
|
101
|
+
stderrCapture.cancel();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const drainGraceMs = options.streamDrainGraceMs ?? DEFAULT_STREAM_DRAIN_GRACE_MS;
|
|
105
|
+
await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(drainGraceMs)]);
|
|
106
|
+
stdoutCapture.cancel();
|
|
107
|
+
stderrCapture.cancel();
|
|
108
|
+
|
|
109
|
+
const stdout = stdoutCapture.text();
|
|
110
|
+
let stderr = stderrCapture.text();
|
|
111
|
+
if (timedOut) {
|
|
112
|
+
const msg = timeoutMessage(cmd, options);
|
|
113
|
+
stderr = stderr ? `${stderr}\n${msg}` : msg;
|
|
114
|
+
}
|
|
35
115
|
return {
|
|
36
|
-
ok: exitCode === 0,
|
|
116
|
+
ok: !timedOut && exitCode === 0,
|
|
37
117
|
exitCode,
|
|
38
118
|
stdout: options.trimStdout === false ? stdout : stdout.trim(),
|
|
39
119
|
stderr: options.trimStderr === false ? stderr : stderr.trim(),
|
|
120
|
+
...(timedOut ? { timedOut } : {}),
|
|
40
121
|
};
|
|
41
122
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type MergePhase = "preview" | "fetch" | "synthesize" | "worktree-add" | "cleanup";
|
|
2
|
+
|
|
3
|
+
const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
|
|
4
|
+
preview: 30_000,
|
|
5
|
+
fetch: 60_000,
|
|
6
|
+
synthesize: 60_000,
|
|
7
|
+
"worktree-add": 60_000,
|
|
8
|
+
cleanup: 30_000,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function positiveEnvMs(name: string): number | undefined {
|
|
12
|
+
const raw = process.env[name];
|
|
13
|
+
if (!raw) return undefined;
|
|
14
|
+
const parsed = Number(raw);
|
|
15
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function mergePhaseTimeoutMs(phase: MergePhase): number {
|
|
19
|
+
const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`;
|
|
20
|
+
return positiveEnvMs(key)
|
|
21
|
+
?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS")
|
|
22
|
+
?? MERGE_PHASE_TIMEOUTS_MS[phase];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function withMergePhaseTimeout<T>(phase: MergePhase, run: () => Promise<T>): Promise<T> {
|
|
26
|
+
const timeoutMs = mergePhaseTimeoutMs(phase);
|
|
27
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
const work = run();
|
|
29
|
+
work.catch(() => {});
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
work,
|
|
33
|
+
new Promise<T>((_, reject) => {
|
|
34
|
+
timeout = setTimeout(() => reject(new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
35
|
+
timeout.unref?.();
|
|
36
|
+
}),
|
|
37
|
+
]);
|
|
38
|
+
} finally {
|
|
39
|
+
if (timeout) clearTimeout(timeout);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import type
|
|
4
|
+
import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
|
|
5
5
|
import { git, gitRaw } from "../git";
|
|
6
6
|
import { execProcess } from "../process";
|
|
7
7
|
import { prMergedState } from "../workspace-pr";
|
|
8
8
|
import { refreshWorkspaceDeps } from "./deps";
|
|
9
9
|
import { type LandGatesResult, runLandGates } from "./land-gates-runner";
|
|
10
10
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
|
|
11
|
+
import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
11
12
|
import { nextBranchName } from "./names";
|
|
12
13
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
13
14
|
import { json, resolveRequestedPath } from "./request";
|
|
@@ -357,7 +358,12 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
357
358
|
// `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
|
|
358
359
|
const liveBranch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath)).stdout || undefined);
|
|
359
360
|
const branch = liveBranch ?? input.branch;
|
|
360
|
-
|
|
361
|
+
let preview: WorkspaceMergePreview;
|
|
362
|
+
try {
|
|
363
|
+
preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
|
|
366
|
+
}
|
|
361
367
|
const strategy = preview.strategy;
|
|
362
368
|
const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
|
|
363
369
|
|
|
@@ -531,14 +537,16 @@ async function synthesizeNoFfMerge(
|
|
|
531
537
|
baseSha: string,
|
|
532
538
|
branchSha: string,
|
|
533
539
|
message: string,
|
|
540
|
+
timeoutMs?: number,
|
|
534
541
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
535
|
-
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
|
|
542
|
+
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
|
|
536
543
|
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
537
544
|
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
538
545
|
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
539
546
|
const commit = await git(
|
|
540
547
|
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
541
548
|
repoRoot,
|
|
549
|
+
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
|
|
542
550
|
);
|
|
543
551
|
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
544
552
|
return { ok: true, mergeSha: commit.stdout };
|
|
@@ -581,7 +589,12 @@ async function runLandGatesOnIntegratedTree(
|
|
|
581
589
|
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
582
590
|
if (behind === 0) return { gates: await runLandGates(worktreePath) };
|
|
583
591
|
|
|
584
|
-
|
|
592
|
+
let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
|
|
593
|
+
try {
|
|
594
|
+
synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
|
|
595
|
+
} catch (err) {
|
|
596
|
+
return { abort: { error: errMessage(err) } };
|
|
597
|
+
}
|
|
585
598
|
if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
|
|
586
599
|
|
|
587
600
|
// Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
|
|
@@ -589,7 +602,17 @@ async function runLandGatesOnIntegratedTree(
|
|
|
589
602
|
// creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
|
|
590
603
|
const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
|
|
591
604
|
const tmpWorktree = join(tmpParent, "tree");
|
|
592
|
-
|
|
605
|
+
let add: Awaited<ReturnType<typeof git>>;
|
|
606
|
+
try {
|
|
607
|
+
add = await withMergePhaseTimeout("worktree-add", () => git(
|
|
608
|
+
["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
|
|
609
|
+
repoRoot,
|
|
610
|
+
{ timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
|
|
611
|
+
));
|
|
612
|
+
} catch (err) {
|
|
613
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
614
|
+
return { abort: { error: errMessage(err) } };
|
|
615
|
+
}
|
|
593
616
|
if (!add.ok) {
|
|
594
617
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
595
618
|
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
@@ -597,7 +620,15 @@ async function runLandGatesOnIntegratedTree(
|
|
|
597
620
|
try {
|
|
598
621
|
return { gates: await runLandGates(tmpWorktree) };
|
|
599
622
|
} finally {
|
|
600
|
-
|
|
623
|
+
try {
|
|
624
|
+
await withMergePhaseTimeout("cleanup", () => git(
|
|
625
|
+
["worktree", "remove", "--force", tmpWorktree],
|
|
626
|
+
repoRoot,
|
|
627
|
+
{ timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
|
|
628
|
+
));
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
|
|
631
|
+
}
|
|
601
632
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
602
633
|
}
|
|
603
634
|
}
|
|
@@ -691,7 +722,16 @@ async function mergeRebaseFf(
|
|
|
691
722
|
let integrationBaseSha = (await git(["rev-parse", base], repoRoot)).stdout;
|
|
692
723
|
let needSync = false;
|
|
693
724
|
if (upstream && remote && pushEnabled) {
|
|
694
|
-
|
|
725
|
+
try {
|
|
726
|
+
const fetch = await withMergePhaseTimeout("fetch", () => git(
|
|
727
|
+
["fetch", remote, base],
|
|
728
|
+
worktreePath,
|
|
729
|
+
{ timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}` },
|
|
730
|
+
));
|
|
731
|
+
if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
|
|
732
|
+
} catch (err) {
|
|
733
|
+
return head({ status: "review_requested", error: errMessage(err) });
|
|
734
|
+
}
|
|
695
735
|
if (!(await git(["merge-base", "--is-ancestor", upstream, base], worktreePath)).ok) {
|
|
696
736
|
// Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
|
|
697
737
|
// of upstream); otherwise it's genuine divergence — refuse without mutating.
|