Skip to content

Commit 6439e3b

Browse files
arielshadclaude
andcommitted
fix(agents): skip agent call for local-only merge, commit programmatically
when no push/pr is needed (local-only mode), the merge node now commits changes in the worktree using programmatic git commands instead of calling the agent executor. this eliminates the second cursor cli call that hangs indefinitely on windows. adds commitAll dep to merge node, wired via gitPrService.commitAll. agent call 1 now only runs when push or openPr is requested. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a528e7f commit 6439e3b

7 files changed

Lines changed: 109 additions & 64 deletions

File tree

packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,14 @@ export async function runWorker(args: WorkerArgs): Promise<void> {
241241
premergeBaseSha?: string
242242
) => gitPrService.verifyMerge(cwd, featureBranch, baseBranch, premergeBaseSha),
243243
revParse: (cwd: string, ref: string) => gitPrService.revParse(cwd, ref),
244+
commitAll: async (cwd: string, message: string) => {
245+
try {
246+
return await gitPrService.commitAll(cwd, message);
247+
} catch {
248+
// commitAll throws when there's nothing to commit — return undefined
249+
return undefined;
250+
}
251+
},
244252
localMergeSquash: (
245253
cwd: string,
246254
featureBranch: string,

packages/core/src/infrastructure/services/agents/feature-agent/nodes/merge/merge.node.ts

Lines changed: 96 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface MergeNodeDeps {
7171
* Resolve a branch ref to its current SHA.
7272
*/
7373
revParse: (cwd: string, ref: string) => Promise<string>;
74+
/**
75+
* Stage all changes and commit in the given directory.
76+
* Returns the commit hash, or undefined if there was nothing to commit.
77+
*/
78+
commitAll: (cwd: string, message: string) => Promise<string | undefined>;
7479
gitPrService: IGitPrService;
7580
cleanupFeatureWorktreeUseCase: Pick<CleanupFeatureWorktreeUseCase, 'execute'>;
7681
}
@@ -159,77 +164,104 @@ export function createMergeNode(deps: MergeNodeDeps) {
159164

160165
// --- Agent Call 1: Commit + Push + PR (skip on approval resume) ---
161166
if (!isResumeAfterInterrupt) {
162-
if (!remoteAvailable) {
163-
log.info('No git remote configured — skipping push and PR, will merge locally');
164-
}
165-
166167
const effectiveState = remoteAvailable ? state : { ...state, push: false, openPr: false };
168+
const needsAgentCall = effectiveState.push || effectiveState.openPr;
167169

168-
log.info('Agent call 1: commit + push + PR');
169-
const commitPushPrPrompt = buildCommitPushPrPrompt(
170-
effectiveState,
171-
branch,
172-
baseBranch,
173-
repoUrl
174-
);
175-
const commitResult = await retryExecute(executor, commitPushPrPrompt, options, {
176-
logger: log,
177-
});
178-
totalInputTokens += commitResult.usage?.inputTokens ?? 0;
179-
totalOutputTokens += commitResult.usage?.outputTokens ?? 0;
180-
181-
commitHash = parseCommitHash(commitResult.result) ?? state.commitHash;
182-
messages.push(`[merge] Agent completed commit/push/PR operations`);
183-
184-
if (effectiveState.openPr) {
185-
const prResult = parsePrUrl(commitResult.result);
186-
if (prResult) {
187-
prUrl = prResult.url;
188-
prNumber = prResult.number;
189-
190-
// Cross-validate agent-parsed PR URL against authoritative source.
191-
// The agent may hallucinate the repo URL or PR number, so we look up
192-
// the real PR for this branch via the GitHub API.
193-
try {
194-
const prStatuses = await deps.gitPrService.listPrStatuses(cwd);
195-
const matchingPr = prStatuses.find((pr) => pr.headRefName === branch);
196-
if (matchingPr) {
197-
prUrl = matchingPr.url;
198-
prNumber = matchingPr.number;
199-
}
200-
} catch {
201-
// gh CLI unavailable or API failure — fall back to agent-parsed URL
170+
if (!needsAgentCall) {
171+
// Local-only: commit programmatically in worktree, no agent needed.
172+
// This avoids calling the agent executor just to run git commit,
173+
// which is slow/unreliable on some platforms (e.g. cursor on Windows hangs).
174+
log.info('Local-only mode — committing in worktree programmatically (no agent)');
175+
const worktreeCwd = state.worktreePath ?? cwd;
176+
try {
177+
const msg = `feat: ${branch}`;
178+
const hash = await deps.commitAll(worktreeCwd, msg);
179+
if (hash) {
180+
commitHash = hash;
181+
log.info(`Committed changes in worktree: ${commitHash}`);
182+
messages.push(`[merge] Programmatic commit: ${commitHash}`);
183+
} else {
184+
log.info('No changes to commit in worktree');
185+
messages.push(`[merge] No changes to commit`);
202186
}
203-
204-
messages.push(`[merge] PR created: ${prUrl}`);
187+
} catch (commitErr) {
188+
const errMsg = commitErr instanceof Error ? commitErr.message : String(commitErr);
189+
log.info(`Programmatic commit failed: ${errMsg} — falling back to agent`);
190+
messages.push(`[merge] Programmatic commit failed, falling back to agent`);
191+
// Fall through — needsAgentCall stays false so agent won't run either.
192+
// The localMergeSquash will handle uncommitted changes via --squash.
205193
}
206194
}
207195

208-
// --- CI watch/fix loop (when push or openPr is enabled and CI watch is not disabled) ---
209-
const ciWatchEnabled = getSettings().workflow?.ciWatchEnabled !== false;
210-
if (ciWatchEnabled && (effectiveState.push || effectiveState.openPr)) {
211-
const ciResult = await runCiWatchFixLoop(
212-
{
213-
executor,
214-
gitPrService: deps.gitPrService,
215-
featureRepository: deps.featureRepository,
216-
},
217-
{
218-
cwd,
219-
branch,
220-
options,
221-
feature,
222-
prUrl,
223-
prNumber,
224-
existingAttempts: ciFixAttempts,
225-
messages,
226-
log,
227-
}
196+
if (needsAgentCall) {
197+
if (!remoteAvailable) {
198+
log.info('No git remote configured — skipping push and PR, will merge locally');
199+
}
200+
201+
log.info('Agent call 1: commit + push + PR');
202+
const commitPushPrPrompt = buildCommitPushPrPrompt(
203+
effectiveState,
204+
branch,
205+
baseBranch,
206+
repoUrl
228207
);
229-
ciStatus = ciResult.ciStatus;
230-
ciFixAttempts = ciResult.ciFixAttempts;
231-
ciFixHistory = ciResult.ciFixHistory;
232-
ciFixStatus = ciResult.ciFixStatus;
208+
const commitResult = await retryExecute(executor, commitPushPrPrompt, options, {
209+
logger: log,
210+
});
211+
totalInputTokens += commitResult.usage?.inputTokens ?? 0;
212+
totalOutputTokens += commitResult.usage?.outputTokens ?? 0;
213+
214+
commitHash = parseCommitHash(commitResult.result) ?? state.commitHash;
215+
messages.push(`[merge] Agent completed commit/push/PR operations`);
216+
217+
if (effectiveState.openPr) {
218+
const prResult = parsePrUrl(commitResult.result);
219+
if (prResult) {
220+
prUrl = prResult.url;
221+
prNumber = prResult.number;
222+
223+
// Cross-validate agent-parsed PR URL against authoritative source.
224+
try {
225+
const prStatuses = await deps.gitPrService.listPrStatuses(cwd);
226+
const matchingPr = prStatuses.find((pr) => pr.headRefName === branch);
227+
if (matchingPr) {
228+
prUrl = matchingPr.url;
229+
prNumber = matchingPr.number;
230+
}
231+
} catch {
232+
// gh CLI unavailable or API failure — fall back to agent-parsed URL
233+
}
234+
235+
messages.push(`[merge] PR created: ${prUrl}`);
236+
}
237+
}
238+
239+
// --- CI watch/fix loop (when push or openPr is enabled and CI watch is not disabled) ---
240+
const ciWatchEnabled = getSettings().workflow?.ciWatchEnabled !== false;
241+
if (ciWatchEnabled && (effectiveState.push || effectiveState.openPr)) {
242+
const ciResult = await runCiWatchFixLoop(
243+
{
244+
executor,
245+
gitPrService: deps.gitPrService,
246+
featureRepository: deps.featureRepository,
247+
},
248+
{
249+
cwd,
250+
branch,
251+
options,
252+
feature,
253+
prUrl,
254+
prNumber,
255+
existingAttempts: ciFixAttempts,
256+
messages,
257+
log,
258+
}
259+
);
260+
ciStatus = ciResult.ciStatus;
261+
ciFixAttempts = ciResult.ciFixAttempts;
262+
ciFixHistory = ciResult.ciFixHistory;
263+
ciFixStatus = ciResult.ciFixStatus;
264+
}
233265
}
234266

235267
// --- Persist lifecycle + PR data before approval gate ---

tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export function createStubMergeNodeDeps(featureId?: string): Omit<MergeNodeDeps,
164164
getDefaultBranch: vi.fn().mockResolvedValue('main'),
165165
verifyMerge: vi.fn().mockResolvedValue(true),
166166
revParse: vi.fn().mockResolvedValue('premerge-sha-mock'),
167+
commitAll: vi.fn().mockResolvedValue('mock-commit-hash'),
167168
localMergeSquash: vi.fn().mockResolvedValue(undefined),
168169
featureRepository: createStatefulFeatureRepo(featureId),
169170
gitPrService: {

tests/integration/infrastructure/services/agents/merge-flow.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ describe('Merge Flow (Graph-level)', () => {
146146
getDefaultBranch: vi.fn().mockResolvedValue('main'),
147147
verifyMerge: vi.fn().mockResolvedValue(true),
148148
revParse: vi.fn().mockResolvedValue('premerge-sha-mock'),
149+
commitAll: vi.fn().mockResolvedValue('mock-commit-hash'),
149150
localMergeSquash: vi.fn().mockResolvedValue(undefined),
150151
featureRepository: featureRepo,
151152
gitPrService: createMockGitPrService(),

tests/integration/infrastructure/services/git/merge-step-real-git/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export function buildDeps(opts: BuildDepsOptions = {}): BuiltDeps {
248248
featureRepository,
249249
verifyMerge: (cwd, fb, bb, pre) => gitPrService.verifyMerge(cwd, fb, bb, pre),
250250
revParse: (cwd, ref) => gitPrService.revParse(cwd, ref),
251+
commitAll: async () => 'mock-commit-hash',
251252
localMergeSquash: (cwd, featureBranch, baseBranch, commitMessage, hasRemote) =>
252253
gitPrService.localMergeSquash(cwd, featureBranch, baseBranch, commitMessage, hasRemote),
253254
gitPrService,

tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.ci-watch.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ function baseDeps(overrides?: Partial<MergeNodeDeps>): MergeNodeDeps {
190190
featureRepository: createMockFeatureRepo(),
191191
verifyMerge: vi.fn().mockResolvedValue(true),
192192
revParse: vi.fn().mockResolvedValue('premerge-sha-mock'),
193+
commitAll: vi.fn().mockResolvedValue('mock-commit-hash'),
193194
localMergeSquash: vi.fn().mockResolvedValue(undefined),
194195
gitPrService: createMockGitPrService(),
195196
cleanupFeatureWorktreeUseCase: { execute: vi.fn().mockResolvedValue(undefined) } as any,

tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ function baseDeps(overrides?: Partial<MergeNodeDeps>): MergeNodeDeps {
165165
featureRepository: createMockFeatureRepo(),
166166
verifyMerge: vi.fn().mockResolvedValue(true),
167167
revParse: vi.fn().mockResolvedValue('premerge-sha-abc'),
168+
commitAll: vi.fn().mockResolvedValue('mock-commit-hash'),
168169
localMergeSquash: vi.fn().mockResolvedValue(undefined),
169170
gitPrService: {
170171
getCiStatus: vi.fn().mockResolvedValue({ status: 'success', runUrl: null }),

0 commit comments

Comments
 (0)