diff --git a/.ade/ade.yaml b/.ade/ade.yaml index abc6ebd97..fbe42d2c4 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -4,3 +4,22 @@ stackButtons: [] testSuites: [] laneOverlayPolicies: [] automations: [] +ai: + features: + narratives: true + conflict_proposals: true + commit_messages: true + pr_descriptions: true + terminal_summaries: true + memory_consolidation: true + mission_planning: true + orchestrator: true + initial_context: true + featureModelOverrides: + commit_messages: anthropic/claude-haiku-4-5 + pr_descriptions: openai/gpt-5.3-codex-spark + terminal_summaries: openai/gpt-5.3-codex-spark + chat: + autoTitleEnabled: true + autoTitleModelId: openai/gpt-5.3-codex-spark + autoTitleRefreshOnComplete: true diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 05368516a..f465ed47c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -796,6 +796,7 @@ app.whenReady().then(async () => { aiIntegrationService, projectConfigService, conflictService, + autoRebaseService, rebaseSuggestionService, onHotRefreshChanged: () => { prPollingServiceRef?.poke(); @@ -2329,6 +2330,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.autoRebaseService?.dispose(); + } catch { + // ignore + } try { ctx.automationIngressService?.dispose(); } catch { diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index 12619043c..bb85cebc0 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { augmentPathWithKnownCliDirs, resolveExecutableFromKnownLocations, @@ -26,6 +26,7 @@ describe("cliExecutableResolver", () => { let tempRoot: string | null = null; afterEach(() => { + vi.restoreAllMocks(); setPlatform(originalPlatform); if (tempRoot) { fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -41,6 +42,20 @@ describe("cliExecutableResolver", () => { fs.mkdirSync(homeDir, { recursive: true }); fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); + // Hide system-installed codex so it doesn't win the known-dirs race. + const realStatSync = fs.statSync; + vi.spyOn(fs, "statSync").mockImplementation(((p: fs.PathLike, opts?: any) => { + const normalizedCandidate = path.normalize(String(p)); + const normalizedTempRoot = path.normalize(tempRoot!); + const candidateBase = path.parse(normalizedCandidate).name.toLowerCase(); + if (candidateBase === "codex" && !normalizedCandidate.startsWith(normalizedTempRoot)) { + const err: NodeJS.ErrnoException = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + } + return realStatSync(normalizedCandidate, opts); + }) as typeof fs.statSync); + const env = { HOME: homeDir, PATH: "/usr/bin:/bin", diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index a2c6013d1..54074a0c4 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -50,6 +50,7 @@ export function buildCodingAgentSystemPrompt(args: { return [ `You are ADE's software engineering agent working in ${args.cwd}.`, + "This session is bound to that worktree. Read, edit, and run commands only inside this path unless ADE explicitly relaunches you in a different lane.", "", "## Mission", describeMode(mode), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2de353cf8..6e8c314e4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -113,6 +113,10 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ unstable_v2_resumeSession: vi.fn(), })); +vi.mock("../ai/codexExecutable", () => ({ + resolveCodexExecutable: vi.fn(() => ({ path: "codex", source: "fallback-command" })), +})); + vi.mock("../ai/providerResolver", () => ({ normalizeCliMcpServers: vi.fn(() => ({})), isModelCliWrapped: vi.fn((modelId: string) => !String(modelId).endsWith("-api")), @@ -209,9 +213,14 @@ import { buildComputerUseDirective, createAgentChatService, } from "./agentChatService"; +import { spawn } from "node:child_process"; import { detectAllAuth } from "../ai/authDetector"; import * as providerResolver from "../ai/providerResolver"; import { createUniversalToolSet } from "../ai/tools/universalTools"; +import { createWorkflowTools } from "../ai/tools/workflowTools"; +import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; +import { runGit } from "../git/git"; +import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import type { AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; @@ -232,17 +241,33 @@ function createLogger() { } function createMockLaneService() { + const laneRoots: Record = { + "lane-1": tmpRoot, + "lane-2": path.join(tmpRoot, "lane-2"), + }; + fs.mkdirSync(laneRoots["lane-2"], { recursive: true }); const lanes = [ - { id: "lane-1", name: "Primary", laneType: "primary", worktreePath: tmpRoot }, - { id: "lane-2", name: "Selected", laneType: "feature", worktreePath: tmpRoot }, + { id: "lane-1", name: "Primary", laneType: "primary", branchRef: "feature/primary", worktreePath: laneRoots["lane-1"] }, + { id: "lane-2", name: "Selected", laneType: "feature", branchRef: "feature/selected", worktreePath: laneRoots["lane-2"] }, ]; return { - getLaneBaseAndBranch: vi.fn((_laneId: string) => ({ - baseRef: "main", - branchRef: "feature/test", - worktreePath: tmpRoot, - laneType: "feature", - })), + getLaneBaseAndBranch: vi.fn((laneId: string) => { + const lane = lanes.find((entry) => entry.id === laneId); + if (lane) { + return { + baseRef: "main", + branchRef: lane.branchRef, + worktreePath: lane.worktreePath, + laneType: lane.laneType, + }; + } + return { + baseRef: "main", + branchRef: "feature/selected", + worktreePath: tmpRoot, + laneType: "feature", + }; + }), list: vi.fn(async () => lanes), ensurePrimaryLane: vi.fn(async () => {}), create: vi.fn(async ({ name, description, parentLaneId }: { name: string; description?: string; parentLaneId?: string }) => { @@ -251,9 +276,11 @@ function createMockLaneService() { name, description: description ?? null, laneType: "feature", - worktreePath: tmpRoot, + branchRef: `feature/generated-lane-${lanes.length + 1}`, + worktreePath: path.join(tmpRoot, `generated-lane-${lanes.length + 1}`), parentLaneId: parentLaneId ?? "lane-1", }; + fs.mkdirSync(lane.worktreePath, { recursive: true }); lanes.push(lane); return lane; }), @@ -735,6 +762,23 @@ describe("createAgentChatService", () => { expect(parsed.type).toBe("session_init"); expect(parsed.sessionId).toBe(session.id); }); + + it("rejects chat creation when the selected lane worktree is unavailable", async () => { + const { service, laneService } = createService(); + laneService.getLaneBaseAndBranch.mockReturnValue({ + baseRef: "main", + branchRef: "feature/test", + worktreePath: path.join(tmpRoot, "missing-lane"), + laneType: "feature", + }); + + await expect(service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + })).rejects.toThrow(/worktree is unavailable/i); + }); }); describe("handoffSession", () => { @@ -970,6 +1014,258 @@ describe("createAgentChatService", () => { }); }); + describe("lane launch directives", () => { + it("injects the selected lane worktree into the first unified user turn only", async () => { + const streamCalls: Array> = []; + vi.mocked(streamText).mockImplementation((args: Record) => { + streamCalls.push(args); + return { + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any; + }); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "openai/gpt-5.4", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the repo and fix the launch bug.", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Now add tests.", + }); + + const firstMessages = Array.isArray(streamCalls[0]?.messages) + ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) + : []; + const secondMessages = Array.isArray(streamCalls[1]?.messages) + ? (streamCalls[1]!.messages as Array<{ role: string; content: unknown }>) + : []; + const firstUserContent = String(firstMessages.at(-1)?.content ?? ""); + const secondUserContent = String(secondMessages.at(-1)?.content ?? ""); + + expect(firstUserContent).toContain("[ADE launch directive]"); + expect(firstUserContent).toContain(tmpRoot); + expect(firstUserContent).toContain("only inside that worktree"); + expect(secondUserContent).not.toContain("[ADE launch directive]"); + }); + + it("roots Codex MCP launches in the selected lane worktree", async () => { + const laneRootPath = path.join(tmpRoot, "lane-2"); + fs.mkdirSync(laneRootPath, { recursive: true }); + const laneRoot = fs.realpathSync(laneRootPath); + vi.mocked(resolveAdeMcpServerLaunch).mockClear(); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-2", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Inspect the repo and fix the lane launch bug.", + }); + + await vi.waitFor(() => { + expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); + }); + + const workspaceRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls + .map(([args]) => (args as { workspaceRoot?: string }).workspaceRoot) + .filter((value): value is string => typeof value === "string"); + + expect(workspaceRoots.length).toBeGreaterThan(0); + expect(new Set(workspaceRoots)).toEqual(new Set([laneRoot])); + }); + + it("executes identity-hosted unified turns from the selected execution lane", async () => { + const streamCalls: Array> = []; + vi.mocked(streamText).mockImplementation((args: Record) => { + streamCalls.push(args); + return { + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any; + }); + vi.mocked(createUniversalToolSet).mockClear(); + vi.mocked(createWorkflowTools).mockClear(); + vi.mocked(buildCodingAgentSystemPrompt).mockClear(); + + const selectedLaneRootPath = path.join(tmpRoot, "lane-2"); + fs.mkdirSync(selectedLaneRootPath, { recursive: true }); + const selectedLaneRoot = fs.realpathSync(selectedLaneRootPath); + const { service } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Fix the lane launch bug without leaving this lane.", + }); + + expect(vi.mocked(createUniversalToolSet)).toHaveBeenCalledWith( + selectedLaneRoot, + expect.any(Object), + ); + expect(vi.mocked(createWorkflowTools)).toHaveBeenCalledWith( + expect.objectContaining({ laneId: "lane-2" }), + ); + expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( + expect.objectContaining({ cwd: selectedLaneRoot }), + ); + + const firstMessages = Array.isArray(streamCalls[0]?.messages) + ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) + : []; + const firstUserContent = String(firstMessages.at(-1)?.content ?? ""); + expect(firstUserContent).toContain("lane 'lane-2'"); + expect(firstUserContent).toContain(selectedLaneRoot); + }); + + it("reinjects the lane binding when an identity session switches execution lanes", async () => { + const streamCalls: Array> = []; + vi.mocked(streamText).mockImplementation((args: Record) => { + streamCalls.push(args); + return { + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any; + }); + + const { service } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Handle the first selected lane task.", + }); + + await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-1", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Handle the second selected lane task.", + }); + + const firstMessages = Array.isArray(streamCalls[0]?.messages) + ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) + : []; + const secondMessages = Array.isArray(streamCalls[1]?.messages) + ? (streamCalls[1]!.messages as Array<{ role: string; content: unknown }>) + : []; + const firstUserContent = String(firstMessages.at(-1)?.content ?? ""); + const secondUserContent = String(secondMessages.at(-1)?.content ?? ""); + + expect(firstUserContent).toContain("lane 'lane-2'"); + expect(firstUserContent).toContain(path.join(tmpRoot, "lane-2")); + expect(secondUserContent).toContain("lane 'lane-1'"); + expect(secondUserContent).toContain(tmpRoot); + }); + + it("rebinds queued unified steers after an identity session switches execution lanes", async () => { + const streamCalls: Array> = []; + const firstTurnControl: { release?: () => void } = {}; + let streamCallCount = 0; + vi.mocked(streamText).mockImplementation((args: Record) => { + streamCalls.push(args); + streamCallCount += 1; + if (streamCallCount === 1) { + return { + fullStream: (async function* () { + await new Promise((resolve) => { + firstTurnControl.release = resolve; + }); + yield { type: "finish", usage: {} }; + })(), + } as any; + } + return { + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any; + }); + + const { service } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Handle the current lane task first.", + }); + await Promise.resolve(); + + await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-1", + }); + await service.steer({ + sessionId: session.id, + text: "Continue in the newly selected lane.", + }); + + expect(firstTurnControl.release).toBeTypeOf("function"); + firstTurnControl.release!(); + await firstTurn; + for (let attempt = 0; attempt < 20 && streamCalls.length < 2; attempt += 1) { + await Promise.resolve(); + } + expect(streamCalls).toHaveLength(2); + + const secondMessages = Array.isArray(streamCalls[1]?.messages) + ? (streamCalls[1]!.messages as Array<{ role: string; content: unknown }>) + : []; + const secondUserContent = String(secondMessages.at(-1)?.content ?? ""); + + expect(secondUserContent).toContain("lane 'lane-1'"); + expect(secondUserContent).toContain(tmpRoot); + }); + + it("does not persist the lane directive key when a unified turn fails before completion", async () => { + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + throw new Error("stream failed"); + })(), + }) as any); + + const { service } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the bug from the selected lane.", + }); + + expect(readPersistedChatState(session.id).lastLaneDirectiveKey).toBeUndefined(); + }); + }); + // -------------------------------------------------------------------------- // listSessions // -------------------------------------------------------------------------- @@ -1074,6 +1370,22 @@ describe("createAgentChatService", () => { expect(reused.id).toBe(canonical.id); expect(reused.laneId).toBe("lane-1"); }); + + it("records headShaStart for the selected execution lane instead of the canonical host lane", async () => { + vi.mocked(runGit).mockImplementation(async (_args, opts) => ({ + stdout: String(opts?.cwd ?? "").includes(path.join(tmpRoot, "lane-2")) ? "lane-2-sha\n" : "lane-1-sha\n", + stderr: "", + exitCode: 0, + })); + + const { service, sessionService } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-2", + }); + + expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-2-sha"); + }); }); describe("identity continuity", () => { @@ -1660,6 +1972,58 @@ describe("createAgentChatService", () => { ); }); + it("evicts disposed chats from the live managed session cache", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + expect(service.getSlashCommands({ sessionId: session.id })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "/clear" }), + ]), + ); + + await service.dispose({ sessionId: session.id }); + + expect(service.getSlashCommands({ sessionId: session.id })).toEqual([]); + }); + + it("terminates the Codex runtime process tree when disposing a live Codex chat", async () => { + const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true as any); + vi.useFakeTimers(); + try { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Inspect the repo", + }); + + await service.dispose({ sessionId: session.id }); + + expect(spawn).toHaveBeenCalledWith( + "codex", + ["app-server"], + expect.objectContaining({ detached: process.platform !== "win32" }), + ); + expect(processKillSpy).toHaveBeenCalledWith(-99999, "SIGTERM"); + + await vi.advanceTimersByTimeAsync(1500); + expect(processKillSpy).toHaveBeenCalledWith(-99999, "SIGKILL"); + } finally { + vi.useRealTimers(); + } + }); + it("throws when disposing an unknown session", async () => { const { service } = createService(); await expect(service.dispose({ sessionId: "no-such-session" })).rejects.toThrow(/not found/i); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ceede8d07..1957c1e25 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -32,6 +32,7 @@ import { } from "./chatTextBatching"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; +import { resolveLaneLaunchContext, type LaneLaunchContext } from "../lanes/laneLaunchContext"; import type { createSessionService } from "../sessions/sessionService"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createFileService } from "../files/fileService"; @@ -189,6 +190,7 @@ type PersistedChatState = { continuitySummaryUpdatedAt?: string | null; preferredExecutionLaneId?: string | null; selectedExecutionLaneId?: string | null; + lastLaneDirectiveKey?: string | null; updatedAt: string; }; @@ -215,6 +217,7 @@ type CodexRuntime = { kind: "codex"; process: ChildProcessWithoutNullStreams; reader: readline.Interface; + killTimer: NodeJS.Timeout | null; suppressExitError: boolean; nextRequestId: number; pending: Map; @@ -437,6 +440,95 @@ function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true return { ready: true }; } +function isSignalPermissionError(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "EPERM"); +} + +function isProcessAlive(pid: number | null): boolean { + if (pid == null || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isSignalPermissionError(error); + } +} + +function isProcessGroupAlive(pid: number | null): boolean { + if (process.platform === "win32") return false; + if (pid == null || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(-pid, 0); + return true; + } catch (error) { + return isSignalPermissionError(error); + } +} + +function signalChildProcessTree( + child: ChildProcessWithoutNullStreams, + signal: NodeJS.Signals, +): boolean { + const pid = child.pid ?? null; + if (process.platform !== "win32" && pid != null && Number.isInteger(pid) && pid > 0) { + try { + process.kill(-pid, signal); + return true; + } catch { + // Fall through to direct child signaling if the process group is gone. + } + } + + try { + child.kill(signal); + return true; + } catch { + // Fall through to direct PID signaling if the child wrapper rejects the signal. + } + + if (pid == null || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +function terminateChildProcessTree( + child: ChildProcessWithoutNullStreams, + previousKillTimer: NodeJS.Timeout | null, + killAfterMs = 1500, +): NodeJS.Timeout | null { + if (previousKillTimer) { + clearTimeout(previousKillTimer); + } + + try { + child.stdin.end(); + } catch { + // ignore + } + + const pid = child.pid ?? null; + const signaled = signalChildProcessTree(child, "SIGTERM"); + if (!signaled || pid == null || !Number.isInteger(pid) || pid <= 0 || killAfterMs <= 0) { + return null; + } + + const timer = setTimeout(() => { + if (process.platform !== "win32") { + if (!isProcessGroupAlive(pid)) return; + signalChildProcessTree(child, "SIGKILL"); + return; + } + if (!isProcessAlive(pid)) return; + signalChildProcessTree(child, "SIGKILL"); + }, killAfterMs); + timer.unref?.(); + return timer; +} + function trimLine(value: string | null | undefined): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed.length ? trimmed : null; @@ -497,6 +589,7 @@ type ManagedChatSession = { continuitySummaryInFlight: boolean; preferredExecutionLaneId: string | null; selectedExecutionLaneId: string | null; + lastLaneDirectiveKey: string | null; localPendingInputs: Map void; }; @@ -1048,6 +1142,24 @@ function buildClaudeInteractionModeDirective( ].join("\n"); } +function buildLaneWorktreeDirective(args: { laneId: string; laneWorktreePath: string }): string | null { + const laneId = args.laneId.trim(); + const laneWorktreePath = args.laneWorktreePath.trim(); + if (!laneId.length || !laneWorktreePath.length) return null; + return [ + "[ADE launch directive]", + `ADE launched this session in lane '${laneId}' at worktree '${laneWorktreePath}'.`, + "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", + ].join("\n"); +} + +function buildLaneDirectiveKey(args: { laneId: string; laneWorktreePath: string }): string | null { + const laneId = args.laneId.trim(); + const laneWorktreePath = args.laneWorktreePath.trim(); + if (!laneId.length || !laneWorktreePath.length) return null; + return `${laneId}:${laneWorktreePath}`; +} + function composeLaunchDirectives(baseText: string, directives: Array): string { const filtered = directives .map((directive) => (typeof directive === "string" ? directive.trim() : "")) @@ -2043,6 +2155,7 @@ export function createAgentChatService(args: { }); const buildAdeMcpServers = ( + workspaceRoot: string, provider: "claude" | "codex", defaultRole: "agent" | "cto", ownerId?: string | null, @@ -2050,7 +2163,7 @@ export function createAgentChatService(args: { computerUsePolicy?: ComputerUsePolicy | null, ): Record> => { const launch = resolveAdeMcpServerLaunch({ - workspaceRoot: projectRoot, + workspaceRoot, runtimeRoot: resolveMcpRuntimeRoot(), defaultRole, ownerId: ownerId ?? undefined, @@ -2067,12 +2180,13 @@ export function createAgentChatService(args: { }; const summarizeAdeMcpLaunch = (args: { + workspaceRoot: string; defaultRole: "agent" | "cto" | "external"; ownerId?: string | null; computerUsePolicy?: ComputerUsePolicy | null; }) => { const { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath } = resolveAdeMcpServerLaunch({ - workspaceRoot: projectRoot, + workspaceRoot: args.workspaceRoot, runtimeRoot: resolveMcpRuntimeRoot(), defaultRole: args.defaultRole, ownerId: args.ownerId ?? undefined, @@ -2085,6 +2199,7 @@ export function createAgentChatService(args: { const tryDiagnosticMcpLaunch = (managed: ManagedChatSession): ReturnType | undefined => { try { return summarizeAdeMcpLaunch({ + workspaceRoot: managed.laneWorktreePath, defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), computerUsePolicy: managed.session.computerUse, @@ -2934,14 +3049,76 @@ export function createAgentChatService(args: { }; const computeHeadShaBestEffort = async (laneId: string): Promise => { - const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const cwd = fs.existsSync(worktreePath) ? worktreePath : projectRoot; + let cwd: string; + try { + ({ laneWorktreePath: cwd } = resolveLaneLaunchContext({ + laneService, + laneId, + purpose: "inspect lane git state", + })); + } catch (error) { + logger.warn("agent_chat.head_sha_skipped_invalid_worktree", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } const res = await runGit(["rev-parse", "HEAD"], { cwd, timeoutMs: 8_000 }); if (res.exitCode !== 0) return null; const sha = res.stdout.trim(); return sha.length ? sha : null; }; + const resolveManagedExecutionLaneId = (managed: ManagedChatSession): string => + trimLine(managed.preferredExecutionLaneId) + ?? trimLine(managed.selectedExecutionLaneId) + ?? managed.session.laneId; + + const refreshHeadShaStartForManagedExecutionLane = async (managed: ManagedChatSession): Promise => { + const headStart = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); + if (headStart) { + sessionService.setHeadShaStart(managed.session.id, headStart); + } + }; + + const resolveManagedExecutionContext = ( + managed: ManagedChatSession, + args: { purpose: string; requestedCwd?: string | null }, + ): LaneLaunchContext & { laneId: string; laneDirectiveKey: string | null } => { + const laneId = resolveManagedExecutionLaneId(managed); + const launchContext = resolveLaneLaunchContext({ + laneService, + laneId, + purpose: args.purpose, + requestedCwd: args.requestedCwd, + }); + return { + ...launchContext, + laneId, + laneDirectiveKey: buildLaneDirectiveKey({ + laneId, + laneWorktreePath: launchContext.laneWorktreePath, + }), + }; + }; + + const refreshManagedLaneLaunchContext = ( + managed: ManagedChatSession, + args: { purpose?: string; requestedCwd?: string | null } = {}, + ): LaneLaunchContext & { laneId: string; laneDirectiveKey: string | null } => { + const launchContext = resolveManagedExecutionContext(managed, { + purpose: args.purpose ?? "continue this chat", + requestedCwd: args.requestedCwd, + }); + const laneWorktreeChanged = managed.laneWorktreePath !== launchContext.laneWorktreePath; + managed.laneWorktreePath = launchContext.laneWorktreePath; + if (laneWorktreeChanged && (managed.runtime?.kind === "claude" || managed.runtime?.kind === "codex" || managed.runtime?.kind === "unified")) { + teardownRuntime(managed); + refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + } + return launchContext; + }; + const resolvePrimaryIdentityLane = async (): Promise => { await laneService.ensurePrimaryLane?.().catch(() => {}); const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); @@ -2997,6 +3174,7 @@ export function createAgentChatService(args: { ...(managed.continuitySummaryUpdatedAt ? { continuitySummaryUpdatedAt: managed.continuitySummaryUpdatedAt } : {}), ...(managed.preferredExecutionLaneId ? { preferredExecutionLaneId: managed.preferredExecutionLaneId } : {}), ...(managed.selectedExecutionLaneId ? { selectedExecutionLaneId: managed.selectedExecutionLaneId } : {}), + ...(managed.lastLaneDirectiveKey ? { lastLaneDirectiveKey: managed.lastLaneDirectiveKey } : {}), updatedAt: nowIso() }; @@ -3109,6 +3287,9 @@ export function createAgentChatService(args: { ...(typeof record.selectedExecutionLaneId === "string" && record.selectedExecutionLaneId.trim().length ? { selectedExecutionLaneId: record.selectedExecutionLaneId.trim() } : {}), + ...(typeof record.lastLaneDirectiveKey === "string" && record.lastLaneDirectiveKey.trim().length + ? { lastLaneDirectiveKey: record.lastLaneDirectiveKey.trim() } + : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; hydrateNativePermissionControls(hydrated as Parameters[0]); @@ -3541,6 +3722,7 @@ export function createAgentChatService(args: { const primaryLaneId = await resolvePrimaryIdentityLane(); const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); const selectedLaneId = explicitLaneId || managed.selectedExecutionLaneId || primaryLaneId; + const previousExecutionLaneId = resolveManagedExecutionLaneId(managed); const primaryLane = lanes.find((lane) => lane.id === primaryLaneId) ?? null; const selectedLane = lanes.find((lane) => lane.id === selectedLaneId) ?? null; const itemId = randomUUID(); @@ -3632,6 +3814,9 @@ export function createAgentChatService(args: { managed.preferredExecutionLaneId = resolvedLaneId; managed.selectedExecutionLaneId = selectedLaneId || managed.selectedExecutionLaneId; + if (resolvedLaneId !== previousExecutionLaneId) { + await refreshHeadShaStartForManagedExecutionLane(managed); + } emitChatEvent(managed, { type: "tool_result", tool: "choose_execution_lane", @@ -3650,7 +3835,10 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "codex") { managed.runtime.suppressExitError = true; try { managed.runtime.reader.close(); } catch { /* ignore */ } - try { managed.runtime.process.kill(); } catch { /* ignore */ } + managed.runtime.killTimer = terminateChildProcessTree( + managed.runtime.process, + managed.runtime.killTimer, + ); managed.runtime.pending.clear(); managed.runtime.approvals.clear(); managed.runtime = null; @@ -3678,6 +3866,7 @@ export function createAgentChatService(args: { managed.runtime.pendingApprovals.clear(); managed.runtime = null; } + clearLaneDirectiveKey(managed); }; const maybeGenerateSessionSummary = async ( @@ -3847,7 +4036,7 @@ export function createAgentChatService(args: { }); } - const endSha = await computeHeadShaBestEffort(managed.session.laneId).catch(() => null); + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); if (endSha) { sessionService.setHeadShaEnd(managed.session.id, endSha); } @@ -3864,6 +4053,8 @@ export function createAgentChatService(args: { } catch { // ignore callback failures } + + managedSessions.delete(managed.session.id); }; const ensureManagedSession = (sessionId: string): ManagedChatSession => { @@ -3933,6 +4124,7 @@ export function createAgentChatService(args: { continuitySummaryInFlight: false, preferredExecutionLaneId: persisted?.preferredExecutionLaneId ?? null, selectedExecutionLaneId: persisted?.selectedExecutionLaneId ?? null, + lastLaneDirectiveKey: persisted?.lastLaneDirectiveKey ?? null, activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, @@ -3960,6 +4152,7 @@ export function createAgentChatService(args: { text: string; attachments: AgentChatFileRef[]; turnId?: string; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): void => { @@ -3972,6 +4165,20 @@ export function createAgentChatService(args: { args.onDispatched?.(); }; + const persistDeliveredLaneDirectiveKey = ( + managed: ManagedChatSession, + laneDirectiveKey?: string | null, + ): void => { + if (!laneDirectiveKey || managed.lastLaneDirectiveKey === laneDirectiveKey) return; + managed.lastLaneDirectiveKey = laneDirectiveKey; + persistChatState(managed); + }; + + const clearLaneDirectiveKey = (managed: ManagedChatSession): void => { + managed.lastLaneDirectiveKey = null; + persistChatState(managed); + }; + const sendCodexMessage = async ( managed: ManagedChatSession, args: { @@ -3979,6 +4186,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4007,12 +4215,14 @@ export function createAgentChatService(args: { emitPreparedUserMessage(managed, { text: displayText, attachments, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); const reviewResult = await runtime.request<{ turn?: { id?: string } }>("review/start", { threadId: managed.session.threadId, target: "uncommittedChanges", }); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); const reviewTurnId = typeof reviewResult.turn?.id === "string" ? reviewResult.turn.id : null; if (reviewTurnId) { runtime.activeTurnId = reviewTurnId; @@ -4061,6 +4271,7 @@ export function createAgentChatService(args: { emitPreparedUserMessage(managed, { text: displayText, attachments, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); if (autoMemoryNotice) { @@ -4077,6 +4288,7 @@ export function createAgentChatService(args: { input, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}) }); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); const turnId = typeof result?.turn?.id === "string" ? result.turn.id : null; if (turnId) { @@ -4185,6 +4397,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4215,6 +4428,7 @@ export function createAgentChatService(args: { text: displayText, attachments, turnId, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); @@ -4327,6 +4541,7 @@ export function createAgentChatService(args: { // V2 pattern: send() then stream() per turn. Session stays alive between turns. await runtime.v2Session.send(messageToSend); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); // Don't emit a pre-emptive "thinking" activity — wait for actual content from the stream. // The renderer will show the turn as "started" (from the status event above) which is sufficient. @@ -4814,7 +5029,7 @@ export function createAgentChatService(args: { }); } - const endSha = await computeHeadShaBestEffort(managed.session.laneId).catch(() => null); + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); if (endSha) { sessionService.setHeadShaEnd(managed.session.id, endSha); } @@ -4825,7 +5040,15 @@ export function createAgentChatService(args: { if (!managed.closed && runtime.pendingSteers.length) { const steerText = runtime.pendingSteers.shift() ?? ""; if (steerText.trim().length) { - await runClaudeTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); + const preparedSteer = prepareSendMessage({ + sessionId: managed.session.id, + text: steerText, + displayText: steerText, + attachments: [], + }); + if (preparedSteer) { + await executePreparedSendMessage(preparedSteer); + } } } } catch (error) { @@ -4888,6 +5111,7 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); runtime.sdkSessionId = null; + clearLaneDirectiveKey(managed); void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); prewarmClaudeV2Session(managed); @@ -4907,6 +5131,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4940,6 +5165,7 @@ export function createAgentChatService(args: { text: displayText, attachments, turnId, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); @@ -5039,6 +5265,7 @@ export function createAgentChatService(args: { }; }); const lightweight = isLightweightSession(managed.session); + const executionLaneId = resolveManagedExecutionLaneId(managed); const tools = lightweight ? {} : createUniversalToolSet(managed.laneWorktreePath, { @@ -5211,7 +5438,7 @@ export function createAgentChatService(args: { }); }, sessionId: managed.session.id, - laneId: managed.session.laneId, + laneId: executionLaneId, }); Object.assign(tools, workflowTools); @@ -5225,7 +5452,7 @@ export function createAgentChatService(args: { if (managed.session.identityKey === "cto") { Object.assign(tools, createCtoOperatorTools({ currentSessionId: managed.session.id, - defaultLaneId: managed.session.laneId, + defaultLaneId: executionLaneId, defaultModelId: managed.session.modelId ?? null, defaultReasoningEffort: managed.session.reasoningEffort ?? null, resolveExecutionLane: async ({ requestedLaneId, purpose, freshLaneName, freshLaneDescription }) => @@ -5473,6 +5700,7 @@ export function createAgentChatService(args: { } // ── Shared turn completion ── + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); clearTimeout(turnTimeout); if (assistantText.trim().length) { runtime.messages.push({ role: "assistant", content: assistantText }); @@ -5500,7 +5728,7 @@ export function createAgentChatService(args: { }); } - const endSha = await computeHeadShaBestEffort(managed.session.laneId).catch(() => null); + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); if (endSha) { sessionService.setHeadShaEnd(managed.session.id, endSha); } @@ -5511,7 +5739,15 @@ export function createAgentChatService(args: { if (!managed.closed && runtime.pendingSteers.length) { const steerText = runtime.pendingSteers.shift() ?? ""; if (steerText.trim().length) { - await runTurn(managed, { promptText: steerText, displayText: steerText, attachments: [] }); + const preparedSteer = prepareSendMessage({ + sessionId: managed.session.id, + text: steerText, + displayText: steerText, + attachments: [], + }); + if (preparedSteer) { + await executePreparedSendMessage(preparedSteer); + } } } } catch (error) { @@ -6167,7 +6403,7 @@ export function createAgentChatService(args: { ...(usage ? { usage } : {}), }); - const endSha = await computeHeadShaBestEffort(managed.session.laneId).catch(() => null); + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); if (endSha) { sessionService.setHeadShaEnd(managed.session.id, endSha); } @@ -6588,7 +6824,8 @@ export function createAgentChatService(args: { } const proc = spawn(codexExecutable, ["app-server"], { cwd: managed.laneWorktreePath, - stdio: ["pipe", "pipe", "pipe"] + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", }); const reader = readline.createInterface({ input: proc.stdout }); @@ -6598,6 +6835,7 @@ export function createAgentChatService(args: { kind: "codex", process: proc, reader, + killTimer: null, suppressExitError: false, nextRequestId: 1, pending, @@ -6732,6 +6970,10 @@ export function createAgentChatService(args: { proc.on("exit", (code, signal) => { const message = `Codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; + if (runtime.killTimer) { + clearTimeout(runtime.killTimer); + runtime.killTimer = null; + } for (const request of pending.values()) { request.reject(new Error(message)); @@ -6805,6 +7047,7 @@ export function createAgentChatService(args: { const mcpServers = isLightweightSession(managed.session) ? {} : buildAdeMcpServers( + managed.laneWorktreePath, "codex", managed.session.identityKey === "cto" ? "cto" : "agent", resolveWorkerIdentityAgentId(managed.session.identityKey), @@ -6894,6 +7137,10 @@ export function createAgentChatService(args: { type: "preset", preset: "claude_code", append: [ + "## ADE Workspace", + `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, + "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", + "", "## ADE Memory", "You have access to ADE's persistent project memory via MCP tools (memory_search, memory_add, memory_pin).", "**Search first:** Before starting non-trivial work, search memory for relevant conventions, past decisions, or known pitfalls.", @@ -6904,6 +7151,7 @@ export function createAgentChatService(args: { }; opts.settingSources = ["user", "project", "local"]; opts.mcpServers = buildAdeMcpServers( + managed.laneWorktreePath, "claude", managed.session.identityKey === "cto" ? "cto" : "agent", resolveWorkerIdentityAgentId(managed.session.identityKey), @@ -7135,8 +7383,13 @@ export function createAgentChatService(args: { const ensureClaudeSessionRuntime = (managed: ManagedChatSession): ClaudeRuntime => { if (managed.runtime?.kind === "claude") return managed.runtime; const persisted = readPersistedState(managed.session.id); - // Old persisted state may have `messages` but no `sdkSessionId` — start fresh - const sdkSessionId = persisted?.sdkSessionId ?? null; + const currentLaneDirectiveKey = buildLaneDirectiveKey({ + laneId: resolveManagedExecutionLaneId(managed), + laneWorktreePath: managed.laneWorktreePath, + }); + const sdkSessionId = currentLaneDirectiveKey != null && persisted?.lastLaneDirectiveKey === currentLaneDirectiveKey + ? persisted?.sdkSessionId ?? null + : null; const runtime: ClaudeRuntime = { kind: "claude", sdkSessionId, @@ -7194,6 +7447,7 @@ export function createAgentChatService(args: { continuitySummaryInFlight: false, preferredExecutionLaneId: null, selectedExecutionLaneId: null, + lastLaneDirectiveKey: null, activeAssistantMessageId: null, previewTextBuffer: null, bufferedText: null, @@ -7282,7 +7536,9 @@ export function createAgentChatService(args: { // ignore } try { - runtime?.process.kill(); + if (runtime) { + terminateChildProcessTree(runtime.process, null); + } } catch { // ignore } @@ -7343,7 +7599,11 @@ export function createAgentChatService(args: { automationRunId, computerUse, }: AgentChatCreateArgs): Promise => { - const lane = laneService.getLaneBaseAndBranch(laneId); + const launchContext = resolveLaneLaunchContext({ + laneService, + laneId, + purpose: "start this chat", + }); const sessionId = randomUUID(); const startedAt = nowIso(); const transcriptPath = path.join(transcriptsDir, `${sessionId}.chat.jsonl`); @@ -7478,7 +7738,7 @@ export function createAgentChatService(args: { transcriptBytesWritten: fileSizeOrZero(transcriptPath), transcriptLimitReached: false, metadataPath, - laneWorktreePath: lane.worktreePath, + laneWorktreePath: launchContext.laneWorktreePath, runtime: null, preview: null, closed: false, @@ -7494,6 +7754,7 @@ export function createAgentChatService(args: { continuitySummaryInFlight: false, preferredExecutionLaneId: null, selectedExecutionLaneId: null, + lastLaneDirectiveKey: null, activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, @@ -7654,6 +7915,7 @@ export function createAgentChatService(args: { const visibleText = displayText?.trim().length ? displayText.trim() : trimmed; const managed = ensureManagedSession(sessionId); + const executionContext = refreshManagedLaneLaunchContext(managed); const publicAttachments = attachments.map((attachment) => ({ ...attachment, path: attachment.path.trim(), @@ -7713,9 +7975,17 @@ export function createAgentChatService(args: { managed.session.interactionMode = interactionMode ?? managed.session.interactionMode ?? "default"; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; } + const laneDirectiveKey = executionContext.laneDirectiveKey; + const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; const promptText = isLiteralSlashCommand(trimmed) ? trimmed : composeLaunchDirectives(trimmed, [ + shouldInjectLaneDirective + ? buildLaneWorktreeDirective({ + laneId: executionContext.laneId, + laneWorktreePath: executionContext.laneWorktreePath, + }) + : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), buildComputerUseDirective( @@ -7738,6 +8008,7 @@ export function createAgentChatService(args: { resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, + laneDirectiveKey: isLiteralSlashCommand(trimmed) ? null : shouldInjectLaneDirective ? laneDirectiveKey : null, }; }; @@ -7812,6 +8083,7 @@ export function createAgentChatService(args: { attachments, resolvedAttachments, reasoningEffort, + laneDirectiveKey, onDispatched, } = prepared; @@ -7826,7 +8098,14 @@ export function createAgentChatService(args: { if (reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } - await runTurn(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); + await runTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + onDispatched, + }); return; } @@ -7893,7 +8172,14 @@ export function createAgentChatService(args: { } } - await sendCodexMessage(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); + await sendCodexMessage(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + onDispatched, + }); return; } @@ -7903,7 +8189,14 @@ export function createAgentChatService(args: { } ensureClaudeSessionRuntime(managed); - await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); + await runClaudeTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + onDispatched, + }); }; const sendMessage = async ( @@ -7987,7 +8280,14 @@ export function createAgentChatService(args: { persistChatState(managed); return; } - await runTurn(managed, { promptText: trimmed, displayText: trimmed, attachments: [] }); + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments: [], + }); + if (!preparedSteer) return; + await executePreparedSendMessage(preparedSteer); return; } @@ -8045,7 +8345,14 @@ export function createAgentChatService(args: { return; } - await runClaudeTurn(managed, { promptText: trimmed, displayText: trimmed, attachments: [] }); + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments: [], + }); + if (!preparedSteer) return; + await executePreparedSendMessage(preparedSteer); }; const interrupt = async ({ sessionId }: AgentChatInterruptArgs): Promise => { @@ -8098,6 +8405,7 @@ export function createAgentChatService(args: { const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => { const managed = ensureManagedSession(sessionId); + refreshManagedLaneLaunchContext(managed, { purpose: "resume this chat" }); const persisted = readPersistedState(sessionId); managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); @@ -8332,6 +8640,7 @@ export function createAgentChatService(args: { normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.selectedExecutionLaneId = selectedExecutionLaneId ?? managed.selectedExecutionLaneId; refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); await retireLegacySessions(); @@ -8398,6 +8707,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(created.id); managed.selectedExecutionLaneId = selectedExecutionLaneId; refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); await retireLegacySessions(); return managed.session; @@ -8679,6 +8989,7 @@ export function createAgentChatService(args: { managed.session.capabilityMode = inferCapabilityMode(nextProvider); if (previousProvider !== nextProvider || previousProvider === "codex") { delete managed.session.threadId; + clearLaneDirectiveKey(managed); } sessionService.updateMeta({ sessionId, @@ -8848,6 +9159,7 @@ export function createAgentChatService(args: { }): Promise => { const managed = managedSessions.get(sessionId); if (!managed) return; + refreshManagedLaneLaunchContext(managed, { purpose: "warm this chat" }); const descriptor = getModelById(modelId) ?? resolveModelAlias(modelId); if (!descriptor) return; diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 91c36d4a9..d5327026e 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -1267,4 +1267,78 @@ describe("conflictService conflict context integrity", () => { fs.rmSync(repoRoot, { recursive: true, force: true }); } }); + + it("prefers origin/ for unparented lanes when the remote has advanced", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-root-base-remote-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-root-base-remote-origin-")); + const remoteWorkRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-root-base-remote-work-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = randomUUID(); + const now = "2026-03-24T12:00:00.000Z"; + + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + + fs.writeFileSync(path.join(repoRoot, "file.txt"), "base\n", "utf8"); + git(repoRoot, ["init", "-b", "main"]); + git(repoRoot, ["config", "user.email", "ade@test.local"]); + git(repoRoot, ["config", "user.name", "ADE Test"]); + git(repoRoot, ["add", "."]); + git(repoRoot, ["commit", "-m", "base"]); + git(remoteRoot, ["init", "--bare"]); + git(repoRoot, ["remote", "add", "origin", remoteRoot]); + git(repoRoot, ["push", "-u", "origin", "main"]); + + git(repoRoot, ["checkout", "-b", "feature/root-lane"]); + + git(remoteWorkRoot, ["clone", remoteRoot, "."]); + git(remoteWorkRoot, ["checkout", "-B", "main", "origin/main"]); + git(remoteWorkRoot, ["config", "user.email", "ade@test.local"]); + git(remoteWorkRoot, ["config", "user.name", "ADE Test"]); + fs.writeFileSync(path.join(remoteWorkRoot, "base.txt"), "new base commit\n", "utf8"); + git(remoteWorkRoot, ["add", "base.txt"]); + git(remoteWorkRoot, ["commit", "-m", "base advance"]); + git(remoteWorkRoot, ["push", "origin", "HEAD:main"]); + + git(repoRoot, ["fetch", "origin"]); + + const rootLane = createLaneSummary(repoRoot, { + id: "lane-root", + name: "Root", + branchRef: "feature/root-lane", + baseRef: "main", + parentLaneId: null, + }); + + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [rootLane], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/root-lane" }), + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" }, local: {} }), + } as any, + }); + + const needs = await service.scanRebaseNeeds(); + expect(needs).toHaveLength(1); + expect(needs[0]).toMatchObject({ + laneId: "lane-root", + baseBranch: "main", + }); + expect(needs[0]!.behindBy).toBeGreaterThan(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + fs.rmSync(remoteRoot, { recursive: true, force: true }); + fs.rmSync(remoteWorkRoot, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 5996a9364..889035853 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -76,7 +76,7 @@ import { extractFirstJsonObject } from "../ai/utils"; import { safeSegment } from "../shared/packLegacyUtils"; import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase"; import type { QueueRebaseOverride } from "../shared/queueRebase"; -import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils"; +import { asString, isRecord, normalizeBranchName, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils"; type PredictionStatus = "clean" | "conflict" | "unknown"; @@ -301,6 +301,15 @@ function resolveLaneRebaseTarget(args: { }; } + const baseBranchRef = args.lane.baseRef?.trim() ?? ""; + if (baseBranchRef) { + return { + comparisonRef: `origin/${baseBranchRef}`, + fallbackRef: baseBranchRef, + displayBaseBranch: baseBranchRef, + }; + } + return { comparisonRef: args.lane.baseRef, displayBaseBranch: args.lane.baseRef, @@ -4273,6 +4282,74 @@ export function createConflictService({ } } + const openPrRows = db.all<{ + id: string; + lane_id: string; + base_branch: string | null; + }>( + ` + select id, lane_id, base_branch + from pull_requests + where project_id = ? + and state in ('open', 'draft') + order by updated_at desc, created_at desc + `, + [projectId], + ); + + const seenPrTargetNeeds = new Set(); + for (const row of openPrRows) { + const lane = lanesById.get(String(row.lane_id ?? "").trim()); + if (!lane || lane.laneType === "primary" || lane.parentLaneId) continue; + const prBaseBranch = normalizeBranchName(String(row.base_branch ?? "").trim()); + const laneBaseBranch = normalizeBranchName(String(lane.baseRef ?? "").trim()); + if (!prBaseBranch || prBaseBranch === laneBaseBranch) continue; + const dedupeKey = `${lane.id}:${prBaseBranch}`; + if (seenPrTargetNeeds.has(dedupeKey)) continue; + seenPrTargetNeeds.add(dedupeKey); + + try { + const remoteRef = `origin/${prBaseBranch}`; + const baseHead = await readHeadSha(projectRoot, remoteRef) + .catch(() => readHeadSha(projectRoot, prBaseBranch)) + .catch(() => ""); + if (!baseHead) continue; + const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); + const behindRes = await runGit( + ["rev-list", "--count", `${laneHead}..${baseHead}`], + { cwd: projectRoot, timeoutMs: 15_000 } + ); + const behindBy = behindRes.exitCode === 0 ? Number(behindRes.stdout.trim()) || 0 : 0; + if (behindBy === 0) continue; + + const mergeBase = await readMergeBase(projectRoot, baseHead, laneHead); + const merge = await runGitMergeTree({ + cwd: projectRoot, + mergeBase, + branchA: baseHead, + branchB: laneHead, + timeoutMs: 60_000 + }); + + const existingNeed = needs.find((need) => need.laneId === lane.id) ?? null; + + needs.push({ + laneId: lane.id, + laneName: lane.name, + baseBranch: prBaseBranch, + behindBy, + conflictPredicted: merge.conflicts.length > 0, + conflictingFiles: merge.conflicts.map((conflict) => conflict.path), + prId: String(row.id), + groupContext: existingNeed?.groupContext ?? null, + dismissedAt: existingNeed?.dismissedAt ?? rebaseDismissed.get(lane.id) ?? null, + deferredUntil: existingNeed?.deferredUntil ?? rebaseDeferred.get(lane.id) ?? null, + }); + } catch (err) { + logger.warn(`scanRebaseNeeds: failed PR target scan for lane ${lane.id}`, { error: err }); + } + } + // Emit rebase-needs-updated so renderer gets notified if (onEvent) { onEvent({ type: "rebase-needs-updated", needs, timestamp: new Date().toISOString() }); @@ -4437,11 +4514,18 @@ export function createConflictService({ projectRoot, laneId: lane.id, }); - const { comparisonRef: rebaseTarget } = resolveLaneRebaseTarget({ + const { comparisonRef, fallbackRef } = resolveLaneRebaseTarget({ lane, lanesById, queueOverride, }); + let rebaseTarget = comparisonRef; + if (fallbackRef) { + const comparisonRefExists = await readHeadSha(projectRoot, comparisonRef).catch(() => ""); + if (!comparisonRefExists) { + rebaseTarget = fallbackRef; + } + } const rebaseRes = await runGit( ["rebase", rebaseTarget], { cwd: lane.worktreePath, timeoutMs: 120_000 } diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts index 2b0817643..53773bc90 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, beforeEach, vi } from "vitest"; import { createAutoRebaseService } from "./autoRebaseService"; -import type { AutoRebaseEventPayload, AutoRebaseLaneStatus, LaneSummary } from "../../../shared/types"; +import type { AutoRebaseEventPayload, AutoRebaseLaneStatus, LaneSummary, RebaseNeed } from "../../../shared/types"; vi.mock("../git/git", () => ({ getHeadSha: vi.fn().mockResolvedValue("abc123"), @@ -69,10 +69,26 @@ function makeLane(id: string, overrides: Partial = {}): LaneSummary }; } +function makeRebaseNeed(lane: LaneSummary, overrides: Partial = {}): RebaseNeed { + return { + laneId: lane.id, + laneName: overrides.laneName ?? lane.name, + baseBranch: overrides.baseBranch ?? "main", + behindBy: overrides.behindBy ?? Math.max(1, lane.status.behind), + conflictPredicted: overrides.conflictPredicted ?? false, + conflictingFiles: overrides.conflictingFiles ?? [], + prId: overrides.prId ?? null, + groupContext: overrides.groupContext ?? null, + dismissedAt: overrides.dismissedAt ?? null, + deferredUntil: overrides.deferredUntil ?? null, + }; +} + describe("autoRebaseService", () => { let db: ReturnType; let events: AutoRebaseEventPayload[]; let laneList: LaneSummary[]; + let rebaseNeedOverrides: Map | null>; let laneService: any; let conflictService: any; let projectConfigService: any; @@ -82,12 +98,31 @@ describe("autoRebaseService", () => { db = createDb(); events = []; laneList = []; + rebaseNeedOverrides = new Map(); + + const resolveNeed = (laneId: string): RebaseNeed | null => { + const lane = laneList.find((entry) => entry.id === laneId); + if (!lane || !lane.parentLaneId) return null; + + const override = rebaseNeedOverrides.get(laneId); + if (override === null) return null; + if (override) return makeRebaseNeed(lane, override); + if (lane.status.behind <= 0) return null; + return makeRebaseNeed(lane); + }; + laneService = { list: vi.fn(async () => laneList), - rebaseStart: vi.fn(async () => ({ run: { error: null } })), + rebaseStart: vi.fn(async () => ({ runId: "run-1", run: { error: null } })), + rebasePush: vi.fn(async ({ laneIds }: { laneIds: string[] }) => ({ + pushedLaneIds: [...laneIds], + lanes: laneIds.map((laneId) => ({ laneId, pushed: true })), + })), + rebaseRollback: vi.fn(async () => ({ runId: "run-1" })), }; conflictService = { - simulateMerge: vi.fn(async () => ({ outcome: "clean", conflictingFiles: [] })), + getRebaseNeed: vi.fn(async (laneId: string) => resolveNeed(laneId)), + scanRebaseNeeds: vi.fn(async () => []), }; projectConfigService = { getEffective: vi.fn(() => ({ git: { autoRebaseOnHeadChange: true } })), @@ -198,15 +233,18 @@ describe("autoRebaseService", () => { }); // --------------------------------------------------------------------------- - // Lanes without parent are skipped + // Lanes without parent remain eligible for status display // --------------------------------------------------------------------------- describe("listStatuses — lanes without parent", () => { - it("clears status for a lane that has no parentLaneId", async () => { + it("keeps status for a lane that has no parentLaneId", async () => { const service = createService(); // Lane with no parent - laneList = [makeLane("lane-orphan", { parentLaneId: null })]; + laneList = [makeLane("lane-orphan", { + parentLaneId: null, + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + })]; db.setJson("auto_rebase:status:lane-orphan", { laneId: "lane-orphan", @@ -219,8 +257,15 @@ describe("autoRebaseService", () => { }); const statuses = await service.listStatuses(); - expect(statuses).toHaveLength(0); - expect(db.getJson("auto_rebase:status:lane-orphan")).toBeNull(); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + laneId: "lane-orphan", + state: "rebasePending", + }); + expect(db.getJson("auto_rebase:status:lane-orphan")).toMatchObject({ + laneId: "lane-orphan", + state: "rebasePending", + }); }); }); @@ -509,6 +554,166 @@ describe("autoRebaseService", () => { expect(laneService.list).not.toHaveBeenCalled(); }); + + it("clears queued timers when disposed before the debounce fires", async () => { + vi.useFakeTimers(); + try { + const service = createService(); + laneList = [ + makeLane("root"), + makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + }), + ]; + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + service.dispose(); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.list).not.toHaveBeenCalled(); + expect(conflictService.getRebaseNeed).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); + + // --------------------------------------------------------------------------- + // refreshActiveRebaseNeeds / recordAttentionStatus + // --------------------------------------------------------------------------- + + describe("refreshActiveRebaseNeeds", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("scans active lanes and queues auto-rebase work without a head change", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 3, conflictPredicted: false, conflictingFiles: [] }); + conflictService.scanRebaseNeeds.mockResolvedValue( + laneList + .filter((lane) => lane.parentLaneId) + .map((lane) => makeRebaseNeed(lane, rebaseNeedOverrides.get(lane.id) ?? undefined)) + .filter((n) => n.behindBy > 0), + ); + + await service.refreshActiveRebaseNeeds("merge_completed"); + await vi.advanceTimersByTimeAsync(1500); + + expect(conflictService.scanRebaseNeeds).toHaveBeenCalled(); + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-1", + reason: "auto_rebase", + }), + ); + expect(laneService.rebasePush).toHaveBeenCalledWith({ runId: "run-1", laneIds: ["child-1"] }); + }); + + it("persists an attention status and emits the updated status stream", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + conflictService.scanRebaseNeeds.mockResolvedValue([]); + + await service.recordAttentionStatus({ + laneId: "child-1", + parentLaneId: "root", + parentHeadSha: "parent-sha", + state: "rebasePending", + conflictCount: 0, + message: "Pending: merge-triggered rebase review needed.", + }); + + expect(db.getJson("auto_rebase:status:child-1")).toMatchObject({ + laneId: "child-1", + state: "rebasePending", + message: "Pending: merge-triggered rebase review needed.", + }); + expect(events[events.length - 1]).toMatchObject({ + type: "auto-rebase-updated", + statuses: expect.arrayContaining([ + expect.objectContaining({ + laneId: "child-1", + state: "rebasePending", + }), + ]), + }); + }); + + it("emits a freshly recorded attention status even before the lane is behind", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + }); + laneList = [root, child]; + conflictService.scanRebaseNeeds.mockResolvedValue([]); + + await service.recordAttentionStatus({ + laneId: "child-1", + parentLaneId: "root", + parentHeadSha: "parent-sha", + state: "rebasePending", + conflictCount: 0, + message: "Pending: review required before behind counts refresh.", + }); + + expect(events[events.length - 1]).toMatchObject({ + type: "auto-rebase-updated", + statuses: expect.arrayContaining([ + expect.objectContaining({ + laneId: "child-1", + state: "rebasePending", + }), + ]), + }); + }); + + it("reruns a forced refresh after an in-flight sweep completes", async () => { + const service = createService(); + const firstSweepControl: { resolve?: (needs: RebaseNeed[]) => void } = {}; + const firstSweep = new Promise((resolve) => { + firstSweepControl.resolve = resolve; + }); + conflictService.scanRebaseNeeds + .mockImplementationOnce(async () => await firstSweep) + .mockImplementationOnce(async () => []); + + const firstRefresh = service.refreshActiveRebaseNeeds("first"); + await Promise.resolve(); + const secondRefresh = service.refreshActiveRebaseNeeds("second"); + expect(firstSweepControl.resolve).toBeTypeOf("function"); + firstSweepControl.resolve!([]); + + await Promise.all([firstRefresh, secondRefresh]); + + expect(conflictService.scanRebaseNeeds).toHaveBeenCalledTimes(2); + }); }); // --------------------------------------------------------------------------- @@ -546,6 +751,88 @@ describe("autoRebaseService", () => { expect(laneService.rebaseStart).not.toHaveBeenCalled(); }); + it("keeps sibling auto-rebase chains independent during a sweep", async () => { + const service = createService(); + const root = makeLane("root"); + const childA = makeLane("child-a", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + const childB = makeLane("child-b", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T02:00:00.000Z", + }); + laneList = [root, childA, childB]; + rebaseNeedOverrides.set("child-a", { + behindBy: 1, + conflictPredicted: true, + conflictingFiles: ["conflict.txt"], + }); + rebaseNeedOverrides.set("child-b", { + behindBy: 1, + conflictPredicted: false, + conflictingFiles: [], + }); + conflictService.scanRebaseNeeds.mockResolvedValue([ + makeRebaseNeed(childA, rebaseNeedOverrides.get("child-a")!), + makeRebaseNeed(childB, rebaseNeedOverrides.get("child-b")!), + ]); + + await service.refreshActiveRebaseNeeds("merge_completed"); + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-b", + reason: "auto_rebase", + }), + ); + expect(laneService.rebasePush).toHaveBeenCalledWith({ runId: "run-1", laneIds: ["child-b"] }); + expect(db.getJson("auto_rebase:status:child-a")).toMatchObject({ + laneId: "child-a", + state: "rebaseConflict", + }); + }); + + it("preserves the current status when rebase-need lookup fails", async () => { + const service = createService(); + laneList = [ + makeLane("root"), + makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + }), + ]; + db.setJson("auto_rebase:status:child-1", { + laneId: "child-1", + parentLaneId: "root", + parentHeadSha: "parent-sha", + state: "rebasePending", + updatedAt: "2026-03-25T12:00:00.000Z", + conflictCount: 0, + message: "Pending before lookup failure.", + }); + conflictService.getRebaseNeed + .mockImplementationOnce(async () => null) + .mockRejectedValueOnce(new Error("lookup failed")); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + await vi.advanceTimersByTimeAsync(1500); + + expect(db.getJson("auto_rebase:status:child-1")).toMatchObject({ + laneId: "child-1", + state: "rebasePending", + message: "Pending before lookup failure.", + }); + }); + it("skips root lane with no descendants", async () => { const service = createService(); laneList = [makeLane("root")]; // root has no children @@ -562,15 +849,16 @@ describe("autoRebaseService", () => { expect(laneService.rebaseStart).not.toHaveBeenCalled(); }); - it("triggers rebase for child lane that is behind", async () => { + it("triggers rebase for child lane when the real rebase-need path reports one", async () => { const service = createService(); const root = makeLane("root"); const child = makeLane("child-1", { parentLaneId: "root", - status: { dirty: false, ahead: 1, behind: 3, remoteBehind: 0, rebaseInProgress: false }, + status: { dirty: false, ahead: 1, behind: 0, remoteBehind: 0, rebaseInProgress: false }, createdAt: "2026-03-10T01:00:00.000Z", }); laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 4, conflictPredicted: false, conflictingFiles: [] }); await service.onHeadChanged({ laneId: "root", @@ -592,6 +880,77 @@ describe("autoRebaseService", () => { ); }); + it("auto-pushes a successful automatic rebase and marks the lane autoRebased", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 2, conflictPredicted: false, conflictingFiles: [] }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + await vi.waitFor(() => { + expect(laneService.rebasePush).toHaveBeenCalled(); + }); + + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-1", + reason: "auto_rebase", + }), + ); + expect(laneService.rebasePush).toHaveBeenCalledWith({ runId: "run-1", laneIds: ["child-1"] }); + + expect(db.setJson).toHaveBeenCalledWith( + "auto_rebase:status:child-1", + expect.objectContaining({ laneId: "child-1", state: "autoRebased" }), + ); + }); + + it("rolls back a successful rebase when auto-push fails and leaves the lane pending", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 2, conflictPredicted: false, conflictingFiles: [] }); + laneService.rebasePush.mockRejectedValueOnce(new Error("remote rejected push")); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "user_commit", + }); + + await vi.advanceTimersByTimeAsync(1500); + await Promise.resolve(); + + expect(laneService.rebaseRollback).toHaveBeenCalledWith({ runId: "run-1" }); + expect(db.setJson).toHaveBeenCalledWith( + "auto_rebase:status:child-1", + expect.objectContaining({ + laneId: "child-1", + parentLaneId: "root", + parentHeadSha: "abc123", + state: "rebaseFailed", + }), + ); + }); + it("marks downstream lanes as rebasePending when an ancestor has conflicts", async () => { const service = createService(); const root = makeLane("root"); @@ -606,12 +965,7 @@ describe("autoRebaseService", () => { createdAt: "2026-03-10T02:00:00.000Z", }); laneList = [root, child, grandchild]; - - // Simulate merge conflict on child-1 - conflictService.simulateMerge.mockResolvedValue({ - outcome: "conflict", - conflictingFiles: ["file.ts"], - }); + rebaseNeedOverrides.set("child-1", { behindBy: 1, conflictPredicted: true, conflictingFiles: ["file.ts"] }); await service.onHeadChanged({ laneId: "root", @@ -651,10 +1005,15 @@ describe("autoRebaseService", () => { let callCount = 0; laneService.list.mockImplementation(async () => { callCount++; - if (callCount <= 1) return [root, child, child2]; + if (callCount <= 1) { + laneList = [root, child, child2]; + return laneList; + } // child-1 disappeared during processing - return [root, child2]; + laneList = [root, child2]; + return laneList; }); + rebaseNeedOverrides.set("child-2", { behindBy: 1, conflictPredicted: false, conflictingFiles: [] }); await service.onHeadChanged({ laneId: "root", diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index d2af299d0..faa52449b 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -4,14 +4,43 @@ import type { AdeDb } from "../state/kvDb"; import type { createConflictService } from "../conflicts/conflictService"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "./laneService"; -import type { AutoRebaseEventPayload, AutoRebaseLaneState, AutoRebaseLaneStatus, LaneSummary } from "../../../shared/types"; +import type { AutoRebaseEventPayload, AutoRebaseLaneState, AutoRebaseLaneStatus, LaneSummary, RebaseNeed } from "../../../shared/types"; import { isRecord, nowIso } from "../shared/utils"; -type StoredStatus = AutoRebaseLaneStatus; +type StoredStatus = AutoRebaseLaneStatus & { + source?: "auto" | "manual"; +}; +type ListStatusesOptions = { + includeAll?: boolean; +}; +type AttentionStatusInput = { + laneId: string; + parentLaneId: string | null; + parentHeadSha: string | null; + state: AutoRebaseLaneState; + conflictCount: number; + message?: string | null; + source?: "auto" | "manual"; +}; + +export type AutoRebaseService = { + listStatuses: (options?: ListStatusesOptions) => Promise; + onHeadChanged: (args: { + laneId: string; + preHeadSha: string | null; + postHeadSha: string | null; + reason: string; + }) => Promise; + emit: (options?: ListStatusesOptions) => Promise; + refreshActiveRebaseNeeds: (reason?: string) => Promise; + recordAttentionStatus: (status: AttentionStatusInput) => Promise; + dispose: () => void; +}; const KEY_PREFIX = "auto_rebase:status:"; const AUTO_REBASED_TTL_MS = 15 * 60_000; const RUN_DEBOUNCE_MS = 1_200; +const SWEEP_DEBOUNCE_MS = 30_000; function keyForLane(laneId: string): string { return `${KEY_PREFIX}${laneId}`; @@ -22,7 +51,9 @@ function sanitizeStoredStatus(value: unknown): StoredStatus | null { const laneId = typeof value.laneId === "string" ? value.laneId.trim() : ""; const stateRaw = typeof value.state === "string" ? value.state.trim() : ""; const state: AutoRebaseLaneState | null = - stateRaw === "autoRebased" || stateRaw === "rebasePending" || stateRaw === "rebaseConflict" ? stateRaw : null; + stateRaw === "autoRebased" || stateRaw === "rebasePending" || stateRaw === "rebaseConflict" || stateRaw === "rebaseFailed" + ? stateRaw + : null; const updatedAt = typeof value.updatedAt === "string" ? value.updatedAt : ""; if (!laneId || !state || !updatedAt) return null; @@ -39,7 +70,8 @@ function sanitizeStoredStatus(value: unknown): StoredStatus | null { state, updatedAt, conflictCount, - message: messageRaw + message: messageRaw, + source: value.source === "manual" ? "manual" : "auto" }; } @@ -50,6 +82,24 @@ function byCreatedAtAsc(a: LaneSummary, b: LaneSummary): number { return a.name.localeCompare(b.name); } +function resolveAffectedChainLaneId( + laneId: string, + laneById: Map, + affectedLaneIds: Set, +): string { + let current = laneId; + const visited = new Set(); + while (!visited.has(current)) { + visited.add(current); + const lane = laneById.get(current); + if (!lane?.parentLaneId || !affectedLaneIds.has(lane.parentLaneId)) { + return current; + } + current = lane.parentLaneId; + } + return laneId; +} + export function createAutoRebaseService(args: { db: AdeDb; logger: Logger; @@ -57,7 +107,7 @@ export function createAutoRebaseService(args: { conflictService: ReturnType; projectConfigService: ReturnType; onEvent?: (event: AutoRebaseEventPayload) => void; -}) { +}): AutoRebaseService { const { db, logger, @@ -74,6 +124,9 @@ export function createAutoRebaseService(args: { reason: string; }; const queueByRoot = new Map(); + let disposed = false; + let sweepPromise: Promise | null = null; + let lastSweepAtMs = 0; const isEnabled = (): boolean => { try { @@ -93,14 +146,7 @@ export function createAutoRebaseService(args: { db.setJson(keyForLane(laneId), null); }; - const setStatus = (status: { - laneId: string; - parentLaneId: string | null; - parentHeadSha: string | null; - state: AutoRebaseLaneState; - conflictCount: number; - message?: string | null; - }): void => { + const setStatus = (status: AttentionStatusInput): void => { saveStatus({ laneId: status.laneId, parentLaneId: status.parentLaneId, @@ -108,12 +154,27 @@ export function createAutoRebaseService(args: { state: status.state, updatedAt: nowIso(), conflictCount: Math.max(0, Math.floor(status.conflictCount)), - message: status.message ?? null + message: status.message ?? null, + source: status.source ?? "auto" }); }; - const listStatuses = async (): Promise => { + const safeGetHeadSha = async (worktreePath: string): Promise => { + try { + return await getHeadSha(worktreePath); + } catch (error) { + logger.warn("autoRebase.parent_head_sha_failed", { + worktreePath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + + const listStatuses = async (options?: ListStatusesOptions): Promise => { + void maybeSweepRoots("listStatuses"); const lanes = await laneService.list({ includeArchived: false }); + if (disposed) return []; const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const nowMs = Date.now(); @@ -121,11 +182,6 @@ export function createAutoRebaseService(args: { for (const lane of lanes) { const status = loadStatus(lane.id); if (!status) continue; - if (!lane.parentLaneId) { - clearStatus(lane.id); - continue; - } - if (status.state === "autoRebased") { const updatedAtMs = Date.parse(status.updatedAt); if (!Number.isFinite(updatedAtMs)) { @@ -136,7 +192,7 @@ export function createAutoRebaseService(args: { clearStatus(lane.id); continue; } - } else if (lane.status.behind <= 0) { + } else if (!options?.includeAll && lane.status.behind <= 0 && status.source !== "manual") { clearStatus(lane.id); continue; } else if (status.parentLaneId && !laneById.has(status.parentLaneId)) { @@ -156,10 +212,10 @@ export function createAutoRebaseService(args: { return out; }; - const emit = async (): Promise => { - if (!onEvent) return; + const emit = async (options?: ListStatusesOptions): Promise => { + if (disposed || !onEvent) return; try { - const statuses = await listStatuses(); + const statuses = await listStatuses(options); onEvent({ type: "auto-rebase-updated", computedAt: nowIso(), @@ -170,6 +226,70 @@ export function createAutoRebaseService(args: { } }; + const queueRootsFromNeeds = async (needs: RebaseNeed[], reason: string): Promise => { + if (needs.length === 0) return; + const lanes = await laneService.list({ includeArchived: false }); + if (disposed) return; + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const affectedLaneIds = new Set(needs.map((need) => need.laneId)); + const rootLaneIds = new Set(); + + for (const need of needs) { + const lane = laneById.get(need.laneId); + if (!lane) continue; + rootLaneIds.add(lane.parentLaneId + ? resolveAffectedChainLaneId(lane.id, laneById, affectedLaneIds) + : lane.id); + } + + for (const rootLaneId of rootLaneIds) { + queueRoot({ rootLaneId, reason: `sweep:${reason}` }); + } + }; + + const maybeSweepRoots = async (reason: string, options?: { force?: boolean }): Promise => { + if (disposed || !isEnabled()) return; + if (options?.force && sweepPromise) { + await sweepPromise.catch(() => {}); + if (disposed) return; + } + if (sweepPromise) return; + const now = Date.now(); + if (!options?.force && now - lastSweepAtMs < SWEEP_DEBOUNCE_MS) return; + + let currentSweep: Promise; + currentSweep = (async () => { + lastSweepAtMs = now; + try { + const needs = await conflictService.scanRebaseNeeds(); + if (disposed) return; + await queueRootsFromNeeds(needs, reason); + if (disposed) return; + } catch (error) { + logger.warn("autoRebase.sweep_failed", { reason, error: String(error) }); + } + })().finally(() => { + if (sweepPromise === currentSweep) { + sweepPromise = null; + } + }); + sweepPromise = currentSweep; + await currentSweep; + if (disposed) return; + }; + + const refreshActiveRebaseNeeds = async (reason = "external_refresh"): Promise => { + await maybeSweepRoots(reason, { force: true }); + if (disposed) return; + await emit(); + }; + + const recordAttentionStatus = async (status: AttentionStatusInput): Promise => { + setStatus(status); + if (disposed) return; + await emit({ includeAll: true }); + }; + const collectDescendantsDepthFirst = (rootLaneId: string, lanes: LaneSummary[]): string[] => { const childrenByParent = new Map(); for (const lane of lanes) { @@ -194,30 +314,56 @@ export function createAutoRebaseService(args: { }; const processRoot = async (rootLaneId: string, reason: string): Promise => { - if (!isEnabled()) return; + if (disposed || !isEnabled()) return; let lanes = await laneService.list({ includeArchived: false }); + if (disposed) return; const rootLane = lanes.find((lane) => lane.id === rootLaneId) ?? null; if (!rootLane) return; - const cascadeOrder = collectDescendantsDepthFirst(rootLaneId, lanes); + const cascadeOrder = [rootLaneId, ...collectDescendantsDepthFirst(rootLaneId, lanes)]; if (cascadeOrder.length === 0) return; let blocked = false; let blockedLaneId: string | null = null; + let blockedByLookupFailure = false; for (const laneId of cascadeOrder) { lanes = await laneService.list({ includeArchived: false }); + if (disposed) return; const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const lane = laneById.get(laneId); if (!lane) { logger.info("autoRebase.lane_not_found", { laneId }); continue; } - if (!lane.parentLaneId) { - logger.debug("autoRebase.no_parent", { laneId }); - continue; + + let parentHeadSha: string | null = null; + let targetLabel = lane.name; + let baseBranchOverride: string | undefined; + let parent: LaneSummary | null = null; + if (lane.parentLaneId) { + parent = laneById.get(lane.parentLaneId) ?? null; + if (!parent) { + setStatus({ + laneId: lane.id, + parentLaneId: lane.parentLaneId, + parentHeadSha: null, + state: "rebasePending", + conflictCount: 0, + message: "Pending: parent lane is unavailable. Open the Rebase tab to review the lane." + }); + blocked = true; + blockedLaneId = lane.id; + continue; + } + parentHeadSha = await safeGetHeadSha(parent.worktreePath); + if (disposed) return; + targetLabel = parent.name; } if (blocked) { + if (blockedByLookupFailure) { + continue; + } setStatus({ laneId: lane.id, parentLaneId: lane.parentLaneId, @@ -225,59 +371,51 @@ export function createAutoRebaseService(args: { state: "rebasePending", conflictCount: 0, message: blockedLaneId - ? `Pending: ancestor lane '${blockedLaneId}' has unresolved rebase conflicts.` - : "Pending: auto-rebase stopped at an earlier lane." + ? `Pending: ancestor lane '${blockedLaneId}' has unresolved rebase conflicts. Open the Rebase tab to continue.` + : "Pending: auto-rebase stopped at an earlier lane. Open the Rebase tab to continue." }); continue; } - if (lane.status.behind <= 0) { + let lookupFailed = false; + const need = await conflictService.getRebaseNeed(lane.id).catch((error) => { + lookupFailed = true; + logger.warn("autoRebase.need_lookup_failed", { laneId: lane.id, error: String(error) }); + return null; + }); + if (disposed) return; + + if (!need) { + if (lookupFailed) { + blocked = true; + blockedByLookupFailure = true; + blockedLaneId = lane.id; + continue; + } const existing = loadStatus(lane.id); - if (existing?.state !== "autoRebased") { + if (existing?.source !== "manual" && existing?.state !== "autoRebased") { clearStatus(lane.id); } continue; } - const parent = laneById.get(lane.parentLaneId); - if (!parent) { + if (need.conflictPredicted) { + blocked = true; + blockedLaneId = lane.id; setStatus({ laneId: lane.id, parentLaneId: lane.parentLaneId, - parentHeadSha: null, - state: "rebasePending", - conflictCount: 0, - message: "Pending: parent lane is unavailable." + parentHeadSha, + state: "rebaseConflict", + conflictCount: Math.max(1, need.conflictingFiles.length), + message: `Auto-rebase blocked: ${Math.max(1, need.conflictingFiles.length)} conflict(s) expected. Open the Rebase tab to resolve and publish.` }); - blocked = true; - blockedLaneId = lane.id; continue; } - const simulation = await conflictService.simulateMerge({ laneAId: lane.id, laneBId: parent.id }); - if (simulation.outcome !== "clean") { - blocked = true; - blockedLaneId = lane.id; - if (simulation.outcome === "conflict") { - setStatus({ - laneId: lane.id, - parentLaneId: lane.parentLaneId, - parentHeadSha: await getHeadSha(parent.worktreePath), - state: "rebaseConflict", - conflictCount: Math.max(1, simulation.conflictingFiles.length), - message: `Auto-rebase blocked: ${Math.max(1, simulation.conflictingFiles.length)} conflict(s) expected.` - }); - } else { - setStatus({ - laneId: lane.id, - parentLaneId: lane.parentLaneId, - parentHeadSha: await getHeadSha(parent.worktreePath), - state: "rebasePending", - conflictCount: 0, - message: simulation.error?.trim() || "Auto-rebase could not run merge simulation." - }); - } - continue; + if (!lane.parentLaneId) { + baseBranchOverride = need.baseBranch; + targetLabel = need.baseBranch || lane.baseRef || lane.branchRef || lane.name; } const rebaseRun = await laneService.rebaseStart({ @@ -285,8 +423,10 @@ export function createAutoRebaseService(args: { scope: "lane_only", pushMode: "none", actor: "system", - reason: "auto_rebase" + reason: "auto_rebase", + ...(baseBranchOverride ? { baseBranchOverride } : {}) }); + if (disposed) return; if (rebaseRun.run.error) { blocked = true; blockedLaneId = lane.id; @@ -294,30 +434,73 @@ export function createAutoRebaseService(args: { setStatus({ laneId: lane.id, parentLaneId: lane.parentLaneId, - parentHeadSha: await getHeadSha(parent.worktreePath), - state: conflictHint ? "rebaseConflict" : "rebasePending", + parentHeadSha, + state: conflictHint ? "rebaseConflict" : "rebaseFailed", conflictCount: conflictHint ? 1 : 0, message: conflictHint - ? "Auto-rebase stopped due to conflicts. Resolve manually, then publish." - : `Auto-rebase failed: ${rebaseRun.run.error}` + ? "Auto-rebase stopped due to conflicts. Open the Rebase tab to resolve, then publish." + : `Auto-rebase failed: ${rebaseRun.run.error}. Open the Rebase tab to retry.` }); continue; } - setStatus({ - laneId: lane.id, - parentLaneId: lane.parentLaneId, - parentHeadSha: await getHeadSha(parent.worktreePath), - state: "autoRebased", - conflictCount: 0, - message: `Rebased automatically after '${parent.name}' advanced.` - }); + let pushSucceeded = false; + try { + const pushedRun = await laneService.rebasePush({ runId: rebaseRun.runId, laneIds: [lane.id] }); + if (disposed) return; + const pushedLaneIds = pushedRun.pushedLaneIds ?? []; + const pushedLane = pushedRun.lanes.find((entry) => entry.laneId === lane.id); + if (!pushedLaneIds.includes(lane.id) || pushedLane?.pushed !== true) { + throw new Error("Auto-push did not complete for the rebased lane."); + } + pushSucceeded = true; + + setStatus({ + laneId: lane.id, + parentLaneId: lane.parentLaneId, + parentHeadSha, + state: "autoRebased", + conflictCount: 0, + message: lane.parentLaneId + ? `Rebased and pushed automatically after '${targetLabel}' advanced.` + : `Rebased and pushed automatically onto '${targetLabel}'.` + }); + } catch (error) { + let rollbackError: string | null = null; + if (!pushSucceeded) { + try { + await laneService.rebaseRollback({ runId: rebaseRun.runId }); + if (disposed) return; + } catch (rollbackFailure) { + rollbackError = rollbackFailure instanceof Error ? rollbackFailure.message : String(rollbackFailure); + logger.warn("autoRebase.rollback_failed", { + laneId: lane.id, + error: rollbackError + }); + } + } + + blocked = true; + blockedLaneId = lane.id; + const pushError = error instanceof Error ? error.message : String(error); + setStatus({ + laneId: lane.id, + parentLaneId: lane.parentLaneId, + parentHeadSha, + state: "rebaseFailed", + conflictCount: 0, + message: rollbackError + ? `Auto-push failed: ${pushError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to retry.` + : `Auto-push failed: ${pushError}. The lane was restored to its pre-rebase state. Open the Rebase tab to retry.` + }); + } } logger.info("autoRebase.run_complete", { rootLaneId, reason, cascaded: cascadeOrder.length, blocked, blockedLaneId }); }; const runRootQueue = async (rootLaneId: string): Promise => { + if (disposed) return; const state = queueByRoot.get(rootLaneId); if (!state || state.running) return; state.running = true; @@ -325,20 +508,24 @@ export function createAutoRebaseService(args: { while (state.pending) { state.pending = false; await processRoot(rootLaneId, state.reason); - await emit(); + if (disposed) return; + await emit({ includeAll: true }); + if (disposed) return; } } catch (error) { logger.warn("autoRebase.run_failed", { rootLaneId, error: String(error) }); - await emit(); + if (disposed) return; + await emit({ includeAll: true }); } finally { state.running = false; - if (state.pending) { + if (state.pending && !disposed) { void runRootQueue(rootLaneId); } } }; const queueRoot = (args: { rootLaneId: string; reason: string }): void => { + if (disposed) return; const rootLaneId = args.rootLaneId.trim(); if (!rootLaneId) return; @@ -350,6 +537,7 @@ export function createAutoRebaseService(args: { } existing.timer = setTimeout(() => { existing.timer = null; + if (disposed) return; void runRootQueue(rootLaneId); }, RUN_DEBOUNCE_MS); queueByRoot.set(rootLaneId, existing); @@ -361,16 +549,32 @@ export function createAutoRebaseService(args: { postHeadSha: string | null; reason: string; }): Promise => { + if (disposed) return; const laneId = args.laneId.trim(); if (!laneId) return; - if (args.reason.startsWith("auto_rebase")) return; + if (args.reason.startsWith("auto_rebase") || args.reason === "rebase_abort" || args.reason === "rebase_rollback") return; if (!isEnabled()) return; queueRoot({ rootLaneId: laneId, reason: args.reason }); }; + const dispose = (): void => { + disposed = true; + for (const state of queueByRoot.values()) { + if (state.timer) { + clearTimeout(state.timer); + } + state.timer = null; + state.pending = false; + } + queueByRoot.clear(); + }; + return { listStatuses, onHeadChanged, - emit + emit, + refreshActiveRebaseNeeds, + recordAttentionStatus, + dispose }; } diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts new file mode 100644 index 000000000..406a3a37b --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { createLaneService } from "./laneService"; +import { resolvePathWithinRoot } from "../shared/utils"; + +export type LaneLaunchContext = { + laneWorktreePath: string; + cwd: string; +}; + +function ensureDirectoryExists(targetPath: string, message: string): string { + let stat: fs.Stats; + try { + stat = fs.statSync(targetPath); + } catch { + throw new Error(message); + } + if (!stat.isDirectory()) { + throw new Error(message); + } + try { + return fs.realpathSync(targetPath); + } catch { + throw new Error(message); + } +} + +export function resolveLaneLaunchContext(args: { + laneService: ReturnType; + laneId: string; + requestedCwd?: string | null; + purpose: string; +}): LaneLaunchContext { + const laneId = String(args.laneId ?? "").trim(); + const purpose = args.purpose.trim() || "launch work"; + const { worktreePath } = args.laneService.getLaneBaseAndBranch(laneId); + const configuredRoot = typeof worktreePath === "string" ? worktreePath.trim() : ""; + if (!configuredRoot.length) { + throw new Error(`Lane '${laneId}' has no worktree configured. ADE cannot ${purpose} outside the selected lane.`); + } + + const unavailableMessage = + `Lane '${laneId}' worktree is unavailable at '${configuredRoot}'. Restore or recreate the lane before trying to ${purpose}.`; + const laneRoot = ensureDirectoryExists(path.resolve(configuredRoot), unavailableMessage); + + const requestedCwd = typeof args.requestedCwd === "string" ? args.requestedCwd.trim() : ""; + if (!requestedCwd.length) { + return { + laneWorktreePath: laneRoot, + cwd: laneRoot, + }; + } + + const requestedTarget = path.isAbsolute(requestedCwd) + ? requestedCwd + : path.resolve(laneRoot, requestedCwd); + + let resolvedCwd: string; + try { + resolvedCwd = resolvePathWithinRoot(laneRoot, requestedTarget); + } catch (error) { + if (error instanceof Error && error.message === "Path escapes root") { + throw new Error( + `Requested cwd '${requestedCwd}' escapes lane '${laneId}'. ADE only launches work inside the selected lane worktree '${laneRoot}'.`, + ); + } + throw error; + } + + ensureDirectoryExists( + resolvedCwd, + `Requested cwd '${requestedCwd}' is not an existing directory inside lane '${laneId}' worktree '${laneRoot}'.`, + ); + + return { + laneWorktreePath: laneRoot, + cwd: resolvedCwd, + }; +} diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index be03d9c8b..49db42281 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -435,6 +435,86 @@ describe("laneService createFromUnstaged", () => { }); }); +describe("laneService create", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("creates an unparented lane from the requested base branch", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-root-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-03-11T12:00:00.000Z"; + + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-create-root", repoRoot, "demo", "main", now, now], + ); + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["lane-main", "proj-create-root", "Main", null, "primary", "main", "fix-rebase-and-new-lane-flow", repoRoot, null, 0, null, null, null, null, "active", now, null], + ); + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "worktree" && args[1] === "add") { + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "main") { + return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; + } + if (args[0] === "push" && args[1] === "-u") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { + return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + } + if ( + args[0] === "rev-parse" + && args[1] === "--abbrev-ref" + && args[2] === "--symbolic-full-name" + && args[3] === "@{upstream}" + ) { + return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-create-root", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const lane = await service.create({ name: "Git actions fixes", baseBranch: "main" }); + + expect(lane.parentLaneId).toBeNull(); + expect(lane.baseRef).toBe("main"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + describe("laneService rebaseStart", () => { beforeEach(() => { vi.mocked(getHeadSha).mockReset(); @@ -482,6 +562,147 @@ describe("laneService rebaseStart", () => { expect(logs.some((line) => line.includes("already up to date"))).toBe(true); }); + it("rebases an unparented lane against its stored base branch", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-base-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-03-11T12:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-root-base", repoRoot, "demo", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-main", "proj-root-base", "Main", null, "primary", "main", "main", path.join(repoRoot, "main"), null, 0, null, null, null, null, "active", now, null], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-root", "proj-root-base", "Root lane", null, "worktree", "main", "feature/root", path.join(repoRoot, "root"), null, 0, null, null, null, null, "active", now, null], + ); + + let rootHeadReads = 0; + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/root")) { + rootHeadReads += 1; + return rootHeadReads === 1 ? "sha-root-before" : "sha-root-after"; + } + return "sha-main"; + }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "fetch" && args[1] === "--prune" && args[2] === "origin") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + if (args[0] === "merge-base" && args[1] === "--is-ancestor") { + expect(args[2]).toBe("sha-origin-main"); + expect(args[3]).toBe("sha-root-before"); + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-main"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-root-base", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.rebaseStart({ laneId: "lane-root", scope: "lane_only", actor: "user" }); + + expect(result.run.state).toBe("completed"); + expect(result.run.lanes[0]?.status).toBe("succeeded"); + }); + + it("persists and restores the overridden base branch for PR-target rebases", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-override-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-03-11T12:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-root-override", repoRoot, "demo", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-main", "proj-root-override", "Main", null, "primary", "main", "main", path.join(repoRoot, "main"), null, 0, null, null, null, null, "active", now, null], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-root", "proj-root-override", "Root lane", null, "worktree", "release-9", "feature/root", path.join(repoRoot, "root"), null, 0, null, null, null, null, "active", now, null], + ); + + const rootHeadSequence = ["sha-root-before", "sha-root-after", "sha-root-after", "sha-root-before"]; + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/root")) { + return rootHeadSequence.shift() ?? "sha-root-before"; + } + return "sha-main"; + }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + if (args[0] === "merge-base" && args[1] === "--is-ancestor") { + expect(args[2]).toBe("sha-origin-main"); + expect(args[3]).toBe("sha-root-before"); + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-main"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-root-override", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const started = await service.rebaseStart({ + laneId: "lane-root", + scope: "lane_only", + actor: "user", + baseBranchOverride: "main", + }); + + const afterRebase = db.get("select base_ref from lanes where id = ?", ["lane-root"]) as { base_ref: string }; + expect(afterRebase.base_ref).toBe("main"); + + await service.rebaseRollback({ runId: started.runId }); + + const afterRollback = db.get("select base_ref from lanes where id = ?", ["lane-root"]) as { base_ref: string }; + expect(afterRollback.base_ref).toBe("release-9"); + }); + it("rejects overlapping rebase runs for the same stack while one is active", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-overlap-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); @@ -535,6 +756,76 @@ describe("laneService rebaseStart", () => { expect(completed.run.state).toBe("completed"); }); + it("rebases an unparented lane onto an override branch and persists the new base ref", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-override-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-root-override", repoRoot }); + const now = "2026-03-11T12:00:00.000Z"; + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["lane-root", "proj-root-override", "Root Lane", null, "worktree", "release-9", "feature/root", path.join(repoRoot, "root"), null, 0, null, null, null, null, "active", now, null], + ); + + let rootHeadReads = 0; + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/root")) { + rootHeadReads += 1; + return rootHeadReads === 1 ? "sha-root-pre" : "sha-root-post"; + } + return "sha-unused"; + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + if (args[0] === "merge-base" && args[1] === "--is-ancestor") { + expect(args[2]).toBe("sha-origin-main"); + expect(args[3]).toBe("sha-root-pre"); + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-main"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "fetch" && args[1] === "--prune" && args[2] === "origin") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-root-override", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.rebaseStart({ + laneId: "lane-root", + scope: "lane_only", + actor: "user", + baseBranchOverride: "main", + }); + + expect(result.run.state).toBe("completed"); + expect(result.run.baseBranch).toBe("main"); + expect(result.run.rootBaseRefBefore).toBe("release-9"); + expect(result.run.rootBaseRefAfter).toBe("main"); + const updated = await service.list({ includeStatus: false }); + expect(updated.find((lane) => lane.id === "lane-root")?.baseRef).toBe("main"); + expect(updated.find((lane) => lane.id === "lane-root")?.parentLaneId).toBeNull(); + }); + it("rebases against the primary lane remote tracking ref when it is available", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-remote-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); @@ -943,3 +1234,68 @@ describe("laneService rebaseStart", () => { expect(revParseVerifyCalls[0]).toBe("origin/main"); }); }); + +describe("laneService reparent", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("uses the primary lane's remote tracking ref when reparenting under primary", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-primary-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-reparent-primary", repoRoot }); + + let childHeadReads = 0; + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/child")) { + childHeadReads += 1; + return childHeadReads === 1 ? "sha-child-pre" : "sha-child-post"; + } + return "sha-unused"; + }); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if ( + args[0] === "rev-parse" + && args[1] === "--abbrev-ref" + && args[2] === "--symbolic-full-name" + && args[3] === "@{upstream}" + ) { + return { exitCode: 0, stdout: "origin/main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-main"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reparent-primary", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.reparent({ laneId: "lane-child", newParentLaneId: "lane-main" }); + + expect(result.previousParentLaneId).toBe("lane-parent"); + expect(result.newParentLaneId).toBe("lane-main"); + expect(result.preHeadSha).toBe("sha-child-pre"); + expect(result.postHeadSha).toBe("sha-child-post"); + expect(runGitOrThrow).toHaveBeenCalledWith( + ["rebase", "sha-origin-main"], + expect.objectContaining({ cwd: path.join(repoRoot, "child") }), + ); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index d84877163..99bfd8d33 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; -import { isWithinDir } from "../shared/utils"; +import { isWithinDir, normalizeBranchName } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import type { createOperationService } from "../history/operationService"; @@ -279,6 +279,42 @@ function describeParentRebaseTarget(parent: LaneRow, label: string): string { return label === parent.name ? parent.name : `${parent.name} (${label})`; } +async function resolveBranchRebaseTarget(args: { + projectRoot: string; + branchRef: string; + preferRemote: boolean; +}): Promise<{ headSha: string; label: string; branchName: string }> { + const branchName = normalizeBranchName(args.branchRef).trim(); + if (!branchName) throw new Error("Base branch is empty."); + if (args.preferRemote) { + await fetchRemoteTrackingBranch({ + projectRoot: args.projectRoot, + targetBranch: branchName, + }).catch(() => {}); + } + + const candidateRefs = args.preferRemote + ? [`origin/${branchName}`, branchName] + : [branchName, `origin/${branchName}`]; + + for (const ref of candidateRefs) { + const res = await runGit( + ["rev-parse", "--verify", ref], + { cwd: args.projectRoot, timeoutMs: 5_000 }, + ); + const sha = res.exitCode === 0 ? res.stdout.trim() : ""; + if (sha) { + return { headSha: sha, label: ref, branchName }; + } + } + + throw new Error(`Unable to resolve base branch "${branchName}".`); +} + +function describeBranchRebaseTarget(branchName: string, label: string): string { + return label === branchName ? branchName : `${branchName} (${label})`; +} + function computeStackDepth(args: { laneId: string; rowsById: Map; @@ -1437,9 +1473,14 @@ export function createLaneService({ const pushMode: PushMode = args.pushMode ?? "none"; const actor = typeof args.actor === "string" && args.actor.trim().length ? args.actor.trim() : "user"; const reason = typeof args.reason === "string" && args.reason.trim().length ? args.reason.trim() : "rebase"; + const baseBranchOverride = normalizeBranchName(args.baseBranchOverride ?? "").trim(); + const persistBaseBranch = baseBranchOverride.length > 0; const target = getLaneRow(args.laneId); if (!target) throw new Error(`Lane not found: ${args.laneId}`); + if (persistBaseBranch && target.parent_lane_id) { + throw new Error("Cannot persist a base branch override for a parented lane."); + } const runId = randomUUID(); const startedAt = new Date().toISOString(); @@ -1478,29 +1519,21 @@ export function createLaneService({ startedAt, finishedAt: null, actor, - baseBranch: target.base_ref, + baseBranch: baseBranchOverride || target.base_ref, lanes, currentLaneId: null, failedLaneId: null, error: null, pushedLaneIds: [], - canRollback: false + canRollback: false, + rootBaseRefBefore: target.base_ref, + rootBaseRefAfter: baseBranchOverride || target.base_ref, }; rebaseRuns.set(runId, run); emitRunLog({ runId, laneId: null, message: `Starting rebase run (${scope})` }); emitRunUpdated(run); - if (!target.parent_lane_id) { - run.state = "failed"; - run.error = "Lane has no parent; nothing to rebase."; - run.finishedAt = new Date().toISOString(); - run.canRollback = false; - emitRunLog({ runId, laneId: target.id, message: run.error }); - emitRunUpdated(run); - return { runId, run: cloneRebaseRun(run) }; - } - const failRunAtLane = (laneItem: RebaseRunLane, laneId: string, index: number, errorMsg: string): void => { laneItem.status = "blocked"; laneItem.error = errorMsg; @@ -1522,32 +1555,57 @@ export function createLaneService({ continue; } - if (!lane.parent_lane_id) { - laneItem.status = "skipped"; - laneItem.error = "Primary lane has no parent to rebase against."; - continue; - } - - const parent = getLaneRow(lane.parent_lane_id); - if (!parent) { - failRunAtLane(laneItem, lane.id, index, `Parent lane not found for ${lane.name}`); - break; - } - - let parentTarget: { headSha: string; label: string }; + const isRootLane = index === 0 && lane.id === target.id; + let parentHead = ""; + let parentTargetLabel = ""; + let operationMetadata: Record = { + reason, + recursive: scope === "lane_and_descendants", + }; try { - parentTarget = await resolveParentRebaseTarget({ projectRoot, parent }); + if (isRootLane && !lane.parent_lane_id) { + const branchTarget = await resolveBranchRebaseTarget({ + projectRoot, + branchRef: baseBranchOverride || lane.base_ref, + preferRemote: true, + }); + parentHead = branchTarget.headSha; + parentTargetLabel = describeBranchRebaseTarget(branchTarget.branchName, branchTarget.label); + operationMetadata = { + ...operationMetadata, + baseBranchRef: branchTarget.branchName, + baseTargetRef: branchTarget.label, + baseHeadSha: branchTarget.headSha, + }; + } else { + if (!lane.parent_lane_id) { + failRunAtLane(laneItem, lane.id, index, `${lane.name} has no parent lane to rebase against.`); + break; + } + const parent = getLaneRow(lane.parent_lane_id); + if (!parent) { + failRunAtLane(laneItem, lane.id, index, `Parent lane not found for ${lane.name}`); + break; + } + const parentTarget = await resolveParentRebaseTarget({ projectRoot, parent }); + parentHead = parentTarget.headSha; + parentTargetLabel = describeParentRebaseTarget(parent, parentTarget.label); + operationMetadata = { + ...operationMetadata, + parentLaneId: parent.id, + parentBranchRef: parent.branch_ref, + parentHeadSha: parentHead, + }; + } } catch (error) { failRunAtLane( laneItem, lane.id, index, - error instanceof Error ? error.message : `Unable to resolve parent HEAD for ${parent.name}`, + error instanceof Error ? error.message : `Unable to resolve rebase target for ${lane.name}`, ); break; } - const parentHead = parentTarget.headSha; - const parentTargetLabel = describeParentRebaseTarget(parent, parentTarget.label); run.currentLaneId = lane.id; laneItem.preHeadSha = await getHeadSha(lane.worktree_path); @@ -1573,7 +1631,7 @@ export function createLaneService({ continue; } if (alreadyCurrent.exitCode !== 1) { - failRunAtLane(laneItem, lane.id, index, alreadyCurrent.stderr.trim() || `Unable to compare ${lane.name} with ${parent.name}`); + failRunAtLane(laneItem, lane.id, index, alreadyCurrent.stderr.trim() || `Unable to compare ${lane.name} with ${parentTargetLabel}`); break; } @@ -1599,13 +1657,7 @@ export function createLaneService({ laneId: lane.id, kind: "lane_rebase", preHeadSha: laneItem.preHeadSha, - metadata: { - reason, - parentLaneId: parent.id, - parentBranchRef: parent.branch_ref, - parentHeadSha: parentHead, - recursive: scope === "lane_and_descendants" - } + metadata: operationMetadata, }); const rebaseRes = await runGit(["rebase", parentHead], { cwd: lane.worktree_path, timeoutMs: 120_000 }); @@ -1686,6 +1738,13 @@ export function createLaneService({ if (run.state === "running") { run.state = "completed"; } + if (run.state === "completed" && persistBaseBranch && target.base_ref !== baseBranchOverride) { + db.run( + "update lanes set parent_lane_id = null, base_ref = ? where id = ? and project_id = ?", + [baseBranchOverride, target.id, projectId], + ); + invalidateLaneListCache(); + } run.canRollback = run.lanes.some((lane) => lane.status === "succeeded"); emitRunUpdated(run); return { runId, run: cloneRebaseRun(run) }; @@ -1754,6 +1813,14 @@ export function createLaneService({ } } + if (run.rootBaseRefBefore && run.rootBaseRefAfter && run.rootBaseRefBefore !== run.rootBaseRefAfter) { + db.run( + "update lanes set base_ref = ? where id = ? and project_id = ?", + [run.rootBaseRefBefore, run.rootLaneId, projectId], + ); + invalidateLaneListCache(); + } + run.state = "aborted"; run.finishedAt = new Date().toISOString(); run.canRollback = false; @@ -1809,8 +1876,8 @@ export function createLaneService({ const previousBaseRef = lane.base_ref; const newBaseRef = newParent.branch_ref; const preHeadSha = await getHeadSha(lane.worktree_path); - const newParentHead = await getHeadSha(newParent.worktree_path); - if (!newParentHead) throw new Error(`Unable to resolve parent HEAD for lane ${newParent.name}`); + const newParentTarget = await resolveParentRebaseTarget({ projectRoot, parent: newParent }); + const newParentHead = newParentTarget.headSha; const operation = operationService?.start({ laneId: lane.id, diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index 1e14fdf37..1c872f1d4 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -3,7 +3,7 @@ import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "./laneService"; import type { LaneSummary, RebaseSuggestion, RebaseSuggestionsEventPayload } from "../../../shared/types"; -import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase"; +import { fetchQueueTargetTrackingBranches, fetchRemoteTrackingBranch, resolveQueueRebaseOverride } from "../shared/queueRebase"; import { isRecord, nowIso } from "../shared/utils"; type StoredSuggestionState = { @@ -97,9 +97,22 @@ export function createRebaseSuggestionService(args: { return result.exitCode === 0 ? Math.max(0, Number(result.stdout.trim()) || 0) : 0; }; + const resolvePrimaryParentHeadSha = async (parent: LaneSummary): Promise => { + const parentBranch = parent.branchRef.trim(); + if (!parentBranch) return null; + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parentBranch, + }).catch(() => {}); + const remoteHeadSha = await readRefHeadSha(`origin/${parentBranch}`); + if (remoteHeadSha) return remoteHeadSha; + return getHeadSha(parent.worktreePath); + }; + const resolveSuggestionBase = async ( lane: LaneSummary, laneById: Map, + primaryParentHeadByBranch: Map, ): Promise<{ parentLaneId: string; parentHeadSha: string; baseLabel: string | null; groupContext: string | null } | null> => { const queueOverride = await resolveQueueRebaseOverride({ db, @@ -121,7 +134,26 @@ export function createRebaseSuggestionService(args: { if (!lane.parentLaneId) return null; const parent = laneById.get(lane.parentLaneId); if (!parent) return null; - const parentHeadSha = await getHeadSha(parent.worktreePath); + let parentHeadSha: string | null; + if (parent.laneType === "primary") { + const parentBranch = parent.branchRef.trim(); + if (!parentBranch) return null; + if (primaryParentHeadByBranch.has(parentBranch)) { + parentHeadSha = primaryParentHeadByBranch.get(parentBranch) ?? null; + } else { + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parentBranch, + }).catch(() => {}); + parentHeadSha = await readRefHeadSha(`origin/${parentBranch}`); + if (!parentHeadSha) { + parentHeadSha = await getHeadSha(parent.worktreePath); + } + primaryParentHeadByBranch.set(parentBranch, parentHeadSha); + } + } else { + parentHeadSha = await getHeadSha(parent.worktreePath); + } if (!parentHeadSha) return null; return { parentLaneId: lane.parentLaneId, @@ -140,13 +172,14 @@ export function createRebaseSuggestionService(args: { const lanes = await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const primaryParentHeadByBranch = new Map(); const prLaneIds = getPrLaneIds(); const out: RebaseSuggestion[] = []; const nowMs = Date.now(); for (const lane of lanes) { - const base = await resolveSuggestionBase(lane, laneById); + const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); if (!base) continue; const behindCount = await readBehindCount({ laneWorktreePath: lane.worktreePath, @@ -240,7 +273,8 @@ export function createRebaseSuggestionService(args: { const lane = lanes.find((l) => l.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); const laneById = new Map(lanes.map((entry) => [entry.id, entry] as const)); - const base = await resolveSuggestionBase(lane, laneById); + const primaryParentHeadByBranch = new Map(); + const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); if (!base) throw new Error("Lane has no rebase suggestion to dismiss."); const existing = loadState(laneId); @@ -272,7 +306,8 @@ export function createRebaseSuggestionService(args: { const lane = lanes.find((l) => l.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); const laneById = new Map(lanes.map((entry) => [entry.id, entry] as const)); - const base = await resolveSuggestionBase(lane, laneById); + const primaryParentHeadByBranch = new Map(); + const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); if (!base) throw new Error("Lane has no rebase suggestion to defer."); const existing = loadState(laneId); @@ -300,14 +335,18 @@ export function createRebaseSuggestionService(args: { reason: string; }): Promise => { const parentId = args.laneId.trim(); - const parentHeadSha = (args.postHeadSha ?? "").trim(); - if (!parentId || !parentHeadSha) return; + if (!parentId) return; // Lightweight: only consider direct children; rebase runs can recurse. // Skip lanes that have a queue override — their base is the queue target, // not the direct parent, so writing direct-parent ids here would cause // listSuggestions() to see a base identity change and reset dismissals. const lanes = await laneService.list({ includeArchived: false }); + const parent = lanes.find((lane) => lane.id === parentId) ?? null; + const resolvedParentHeadSha = parent?.laneType === "primary" + ? await resolvePrimaryParentHeadSha(parent) + : (args.postHeadSha ?? "").trim(); + if (!resolvedParentHeadSha) return; const directChildren = lanes.filter((lane) => lane.parentLaneId === parentId && lane.status.behind > 0); if (directChildren.length === 0) return; @@ -326,11 +365,11 @@ export function createRebaseSuggestionService(args: { const next: StoredSuggestionState = { laneId: child.id, parentLaneId: parentId, - parentHeadSha, + parentHeadSha: resolvedParentHeadSha, behindCount: Math.max(0, Math.floor(child.status.behind)), - lastSuggestedAt: existing?.parentHeadSha === parentHeadSha ? existing.lastSuggestedAt : ts, + lastSuggestedAt: existing?.parentHeadSha === resolvedParentHeadSha ? existing.lastSuggestedAt : ts, deferredUntil: existing?.deferredUntil ?? null, - dismissedAt: existing?.parentHeadSha === parentHeadSha ? existing.dismissedAt ?? null : null + dismissedAt: existing?.parentHeadSha === resolvedParentHeadSha ? existing.dismissedAt ?? null : null }; saveState(next); } diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index 92f6e9988..0a3686da3 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -644,7 +644,7 @@ export function createUnifiedOrchestratorAdapter(options?: { memoryBriefing: args.memoryBriefing, }); const provider = resolveProviderGroupForModel(descriptor); - const model = descriptor.isCliWrapped ? descriptor.shortId : descriptor.id; + const model = descriptor.isCliWrapped ? descriptor.sdkModelId : descriptor.id; const reasoningEffort = typeof args.step.metadata?.reasoningEffort === "string" && args.step.metadata.reasoningEffort.trim().length > 0 ? args.step.metadata.reasoningEffort.trim() diff --git a/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts b/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts new file mode 100644 index 000000000..3b900f0ae --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts @@ -0,0 +1,368 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; +import { openKvDb } from "../state/kvDb"; + +const runGitMock = vi.fn(); +const runGitOrThrowMock = vi.fn(); +const fetchRemoteTrackingBranchMock = vi.fn(); + +vi.mock("../git/git", () => ({ + runGit: (...args: unknown[]) => runGitMock(...args), + runGitOrThrow: (...args: unknown[]) => runGitOrThrowMock(...args), + runGitMergeTree: vi.fn(), +})); + +vi.mock("../shared/queueRebase", () => ({ + fetchRemoteTrackingBranch: (...args: unknown[]) => fetchRemoteTrackingBranchMock(...args), +})); + +async function createServiceModule() { + return await import("./prService"); +} + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +function makeLane(id: string, name: string, branchRef: string, worktreePath: string, overrides: Partial = {}): LaneSummary { + return { + id, + name, + description: null, + laneType: "worktree", + baseRef: "refs/heads/main", + branchRef, + worktreePath, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-30T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +async function seedProject(db: any, projectId: string, repoRoot: string) { + const now = "2026-03-30T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "ADE", "main", now, now], + ); +} + +async function seedPr(db: any, args: { + prId: string; + projectId: string; + laneId: string; + number: number; + baseBranch: string; + headBranch: string; + title: string; +}) { + const now = "2026-03-30T00:00:00.000Z"; + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + args.prId, + args.projectId, + args.laneId, + "acme", + "ade", + args.number, + `https://github.com/acme/ade/pull/${args.number}`, + `node-${args.number}`, + args.title, + "open", + args.baseBranch, + args.headBranch, + "passing", + "approved", + 0, + 0, + now, + now, + now, + ], + ); +} + +describe("prService.land auto-rebase follow-up", () => { + beforeEach(() => { + runGitMock.mockReset(); + runGitOrThrowMock.mockReset(); + fetchRemoteTrackingBranchMock.mockReset(); + fetchRemoteTrackingBranchMock.mockResolvedValue(undefined); + }); + + it("reparents, pushes, and retargets direct child lanes after a merged parent lane", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-land-auto-rebase-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + const { createPrService } = await createServiceModule(); + const projectId = "proj-land-auto-rebase"; + + const mainLane = makeLane("lane-main", "main", "main", root, { laneType: "primary" }); + const parentLane = makeLane("lane-parent", "feature/parent", "feature/parent", path.join(root, "parent"), { + parentLaneId: mainLane.id, + }); + const childLane = makeLane("lane-child", "feature/child", "feature/child", path.join(root, "child"), { + parentLaneId: parentLane.id, + baseRef: "refs/heads/feature/parent", + }); + const lanes = [mainLane, parentLane, childLane]; + + await seedProject(db, projectId, root); + await seedPr(db, { + prId: "pr-parent", + projectId, + laneId: parentLane.id, + number: 101, + baseBranch: "main", + headBranch: "feature/parent", + title: "Parent PR", + }); + await seedPr(db, { + prId: "pr-child", + projectId, + laneId: childLane.id, + number: 202, + baseBranch: "feature/parent", + headBranch: "feature/child", + title: "Child PR", + }); + + const laneService = { + list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => + includeArchived ? lanes : lanes.filter((lane) => !lane.archivedAt) + ), + getChildren: vi.fn(async (laneId: string) => lanes.filter((lane) => lane.parentLaneId === laneId && !lane.archivedAt)), + reparent: vi.fn(async ({ laneId, newParentLaneId }: { laneId: string; newParentLaneId: string }) => { + const lane = lanes.find((entry) => entry.id === laneId)!; + const newParent = lanes.find((entry) => entry.id === newParentLaneId)!; + lane.parentLaneId = newParent.id; + lane.baseRef = newParent.branchRef; + return { + laneId, + previousParentLaneId: parentLane.id, + newParentLaneId, + previousBaseRef: "refs/heads/feature/parent", + newBaseRef: newParent.branchRef, + preHeadSha: "child-pre", + postHeadSha: "child-post", + }; + }), + archive: vi.fn(async ({ laneId }: { laneId: string }) => { + const lane = lanes.find((entry) => entry.id === laneId)!; + lane.archivedAt = "2026-03-30T01:00:00.000Z"; + }), + invalidateCache: vi.fn(), + }; + + runGitMock.mockResolvedValue({ exitCode: 0, stdout: "origin/feature/child\n", stderr: "" }); + runGitOrThrowMock.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + + const apiRequest = vi.fn(async ({ method, path: requestPath, body }: { method: string; path: string; body?: any }) => { + if (method === "PUT" && requestPath === "/repos/acme/ade/pulls/101/merge") { + return { data: { sha: "merge-sha" } }; + } + if (method === "PATCH" && requestPath === "/repos/acme/ade/pulls/202") { + expect(body).toMatchObject({ base: "main" }); + return { data: {} }; + } + if (method === "DELETE" && requestPath === "/repos/acme/ade/git/refs/heads/feature/parent") { + return { data: {} }; + } + return { data: {} }; + }); + + const autoRebaseService = { + recordAttentionStatus: vi.fn(async () => undefined), + refreshActiveRebaseNeeds: vi.fn(async () => undefined), + }; + + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: laneService as any, + operationService: { + start: () => ({ operationId: "op-1" }), + finish: vi.fn(), + } as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: { + getEffective: () => ({ git: { autoRebaseOnHeadChange: true } }), + } as any, + conflictService: { scanRebaseNeeds: vi.fn(async () => []) } as any, + autoRebaseService: autoRebaseService as any, + rebaseSuggestionService: { refresh: vi.fn(async () => undefined) } as any, + openExternal: async () => {}, + }); + + const result = await service.land({ prId: "pr-parent", method: "squash", archiveLane: true }); + + expect(result).toMatchObject({ success: true, branchDeleted: true, laneArchived: true }); + expect(laneService.reparent).toHaveBeenCalledWith({ laneId: "lane-child", newParentLaneId: "lane-main" }); + expect(runGitOrThrowMock).toHaveBeenCalledWith( + ["push", "--force-with-lease"], + expect.objectContaining({ cwd: childLane.worktreePath }), + ); + expect(apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + method: "PATCH", + path: "/repos/acme/ade/pulls/202", + })); + expect(laneService.archive).toHaveBeenCalledWith({ laneId: "lane-parent" }); + expect(autoRebaseService.recordAttentionStatus).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-child", + state: "autoRebased", + })); + expect(autoRebaseService.refreshActiveRebaseNeeds).toHaveBeenCalledWith("merge_completed"); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("restores the child lane and skips cleanup when the auto-push fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-land-auto-rebase-fail-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + const { createPrService } = await createServiceModule(); + const projectId = "proj-land-auto-rebase-fail"; + + const mainLane = makeLane("lane-main", "main", "main", root, { laneType: "primary" }); + const parentLane = makeLane("lane-parent", "feature/parent", "feature/parent", path.join(root, "parent"), { + parentLaneId: mainLane.id, + }); + const childLane = makeLane("lane-child", "feature/child", "feature/child", path.join(root, "child"), { + parentLaneId: parentLane.id, + baseRef: "refs/heads/feature/parent", + }); + const lanes = [mainLane, parentLane, childLane]; + + await seedProject(db, projectId, root); + await seedPr(db, { + prId: "pr-parent", + projectId, + laneId: parentLane.id, + number: 101, + baseBranch: "main", + headBranch: "feature/parent", + title: "Parent PR", + }); + + const laneService = { + list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => + includeArchived ? lanes : lanes.filter((lane) => !lane.archivedAt) + ), + getChildren: vi.fn(async () => [childLane]), + reparent: vi.fn(async ({ laneId, newParentLaneId }: { laneId: string; newParentLaneId: string }) => { + childLane.parentLaneId = newParentLaneId; + childLane.baseRef = "main"; + return { + laneId, + previousParentLaneId: parentLane.id, + newParentLaneId, + previousBaseRef: "refs/heads/feature/parent", + newBaseRef: "main", + preHeadSha: "child-pre", + postHeadSha: "child-post", + }; + }), + archive: vi.fn(async () => undefined), + invalidateCache: vi.fn(), + }; + + runGitMock.mockResolvedValue({ exitCode: 0, stdout: "origin/feature/child\n", stderr: "" }); + runGitOrThrowMock.mockImplementation(async (args: string[]) => { + if (args[0] === "push") { + throw new Error("remote rejected push"); + } + if (args[0] === "reset") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + const apiRequest = vi.fn(async ({ method, path: requestPath }: { method: string; path: string }) => { + if (method === "PUT" && requestPath === "/repos/acme/ade/pulls/101/merge") { + return { data: { sha: "merge-sha" } }; + } + if (method === "DELETE" && requestPath === "/repos/acme/ade/git/refs/heads/feature/parent") { + return { data: {} }; + } + return { data: {} }; + }); + + const autoRebaseService = { + recordAttentionStatus: vi.fn(async () => undefined), + refreshActiveRebaseNeeds: vi.fn(async () => undefined), + }; + + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: laneService as any, + operationService: { + start: () => ({ operationId: "op-1" }), + finish: vi.fn(), + } as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: { + getEffective: () => ({ git: { autoRebaseOnHeadChange: true } }), + } as any, + conflictService: { scanRebaseNeeds: vi.fn(async () => []) } as any, + autoRebaseService: autoRebaseService as any, + rebaseSuggestionService: { refresh: vi.fn(async () => undefined) } as any, + openExternal: async () => {}, + }); + + const result = await service.land({ prId: "pr-parent", method: "squash", archiveLane: true }); + + expect(result).toMatchObject({ success: true, branchDeleted: false, laneArchived: false }); + expect(runGitOrThrowMock).toHaveBeenCalledWith( + ["reset", "--hard", "child-pre"], + expect.objectContaining({ cwd: childLane.worktreePath }), + ); + expect(laneService.archive).not.toHaveBeenCalled(); + expect(apiRequest).not.toHaveBeenCalledWith(expect.objectContaining({ + method: "DELETE", + path: "/repos/acme/ade/git/refs/heads/feature/parent", + })); + expect(autoRebaseService.recordAttentionStatus).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-child", + state: "rebaseFailed", + })); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 00232f7de..21cc1d90b 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -89,6 +89,7 @@ import type { import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; +import type { createAutoRebaseService } from "../lanes/autoRebaseService"; import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { createOperationService } from "../history/operationService"; import type { createGithubService } from "../github/githubService"; @@ -586,6 +587,7 @@ export function createPrService({ aiIntegrationService, projectConfigService, conflictService, + autoRebaseService, rebaseSuggestionService, openExternal, onHotRefreshChanged, @@ -600,6 +602,7 @@ export function createPrService({ aiIntegrationService?: ReturnType; projectConfigService: ReturnType; conflictService?: ReturnType; + autoRebaseService?: ReturnType | null; rebaseSuggestionService?: ReturnType | null; openExternal: (url: string) => Promise; onHotRefreshChanged?: () => void; @@ -690,6 +693,219 @@ export function createPrService({ return nextDelay; }; + const isAutoRebaseEnabled = (): boolean => { + try { + return Boolean(projectConfigService.getEffective()?.git?.autoRebaseOnHeadChange); + } catch { + return false; + } + }; + + const pushRebasedLane = async (lane: LaneSummary): Promise => { + const headBranch = branchNameFromRef(lane.branchRef); + const upstreamCheck = await runGit( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + { cwd: lane.worktreePath, timeoutMs: 10_000 } + ); + if (upstreamCheck.exitCode === 0) { + await runGitOrThrow(["push", "--force-with-lease"], { cwd: lane.worktreePath, timeoutMs: 60_000 }); + return; + } + await runGitOrThrow(["push", "-u", "origin", headBranch], { cwd: lane.worktreePath, timeoutMs: 60_000 }); + }; + + const restoreAutoRebaseChildLane = async (args: { + lane: LaneSummary; + previousParentLaneId: string | null; + previousBaseRef: string; + preHeadSha: string; + }): Promise => { + await runGitOrThrow(["reset", "--hard", args.preHeadSha], { cwd: args.lane.worktreePath, timeoutMs: 90_000 }); + db.run( + "update lanes set parent_lane_id = ?, base_ref = ? where id = ? and project_id = ?", + [args.previousParentLaneId, args.previousBaseRef, args.lane.id, projectId] + ); + laneService.invalidateCache?.(); + }; + + const advanceChildLanesAfterLand = async (args: { + landedLaneId: string; + landedLaneName: string; + }): Promise<{ + updatedLaneIds: string[]; + failedLaneIds: string[]; + blockCleanup: boolean; + }> => { + if (!isAutoRebaseEnabled()) { + return { updatedLaneIds: [], failedLaneIds: [], blockCleanup: false }; + } + + const allLanes = await laneService.list({ includeArchived: true }); + const allLanesById = new Map(allLanes.map((lane) => [lane.id, lane] as const)); + const landedLane = allLanes.find((lane) => lane.id === args.landedLaneId) ?? null; + const directChildren = await laneService.getChildren(args.landedLaneId); + if (directChildren.length === 0) { + return { updatedLaneIds: [], failedLaneIds: [], blockCleanup: false }; + } + + let successorParent = landedLane?.parentLaneId + ? allLanesById.get(landedLane.parentLaneId) ?? null + : null; + if (!successorParent || successorParent.archivedAt) { + successorParent = allLanes.find((lane) => lane.laneType === "primary" && !lane.archivedAt) ?? null; + } + + type AttentionStatus = { + laneId: string; + parentLaneId: string | null; + parentHeadSha: string | null; + state: "autoRebased" | "rebasePending" | "rebaseConflict" | "rebaseFailed"; + conflictCount: number; + message?: string | null; + }; + const recordAttentionStatusSafely = async (status: AttentionStatus, laneId: string): Promise => { + try { + await autoRebaseService?.recordAttentionStatus(status); + return true; + } catch (error) { + logger.warn("prs.child_auto_rebase_attention_status_failed", { + landedLaneId: args.landedLaneId, + childLaneId: laneId, + error: getErrorMessage(error), + }); + return false; + } + }; + + if (!successorParent) { + for (const child of directChildren) { + await recordAttentionStatusSafely({ + laneId: child.id, + parentLaneId: child.parentLaneId, + parentHeadSha: null, + state: "rebaseFailed", + conflictCount: 0, + message: `Auto-rebase failed after '${args.landedLaneName}' merged because ADE could not find a new parent lane. Open the Rebase tab to recover this lane.`, + }, child.id); + } + return { + updatedLaneIds: [], + failedLaneIds: directChildren.map((lane) => lane.id), + blockCleanup: true, + }; + } + + const successorBaseBranch = branchNameFromRef(successorParent.branchRef); + const updatedLaneIds: string[] = []; + const failedLaneIds: string[] = []; + + for (const child of directChildren) { + const previousParentLaneId = child.parentLaneId; + const previousBaseRef = child.baseRef; + const childPr = getRowForLane(child.id); + + let reparentResult: + | { + preHeadSha: string | null; + newParentLaneId: string; + } + | null = null; + + try { + reparentResult = await laneService.reparent({ + laneId: child.id, + newParentLaneId: successorParent.id, + }); + const refreshedChild = { + ...(allLanesById.get(child.id) ?? child), + parentLaneId: successorParent.id, + baseRef: successorParent.branchRef, + }; + allLanesById.set(child.id, refreshedChild); + await pushRebasedLane(refreshedChild); + if (childPr && childPr.base_branch !== successorBaseBranch) { + const retargetError = await retargetBase(childPr.id, successorBaseBranch).catch((error) => { + logger.warn("prs.child_auto_rebase_retarget_failed", { + landedLaneId: args.landedLaneId, + childLaneId: child.id, + prId: childPr.id, + error: getErrorMessage(error), + }); + return getErrorMessage(error); + }); + markHotRefresh([childPr.id]); + if (retargetError) { + failedLaneIds.push(child.id); + await recordAttentionStatusSafely({ + laneId: child.id, + parentLaneId: successorParent.id, + parentHeadSha: null, + state: "rebaseFailed", + conflictCount: 0, + message: `Auto-rebase pushed this lane after '${args.landedLaneName}' merged, but ADE could not retarget the PR base to '${successorBaseBranch}': ${retargetError}. The merged parent lane was left in place so you can finish cleanup manually.`, + }, child.id); + continue; + } + } + const recorded = await recordAttentionStatusSafely({ + laneId: child.id, + parentLaneId: successorParent.id, + parentHeadSha: null, + state: "autoRebased", + conflictCount: 0, + message: `Rebased and pushed automatically after '${args.landedLaneName}' merged.`, + }, child.id); + if (!recorded) { + failedLaneIds.push(child.id); + continue; + } + updatedLaneIds.push(child.id); + } catch (error) { + const childError = getErrorMessage(error); + let rollbackError: string | null = null; + if (reparentResult?.preHeadSha) { + try { + await restoreAutoRebaseChildLane({ + lane: child, + previousParentLaneId, + previousBaseRef, + preHeadSha: reparentResult.preHeadSha, + }); + allLanesById.set(child.id, { + ...(allLanesById.get(child.id) ?? child), + parentLaneId: previousParentLaneId, + baseRef: previousBaseRef, + }); + } catch (restoreError) { + rollbackError = getErrorMessage(restoreError); + logger.warn("prs.child_auto_rebase_restore_failed", { + landedLaneId: args.landedLaneId, + childLaneId: child.id, + error: rollbackError, + }); + } + } + await recordAttentionStatusSafely({ + laneId: child.id, + parentLaneId: previousParentLaneId, + parentHeadSha: null, + state: "rebaseFailed", + conflictCount: 0, + message: rollbackError + ? `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to recover this lane.` + : `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. The lane was restored to its pre-rebase state. Open the Rebase tab to recover this lane.`, + }, child.id); + failedLaneIds.push(child.id); + } + } + + return { + updatedLaneIds, + failedLaneIds, + blockCleanup: failedLaneIds.length > 0, + }; + }; + const upsertSnapshotRow = (args: { prId: string; detail?: PrDetail | null; @@ -1725,7 +1941,7 @@ export function createPrService({ const headBranch = branchNameFromRef(lane.branchRef); const parentLane = lane.parentLaneId ? allLanes.find((entry) => entry.id === lane.parentLaneId) ?? null : null; const primaryLane = allLanes.find((entry) => entry.laneType === "primary") ?? null; - const inferredBaseRef = parentLane?.branchRef ?? (lane.parentLaneId ? lane.baseRef : (primaryLane?.branchRef ?? lane.baseRef)); + const inferredBaseRef = parentLane?.branchRef ?? lane.baseRef ?? primaryLane?.branchRef ?? "main"; const baseBranch = (args.baseBranch ?? branchNameFromRef(inferredBaseRef)).trim(); // Push the branch to remote before creating the PR @@ -1884,18 +2100,9 @@ export function createPrService({ const headBranch = row.head_branch; let branchDeleted = false; let laneArchived = false; + let childAutoRebaseBlockedCleanup = false; try { - try { - await githubService.apiRequest({ - method: "DELETE", - path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` - }); - branchDeleted = true; - } catch (error) { - logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: getErrorMessage(error) }); - } - // Remove PR from any group membership before archiving (lane archive blocks if still in a group) try { db.run("delete from pr_group_members where pr_id = ?", [row.id]); @@ -1903,15 +2110,6 @@ export function createPrService({ logger.warn("prs.group_membership_cleanup_failed", { prId: row.id, error: getErrorMessage(groupErr) }); } - if (args.archiveLane) { - try { - await laneService.archive({ laneId: row.lane_id }); - laneArchived = true; - } catch (archiveErr) { - logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: getErrorMessage(archiveErr) }); - } - } - await fetchRemoteTrackingBranch({ projectRoot, targetBranch: row.base_branch, @@ -1931,10 +2129,61 @@ export function createPrService({ }); } + const childAdvanceResult = await advanceChildLanesAfterLand({ + landedLaneId: row.lane_id, + landedLaneName: row.title?.trim() || row.head_branch, + }).catch((error) => { + logger.warn("prs.child_auto_rebase_failed", { + prId: row.id, + laneId: row.lane_id, + error: getErrorMessage(error), + }); + return { + updatedLaneIds: [], + failedLaneIds: [], + blockCleanup: true, + }; + }); + childAutoRebaseBlockedCleanup = childAdvanceResult.blockCleanup; + + if (!childAutoRebaseBlockedCleanup) { + try { + await githubService.apiRequest({ + method: "DELETE", + path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` + }); + branchDeleted = true; + } catch (error) { + logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: getErrorMessage(error) }); + } + + if (args.archiveLane) { + try { + await laneService.archive({ laneId: row.lane_id }); + laneArchived = true; + } catch (archiveErr) { + logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: getErrorMessage(archiveErr) }); + } + } + } else { + logger.warn("prs.post_merge_cleanup_blocked", { + prId: row.id, + laneId: row.lane_id, + failedLaneIds: childAdvanceResult.failedLaneIds, + }); + } + operationService.finish({ operationId: op.operationId, status: "succeeded", - metadataPatch: { mergeCommitSha, branchDeleted, laneArchived } + metadataPatch: { + mergeCommitSha, + branchDeleted, + laneArchived, + childAutoRebaseBlockedCleanup, + autoRebasedChildLaneIds: childAdvanceResult.updatedLaneIds, + failedAutoRebaseChildLaneIds: childAdvanceResult.failedLaneIds, + } }); markHotRefresh([row.id]); @@ -1951,6 +2200,12 @@ export function createPrService({ error: getErrorMessage(error), }); }); + await autoRebaseService?.refreshActiveRebaseNeeds("merge_completed").catch((error) => { + logger.warn("prs.refresh_auto_rebase_failed", { + prId: row.id, + error: getErrorMessage(error), + }); + }); } catch (cleanupError) { // The merge itself succeeded -- cleanup failure must not mask that. const cleanupMsg = getErrorMessage(cleanupError); @@ -1964,7 +2219,13 @@ export function createPrService({ operationService.finish({ operationId: op.operationId, status: "succeeded", - metadataPatch: { mergeCommitSha, branchDeleted, laneArchived, cleanupError: cleanupMsg } + metadataPatch: { + mergeCommitSha, + branchDeleted, + laneArchived, + childAutoRebaseBlockedCleanup, + cleanupError: cleanupMsg, + } }); } catch { /* already finished or double-finish -- ignore */ } } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index c20655f0d..63d327b8b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { EventEmitter } from "node:events"; +import path from "node:path"; import type { IPty } from "node-pty"; // --------------------------------------------------------------------------- @@ -8,11 +9,32 @@ import type { IPty } from "node-pty"; const mocks = vi.hoisted(() => { const existsSyncResults = new Map(); + const realpathOverrides = new Map(); return { existsSyncResults, + realpathOverrides, mkdirSync: vi.fn(), existsSync: vi.fn((p: string) => existsSyncResults.get(p) ?? true), - statSync: vi.fn(() => ({ size: 0 })), + lstatSync: vi.fn((p: string) => { + if ((existsSyncResults.get(p) ?? true) === false) { + const error = new Error(`ENOENT: no such file or directory, lstat '${p}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return { isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }; + }), + realpathSync: Object.assign( + vi.fn((p: string) => p), + { native: vi.fn((p: string) => p) }, + ), + statSync: vi.fn((p: string) => { + if ((existsSyncResults.get(p) ?? true) === false) { + const error = new Error(`ENOENT: no such file or directory, stat '${p}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return { size: 0, isDirectory: () => true }; + }), createWriteStream: vi.fn(() => ({ write: vi.fn(), end: vi.fn(), @@ -44,6 +66,8 @@ const mocks = vi.hoisted(() => { vi.mock("node:fs", () => ({ default: { existsSync: mocks.existsSync, + lstatSync: mocks.lstatSync, + realpathSync: mocks.realpathSync, mkdirSync: mocks.mkdirSync, statSync: mocks.statSync, createWriteStream: mocks.createWriteStream, @@ -51,6 +75,8 @@ vi.mock("node:fs", () => ({ writeFileSync: mocks.writeFileSync, }, existsSync: mocks.existsSync, + lstatSync: mocks.lstatSync, + realpathSync: mocks.realpathSync, mkdirSync: mocks.mkdirSync, statSync: mocks.statSync, createWriteStream: mocks.createWriteStream, @@ -130,7 +156,12 @@ function createMockPty(): IPty & { _emitter: EventEmitter } { } as any; } -function createHarness() { +function createHarness(overrides: { + aiIntegrationService?: { + getMode: ReturnType; + summarizeTerminal: ReturnType; + } | null; +} = {}) { const mockPty = createMockPty(); const broadcastData = vi.fn(); const broadcastExit = vi.fn(); @@ -178,6 +209,7 @@ function createHarness() { transcriptsDir: "/tmp/transcripts", laneService: laneService as any, sessionService: sessionService as any, + ...(overrides.aiIntegrationService ? { aiIntegrationService: overrides.aiIntegrationService as any } : {}), logger: logger as any, broadcastData, broadcastExit, @@ -208,6 +240,10 @@ describe("ptyService", () => { beforeEach(() => { vi.clearAllMocks(); mocks.existsSyncResults.clear(); + mocks.realpathOverrides.clear(); + const resolveRealpath = (p: string) => mocks.realpathOverrides.get(p) ?? path.resolve(p); + mocks.realpathSync.mockImplementation((p: string) => resolveRealpath(p)); + mocks.realpathSync.native.mockImplementation((p: string) => resolveRealpath(p)); mocks.existsSyncResults.set("/tmp/test-worktree", true); let counter = 0; mocks.randomUUID.mockImplementation(() => `uuid-${++counter}`); @@ -247,27 +283,77 @@ describe("ptyService", () => { ); }); - it("uses projectRoot as fallback cwd when worktree does not exist", async () => { + it("rejects terminal launches when the lane worktree does not exist", async () => { mocks.existsSyncResults.set("/tmp/test-worktree", false); - const { service, logger, loadPty } = createHarness(); + const { service, loadPty } = createHarness(); + await expect(service.create({ + laneId: "lane-1", + title: "Missing worktree", + cols: 80, + rows: 24, + })).rejects.toThrow(/worktree is unavailable/i); + expect(loadPty).not.toHaveBeenCalled(); + }); + + it("uses an explicit cwd when it stays inside the selected lane worktree", async () => { + mocks.existsSyncResults.set("/tmp/test-worktree/subdir", true); + const { service, loadPty } = createHarness(); await service.create({ laneId: "lane-1", - title: "Fallback cwd", + cwd: "/tmp/test-worktree/subdir", + title: "Subdir terminal", cols: 80, rows: 24, }); - expect(logger.warn).toHaveBeenCalledWith( - "pty.cwd_missing_fallback", - expect.objectContaining({ fallbackCwd: "/tmp/test-project" }), - ); const spawnCall = loadPty.mock.results[0].value.spawn; expect(spawnCall).toHaveBeenCalledWith( expect.any(String), expect.any(Array), - expect.objectContaining({ cwd: "/tmp/test-project" }), + expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), ); }); + it("rejects an explicit cwd outside the selected lane worktree", async () => { + mocks.existsSyncResults.set("/tmp/outside", true); + const { service, loadPty } = createHarness(); + await expect(service.create({ + laneId: "lane-1", + cwd: "/tmp/outside", + title: "Escaping terminal", + cols: 80, + rows: 24, + })).rejects.toThrow(/escapes lane/i); + expect(loadPty).not.toHaveBeenCalled(); + }); + + it("rejects a cwd whose realpath hops outside the lane worktree", async () => { + const childPath = "/tmp/test-worktree/hop-child"; + mocks.existsSyncResults.set(childPath, true); + mocks.realpathOverrides.set(childPath, "/private/tmp/hop-child"); + const { service, loadPty } = createHarness(); + await expect(service.create({ + laneId: "lane-1", + cwd: childPath, + title: "Realpath hop", + cols: 80, + rows: 24, + })).rejects.toThrow(/escapes lane/i); + expect(loadPty).not.toHaveBeenCalled(); + }); + + it("preserves non-escape cwd errors instead of rewriting them as lane escapes", async () => { + mocks.existsSyncResults.set("/tmp/test-worktree/missing", false); + const { service, loadPty } = createHarness(); + await expect(service.create({ + laneId: "lane-1", + cwd: "/tmp/test-worktree/missing", + title: "Missing cwd", + cols: 80, + rows: 24, + })).rejects.toThrow(/path does not exist/i); + expect(loadPty).not.toHaveBeenCalled(); + }); + it("clamps very small dimensions to minimum values", async () => { const { service, loadPty } = createHarness(); await service.create({ @@ -353,6 +439,41 @@ describe("ptyService", () => { expect.objectContaining({ toolType: null }), ); }); + + it("uses the bound cwd for AI title generation even if the lane mapping changes later", async () => { + vi.useFakeTimers(); + try { + mocks.existsSyncResults.set("/tmp/test-worktree/subdir", true); + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "Bound title" })), + }; + const { service, mockPty, laneService } = createHarness({ aiIntegrationService }); + await service.create({ + laneId: "lane-1", + cwd: "/tmp/test-worktree/subdir", + title: "Claude session", + cols: 80, + rows: 24, + toolType: "claude", + }); + + laneService.getLaneBaseAndBranch.mockReturnValue({ + worktreePath: "/tmp/other-worktree", + baseRef: "origin/main", + branchRef: "feature/moved", + }); + + mockPty._emitter.emit("data", "generated enough output for a better title"); + await vi.advanceTimersByTimeAsync(4000); + + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), + ); + } finally { + vi.useRealTimers(); + } + }); }); describe("write", () => { @@ -473,6 +594,37 @@ describe("ptyService", () => { expect(logger.warn).toHaveBeenCalledWith("pty.dispose_orphaned", expect.any(Object)); }); + it("uses the bound cwd for AI summaries after exit even if the lane mapping changes later", async () => { + mocks.existsSyncResults.set("/tmp/test-worktree/subdir", true); + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "Bound summary" })), + }; + const { service, mockPty, laneService } = createHarness({ aiIntegrationService }); + await service.create({ + laneId: "lane-1", + cwd: "/tmp/test-worktree/subdir", + title: "Summary session", + cols: 80, + rows: 24, + }); + + laneService.getLaneBaseAndBranch.mockReturnValue({ + worktreePath: "/tmp/other-worktree", + baseRef: "origin/main", + branchRef: "feature/moved", + }); + + mockPty._emitter.emit("exit", { exitCode: 0 }); + await vi.waitFor(() => { + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); + }); + + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), + ); + }); + it("silently ignores dispose for completely unknown pty/session", () => { const { service } = createHarness(); expect(() => service.dispose({ ptyId: "non-existent" })).not.toThrow(); @@ -524,6 +676,52 @@ describe("ptyService", () => { expect.objectContaining({ sessionId, exitCode: null, status: "completed" }), ); }); + + it("does not auto-close user-launched Claude sessions when they become waiting-input", async () => { + vi.useFakeTimers(); + try { + mocks.runtimeStateFromOsc133Chunk.mockReturnValue("waiting-input"); + const { service, mockPty } = createHarness(); + await service.create({ laneId: "lane-1", title: "Claude", cols: 80, rows: 24, toolType: "claude" }); + + await vi.advanceTimersByTimeAsync(6000); + mockPty._emitter.emit("data", "\u001b]133;A\u0007"); + await vi.advanceTimersByTimeAsync(2000); + + expect(mockPty.kill).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("still auto-closes orchestrated worker sessions after the wrapped CLI exits", async () => { + vi.useFakeTimers(); + try { + mocks.runtimeStateFromOsc133Chunk.mockReturnValue("waiting-input"); + const { service, mockPty, logger } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Claude worker", + cols: 80, + rows: 24, + toolType: "claude-orchestrated", + }); + + await vi.advanceTimersByTimeAsync(6000); + mockPty._emitter.emit("data", "\u001b]133;A\u0007"); + await vi.advanceTimersByTimeAsync(1499); + expect(mockPty.kill).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(mockPty.kill).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + "pty.tool_exit_auto_close", + expect.objectContaining({ sessionId, toolType: "claude-orchestrated" }), + ); + } finally { + vi.useRealTimers(); + } + }); }); describe("spawn failure handling", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index c9f1251ea..bddb3ca92 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -5,6 +5,7 @@ import type { IPty, IWindowsPtyForkOptions } from "node-pty"; import type * as ptyNs from "node-pty"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; +import { resolveLaneLaunchContext } from "../lanes/laneLaunchContext"; import type { createSessionService } from "../sessions/sessionService"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createProjectConfigService } from "../config/projectConfigService"; @@ -34,6 +35,8 @@ import { type PtyEntry = { pty: IPty; laneId: string; + laneWorktreePath: string; + boundCwd: string; sessionId: string; tracked: boolean; transcriptPath: string; @@ -236,10 +239,11 @@ export function createPtyService({ return ai?.sessionIntelligence; }; - /** Tool types that run a CLI tool inside the shell and should auto-close when the tool exits */ + /** Only orchestrated worker sessions auto-close after the wrapped CLI exits back to shell. */ const TOOL_TYPES_WITH_AUTO_CLOSE = new Set([ - "claude", "codex", "claude-orchestrated", "codex-orchestrated", - "aider", "cursor", "continue" + "claude-orchestrated", + "codex-orchestrated", + "ai-orchestrated" ]); const clearToolAutoCloseTimer = (ptyId: string) => { @@ -301,7 +305,18 @@ export function createPtyService({ return sha.length ? sha : null; }; - const summarizeSessionBestEffort = (sessionId: string): void => { + const summarizeSessionBestEffort = ( + sessionId: string, + context?: { laneWorktreePath?: string | null; boundCwd?: string | null }, + ): void => { + const entryContext = Array.from(ptys.values()).find((entry) => entry.sessionId === sessionId) ?? null; + const summaryCwd = ( + context?.boundCwd + ?? context?.laneWorktreePath + ?? entryContext?.boundCwd + ?? entryContext?.laneWorktreePath + ?? "" + ).trim(); Promise.resolve() .then(async () => { const session = sessionService.get(sessionId); @@ -325,7 +340,6 @@ export function createPtyService({ if (si?.summaries?.enabled === false) return; if (!aiIntegrationService || aiIntegrationService.getMode() === "guest") return; - const lane = laneService.getLaneBaseAndBranch(session.laneId); const prompt = [ "You are ADE's terminal summary assistant.", "Rewrite this terminal session into a concise 1-3 sentence summary with outcome and next action.", @@ -343,7 +357,7 @@ export function createPtyService({ : undefined; const aiSummary = await aiIntegrationService.summarizeTerminal({ - cwd: lane.worktreePath, + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, prompt, ...(summaryModelId ? { model: summaryModelId } : {}), }); @@ -390,13 +404,15 @@ export function createPtyService({ } catch { // ignore callback failures } - summarizeSessionBestEffort(entry.sessionId); + summarizeSessionBestEffort(entry.sessionId, { + laneWorktreePath: entry.laneWorktreePath, + boundCwd: entry.boundCwd, + }); // Best-effort head SHA at end; never block exit. Promise.resolve() .then(async () => { - const { worktreePath } = laneService.getLaneBaseAndBranch(entry.laneId); - const sha = await computeHeadShaBestEffort(worktreePath); + const sha = await computeHeadShaBestEffort(entry.boundCwd || entry.laneWorktreePath); if (sha) sessionService.setHeadShaEnd(entry.sessionId, sha); }) .catch(() => {}) @@ -500,11 +516,13 @@ export function createPtyService({ return { async create(args: PtyCreateArgs): Promise { const { laneId, title } = args; - const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const cwd = fs.existsSync(worktreePath) ? worktreePath : projectRoot; - if (cwd !== worktreePath) { - logger.warn("pty.cwd_missing_fallback", { laneId, missingCwd: worktreePath, fallbackCwd: cwd }); - } + const launchContext = resolveLaneLaunchContext({ + laneService, + laneId, + requestedCwd: args.cwd, + purpose: "start a terminal session", + }); + const { laneWorktreePath: worktreePath, cwd } = launchContext; const { cols, rows } = clampDims(args.cols, args.rows); const ptyId = randomUUID(); @@ -552,7 +570,7 @@ export function createPtyService({ // Best-effort head SHA at start; do not block terminal creation. Promise.resolve() .then(async () => { - const sha = await computeHeadShaBestEffort(worktreePath); + const sha = await computeHeadShaBestEffort(cwd || worktreePath); if (sha) sessionService.setHeadShaStart(sessionId, sha); }) .catch(() => {}); @@ -626,7 +644,10 @@ export function createPtyService({ clearIdleTimer(sessionId); setRuntimeState(sessionId, "exited", { touch: false }); runtimeStates.delete(sessionId); - summarizeSessionBestEffort(sessionId); + summarizeSessionBestEffort(sessionId, { + laneWorktreePath: worktreePath, + boundCwd: cwd, + }); broadcastExit({ ptyId, sessionId, exitCode: null }); throw err; } @@ -634,6 +655,8 @@ export function createPtyService({ const entry: PtyEntry = { pty, laneId, + laneWorktreePath: worktreePath, + boundCwd: cwd, sessionId, tracked, transcriptPath, @@ -765,7 +788,6 @@ export function createPtyService({ const toolType = session.toolType; if (!toolType || toolType === "shell") return; - const lane = laneService.getLaneBaseAndBranch(laneId); const prompt = [ "Generate a concise terminal session title.", "Return only plain text, max 80 characters, no punctuation at the end.", @@ -780,7 +802,7 @@ export function createPtyService({ capturedAi .summarizeTerminal({ - cwd: lane.worktreePath, + cwd: entry.boundCwd || entry.laneWorktreePath, prompt, timeoutMs: 8_000, ...(titleModelId ? { model: titleModelId } : {}), @@ -907,7 +929,10 @@ export function createPtyService({ } catch { // ignore callback failures } - summarizeSessionBestEffort(entry.sessionId); + summarizeSessionBestEffort(entry.sessionId, { + laneWorktreePath: entry.laneWorktreePath, + boundCwd: entry.boundCwd, + }); broadcastExit({ ptyId, sessionId: entry.sessionId, exitCode: null }); ptys.delete(ptyId); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 235aa534c..ea36ff74c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -9,7 +9,7 @@ import type { UpdateSessionMetaArgs } from "../../../shared/types"; import { stripAnsi } from "../../utils/ansiStrip"; -import { defaultResumeCommandForTool } from "../../utils/terminalSessionSignals"; +import { defaultResumeCommandForTool, normalizeResumeCommand } from "../../utils/terminalSessionSignals"; type SessionRow = { id: string; @@ -84,16 +84,19 @@ export function createSessionService({ db }: { db: AdeDb }) { return (allowed as string[]).includes(value) ? (value as TerminalToolType) : "other"; }; - const mapRow = (row: SessionRow) => ({ - ...row, - tracked: row.tracked === 1, - pinned: row.pinned === 1, - goal: row.goal ?? null, - toolType: normalizeToolType(row.toolType), - summary: row.summary ?? null, - runtimeState: runtimeStateFromStatus(row.status), - resumeCommand: row.resumeCommand ?? null, - }); + const mapRow = (row: SessionRow) => { + const toolType = normalizeToolType(row.toolType); + return { + ...row, + tracked: row.tracked === 1, + pinned: row.pinned === 1, + goal: row.goal ?? null, + toolType, + summary: row.summary ?? null, + runtimeState: runtimeStateFromStatus(row.status), + resumeCommand: normalizeResumeCommand(row.resumeCommand, toolType), + }; + }; const list =({ laneId, status, limit }: { laneId?: string; status?: TerminalSessionStatus; limit?: number } = {}) => { const where: string[] = []; @@ -169,6 +172,7 @@ export function createSessionService({ db }: { db: AdeDb }) { updateMeta(args: UpdateSessionMetaArgs): TerminalSessionSummary | null { const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; if (!sessionId) return null; + const currentSession = this.get(sessionId); const sets: string[] = []; const params: (string | number | null)[] = []; @@ -198,9 +202,15 @@ export function createSessionService({ db }: { db: AdeDb }) { } if (args.resumeCommand !== undefined) { - const next = typeof args.resumeCommand === "string" ? args.resumeCommand.trim() : ""; + const preferredToolType = args.toolType !== undefined + ? normalizeToolType(args.toolType) + : currentSession?.toolType ?? null; + const next = normalizeResumeCommand( + args.resumeCommand, + preferredToolType, + ); sets.push("resume_command = ?"); - params.push(next || null); + params.push(next); } if (sets.length) { @@ -215,7 +225,7 @@ export function createSessionService({ db }: { db: AdeDb }) { if (args.toolType !== undefined && !updated.resumeCommand) { const fallback = defaultResumeCommandForTool(updated.toolType); if (fallback) { - db.run("update terminal_sessions set resume_command = ? where id = ? and resume_command is null", [fallback, sessionId]); + db.run("update terminal_sessions set resume_command = ? where id = ?", [fallback, sessionId]); const withResume = this.get(sessionId); return withResume ?? updated; } @@ -245,10 +255,10 @@ export function createSessionService({ db }: { db: AdeDb }) { resumeCommand?: string | null; }): void { const normalizedToolType = normalizeToolType(toolType); - const normalizedResumeCommand = - typeof resumeCommand === "string" && resumeCommand.trim().length - ? resumeCommand.trim() - : defaultResumeCommandForTool(normalizedToolType); + const normalizedResumeCommand = normalizeResumeCommand( + resumeCommand, + normalizedToolType, + ) ?? defaultResumeCommandForTool(normalizedToolType); db.run( ` insert into terminal_sessions( @@ -303,8 +313,9 @@ export function createSessionService({ db }: { db: AdeDb }) { }, setResumeCommand(sessionId: string, resumeCommand: string | null): void { - const next = typeof resumeCommand === "string" ? resumeCommand.trim() : ""; - db.run("update terminal_sessions set resume_command = ? where id = ?", [next || null, sessionId]); + const currentSession = this.get(sessionId); + const next = normalizeResumeCommand(resumeCommand, currentSession?.toolType ?? null); + db.run("update terminal_sessions set resume_command = ? where id = ?", [next, sessionId]); }, end({ diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 99d816eca..bbe87f620 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -232,6 +232,7 @@ function parseRebaseStartArgs(value: Record): RebaseStartArgs { ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), + ...(asTrimmedString(value.baseBranchOverride) ? { baseBranchOverride: asTrimmedString(value.baseBranchOverride)! } : {}), }; } diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index 589a733bb..53e1a1957 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest"; import { defaultResumeCommandForTool, extractResumeCommandFromOutput, + normalizeResumeCommand, runtimeStateFromOsc133Chunk } from "./terminalSessionSignals"; describe("terminalSessionSignals", () => { - it("extracts concrete resume command from backticks", () => { + it("extracts and normalizes concrete Claude resume commands from backticks", () => { const chunk = "Resume with `claude resume 01HF4F5J1A3R8NBV3K` whenever needed."; - expect(extractResumeCommandFromOutput(chunk, "claude")).toBe("claude resume 01HF4F5J1A3R8NBV3K"); + expect(extractResumeCommandFromOutput(chunk, "claude")).toBe("claude --resume 01HF4F5J1A3R8NBV3K"); }); it("extracts plain resume command lines", () => { @@ -18,12 +19,17 @@ describe("terminalSessionSignals", () => { it("respects preferred tool when both tools appear", () => { const chunk = [ - "claude resume abc", + "claude --resume abc", "codex resume def" ].join("\n"); expect(extractResumeCommandFromOutput(chunk, "codex")).toBe("codex resume def"); }); + it("normalizes legacy Claude resume commands stored in older sessions", () => { + expect(normalizeResumeCommand("claude resume abc123", "claude")).toBe("claude --resume abc123"); + expect(normalizeResumeCommand("claude -r abc123", "claude")).toBe("claude --resume abc123"); + }); + it("maps OSC 133 prompt markers to waiting-input", () => { const marker = "\u001b]133;A\u0007"; expect(runtimeStateFromOsc133Chunk(marker, "running")).toBe("waiting-input"); @@ -35,7 +41,7 @@ describe("terminalSessionSignals", () => { }); it("returns default resume command for known tools", () => { - expect(defaultResumeCommandForTool("claude")).toBe("claude resume"); + expect(defaultResumeCommandForTool("claude")).toBe("claude --resume"); expect(defaultResumeCommandForTool("codex")).toBe("codex resume"); expect(defaultResumeCommandForTool("shell")).toBeNull(); }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 30843be30..85bfd69e9 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -1,8 +1,8 @@ import type { TerminalRuntimeState, TerminalToolType } from "../../shared/types"; const OSC_133_REGEX = /\u001b\]133;([ABCD])(?:;[^\u0007\u001b]*)?(?:\u0007|\u001b\\)/g; -const RESUME_BACKTICK_REGEX = /`((?:claude|codex)\s+(?:resume|--resume\b)[^`\r\n]*)`/gi; -const RESUME_PLAIN_REGEX = /\b((?:claude|codex)\s+(?:resume|--resume\b)[^\r\n]*)/gi; +const RESUME_BACKTICK_REGEX = /`((?:claude|codex)\s+(?:(?:--resume|-r|resume)\b)[^`\r\n]*)`/gi; +const RESUME_PLAIN_REGEX = /\b((?:claude|codex)\s+(?:(?:--resume|-r|resume)\b)[^\r\n]*?(?=\s+(?:claude|codex)\s|$))/gi; function normalizeCommand(raw: string): string { return raw @@ -19,14 +19,38 @@ function toolFromCommand(raw: string): TerminalToolType | null { return null; } +function canonicalizePreferredTool(preferredTool: TerminalToolType | null | undefined): TerminalToolType | null | undefined { + if (preferredTool === "claude-orchestrated") return "claude"; + if (preferredTool === "codex-orchestrated") return "codex"; + return preferredTool; +} + function prefersTool(raw: string, preferredTool: TerminalToolType | null | undefined): boolean { - if (!preferredTool || (preferredTool !== "claude" && preferredTool !== "codex")) return true; + const canonicalPreferredTool = canonicalizePreferredTool(preferredTool); + if (!canonicalPreferredTool || (canonicalPreferredTool !== "claude" && canonicalPreferredTool !== "codex")) return true; const cmdTool = toolFromCommand(raw); - return cmdTool === preferredTool; + return cmdTool === canonicalPreferredTool; +} + +export function normalizeResumeCommand( + raw: string | null | undefined, + preferredTool?: TerminalToolType | null, +): string | null { + const normalized = normalizeCommand(raw ?? ""); + if (!normalized) return null; + if (!prefersTool(normalized, preferredTool)) return null; + + if (/^claude\s+/i.test(normalized)) { + return normalized + .replace(/^claude\s+resume\b/i, "claude --resume") + .replace(/^claude\s+-r\b/i, "claude --resume"); + } + + return normalized; } export function defaultResumeCommandForTool(toolType: TerminalToolType | null | undefined): string | null { - if (toolType === "claude" || toolType === "claude-orchestrated") return "claude resume"; + if (toolType === "claude" || toolType === "claude-orchestrated") return "claude --resume"; if (toolType === "codex" || toolType === "codex-orchestrated") return "codex resume"; return null; } @@ -38,17 +62,17 @@ export function extractResumeCommandFromOutput( if (!text.trim()) return null; const fromBackticks = Array.from(text.matchAll(RESUME_BACKTICK_REGEX)) - .map((m) => normalizeCommand(m[1] ?? "")) + .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) .filter(Boolean); for (const candidate of fromBackticks) { - if (prefersTool(candidate, preferredTool)) return candidate; + return candidate; } const fromPlain = Array.from(text.matchAll(RESUME_PLAIN_REGEX)) - .map((m) => normalizeCommand(m[1] ?? "")) + .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) .filter(Boolean); for (const candidate of fromPlain) { - if (prefersTool(candidate, preferredTool)) return candidate; + return candidate; } return null; diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 09f87e717..51d0b926d 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -2611,6 +2611,7 @@ function GraphInner() { const autoRebase = autoRebaseByLaneId[selectedLane.id] ?? null; if (remoteSync?.diverged) return { label: "Diverged", tone: "text-red-300" }; if (autoRebase?.state === "rebaseConflict") return { label: "Rebase conflict", tone: "text-red-300" }; + if (autoRebase?.state === "rebaseFailed") return { label: "Rebase failed", tone: "text-red-300" }; if (autoRebase?.state === "rebasePending") return { label: "Rebase pending", tone: "text-amber-300" }; if (remoteSync && ((remoteSync.hasUpstream === false) || remoteSync.ahead > 0)) { return { label: remoteSync.hasUpstream === false ? "Publish lane" : "Needs push", tone: "text-emerald-300" }; diff --git a/apps/desktop/src/renderer/components/graph/graphNodes/LaneNode.tsx b/apps/desktop/src/renderer/components/graph/graphNodes/LaneNode.tsx index b5c65718a..19951e7c6 100644 --- a/apps/desktop/src/renderer/components/graph/graphNodes/LaneNode.tsx +++ b/apps/desktop/src/renderer/components/graph/graphNodes/LaneNode.tsx @@ -25,6 +25,7 @@ export function GraphLaneNode({ data, selected }: NodeProps> const syncBadge = (() => { if (remoteDiverged) return { label: "Diverged", className: "text-red-300" }; if (autoRebase?.state === "rebaseConflict") return { label: "Rebase conflict", className: "text-red-300" }; + if (autoRebase?.state === "rebaseFailed") return { label: "Rebase failed", className: "text-red-300" }; if (autoRebase?.state === "rebasePending") return { label: "Rebase pending", className: "text-amber-300" }; if (remoteNeedsPublish) return { label: remoteSync?.hasUpstream === false ? "Publish lane" : "Needs push", className: "text-emerald-300" }; if (remoteNeedsPull) return { label: "Needs pull", className: "text-sky-300" }; diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 7ecf1471e..b5052ec67 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -140,7 +140,7 @@ export function CreateLaneDialog({
Starting point
- Choose whether the new lane starts from primary or from another lane in the stack. + Choose whether the new lane starts as its own branch from primary or stacks under another lane.
@@ -217,7 +217,7 @@ export function CreateLaneDialog({
)}
- Lane will be created from primary/{createBaseBranch || "..."}. + Lane will be created from primary/{createBaseBranch || "..."} as its own lane, not stacked under Primary.
)} diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index 165b43205..2f42ea851 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -18,6 +18,16 @@ let mockStoreState: { selectLane: ReturnType; }; +let mockAutoRebaseStatuses: Array<{ + laneId: string; + parentLaneId: string | null; + parentHeadSha: string | null; + state: "autoRebased" | "rebasePending" | "rebaseConflict" | "rebaseFailed"; + updatedAt: string; + conflictCount: number; + message: string | null; +}> = []; + vi.mock("../../state/appStore", () => ({ useAppStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), })); @@ -85,6 +95,7 @@ describe("LaneGitActionsPane rescue action", () => { diverged: false, recommendedAction: "push", }; + mockAutoRebaseStatuses = []; globalThis.window.ade = { diff: { @@ -96,7 +107,7 @@ describe("LaneGitActionsPane rescue action", () => { getConflictState: vi.fn(async () => mockConflictState), }, lanes: { - listAutoRebaseStatuses: vi.fn(async () => []), + listAutoRebaseStatuses: vi.fn(async () => mockAutoRebaseStatuses), onAutoRebaseEvent: vi.fn(() => () => undefined), createFromUnstaged: vi.fn(async () => buildLane({ id: "lane-2", name: "Rescue lane", status: { dirty: true, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false } })), }, @@ -115,7 +126,7 @@ describe("LaneGitActionsPane rescue action", () => { } }); - function renderPane() { + function renderPane(overrides?: Partial>) { render( { selectedPath={null} selectedMode={null} selectedCommitSha={null} + {...overrides} /> , ); @@ -182,4 +194,30 @@ describe("LaneGitActionsPane rescue action", () => { expect((rescueButton as HTMLButtonElement).disabled).toBe(true); expect(rescueButton.getAttribute("title")).toMatch(/finish the current merge/i); }); + + it("treats auto-rebase conflicts as failures and links to the Rebase tab", async () => { + const user = userEvent.setup(); + const resolveRebaseConflict = vi.fn(); + mockAutoRebaseStatuses = [ + { + laneId: "lane-1", + parentLaneId: "lane-main", + parentHeadSha: "parent-sha", + state: "rebaseConflict", + updatedAt: "2026-03-30T12:00:00.000Z", + conflictCount: 2, + message: "Files need follow-up before this lane can be pushed.", + }, + ]; + + renderPane({ onResolveRebaseConflict: resolveRebaseConflict }); + + const rebaseTabButton = await screen.findByRole("button", { name: /open rebase tab/i }); + screen.getByText("AUTO-REBASE FAILED"); + screen.getByText(/auto-rebase failed\. files need follow-up before this lane can be pushed\./i); + + await user.click(rebaseTabButton); + + expect(resolveRebaseConflict).toHaveBeenCalledWith("lane-1", "lane-main"); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 1bee74122..d47add419 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -122,20 +122,22 @@ function getAutoRebaseBannerConfig(state: AutoRebaseLaneStatus["state"]): { return { color: COLORS.success, label: "AUTO REBASED", - fallbackMessage: "Lane was rebased automatically." + fallbackMessage: "Lane was rebased and pushed automatically." }; } - if (state === "rebaseConflict") { + if (state === "rebaseConflict" || state === "rebaseFailed") { return { color: COLORS.danger, - label: "AUTO REBASE BLOCKED", - fallbackMessage: "Conflicts are expected. Resolve manually, then publish." + label: "AUTO-REBASE FAILED", + fallbackMessage: state === "rebaseConflict" + ? "ADE predicted conflicts for this lane and stopped before rewriting or pushing it." + : "ADE tried to auto-rebase this lane, restored the previous state, and stopped before pushing changes." }; } return { color: COLORS.warning, - label: "AUTO REBASE PENDING", - fallbackMessage: "Waiting for manual rebase." + label: "AUTO-REBASE PENDING", + fallbackMessage: "ADE will auto-rebase and auto-push this lane when its parent advances." }; } @@ -311,7 +313,7 @@ export function LaneGitActionsPane({ onOpenSettings: () => void; onRebaseNowLocal?: (laneId: string) => Promise | void; onRebaseAndPush?: (laneId: string) => Promise | void; - onViewRebaseDetails?: () => void; + onViewRebaseDetails?: (laneId?: string | null) => void; onResolveRebaseConflict?: (laneId: string, parentLaneId: string | null) => void; onSelectFile: (path: string, mode: "staged" | "unstaged") => void; onSelectCommit: (commit: GitCommitSummary | null) => void; @@ -1117,6 +1119,22 @@ export function LaneGitActionsPane({ {autoRebaseStatus ? (() => { const bannerConfig = getAutoRebaseBannerConfig(autoRebaseStatus.state); + const isAutoRebaseFailure = autoRebaseStatus.state === "rebaseConflict" || autoRebaseStatus.state === "rebaseFailed"; + const bannerMessage = isAutoRebaseFailure + ? autoRebaseStatus.message + ? `Auto-rebase failed. ${autoRebaseStatus.message}` + : bannerConfig.fallbackMessage + : autoRebaseStatus.message ?? bannerConfig.fallbackMessage; + const openRebaseTab = () => { + if (!laneId) return; + if (autoRebaseStatus.state === "rebaseConflict" && onResolveRebaseConflict) { + onResolveRebaseConflict(laneId, rebaseConflictParentLaneId); + return; + } + const search = new URLSearchParams({ tab: "rebase", laneId }); + if (rebaseConflictParentLaneId) search.set("parentLaneId", rebaseConflictParentLaneId); + navigate(`/prs?${search.toString()}`); + }; return (
- {autoRebaseStatus.message ?? bannerConfig.fallbackMessage} + {bannerMessage} {autoRebaseStatus.state !== "autoRebased" ? ( - autoRebaseStatus.state === "rebaseConflict" ? ( + isAutoRebaseFailure ? ( ) : ( @@ -179,6 +179,8 @@ export function LaneRebaseBanner({ {lane.name} {status.state === "rebaseConflict" ? ( CONFLICT + ) : status.state === "rebaseFailed" ? ( + FAILED ) : ( PENDING )} @@ -196,6 +198,14 @@ export function LaneRebaseBanner({ > RESOLVE IN CONFLICTS + ) : status.state === "rebaseFailed" ? ( + ) : (
diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index a83f37ef4..88751f690 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -13,6 +13,7 @@ import { useNavigate } from "react-router-dom"; import { sessionIndicatorState } from "../../lib/terminalAttention"; import { isChatToolType, isRunOwnedSession, primarySessionLabel, secondarySessionLabel } from "../../lib/sessions"; import { listSessionsCached } from "../../lib/sessionListCache"; +import { defaultTrackedCliStartupCommand } from "../terminals/cliLaunch"; import { ToolLogo } from "../terminals/ToolLogos"; import { persistLaunchTracked, readLaunchTracked } from "../../lib/terminalLaunchPreferences"; @@ -202,7 +203,7 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string : toolType === "claude" ? "Claude Code" : "Codex"; - const startupCommand = toolType === "shell" ? undefined : toolType; + const startupCommand = toolType === "shell" ? undefined : defaultTrackedCliStartupCommand(toolType); window.ade.pty .create({ diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts new file mode 100644 index 000000000..fd93b4256 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveCreateLaneRequest } from "./LanesPage"; + +describe("resolveCreateLaneRequest", () => { + it("creates an independent lane from the selected primary branch", () => { + expect( + resolveCreateLaneRequest({ + name: "git actions fixes", + createAsChild: false, + createParentLaneId: "lane-primary", + createBaseBranch: "main", + }), + ).toEqual({ + kind: "root", + args: { + name: "git actions fixes", + baseBranch: "main", + }, + }); + }); + + it("creates a stacked child lane only when child mode is selected", () => { + expect( + resolveCreateLaneRequest({ + name: "git actions fixes", + createAsChild: true, + createParentLaneId: "lane-primary", + createBaseBranch: "main", + }), + ).toEqual({ + kind: "child", + args: { + name: "git actions fixes", + parentLaneId: "lane-primary", + }, + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index f0ff3956a..4d7b3dafd 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -80,6 +80,35 @@ type RebasePushReviewState = { const ADOPT_HINT_DISMISSED_KEY = "ade.lanes.adoptHintDismissed.v1"; +type CreateLaneRequest = + | { kind: "child"; args: { name: string; parentLaneId: string } } + | { kind: "root"; args: { name: string; baseBranch: string } }; + +export function resolveCreateLaneRequest(args: { + name: string; + createAsChild: boolean; + createParentLaneId: string; + createBaseBranch: string; +}): CreateLaneRequest { + if (args.createAsChild) { + return { + kind: "child", + args: { + name: args.name, + parentLaneId: args.createParentLaneId, + }, + }; + } + + return { + kind: "root", + args: { + name: args.name, + baseBranch: args.createBaseBranch, + }, + }; +} + /* ---- Component ---- */ export function LanesPage() { @@ -989,7 +1018,15 @@ export function LanesPage() { }; const openAutoRebaseSettings = useCallback(() => { navigate("/settings?tab=lane-templates"); }, [navigate]); - const openRebaseDetails = useCallback(() => { navigate("/prs?tab=rebase"); }, [navigate]); + const openRebaseDetails = useCallback((laneId?: string | null) => { + const trimmedLaneId = typeof laneId === "string" ? laneId.trim() : ""; + if (trimmedLaneId.length) { + const search = new URLSearchParams({ tab: "rebase", laneId: trimmedLaneId }); + navigate(`/prs?${search.toString()}`); + return; + } + navigate("/prs?tab=rebase"); + }, [navigate]); const openRebaseConflictResolver = useCallback((laneId: string, parentLaneId: string | null) => { const search = new URLSearchParams({ tab: "rebase", laneId }); @@ -1067,7 +1104,7 @@ export function LanesPage() { const handleCreateSubmit = useCallback(async () => { const name = createLaneName.trim(); - if (!name || createBusy || (createAsChild && !createParentLaneId)) return; + if (!name || createBusy || (createAsChild && !createParentLaneId) || (!createAsChild && !createBaseBranch)) return; if (selectedTemplateId && !templates.some((template) => template.id === selectedTemplateId)) { setCreateError("The selected lane template no longer exists. Refresh templates or choose a different option."); return; @@ -1079,14 +1116,15 @@ export function LanesPage() { createEnvInitLaneIdRef.current = null; try { - const lane = createAsChild && createParentLaneId - ? await window.ade.lanes.createChild({ name, parentLaneId: createParentLaneId }) - : await (() => { - const primary = lanes.find((entry) => entry.laneType === "primary"); - return primary - ? window.ade.lanes.create({ name, parentLaneId: primary.id }) - : window.ade.lanes.create({ name }); - })(); + const request = resolveCreateLaneRequest({ + name, + createAsChild, + createParentLaneId, + createBaseBranch, + }); + const lane = request.kind === "child" + ? await window.ade.lanes.createChild(request.args) + : await window.ade.lanes.create(request.args); await refreshLanes(); navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); @@ -1642,6 +1680,14 @@ export function LanesPage() { PENDING ) : null} + {autoRebaseStatus?.state === "rebaseFailed" ? ( + + FAILED + + ) : null} {autoRebaseStatus?.state === "rebaseConflict" ? ( { const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" } as const; - const commandMap = { claude: "claude", codex: "codex", shell: "" } as const; + const commandMap = { + claude: defaultTrackedCliStartupCommand("claude"), + codex: defaultTrackedCliStartupCommand("codex"), + shell: "", + } as const; const result = await window.ade.pty.create({ laneId: args.laneId, cols: 100, diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index ea0d9dc1e..35f964c26 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -68,18 +68,36 @@ import { CreatePrModal, reorderQueueLaneIds } from "./CreatePrModal"; describe("CreatePrModal queue workflow", () => { const originalAde = globalThis.window.ade; const createQueue = vi.fn(); + const createFromLane = vi.fn(); beforeEach(() => { createQueue.mockReset(); + createFromLane.mockReset(); createQueue.mockResolvedValue({ groupId: "queue-group-1", prs: [], errors: [], }); + createFromLane.mockResolvedValue({ + id: "pr-1", + laneId: "lane-1", + provider: "github", + number: 1, + title: "Queue lane", + body: "", + state: "open", + url: "https://example.test/pr/1", + headBranch: "feature/queue-1", + baseBranch: "main", + mergeable: true, + draft: false, + updatedAt: "2026-03-23T12:30:00.000Z", + }); globalThis.window.ade = { prs: { createQueue, + createFromLane, }, git: { getSyncStatus: vi.fn().mockResolvedValue(null), @@ -139,6 +157,64 @@ describe("CreatePrModal queue workflow", () => { }), ); }); + + it("lets single-PR creation target a different branch than Primary's current branch", async () => { + const user = userEvent.setup(); + render(); + + await user.selectOptions(screen.getByRole("combobox"), "lane-1"); + const targetBranchInput = screen.getByDisplayValue("main"); + await user.clear(targetBranchInput); + await user.type(targetBranchInput, "release-9"); + + await user.click(screen.getByRole("button", { name: /next step/i })); + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createFromLane).toHaveBeenCalledTimes(1)); + expect(createFromLane).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + baseBranch: "release-9", + }), + ); + }); + + it("warns when the PR target branch differs from the lane base branch", async () => { + const user = userEvent.setup(); + render(); + + await user.selectOptions(screen.getByRole("combobox"), "lane-1"); + const targetBranchInput = screen.getByDisplayValue("main"); + await user.clear(targetBranchInput); + await user.type(targetBranchInput, "release-9"); + + expect(screen.getByText("Lane Needs Attention")).toBeTruthy(); + expect(screen.getByText(/targets release-9, but this lane currently tracks main/i)).toBeTruthy(); + expect(screen.getByText(/use rebase or reparent instead of only retargeting the pr/i)).toBeTruthy(); + }); + + it("lets queue creation target a different branch than Primary's current branch", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); + await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); + + const targetBranchInput = screen.getByDisplayValue("main"); + await user.clear(targetBranchInput); + await user.type(targetBranchInput, "release-9"); + + await user.click(screen.getByRole("button", { name: /next step/i })); + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createQueue).toHaveBeenCalledTimes(1)); + expect(createQueue).toHaveBeenCalledWith( + expect.objectContaining({ + laneIds: ["lane-1"], + targetBranch: "release-9", + }), + ); + }); }); describe("reorderQueueLaneIds", () => { diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 1a9e97c66..155a5076f 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -9,9 +9,11 @@ import type { IntegrationProposalStep, CreateIntegrationPrResult, GitUpstreamSyncStatus, + LaneSummary, } from "../../../shared/types"; import { COLORS, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "./shared/dirtyWorktree"; +import { branchNameFromRef, describePrTargetDiff, resolveLaneBaseBranch } from "./shared/laneBranchTargets"; import { buildLaneRebaseRecommendedLaneIds, describeLanePrIssues } from "./shared/lanePrWarnings"; type CreateMode = "normal" | "queue" | "integration"; @@ -213,12 +215,22 @@ async function runWithDirtyWorktreeConfirmation(args: { function buildLaneWarningSummaries(args: { selectedLaneIds: string[]; lanes: Array<{ id: string; name: string }>; + allLanes: LaneSummary[]; laneWarningItemsById: Record; + targetBranch?: string; + primaryBranchRef?: string | null; }): LaneWarningSummary[] { return args.selectedLaneIds .map((laneId) => { const lane = args.lanes.find((entry) => entry.id === laneId); - const messages = args.laneWarningItemsById[laneId] ?? []; + const baseMessages = args.laneWarningItemsById[laneId] ?? []; + const targetDiffMessage = describePrTargetDiff({ + lane: args.allLanes.find((entry) => entry.id === laneId) ?? null, + lanes: args.allLanes, + targetBranch: args.targetBranch ?? null, + primaryBranchRef: args.primaryBranchRef ?? null, + }); + const messages = targetDiffMessage ? [...baseMessages, targetDiffMessage] : baseMessages; if (!lane || messages.length === 0) return null; return { laneId, laneName: lane.name, messages }; }) @@ -238,6 +250,14 @@ function getCreateActionLabel(mode: CreateMode, busy: boolean): string { return mode === "integration" ? "SAVE PROPOSAL" : "CREATE PR"; } +function resolveDefaultBaseBranchForLane(args: { + lane: LaneSummary | null; + lanes: LaneSummary[]; + primaryBranchRef?: string | null; +}): string { + return resolveLaneBaseBranch(args); +} + export function reorderQueueLaneIds(queueLaneIds: string[], draggedLaneId: string, targetLaneId: string): string[] { const draggedIndex = queueLaneIds.indexOf(draggedLaneId); const targetIndex = queueLaneIds.indexOf(targetLaneId); @@ -453,11 +473,14 @@ export function CreatePrModal({ const [normalLaneId, setNormalLaneId] = React.useState(""); const [normalTitle, setNormalTitle] = React.useState(""); const [normalDraft, setNormalDraft] = React.useState(false); + const [normalBaseBranch, setNormalBaseBranch] = React.useState(""); + const normalBaseBranchDefaultRef = React.useRef(""); // Queue PRs const [queueLaneIds, setQueueLaneIds] = React.useState([]); const [queueDraft, setQueueDraft] = React.useState(false); const [queueDragLaneId, setQueueDragLaneId] = React.useState(null); + const [queueTargetBranch, setQueueTargetBranch] = React.useState(""); // Body & AI draft const [normalBody, setNormalBody] = React.useState(""); @@ -465,6 +488,7 @@ export function CreatePrModal({ // Integration PR const [integrationSources, setIntegrationSources] = React.useState([]); + const [integrationBaseBranch, setIntegrationBaseBranch] = React.useState(""); const [integrationName, setIntegrationName] = React.useState(""); const [integrationTitle, setIntegrationTitle] = React.useState(""); const [integrationBody, setIntegrationBody] = React.useState(""); @@ -514,11 +538,14 @@ export function CreatePrModal({ setNumericStep(1); setMergeMethod("squash"); setNormalLaneId(""); + setNormalBaseBranch(""); + normalBaseBranchDefaultRef.current = ""; setNormalTitle(""); setNormalDraft(false); setQueueLaneIds([]); setQueueDraft(false); setQueueDragLaneId(null); + setQueueTargetBranch(""); setBusy(false); setExecError(null); setResults(null); @@ -526,6 +553,7 @@ export function CreatePrModal({ setDrafting(false); setDraftError(null); setIntegrationSources([]); + setIntegrationBaseBranch(""); setIntegrationName(""); setIntegrationTitle(""); setIntegrationBody(""); @@ -574,13 +602,49 @@ export function CreatePrModal({ }; }, [open, lanes]); + const selectedNormalLane = React.useMemo( + () => lanes.find((lane) => lane.id === normalLaneId) ?? null, + [lanes, normalLaneId], + ); + + React.useEffect(() => { + if (!open) return; + const primaryBranch = branchNameFromRef(primaryLane?.branchRef ?? "main"); + setQueueTargetBranch((current) => current || primaryBranch); + setIntegrationBaseBranch((current) => current || primaryBranch); + }, [open, primaryLane?.branchRef]); + + React.useEffect(() => { + if (!open) return; + if (!selectedNormalLane) { + normalBaseBranchDefaultRef.current = ""; + setNormalBaseBranch(""); + return; + } + const nextDefault = resolveDefaultBaseBranchForLane({ + lane: selectedNormalLane, + lanes, + primaryBranchRef: primaryLane?.branchRef ?? null, + }); + setNormalBaseBranch((current) => { + const trimmedCurrent = current.trim(); + const previousDefault = normalBaseBranchDefaultRef.current; + if (trimmedCurrent.length === 0 || trimmedCurrent === previousDefault) { + normalBaseBranchDefaultRef.current = nextDefault; + return nextDefault; + } + return current; + }); + }, [open, selectedNormalLane, lanes, primaryLane?.branchRef]); + const handleSimulate = async () => { if (integrationSources.length === 0) return; setSimulating(true); setExecError(null); setProposal(null); try { - const baseBranch = primaryLane?.branchRef ?? "main"; + const trimmedIntegrationBaseBranch = integrationBaseBranch.trim(); + const baseBranch = trimmedIntegrationBaseBranch || branchNameFromRef(primaryLane?.branchRef ?? "main"); const result = await window.ade.prs.simulateIntegration({ sourceLaneIds: integrationSources, baseBranch, @@ -613,13 +677,15 @@ export function CreatePrModal({ title: normalTitle || lane?.name || "PR", body: normalBody, draft: normalDraft, + ...(normalBaseBranch.trim() ? { baseBranch: normalBaseBranch.trim() } : {}), ...(allowDirtyWorktree ? { allowDirtyWorktree: true } : {}) }) }); setResults([pr]); setNumericStep(3); } else if (mode === "queue") { - const baseBranch = primaryLane?.branchRef ?? "main"; + const trimmedQueueTargetBranch = (queueTargetBranch ?? "").trim(); + const baseBranch = (trimmedQueueTargetBranch || branchNameFromRef(primaryLane?.branchRef ?? "main")).trim(); const result = await runWithDirtyWorktreeConfirmation({ confirmMessage: "Continue and create the queue PRs anyway?", run: async (allowDirtyWorktree) => await window.ade.prs.createQueue({ @@ -726,10 +792,6 @@ export function CreatePrModal({ }; /* ── selected lane info for comparison section ──────────────────── */ - const selectedNormalLane = React.useMemo( - () => lanes.find((l) => l.id === normalLaneId) ?? null, - [lanes, normalLaneId], - ); const laneWarningItemsById = React.useMemo(() => { return Object.fromEntries( nonPrimaryLanes.map((lane) => [ @@ -740,24 +802,37 @@ export function CreatePrModal({ }, [laneSyncStatusById, nonPrimaryLanes]); const selectedNormalWarnings = React.useMemo(() => { if (!selectedNormalLane) return []; - const messages = laneWarningItemsById[selectedNormalLane.id] ?? []; + const baseMessages = laneWarningItemsById[selectedNormalLane.id] ?? []; + const targetDiffMessage = describePrTargetDiff({ + lane: selectedNormalLane, + lanes, + targetBranch: normalBaseBranch, + primaryBranchRef: primaryLane?.branchRef ?? null, + }); + const messages = targetDiffMessage ? [...baseMessages, targetDiffMessage] : baseMessages; if (!messages.length) return []; return [{ laneId: selectedNormalLane.id, laneName: selectedNormalLane.name, messages }]; - }, [laneWarningItemsById, selectedNormalLane]); + }, [laneWarningItemsById, lanes, normalBaseBranch, primaryLane?.branchRef, selectedNormalLane]); const selectedQueueWarnings = React.useMemo(() => { return buildLaneWarningSummaries({ selectedLaneIds: queueLaneIds, lanes, - laneWarningItemsById + allLanes: nonPrimaryLanes, + laneWarningItemsById, + targetBranch: queueTargetBranch, + primaryBranchRef: primaryLane?.branchRef ?? null, }); - }, [laneWarningItemsById, lanes, queueLaneIds]); + }, [laneWarningItemsById, lanes, nonPrimaryLanes, primaryLane?.branchRef, queueLaneIds, queueTargetBranch]); const selectedIntegrationWarnings = React.useMemo(() => { return buildLaneWarningSummaries({ selectedLaneIds: integrationSources, lanes, - laneWarningItemsById + allLanes: nonPrimaryLanes, + laneWarningItemsById, + targetBranch: integrationBaseBranch, + primaryBranchRef: primaryLane?.branchRef ?? null, }); - }, [integrationSources, laneWarningItemsById, lanes]); + }, [integrationBaseBranch, integrationSources, laneWarningItemsById, lanes, nonPrimaryLanes, primaryLane?.branchRef]); const selectedNormalLoading = Boolean(normalLaneId) && laneSyncLoadingById[normalLaneId] === true; const selectedQueueLoading = queueLaneIds.some((laneId) => laneSyncLoadingById[laneId] === true); const selectedIntegrationLoading = integrationSources.some((laneId) => laneSyncLoadingById[laneId] === true); @@ -982,16 +1057,25 @@ export function CreatePrModal({
TARGET BRANCH -
- - {primaryLane?.branchRef ?? "main"} +
+ + setNormalBaseBranch(e.target.value)} + style={{ ...inputStyle, paddingLeft: 32 }} + placeholder={branchNameFromRef(primaryLane?.branchRef ?? "main")} + />
@@ -1235,12 +1319,59 @@ export function CreatePrModal({ {queueLaneIds.length} lane{queueLaneIds.length !== 1 ? "s" : ""} selected
)} +
+ TARGET BRANCH +
+ + setQueueTargetBranch(e.target.value)} + style={{ ...inputStyle, paddingLeft: 32 }} + placeholder={branchNameFromRef(primaryLane?.branchRef ?? "main")} + /> +
+
)} {/* ── Integration mode: Source lanes + simulation ─── */} {mode === "integration" && ( <> +
+ TARGET BRANCH +
+ + setIntegrationBaseBranch(e.target.value)} + style={{ ...inputStyle, paddingLeft: 32 }} + placeholder={branchNameFromRef(primaryLane?.branchRef ?? "main")} + /> +
+
+
SOURCE LANES TO INTEGRATE (SELECT 2+) = {}): LaneSummary { + return { + id: "lane-1", + name: "lane", + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: "feature/lane", + worktreePath: "/tmp/lane-1", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-30T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +describe("resolveDefaultBaseBranch", () => { + it("uses lane.baseRef for unparented lanes", () => { + expect( + resolveDefaultBaseBranch({ + lane: makeLane({ baseRef: "release-9", parentLaneId: null }), + parentLane: null, + primaryBranchRef: "fix-rebase-and-new-lane-flow", + }), + ).toBe("release-9"); + }); + + it("falls back to lane.baseRef when parentLaneId is set but parentLane is missing", () => { + expect( + resolveDefaultBaseBranch({ + lane: makeLane({ parentLaneId: "some-id", baseRef: "release-9" }), + parentLane: null, + primaryBranchRef: "main", + }), + ).toBe("release-9"); + }); + + it("uses the parent branch for child lanes", () => { + expect( + resolveDefaultBaseBranch({ + lane: makeLane({ parentLaneId: "lane-parent" }), + parentLane: makeLane({ id: "lane-parent", branchRef: "feature/stack-root" }), + primaryBranchRef: "main", + }), + ).toBe("feature/stack-root"); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx b/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx index e6f13841b..4aab0dbcc 100644 --- a/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx +++ b/apps/desktop/src/renderer/components/prs/LanePrPanel.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Warning, Trash, Archive } from "@phosphor-icons/react"; -import type { AiConfig, CreatePrFromLaneArgs, LandResult, MergeMethod, PrCheck, PrReview, PrStatus, PrSummary } from "../../../shared/types"; +import type { AiConfig, CreatePrFromLaneArgs, LandResult, LaneSummary, MergeMethod, PrCheck, PrReview, PrStatus, PrSummary } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; import { useAppStore } from "../../state/appStore"; @@ -10,11 +10,20 @@ import { Chip } from "../ui/Chip"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "./shared/dirtyWorktree"; - -function branchNameFromRef(ref: string): string { - const trimmed = ref.trim(); - if (trimmed.startsWith("refs/heads/")) return trimmed.slice("refs/heads/".length); - return trimmed; +import { branchNameFromRef, describePrTargetDiff, resolveLaneBaseBranch } from "./shared/laneBranchTargets"; + +export { branchNameFromRef }; + +export function resolveDefaultBaseBranch(args: { + lane: LaneSummary | null; + parentLane: LaneSummary | null; + primaryBranchRef?: string | null; +}): string { + return resolveLaneBaseBranch({ + lane: args.lane, + lanes: args.parentLane ? [args.parentLane] : [], + primaryBranchRef: args.primaryBranchRef, + }); } function titleFromBranch(branch: string): string { @@ -109,10 +118,11 @@ export function LanePrPanel({ laneId }: { laneId: string | null }) { useEffect(() => { void loadPrDescModel(); }, [loadPrDescModel]); const defaultBaseBranch = React.useMemo(() => { - if (!lane) return branchNameFromRef(primaryLane?.branchRef ?? "main"); - if (parentLane) return branchNameFromRef(parentLane.branchRef); - const primaryBranch = primaryLane?.branchRef ?? lane.baseRef; - return branchNameFromRef(primaryBranch); + return resolveDefaultBaseBranch({ + lane, + parentLane, + primaryBranchRef: primaryLane?.branchRef ?? null, + }); }, [lane, parentLane, primaryLane?.branchRef]); const defaultHeadBranch = lane ? branchNameFromRef(lane.branchRef) : ""; @@ -575,6 +585,28 @@ export function LanePrPanel({ laneId }: { laneId: string | null }) {
)} + {(() => { + const targetDiffMessage = describePrTargetDiff({ + lane, + lanes, + targetBranch: pr.baseBranch, + primaryBranchRef: primaryLane?.branchRef ?? null, + }); + if (!targetDiffMessage || pr.state !== "open") return null; + return ( +
+ +
+ PR target differs from lane base + {targetDiffMessage} +
+ +
+ ); + })()} + {/* Rebase warning banner — uses local lane status as primary source */} {(() => { const localBehind = lane?.status?.behind ?? 0; diff --git a/apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx b/apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx index 08835e9ec..057dfdf33 100644 --- a/apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx +++ b/apps/desktop/src/renderer/components/prs/PrRebaseBanner.tsx @@ -15,7 +15,7 @@ export function PrRebaseBanner({ laneId, rebaseNeeds, autoRebaseStatuses, onTabC const need = rebaseNeeds.find((n) => n.laneId === laneId); const autoStatus = autoRebaseStatuses?.find((s) => s.laneId === laneId); - const hasAutoRebaseError = autoStatus?.state === "rebaseConflict"; + const hasAutoRebaseError = autoStatus?.state === "rebaseConflict" || autoStatus?.state === "rebaseFailed"; // Reset dismissed state when lane changes React.useEffect(() => { @@ -39,7 +39,7 @@ export function PrRebaseBanner({ laneId, rebaseNeeds, autoRebaseStatuses, onTabC
- AUTO-REBASE FAILED — conflicts need manual resolution + AUTO-REBASE FAILED — manual follow-up required
+ )} + + + ); + })()} + {/* ---- Rebase Banner (when PR is behind base branch — checks both GitHub API and local lane status) ---- */} {(() => { const ghBehind = status?.behindBaseBy ?? 0; diff --git a/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts b/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts new file mode 100644 index 000000000..16eb3eb4f --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts @@ -0,0 +1,44 @@ +import type { LaneSummary } from "../../../../shared/types"; + +export function branchNameFromRef(ref?: string | null): string { + const trimmed = (ref ?? "").trim(); + if (trimmed.startsWith("refs/heads/")) return trimmed.slice("refs/heads/".length); + if (trimmed.startsWith("refs/remotes/")) { + const remoteRef = trimmed.slice("refs/remotes/".length); + const slashIndex = remoteRef.indexOf("/"); + return slashIndex >= 0 ? remoteRef.slice(slashIndex + 1) : remoteRef; + } + if (trimmed.startsWith("origin/")) return trimmed.slice("origin/".length); + return trimmed; +} + +export function resolveLaneBaseBranch(args: { + lane: LaneSummary | null; + lanes: LaneSummary[]; + primaryBranchRef?: string | null; +}): string { + if (!args.lane) return branchNameFromRef(args.primaryBranchRef ?? "main"); + if (args.lane.parentLaneId) { + const parent = args.lanes.find((entry) => entry.id === args.lane?.parentLaneId) ?? null; + if (parent?.branchRef) return branchNameFromRef(parent.branchRef); + } + return branchNameFromRef(args.lane.baseRef || args.primaryBranchRef || "main"); +} + +export function describePrTargetDiff(args: { + lane: LaneSummary | null; + lanes: LaneSummary[]; + targetBranch?: string | null; + primaryBranchRef?: string | null; +}): string | null { + if (!args.lane) return null; + const targetBranch = branchNameFromRef(args.targetBranch); + if (!targetBranch) return null; + const laneBaseBranch = resolveLaneBaseBranch({ + lane: args.lane, + lanes: args.lanes, + primaryBranchRef: args.primaryBranchRef, + }); + if (!laneBaseBranch || laneBaseBranch === targetBranch) return null; + return `targets ${targetBranch}, but this lane currently tracks ${laneBaseBranch}. If you want to move the lane onto ${targetBranch}, use rebase or reparent instead of only retargeting the PR.`; +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.test.ts b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.test.ts index 85bd6328a..b594c0efb 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.test.ts +++ b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.test.ts @@ -1,21 +1,14 @@ -/** - * Tests for RebaseTab categorization and style helpers. - * - * The RebaseTab component has a categorize() function that determines - * the urgency bucket for each RebaseNeed. We re-derive and test it. - */ import { describe, expect, it } from "vitest"; -import type { RebaseNeed } from "../../../../shared/types"; +import type { LaneSummary, RebaseNeed } from "../../../../shared/types"; +import { branchNameFromRef, resolveLaneBaseBranch } from "../shared/laneBranchTargets"; -type UrgencyCategory = "attention" | "clean" | "recent" | "upToDate"; - -// Re-derive the categorize function from RebaseTab -function categorize(need: RebaseNeed): UrgencyCategory { - if (need.dismissedAt) return "upToDate"; - if (need.deferredUntil && new Date(need.deferredUntil) > new Date()) return "upToDate"; - if (need.behindBy === 0) return "upToDate"; - if (need.conflictPredicted) return "attention"; - return "clean"; +function isPrTargetNeed(need: RebaseNeed, lane: LaneSummary): boolean { + const laneBaseBranch = branchNameFromRef(resolveLaneBaseBranch({ + lane, + lanes: [lane], + primaryBranchRef: null, + })); + return Boolean(need.prId) && laneBaseBranch !== branchNameFromRef(need.baseBranch); } function makeNeed(overrides: Partial = {}): RebaseNeed { @@ -34,38 +27,41 @@ function makeNeed(overrides: Partial = {}): RebaseNeed { }; } -describe("RebaseTab categorize", () => { - it("returns 'upToDate' when dismissed", () => { - expect(categorize(makeNeed({ dismissedAt: "2026-03-01T00:00:00.000Z" }))).toBe("upToDate"); - }); - - it("returns 'upToDate' when deferred until future", () => { - const future = new Date(Date.now() + 3_600_000).toISOString(); - expect(categorize(makeNeed({ deferredUntil: future }))).toBe("upToDate"); - }); - - it("returns 'upToDate' when behindBy is 0", () => { - expect(categorize(makeNeed({ behindBy: 0 }))).toBe("upToDate"); - }); - - it("returns 'attention' when conflict is predicted", () => { - expect(categorize(makeNeed({ conflictPredicted: true, behindBy: 5 }))).toBe("attention"); - }); +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Feature Lane", + description: null, + laneType: "worktree", + baseRef: "release-9", + branchRef: "feature/lane", + worktreePath: "/tmp/lane", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-30T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} - it("returns 'clean' when behind but no conflict", () => { - expect(categorize(makeNeed({ behindBy: 3, conflictPredicted: false }))).toBe("clean"); +describe("RebaseTab grouping helpers", () => { + it("treats a linked PR on a different branch as a PR-target need", () => { + expect(isPrTargetNeed(makeNeed({ prId: "pr-1", baseBranch: "main" }), makeLane({ baseRef: "release-9" }))).toBe(true); }); - it("prioritizes dismissedAt over conflictPredicted", () => { - expect(categorize(makeNeed({ - dismissedAt: "2026-03-01T00:00:00.000Z", - conflictPredicted: true, - behindBy: 5, - }))).toBe("upToDate"); + it("treats a matching linked PR as a lane-base need", () => { + expect(isPrTargetNeed(makeNeed({ prId: "pr-1", baseBranch: "release-9" }), makeLane({ baseRef: "release-9" }))).toBe(false); }); - it("does not treat past deferredUntil as upToDate", () => { - const past = new Date(Date.now() - 3_600_000).toISOString(); - expect(categorize(makeNeed({ deferredUntil: past, behindBy: 2 }))).toBe("clean"); + it("treats non-PR suggestions as lane-base needs", () => { + expect(isPrTargetNeed(makeNeed({ prId: null, baseBranch: "main" }), makeLane({ baseRef: "release-9" }))).toBe(false); }); }); diff --git a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx index 25a6a14cf..3118397c3 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx @@ -6,6 +6,7 @@ import { EmptyState } from "../../ui/EmptyState"; import { cn } from "../../ui/cn"; import { PaneTilingLayout, type PaneConfig } from "../../ui/PaneTilingLayout"; import { UrgencyGroup } from "../shared/UrgencyGroup"; +import { branchNameFromRef, resolveLaneBaseBranch } from "../shared/laneBranchTargets"; import { StatusDot } from "../shared/StatusDot"; import { PR_TAB_TILING_TREE } from "../shared/tilingConstants"; import { PrResolverLaunchControls } from "../shared/PrResolverLaunchControls"; @@ -25,14 +26,14 @@ type RebaseTabProps = { onNavigate: (path: string) => void; }; -type UrgencyCategory = "attention" | "clean" | "recent" | "upToDate"; +type RebaseSectionKey = "lane_base" | "pr_target"; -function categorize(need: RebaseNeed): UrgencyCategory { - if (need.dismissedAt) return "upToDate"; - if (need.deferredUntil && new Date(need.deferredUntil) > new Date()) return "upToDate"; - if (need.behindBy === 0) return "upToDate"; - if (need.conflictPredicted) return "attention"; - return "clean"; +function rebaseNeedKey(need: RebaseNeed): string { + return `${need.laneId}:${need.prId ?? "base"}:${need.baseBranch}`; +} + +function rebaseRunKey(args: { laneId: string; baseBranch?: string | null }): string { + return `${args.laneId}:${branchNameFromRef(args.baseBranch)}`; } /* ── inline style constants ── */ @@ -95,31 +96,47 @@ export function RebaseTab({ const [commitFilesMap, setCommitFilesMap] = React.useState>({}); const [expandedCommitSha, setExpandedCommitSha] = React.useState(null); - const [collapsed, setCollapsed] = React.useState>({ - attention: false, - clean: false, - recent: true, - upToDate: true, + const [collapsed, setCollapsed] = React.useState>({ + lane_base: false, + pr_target: false, }); + const getLaneBaseBranch = React.useCallback((laneId: string): string => { + const lane = laneById.get(laneId) ?? null; + return resolveLaneBaseBranch({ + lane, + lanes, + primaryBranchRef: null, + }); + }, [laneById, lanes]); + + const isPrTargetNeed = React.useCallback((need: RebaseNeed): boolean => { + if (!need.prId) return false; + const laneBaseBranch = branchNameFromRef(getLaneBaseBranch(need.laneId)); + return laneBaseBranch.length > 0 && laneBaseBranch !== branchNameFromRef(need.baseBranch); + }, [getLaneBaseBranch]); + const grouped = React.useMemo(() => { - const groups: Record = { - attention: [], - clean: [], - recent: [], - upToDate: [], + const groups: Record = { + lane_base: [], + pr_target: [], }; for (const need of rebaseNeeds) { - groups[categorize(need)].push(need); + groups[isPrTargetNeed(need) ? "pr_target" : "lane_base"].push(need); } - groups.attention.sort((a, b) => b.behindBy - a.behindBy); - groups.clean.sort((a, b) => b.behindBy - a.behindBy); + groups.lane_base.sort((a, b) => b.behindBy - a.behindBy); + groups.pr_target.sort((a, b) => b.behindBy - a.behindBy); return groups; - }, [rebaseNeeds]); + }, [isPrTargetNeed, rebaseNeeds]); + + const selectedNeed = React.useMemo(() => { + if (!selectedItemId) return null; + return rebaseNeeds.find((need) => rebaseNeedKey(need) === selectedItemId) ?? null; + }, [rebaseNeeds, selectedItemId]); - const selectedNeed = React.useMemo( - () => rebaseNeeds.find((n) => n.laneId === selectedItemId) ?? null, - [rebaseNeeds, selectedItemId], + const selectedNeedRunKey = React.useMemo( + () => (selectedNeed ? rebaseRunKey({ laneId: selectedNeed.laneId, baseBranch: selectedNeed.baseBranch }) : null), + [selectedNeed], ); const selectedLane = React.useMemo( @@ -128,6 +145,12 @@ export function RebaseTab({ ); const hasChildren = (selectedLane?.childCount ?? 0) > 0; + const selectedNeedIsPrTarget = React.useMemo( + () => (selectedNeed ? isPrTargetNeed(selectedNeed) : false), + [isPrTargetNeed, selectedNeed], + ); + + const activeRunKeyRef = React.useRef(null); // Auto-default scope based on children React.useEffect(() => { @@ -154,9 +177,9 @@ export function RebaseTab({ // Auto-select first item in highest-urgency group React.useEffect(() => { if (rebaseNeeds.length === 0 && selectedItemId === null) return; - if (selectedItemId && rebaseNeeds.some((n) => n.laneId === selectedItemId)) return; - const first = grouped.attention[0] ?? grouped.clean[0] ?? grouped.recent[0] ?? grouped.upToDate[0]; - onSelectItem(first?.laneId ?? null); + if (selectedItemId && rebaseNeeds.some((need) => rebaseNeedKey(need) === selectedItemId)) return; + const first = grouped.lane_base[0] ?? grouped.pr_target[0]; + onSelectItem(first ? rebaseNeedKey(first) : null); }, [rebaseNeeds, selectedItemId, grouped, onSelectItem]); React.useEffect(() => { @@ -165,17 +188,27 @@ export function RebaseTab({ React.useEffect(() => { activeRunIdRef.current = activeRun?.runId ?? null; + activeRunKeyRef.current = activeRun + ? rebaseRunKey({ laneId: activeRun.rootLaneId, baseBranch: activeRun.baseBranch }) + : null; }, [activeRun]); // Subscribe to rebase events React.useEffect(() => { const unsubscribe = window.ade.lanes.rebaseSubscribe((event) => { if (event.type === "rebase-run-updated") { + const incomingRunKey = rebaseRunKey({ + laneId: event.run.rootLaneId, + baseBranch: event.run.baseBranch, + }); setActiveRun((prev) => { if (prev?.runId) { - return event.run.runId === prev.runId ? event.run : prev; + if (event.run.runId !== prev.runId) return prev; + activeRunKeyRef.current = incomingRunKey; + return event.run; } - if (!selectedNeed || event.run.rootLaneId !== selectedNeed.laneId) return prev; + if (!selectedNeedRunKey || incomingRunKey !== selectedNeedRunKey) return prev; + activeRunKeyRef.current = incomingRunKey; return event.run; }); if (event.run.state !== "running") { @@ -192,9 +225,19 @@ export function RebaseTab({ } }); return unsubscribe; - }, [refreshRebaseNeeds, selectedNeed]); + }, [refreshRebaseNeeds, selectedNeedRunKey]); // Fetch drift commits when selected need changes + const driftSourceLaneId = React.useMemo(() => { + if (!selectedNeed || selectedNeed.behindBy === 0) return null; + if (selectedNeedIsPrTarget) { + const baseBranch = branchNameFromRef(selectedNeed.baseBranch); + if (!baseBranch) return null; + return lanes.find((lane) => branchNameFromRef(lane.branchRef) === baseBranch)?.id ?? null; + } + return selectedLane?.parentLaneId ? (laneById.get(selectedLane.parentLaneId)?.id ?? null) : null; + }, [laneById, lanes, selectedLane?.parentLaneId, selectedNeed, selectedNeedIsPrTarget]); + React.useEffect(() => { if (!selectedNeed || selectedNeed.behindBy === 0) { setDriftCommits([]); @@ -203,15 +246,23 @@ export function RebaseTab({ return; } - const parentLane = selectedLane?.parentLaneId ? laneById.get(selectedLane.parentLaneId) : null; - if (!parentLane) { + if (selectedNeedIsPrTarget && !driftSourceLaneId) { setDriftCommits([]); + setCommitFilesMap({}); + setExpandedCommitSha(null); + return; + } + + if (!driftSourceLaneId) { + setDriftCommits([]); + setCommitFilesMap({}); + setExpandedCommitSha(null); return; } let cancelled = false; setDriftCommitsLoading(true); - window.ade.git.listRecentCommits({ laneId: parentLane.id, limit: selectedNeed.behindBy }) + window.ade.git.listRecentCommits({ laneId: driftSourceLaneId, limit: selectedNeed.behindBy }) .then((commits) => { if (!cancelled) setDriftCommits(commits); }) @@ -223,10 +274,10 @@ export function RebaseTab({ }); return () => { cancelled = true; }; - }, [selectedNeed?.laneId, selectedNeed?.behindBy, selectedLane?.parentLaneId, laneById]); + }, [driftSourceLaneId, selectedNeed?.behindBy, selectedNeed?.laneId, selectedNeedIsPrTarget]); // Load files for expanded commit - const parentLaneId = selectedLane?.parentLaneId ? (laneById.get(selectedLane.parentLaneId)?.id ?? null) : null; + const parentLaneId = driftSourceLaneId; React.useEffect(() => { if (!expandedCommitSha || commitFilesMap[expandedCommitSha] || !parentLaneId) return; window.ade.git.listCommitFiles({ laneId: parentLaneId, commitSha: expandedCommitSha }) @@ -241,8 +292,18 @@ export function RebaseTab({ const handleRebase = async (aiAssisted: boolean, pushMode: "none" | "review_then_push" = "none") => { if (!selectedNeed) return; setRebaseError(null); + const requestedNeedRunKey = selectedNeedRunKey; + + if (selectedNeedIsPrTarget && selectedLane?.parentLaneId) { + setRebaseError("PR-target rebases are only supported for lanes that are already detached from a parent lane."); + return; + } if (aiAssisted) { + if (selectedNeedIsPrTarget) { + setRebaseError("AI-assisted rebase currently only supports lane-base rebases."); + return; + } setResolverLaunching(true); try { const result = await window.ade.prs.rebaseResolutionStart({ @@ -266,8 +327,10 @@ export function RebaseTab({ laneId: selectedNeed.laneId, scope: runScope, pushMode, - actor: "user" + actor: "user", + ...(selectedNeedIsPrTarget ? { baseBranchOverride: selectedNeed.baseBranch } : {}), }); + activeRunKeyRef.current = requestedNeedRunKey; setActiveRun(started.run); setRunLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] Started run ${started.runId}`].slice(-80)); const pushable = started.run.lanes.filter((lane) => lane.status === "succeeded").map((lane) => lane.laneId); @@ -289,13 +352,20 @@ export function RebaseTab({ setRebaseBusy(false); } }; - const selectedRunIsActive = activeRun?.state === "running"; + const activeRunMatchesSelectedNeed = Boolean( + activeRun + && selectedNeedRunKey + && activeRunKeyRef.current === selectedNeedRunKey, + ); + const activeRunForSelectedNeed = activeRunMatchesSelectedNeed ? activeRun : null; + const selectedRunIsActive = activeRunMatchesSelectedNeed && activeRun?.state === "running"; const selectedRunLane = React.useMemo( - () => (selectedNeed ? activeRun?.lanes.find((lane) => lane.laneId === selectedNeed.laneId) ?? null : null), - [activeRun, selectedNeed], + () => (activeRunMatchesSelectedNeed && selectedNeed ? activeRun?.lanes.find((lane) => lane.laneId === selectedNeed.laneId) ?? null : null), + [activeRun, activeRunMatchesSelectedNeed, selectedNeed], ); const selectedNeedResolvedByRun = Boolean( selectedRunLane + && activeRunMatchesSelectedNeed && activeRun?.state === "completed" && (selectedRunLane.status === "succeeded" || selectedRunLane.status === "skipped"), ); @@ -370,11 +440,13 @@ export function RebaseTab({ }; const renderNeedItem = (need: RebaseNeed) => { - const isSelected = need.laneId === selectedItemId; + const itemKey = rebaseNeedKey(need); + const isSelected = itemKey === selectedItemId; const laneName = laneById.get(need.laneId)?.name ?? need.laneId; + const kindLabel = isPrTargetNeed(need) ? "PR TARGET" : "LANE BASE"; return ( - - {driftCommitsExpanded && ( -
- {driftCommitsLoading ? ( -
- Loading commits... -
- ) : driftCommits.length === 0 ? ( -
- No commit details available. The parent lane may not be tracked locally. +
+ + + NEW COMMITS ON {selectedNeed.baseBranch.toUpperCase()} + + + {selectedNeed.behindBy} +
- ) : ( -
- {driftCommits.map((commit) => { - const isExpanded = expandedCommitSha === commit.sha; - const files = commitFilesMap[commit.sha]; - const overlappingFiles = files - ? files.filter((f) => selectedNeed.conflictingFiles.includes(f)) - : []; - return ( -
- - {isExpanded && ( -
- {!files ? ( -
Loading files...
- ) : files.length === 0 ? ( -
No files changed
- ) : ( -
- {files.map((f) => { - const isOverlap = selectedNeed.conflictingFiles.includes(f); - return ( -
- - - {f} - - {isOverlap && ( - - OVERLAP - - )} -
- ); - })} - {overlappingFiles.length > 0 && ( -
- {overlappingFiles.length} file{overlappingFiles.length !== 1 ? "s" : ""} overlap with your branch + {driftCommitsExpanded + ? + : } + + + {driftCommitsExpanded && ( +
+ {driftCommitsLoading ? ( +
+ Loading commits... +
+ ) : driftCommits.length === 0 ? ( +
+ No commit details available. The parent lane may not be tracked locally. +
+ ) : ( +
+ {driftCommits.map((commit) => { + const isExpanded = expandedCommitSha === commit.sha; + const files = commitFilesMap[commit.sha]; + const overlappingFiles = files + ? files.filter((f) => selectedNeed.conflictingFiles.includes(f)) + : []; + return ( +
+ + {isExpanded && ( +
+ {!files ? ( +
Loading files...
+ ) : files.length === 0 ? ( +
No files changed
+ ) : ( +
+ {files.map((f) => { + const isOverlap = selectedNeed.conflictingFiles.includes(f); + return ( +
+ + + {f} + + {isOverlap && ( + + OVERLAP + + )} +
+ ); + })} + {overlappingFiles.length > 0 && ( +
+ {overlappingFiles.length} file{overlappingFiles.length !== 1 ? "s" : ""} overlap with your branch +
+ )}
)}
)}
- )} -
- ); - })} + ); + })} +
+ )}
)}
- )} -
- {/* ── File Overlap Analysis ── */} -
-
- FILE OVERLAP ANALYSIS -
- - {selectedNeed.conflictingFiles.length > 0 ? ( -
-
- {selectedNeed.conflictingFiles.length} file{selectedNeed.conflictingFiles.length !== 1 ? "s" : ""} modified in both your branch and {selectedNeed.baseBranch} + {/* ── File Overlap Analysis ── */} +
+
+ FILE OVERLAP ANALYSIS
- {selectedNeed.conflictingFiles.map((f) => ( -
- - {f} - - both modified + + {selectedNeed.conflictingFiles.length > 0 ? ( +
+
+ {selectedNeed.conflictingFiles.length} file{selectedNeed.conflictingFiles.length !== 1 ? "s" : ""} modified in both your branch and {selectedNeed.baseBranch} +
+ {selectedNeed.conflictingFiles.map((f) => ( +
+ + {f} + + both modified +
+ ))}
- ))} -
- ) : ( -
- - - No file overlaps detected — clean rebase expected - + ) : ( +
+ + + No file overlaps detected — clean rebase expected + +
+ )}
- )} -
+ + ) : null} {/* ── Rebase Actions ── */}
{ if (resolverExpanded) { void handleRebase(true); @@ -1012,6 +1093,7 @@ export function RebaseTab({
- {activeRun.state.toUpperCase()} + {run.state.toUpperCase()}
- {activeRun.state === "running" && ( + {run.state === "running" && ( )} - {activeRun.canRollback && ( + {run.canRollback && ( @@ -1240,7 +1324,7 @@ export function RebaseTab({ {/* Lane statuses */}
- {activeRun.lanes.map((lane) => { + {run.lanes.map((lane) => { const statusColor = lane.status === "succeeded" ? S.success : lane.status === "running" @@ -1250,7 +1334,7 @@ export function RebaseTab({ : lane.status === "blocked" ? S.warning : S.textMuted; - const pushable = lane.status === "succeeded" && !activeRun.pushedLaneIds.includes(lane.laneId); + const pushable = lane.status === "succeeded" && !run.pushedLaneIds.includes(lane.laneId); return (
(null); @@ -45,7 +43,10 @@ export function CommandCard({ style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, - borderLeft: running ? `3px solid ${COLORS.success}` : `1px solid ${COLORS.border}`, + borderLeft: + statusText && status + ? `3px solid ${processStatusColor(status)}` + : `1px solid ${COLORS.border}`, borderRadius: 0, padding: "14px 16px", display: "flex", @@ -90,9 +91,9 @@ export function CommandCard({
{/* Status badge (when running) */} - {running && status && ( - - {status} + {statusText && status && ( + + {statusText} )} diff --git a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx index 006b978ff..c4a6c3fc5 100644 --- a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx +++ b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx @@ -2,93 +2,134 @@ import React from "react"; import { CaretUp, CaretDown, Terminal, X } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, LABEL_STYLE, inlineBadge, processStatusColor } from "../lanes/laneDesignTokens"; import { formatDurationMs } from "../../lib/format"; -import { TerminalView } from "../terminals/TerminalView"; -import type { ProcessRuntime, TerminalSessionSummary } from "../../../shared/types"; -import { isRunOwnedSession } from "../../lib/sessions"; +import { commandArrayToLine } from "../../lib/shell"; +import type { ProcessDefinition, ProcessEvent, ProcessRuntime } from "../../../shared/types"; +import { formatProcessStatus, hasInspectableProcessOutput, isActiveProcessStatus } from "./processUtils"; type ProcessMonitorProps = { laneId: string | null; runtimes: ProcessRuntime[]; + processDefinitions: Record; processNames: Record; // processId -> display name onKill: (processId: string) => void; }; const GRID_COLUMNS = "1fr 80px 80px 80px 50px"; +const LOG_TAIL_MAX_BYTES = 220_000; -export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: ProcessMonitorProps) { - const [expanded, setExpanded] = React.useState(false); - const [sessions, setSessions] = React.useState([]); - const [activeSessionId, setActiveSessionId] = React.useState(null); - const sessionsRef = React.useRef(sessions); - sessionsRef.current = sessions; - const activeRuntimes = runtimes.filter((r) => r.status !== "stopped"); - const activeCount = activeRuntimes.length; - const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0] ?? null; +function normalizeLog(raw: string): string { + return raw.replace(/\u0000/g, ""); +} - const refreshSessions = React.useCallback(async () => { - if (!laneId) { - setSessions([]); - return; - } - try { - const rows = await window.ade.sessions.list({ laneId, limit: 80 }); - setSessions( - rows.filter((session) => isRunOwnedSession(session) && session.ptyId), - ); - } catch { - // best effort - } - }, [laneId]); +function formatEndedAt( + value: string | null, + locale: string | undefined = typeof navigator !== "undefined" ? navigator.language : undefined, + options: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" }, +): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleTimeString(locale, options); +} - React.useEffect(() => { - void refreshSessions(); - }, [refreshSessions, runtimes.length]); +export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNames, onKill }: ProcessMonitorProps) { + const [expanded, setExpanded] = React.useState(false); + const [activeProcessId, setActiveProcessId] = React.useState(null); + const [logText, setLogText] = React.useState(""); + const [logLoading, setLogLoading] = React.useState(false); + const [logError, setLogError] = React.useState(null); + const [pauseAutoscroll, setPauseAutoscroll] = React.useState(false); + const activeProcessIdRef = React.useRef(null); + activeProcessIdRef.current = activeProcessId; + const laneIdRef = React.useRef(laneId); + laneIdRef.current = laneId; + const logRef = React.useRef(null); + const activeRuntimes = runtimes.filter((runtime) => isActiveProcessStatus(runtime.status)); + const activeCount = activeRuntimes.length; + const inspectableRuntimes = React.useMemo( + () => runtimes.filter((runtime) => hasInspectableProcessOutput(runtime)), + [runtimes], + ); + const activeRuntime = inspectableRuntimes.find((runtime) => runtime.processId === activeProcessId) ?? null; React.useEffect(() => { - if (sessions.length === 0) { - setActiveSessionId(null); + if (inspectableRuntimes.length === 0) { + setActiveProcessId(null); return; } - if (sessions.some((session) => session.id === activeSessionId)) return; - setActiveSessionId(sessions[0]?.id ?? null); - }, [activeSessionId, sessions]); + if (activeProcessId && inspectableRuntimes.some((runtime) => runtime.processId === activeProcessId)) return; + const preferred = + inspectableRuntimes.find((runtime) => isActiveProcessStatus(runtime.status)) + ?? inspectableRuntimes.find((runtime) => runtime.status === "crashed") + ?? inspectableRuntimes[0] + ?? null; + setActiveProcessId(preferred?.processId ?? null); + }, [activeProcessId, inspectableRuntimes]); React.useEffect(() => { - const unsubData = window.ade.pty.onData((event) => { - if (!sessionsRef.current.some((session) => session.id === event.sessionId)) { - void refreshSessions(); - return; - } - setSessions((prev) => - prev.map((session) => - session.id === event.sessionId - ? { ...session, lastOutputPreview: event.data.slice(-240) } - : session, - ), - ); - }); - const unsubExit = window.ade.pty.onExit((event) => { - if (!sessionsRef.current.some((session) => session.id === event.sessionId)) return; - void refreshSessions(); + const unsubscribe = window.ade.processes.onEvent((event: ProcessEvent) => { + if (event.type !== "log") return; + if (event.laneId !== laneIdRef.current) return; + if (event.processId !== activeProcessIdRef.current) return; + setLogText((prev) => { + const next = normalizeLog(`${prev}${event.chunk}`); + return next.length > LOG_TAIL_MAX_BYTES ? next.slice(-LOG_TAIL_MAX_BYTES) : next; + }); }); return () => { try { - unsubData(); - unsubExit(); + unsubscribe(); } catch { // ignore } }; - }, [refreshSessions]); + }, []); - const closeSession = React.useCallback(async (session: TerminalSessionSummary) => { - if (!session.ptyId) return; - try { - await window.ade.pty.dispose({ ptyId: session.ptyId, sessionId: session.id }); - } finally { - await refreshSessions(); + React.useEffect(() => { + if (!laneId || !activeRuntime) { + setLogText(""); + setLogError(null); + setLogLoading(false); + return; } - }, [refreshSessions]); + let cancelled = false; + const processId = activeRuntime.processId; + setPauseAutoscroll(false); + setLogError(null); + setLogLoading(true); + setLogText(""); + window.ade.processes + .getLogTail({ laneId, processId, maxBytes: LOG_TAIL_MAX_BYTES }) + .then((log) => { + if (cancelled || activeProcessIdRef.current !== processId) return; + setLogText(normalizeLog(log)); + setLogLoading(false); + }) + .catch((error) => { + if (cancelled || activeProcessIdRef.current !== processId) return; + setLogText(""); + setLogError(error instanceof Error ? error.message : String(error)); + setLogLoading(false); + }); + return () => { + cancelled = true; + }; + }, [activeRuntime, laneId]); + + React.useEffect(() => { + if (pauseAutoscroll) return; + if (!logRef.current) return; + logRef.current.scrollTop = logRef.current.scrollHeight; + }, [logText, pauseAutoscroll]); + + const activeDefinition = activeRuntime ? processDefinitions[activeRuntime.processId] ?? null : null; + const activeCommand = activeDefinition ? commandArrayToLine(activeDefinition.command) : null; + const activeCwd = activeDefinition?.cwd?.trim()?.length ? activeDefinition.cwd : "."; + const logPlaceholder = logLoading + ? "Loading recent output..." + : activeRuntime && isActiveProcessStatus(activeRuntime.status) + ? "Waiting for output..." + : "(no output yet)"; return (
)} - {sessions.length > 0 && ( + {inspectableRuntimes.length > 0 && ( - {sessions.length} inspector tab{sessions.length === 1 ? "" : "s"} + {inspectableRuntimes.length} output tab{inspectableRuntimes.length === 1 ? "" : "s"} )}
@@ -250,7 +291,7 @@ export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: Proce {processNames[rt.processId] ?? rt.processId}
- {rt.status} + {formatProcessStatus(rt)} onKill(rt.processId)} - disabled={rt.status === "stopped"} + disabled={!isActiveProcessStatus(rt.status)} style={{ display: "inline-flex", alignItems: "center", @@ -284,11 +325,11 @@ export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: Proce width: 24, height: 24, background: "transparent", - border: `1px solid ${rt.status === "stopped" ? COLORS.border : COLORS.danger + "30"}`, + border: `1px solid ${isActiveProcessStatus(rt.status) ? COLORS.danger + "30" : COLORS.border}`, borderRadius: 0, - color: rt.status === "stopped" ? COLORS.textDim : COLORS.danger, - cursor: rt.status === "stopped" ? "default" : "pointer", - opacity: rt.status === "stopped" ? 0.4 : 1, + color: isActiveProcessStatus(rt.status) ? COLORS.danger : COLORS.textDim, + cursor: isActiveProcessStatus(rt.status) ? "pointer" : "default", + opacity: isActiveProcessStatus(rt.status) ? 1 : 0.4, }} title="Kill process" > @@ -320,7 +361,7 @@ export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: Proce
- {sessions.length === 0 ? ( + {inspectableRuntimes.length === 0 ? (
- Start a command to open its inspector terminal here. + Start a command to see its stdout and stderr here.
) : ( <> @@ -341,13 +382,14 @@ export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: Proce paddingBottom: 8, }} > - {sessions.map((session) => { - const isActive = session.id === activeSession?.id; + {inspectableRuntimes.map((runtime) => { + const isActive = runtime.processId === activeRuntime?.processId; + const label = processNames[runtime.processId] ?? runtime.processId; return ( @@ -376,38 +418,142 @@ export function ProcessMonitor({ laneId, runtimes, processNames, onKill }: Proce })}
- {activeSession?.ptyId ? ( + {activeRuntime ? (
- +
+ + {logError ? ( +
+ {logError} +
+ ) : null} + +
{ + const element = event.currentTarget; + const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + setPauseAutoscroll(distanceFromBottom > 24); + }} style={{ - position: "absolute", - top: 8, - right: 8, - zIndex: 2, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 24, - height: 24, - background: COLORS.cardBg, + height: 220, + overflowY: "auto", + overflowX: "hidden", + padding: 12, border: `1px solid ${COLORS.border}`, - color: COLORS.textMuted, - cursor: "pointer", + borderWidth: "1px 0 0", }} - title="Close inspector terminal" > - - - +
+                        {logText || logPlaceholder}
+                      
+
) : (
- This inspector terminal has ended. + Select a process to inspect its output.
)} diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index 1831490df..c285a51c6 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -178,9 +178,10 @@ export function RunPage() { const [editingProcess, setEditingProcess] = useState<{ id: string; values: AddCommandInitialValues } | null>(null); const [moveToStackProcessId, setMoveToStackProcessId] = useState(null); const [networkDrawerOpen, setNetworkDrawerOpen] = useState(false); + const [actionError, setActionError] = useState(null); const runtimeRefreshTimerRef = useRef(null); - const effectiveLaneId = runLaneId ?? selectedLaneId ?? lanes[0]?.id ?? null; + const effectiveLaneId = runLaneId ?? selectedLaneId ?? null; const effectiveLaneIdRef = useRef(effectiveLaneId); effectiveLaneIdRef.current = effectiveLaneId; const selectedLane = useMemo( @@ -235,6 +236,10 @@ export function RunPage() { void refreshDefinitions(); }, [refreshDefinitions, showWelcome]); + useEffect(() => { + setActionError(null); + }, [effectiveLaneId]); + useEffect(() => { if (runtimeRefreshTimerRef.current != null) { window.clearTimeout(runtimeRefreshTimerRef.current); @@ -261,7 +266,7 @@ export function RunPage() { const unsub = window.ade.processes.onEvent((ev: ProcessEvent) => { if (ev.type === "runtime") { const currentLaneId = effectiveLaneIdRef.current; - if (currentLaneId && ev.runtime.laneId !== currentLaneId) return; + if (!currentLaneId || ev.runtime.laneId !== currentLaneId) return; setRuntime((prev) => { const idx = prev.findIndex( (r) => r.processId === ev.runtime.processId && r.laneId === ev.runtime.laneId @@ -279,13 +284,22 @@ export function RunPage() { }, []); // Derived - const stacks: StackButtonDefinition[] = config?.effective.stackButtons ?? []; + const stacks = useMemo( + () => config?.effective.stackButtons ?? [], + [config?.effective.stackButtons], + ); const processNames = useMemo(() => { const map: Record = {}; for (const d of definitions) map[d.id] = d.name; return map; }, [definitions]); + const processDefinitions = useMemo(() => { + const map: Record = {}; + for (const definition of definitions) map[definition.id] = definition; + return map; + }, [definitions]); + const filteredDefinitions = useMemo(() => { if (!selectedStackId) return definitions; const stack = stacks.find((s) => s.id === selectedStackId); @@ -305,37 +319,24 @@ export function RunPage() { async (processId: string) => { try { if (!effectiveLaneId) return; - const def = definitions.find((d) => d.id === processId); - if (!def) return; - // Start the managed process + setActionError(null); await window.ade.processes.start({ laneId: effectiveLaneId, processId }); - // Also open a shell in the Work tab for manual inspection, but do not - // replay the managed process command there. - try { - await window.ade.pty.create({ - laneId: effectiveLaneId, - cols: 120, - rows: 30, - title: `${def.name} inspector`, - tracked: true, - toolType: "run-shell", - }); - } catch { - // Terminal creation is best-effort - } } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); console.error("[RunPage] handleRun failed:", err); } }, - [effectiveLaneId, definitions] + [effectiveLaneId] ); const handleStop = useCallback( async (processId: string) => { try { if (!effectiveLaneId) return; + setActionError(null); await window.ade.processes.stop({ laneId: effectiveLaneId, processId }); } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); console.error("[RunPage] handleStop failed:", err); } }, @@ -346,8 +347,10 @@ export function RunPage() { async (processId: string) => { try { if (!effectiveLaneId) return; + setActionError(null); await window.ade.processes.kill({ laneId: effectiveLaneId, processId }); } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); console.error("[RunPage] handleKill failed:", err); } }, @@ -357,56 +360,29 @@ export function RunPage() { const handleStartAll = useCallback(async () => { try { if (!effectiveLaneId) return; + setActionError(null); if (selectedStackId) { await window.ade.processes.startStack({ laneId: effectiveLaneId, stackId: selectedStackId }); } else { await window.ade.processes.startAll({ laneId: effectiveLaneId }); } - // Create inspector terminals for each process being started so the user - // gets a shell tab per process (matching the behavior of handleRun). - // Filter out processes that are already running to avoid spawning duplicate - // inspector tabs when "Start All" is used on a partially running selection. - const allTargetDefs = selectedStackId - ? (() => { - const stack = stacks.find((s) => s.id === selectedStackId); - if (!stack) return definitions; - const ids = new Set(stack.processIds); - return definitions.filter((d) => ids.has(d.id)); - })() - : definitions; - const runningStatuses = new Set(["running", "starting"]); - const targetDefs = allTargetDefs.filter((d) => { - const rt = runtimeMap[d.id]; - return !rt || !runningStatuses.has(rt.status); - }); - for (const def of targetDefs) { - try { - await window.ade.pty.create({ - laneId: effectiveLaneId, - cols: 120, - rows: 30, - title: `${def.name} inspector`, - tracked: true, - toolType: "run-shell", - }); - } catch { - // Terminal creation is best-effort - } - } } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); console.error("[RunPage] handleStartAll failed:", err); } - }, [effectiveLaneId, selectedStackId, definitions, stacks, runtimeMap]); + }, [effectiveLaneId, selectedStackId]); const handleStopAll = useCallback(async () => { try { if (!effectiveLaneId) return; + setActionError(null); if (selectedStackId) { await window.ade.processes.stopStack({ laneId: effectiveLaneId, stackId: selectedStackId }); } else { await window.ade.processes.stopAll({ laneId: effectiveLaneId }); } } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); console.error("[RunPage] handleStopAll failed:", err); } }, [effectiveLaneId, selectedStackId]); @@ -668,7 +644,7 @@ export function RunPage() { Lane