From 234ac05fb60fd22c6512401a1d9998b671df5b87 Mon Sep 17 00:00:00 2001 From: terra tauri Date: Mon, 23 Feb 2026 00:09:39 -0800 Subject: [PATCH] fix(memento): create one PR per iteration instead of one at end Previously --pr created a single worktree/branch at iteration 1 and attempted one PR after the loop exited, which often never happened. Now each iteration gets its own branch forked from the default branch, making PRs independently reviewable and mergeable. Also adds graceful SIGINT handling so Ctrl+C finishes the current iteration before exiting. --- packages/autonav/src/cli/nav-memento.ts | 7 +- packages/autonav/src/memento/loop.ts | 167 +++++++++++++----------- packages/autonav/src/memento/types.ts | 4 +- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/packages/autonav/src/cli/nav-memento.ts b/packages/autonav/src/cli/nav-memento.ts index 352a89f..394ee18 100644 --- a/packages/autonav/src/cli/nav-memento.ts +++ b/packages/autonav/src/cli/nav-memento.ts @@ -208,8 +208,11 @@ async function executeMemento( console.log(`${chalk.blue("Branch:")} ${result.branch}`); } - if (result.prUrl) { - console.log(`${chalk.blue("PR:")} ${result.prUrl}`); + if (result.prUrls && result.prUrls.length > 0) { + console.log(chalk.blue(`PRs (${result.prUrls.length}):`)); + for (const url of result.prUrls) { + console.log(` ${url}`); + } } if (result.completionMessage) { diff --git a/packages/autonav/src/memento/loop.ts b/packages/autonav/src/memento/loop.ts index cc39ddc..272a413 100644 --- a/packages/autonav/src/memento/loop.ts +++ b/packages/autonav/src/memento/loop.ts @@ -1451,19 +1451,33 @@ export async function runMementoLoop( const defaultBranch = getDefaultBranch({ cwd: codeDirectory }); const worktreesBase = path.join(resolveConfigDir(), "worktrees"); - // Track the effective branch and worktree for this run - let activeBranch: string | undefined = options.branch; - let worktreePath: string | null = null; + // Per-iteration PR URLs (one PR per iteration when --pr is used) + const prUrls: string[] = []; - // The working directory for the implementer — starts as codeDirectory, - // switches to worktree once created after the first plan + // Safety tracker: if an error interrupts mid-iteration, clean up this worktree + let currentIterWorktree: string | null = null; + + // The working directory for the implementer — resets to codeDirectory each iteration, + // switches to a fresh worktree after the plan is received let workDir = codeDirectory; const errors: string[] = []; + // Graceful shutdown: finish current iteration's PR before exiting + let shuttingDown = false; + const sigintHandler = (): void => { + if (shuttingDown) { + process.stdout.write("\x1B[?25h"); + process.exit(1); + } + shuttingDown = true; + console.log(chalk.yellow("\n\nGraceful shutdown after this iteration...")); + }; + process.on("SIGINT", sigintHandler); + try { - // Main loop - only max-iterations stops the loop (0 = unlimited = run forever) - while (maxIterations === 0 || state.iteration < maxIterations) { + // Main loop - only max-iterations or SIGINT stops the loop (0 = unlimited = run forever) + while (!shuttingDown && (maxIterations === 0 || state.iteration < maxIterations)) { state.iteration += 1; if (verbose) { @@ -1507,6 +1521,8 @@ export async function runMementoLoop( let iterationTokens = 0; let implementerTokens = 0; let implementerSummary = ""; + let iterBranch = ""; + let iterWorktreePath = ""; try { // Query navigator for plan (with rate limit retry) @@ -1556,28 +1572,26 @@ export async function runMementoLoop( // Record plan in memory (for PR body) state.planHistory.push({ iteration: state.iteration, summary: plan.summary }); - // Create worktree after first plan returns - if (state.iteration === 1 && !worktreePath) { - const branchName = activeBranch || slugifyBranchName(plan.summary); - activeBranch = branchName; - worktreePath = path.join(worktreesBase, branchName); - - // Ensure worktrees base directory exists - fs.mkdirSync(worktreesBase, { recursive: true }); + // Create per-iteration worktree forked from default branch + iterBranch = options.branch + ? `${options.branch}-${state.iteration}` + : `memento/${state.iteration}-${slugifyBranchName(plan.summary)}`; + iterWorktreePath = path.join(worktreesBase, iterBranch); + fs.mkdirSync(worktreesBase, { recursive: true }); - if (verbose) { - console.log(`[Memento] Creating worktree: ${worktreePath}`); - console.log(`[Memento] Branch: ${branchName} (from ${defaultBranch})`); - } + if (verbose) { + console.log(`[Memento] Creating worktree: ${iterWorktreePath}`); + console.log(`[Memento] Branch: ${iterBranch} (from ${defaultBranch})`); + } - createWorktree(codeDirectory, worktreePath, branchName, defaultBranch); - workDir = worktreePath; + createWorktree(codeDirectory, iterWorktreePath, iterBranch, defaultBranch); + workDir = iterWorktreePath; + currentIterWorktree = iterWorktreePath; - if (!verbose) { - animation.stop(); - console.log(chalk.dim(` └─ Branch: ${branchName}`)); - animation.start(); - } + if (!verbose) { + animation.stop(); + console.log(chalk.dim(` └─ Branch: ${iterBranch}`)); + animation.start(); } // Show what the implementer will be working on (truncate to ~60 chars) @@ -1674,50 +1688,53 @@ export async function runMementoLoop( isComplete: plan?.isComplete, completionMessage: plan?.completionMessage, }); - } - - // Loop exited - must have hit max iterations - console.log(`\nMax iterations (${maxIterations}) reached.`); - - // Handle PR creation if requested - let prUrl: string | undefined; - - if (pr && activeBranch) { - if (!isGhAvailable()) { - console.warn( - "\nWarning: gh CLI not available. Cannot create PR. Install and authenticate gh CLI." - ); - } else { - console.log("\nCreating pull request..."); - - // Push branch (from worktree where commits live) - pushBranch(activeBranch, { - cwd: workDir, - verbose, - setUpstream: true, - }); - - // Create PR - const prBody = `## Summary -${state.completionMessage || task} - -## Iterations - -${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} + // Per-iteration PR creation + if (pr && commitHash) { + if (isGhAvailable()) { + pushBranch(iterBranch, { cwd: workDir, verbose, setUpstream: true }); + const prTitle = plan?.summary + ? (plan.summary.length > 70 ? `${plan.summary.substring(0, 67)}...` : plan.summary) + : `memento iteration ${state.iteration}`; + const prBody = `## Summary\n\n${plan?.summary || task}\n\n---\n*Created by autonav memento loop (iteration ${state.iteration})*`; + const url = createPullRequest({ + cwd: workDir, + verbose, + title: prTitle, + body: prBody, + }); + prUrls.push(url); + console.log(`PR created: ${url}`); + } else { + console.warn("Warning: gh CLI not available. Skipping PR."); + } + } ---- -*Created by autonav memento loop*`; + // Cleanup iteration worktree + try { + removeWorktree(codeDirectory, iterWorktreePath); + } catch (err) { + if (verbose) { + console.log( + chalk.yellow( + `[Memento] Worktree cleanup failed: ${err instanceof Error ? err.message : err}` + ) + ); + } + } + currentIterWorktree = null; + workDir = codeDirectory; // reset for next iteration + } - prUrl = createPullRequest({ - cwd: workDir, - verbose, - title: task.length > 70 ? `${task.substring(0, 67)}...` : task, - body: prBody, - }); + // Loop exited + if (shuttingDown) { + console.log(chalk.yellow("\nShutdown complete.")); + } else if (maxIterations > 0) { + console.log(`\nMax iterations (${maxIterations}) reached.`); + } - console.log(`PR created: ${prUrl}`); - } + if (prUrls.length > 0) { + console.log(`\nCreated ${prUrls.length} PR(s).`); } const durationMs = Date.now() - startTime; @@ -1726,8 +1743,8 @@ ${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} success: true, // Completed max iterations without fatal error iterations: state.iteration, completionMessage: state.completionMessage, - prUrl, - branch: activeBranch || getCurrentBranch({ cwd: workDir }), + prUrls: prUrls.length > 0 ? prUrls : undefined, + branch: prUrls.length > 0 ? "multiple" : getCurrentBranch({ cwd: codeDirectory }), durationMs, errors: errors.length > 0 ? errors : undefined, }; @@ -1739,18 +1756,22 @@ ${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} return { success: false, iterations: state.iteration, - branch: activeBranch || getCurrentBranch({ cwd: workDir }), + prUrls: prUrls.length > 0 ? prUrls : undefined, + branch: getCurrentBranch({ cwd: codeDirectory }), durationMs, errors, }; } finally { - // Clean up worktree on exit - if (worktreePath) { + // Remove SIGINT handler + process.removeListener("SIGINT", sigintHandler); + + // Safety cleanup: if an error interrupted mid-iteration, clean up the worktree + if (currentIterWorktree) { if (verbose) { - console.log(`[Memento] Cleaning up worktree: ${worktreePath}`); + console.log(`[Memento] Cleaning up interrupted worktree: ${currentIterWorktree}`); } try { - removeWorktree(codeDirectory, worktreePath); + removeWorktree(codeDirectory, currentIterWorktree); } catch (err) { if (verbose) { console.log( diff --git a/packages/autonav/src/memento/types.ts b/packages/autonav/src/memento/types.ts index ccc89e3..256fd34 100644 --- a/packages/autonav/src/memento/types.ts +++ b/packages/autonav/src/memento/types.ts @@ -93,8 +93,8 @@ export interface MementoResult { /** Completion message from nav */ completionMessage?: string; - /** PR URL if --pr was used */ - prUrl?: string; + /** PR URLs if --pr was used (one per iteration) */ + prUrls?: string[]; /** Git branch name */ branch?: string;