From e9432f283257016f3b092c071aa88f47890a9475 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:01:49 -0400 Subject: [PATCH 1/6] Agent: bind sessions to lane worktrees; rebase Bind AI agent sessions to the selected lane worktree and persist that binding, injecting an "ADE launch directive" into initial turns and CI-backed launches. Add lane launch context resolution (new laneLaunchContext module) and thread laneWorktreePath through MCP launches, tool creation, and system prompts; reset runtimes when the execution lane/worktree changes. Track a laneDirectiveKey in session state to avoid redundant injections and use the resolved execution lane for HEAD/sha computations and tool defaults. Revamp auto-rebase logic: introduce RebaseNeed usage and scanning, root sweeping with debounce, queuing roots from needs, auto-push handling (and rollback on failure), rebase attention status persistence/emission, and improved status messages. Add tests covering lane launch directives and many auto-rebase scenarios. Also wire autoRebaseService into app startup and add a brief systemPrompt warning that agents are bound to the worktree. --- apps/desktop/src/main/main.ts | 1 + .../main/services/ai/tools/systemPrompt.ts | 1 + .../services/chat/agentChatService.test.ts | 205 +++++++++- .../main/services/chat/agentChatService.ts | 183 ++++++++- .../services/lanes/autoRebaseService.test.ts | 191 ++++++++- .../main/services/lanes/autoRebaseService.ts | 192 ++++++--- .../main/services/lanes/laneLaunchContext.ts | 72 ++++ .../main/services/lanes/laneService.test.ts | 65 ++++ .../src/main/services/lanes/laneService.ts | 4 +- .../services/lanes/rebaseSuggestionService.ts | 18 +- .../unifiedOrchestratorAdapter.ts | 2 +- .../prs/prService.landAutoRebase.test.ts | 368 ++++++++++++++++++ .../src/main/services/prs/prService.ts | 270 ++++++++++++- .../src/main/services/pty/ptyService.test.ts | 64 ++- .../src/main/services/pty/ptyService.ts | 13 +- .../components/graph/WorkspaceGraphPage.tsx | 1 + .../components/graph/graphNodes/LaneNode.tsx | 1 + .../lanes/LaneGitActionsPane.test.tsx | 42 +- .../components/lanes/LaneGitActionsPane.tsx | 42 +- .../components/lanes/LaneRebaseBanner.tsx | 10 + .../renderer/components/lanes/LanesPage.tsx | 8 + .../components/prs/PrRebaseBanner.tsx | 4 +- .../src/renderer/components/run/RunPage.tsx | 2 +- .../settings/LaneBehaviorSection.tsx | 84 +++- .../components/terminals/WorkStartSurface.tsx | 16 +- apps/desktop/src/shared/types/lanes.ts | 2 +- 26 files changed, 1696 insertions(+), 165 deletions(-) create mode 100644 apps/desktop/src/main/services/lanes/laneLaunchContext.ts create mode 100644 apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 05368516a..dfee935f0 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(); 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..06970582f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -212,6 +212,9 @@ import { 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 { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; import type { AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; @@ -232,16 +235,21 @@ 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", worktreePath: laneRoots["lane-1"] }, + { id: "lane-2", name: "Selected", laneType: "feature", worktreePath: laneRoots["lane-2"] }, ]; return { - getLaneBaseAndBranch: vi.fn((_laneId: string) => ({ + getLaneBaseAndBranch: vi.fn((laneId: string) => ({ baseRef: "main", - branchRef: "feature/test", - worktreePath: tmpRoot, - laneType: "feature", + branchRef: laneId === "lane-1" ? "feature/primary" : "feature/selected", + worktreePath: laneRoots[laneId] ?? tmpRoot, + laneType: laneId === "lane-1" ? "primary" : "feature", })), list: vi.fn(async () => lanes), ensurePrimaryLane: vi.fn(async () => {}), @@ -251,9 +259,10 @@ function createMockLaneService() { name, description: description ?? null, laneType: "feature", - worktreePath: tmpRoot, + 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 +744,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 +996,171 @@ 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 laneRoot = path.join(tmpRoot, "lane-2"); + 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 selectedLaneRoot = path.join(tmpRoot, "lane-2"); + 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); + }); + }); + // -------------------------------------------------------------------------- // listSessions // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ceede8d07..684cb29ed 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; }; @@ -497,6 +499,7 @@ type ManagedChatSession = { continuitySummaryInFlight: boolean; preferredExecutionLaneId: string | null; selectedExecutionLaneId: string | null; + lastLaneDirectiveKey: string | null; localPendingInputs: Map void; }; @@ -1048,6 +1052,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 +2065,7 @@ export function createAgentChatService(args: { }); const buildAdeMcpServers = ( + workspaceRoot: string, provider: "claude" | "codex", defaultRole: "agent" | "cto", ownerId?: string | null, @@ -2050,7 +2073,7 @@ export function createAgentChatService(args: { computerUsePolicy?: ComputerUsePolicy | null, ): Record> => { const launch = resolveAdeMcpServerLaunch({ - workspaceRoot: projectRoot, + workspaceRoot, runtimeRoot: resolveMcpRuntimeRoot(), defaultRole, ownerId: ownerId ?? undefined, @@ -2067,12 +2090,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 +2109,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 +2959,69 @@ 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 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")) { + 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 +3077,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 +3190,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]); @@ -3847,7 +3931,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); } @@ -3933,6 +4017,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,9 +4045,14 @@ export function createAgentChatService(args: { text: string; attachments: AgentChatFileRef[]; turnId?: string; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): void => { + if (args.laneDirectiveKey && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { + managed.lastLaneDirectiveKey = args.laneDirectiveKey; + persistChatState(managed); + } emitChatEvent(managed, { type: "user_message", text: args.text, @@ -3979,6 +4069,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4007,6 +4098,7 @@ 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", { @@ -4061,6 +4153,7 @@ export function createAgentChatService(args: { emitPreparedUserMessage(managed, { text: displayText, attachments, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); if (autoMemoryNotice) { @@ -4185,6 +4278,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4215,6 +4309,7 @@ export function createAgentChatService(args: { text: displayText, attachments, turnId, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); @@ -4814,7 +4909,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); } @@ -4907,6 +5002,7 @@ export function createAgentChatService(args: { displayText?: string; attachments?: AgentChatFileRef[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; onDispatched?: () => void; }, ): Promise => { @@ -4940,6 +5036,7 @@ export function createAgentChatService(args: { text: displayText, attachments, turnId, + laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, }); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); @@ -5039,6 +5136,7 @@ export function createAgentChatService(args: { }; }); const lightweight = isLightweightSession(managed.session); + const executionLaneId = resolveManagedExecutionLaneId(managed); const tools = lightweight ? {} : createUniversalToolSet(managed.laneWorktreePath, { @@ -5211,7 +5309,7 @@ export function createAgentChatService(args: { }); }, sessionId: managed.session.id, - laneId: managed.session.laneId, + laneId: executionLaneId, }); Object.assign(tools, workflowTools); @@ -5225,7 +5323,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 }) => @@ -5500,7 +5598,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); } @@ -6167,7 +6265,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); } @@ -6805,6 +6903,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 +6993,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 +7007,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 +7239,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 +7303,7 @@ export function createAgentChatService(args: { continuitySummaryInFlight: false, preferredExecutionLaneId: null, selectedExecutionLaneId: null, + lastLaneDirectiveKey: null, activeAssistantMessageId: null, previewTextBuffer: null, bufferedText: null, @@ -7343,7 +7453,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 +7592,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 +7608,7 @@ export function createAgentChatService(args: { continuitySummaryInFlight: false, preferredExecutionLaneId: null, selectedExecutionLaneId: null, + lastLaneDirectiveKey: null, activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, @@ -7654,6 +7769,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 +7829,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 +7862,7 @@ export function createAgentChatService(args: { resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, + laneDirectiveKey, }; }; @@ -7812,6 +7937,7 @@ export function createAgentChatService(args: { attachments, resolvedAttachments, reasoningEffort, + laneDirectiveKey, onDispatched, } = prepared; @@ -7826,7 +7952,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 +8026,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 +8043,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 ( @@ -8098,6 +8245,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) }); @@ -8848,6 +8996,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/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts index 2b0817643..370d16539 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 () => laneList.map((lane) => resolveNeed(lane.id)).filter((need): need is RebaseNeed => need !== null)), }; projectConfigService = { getEffective: vi.fn(() => ({ git: { autoRebaseOnHeadChange: true } })), @@ -511,6 +546,80 @@ describe("autoRebaseService", () => { }); }); + // --------------------------------------------------------------------------- + // 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: [] }); + + 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", + }), + ]), + }); + }); + }); + // --------------------------------------------------------------------------- // processRoot — cascade behavior (tested indirectly via onHeadChanged + timers) // @@ -562,15 +671,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 +702,61 @@ 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 Promise.resolve(); + + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-1", + reason: "auto_rebase", + }), + ); + expect(laneService.rebasePush).toHaveBeenCalledWith({ runId: "run-1", laneIds: ["child-1"] }); + }); + + 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" }); + }); + it("marks downstream lanes as rebasePending when an ancestor has conflicts", async () => { const service = createService(); const root = makeLane("root"); @@ -606,12 +771,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 +811,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..ff38923bf 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -4,7 +4,7 @@ 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; @@ -12,6 +12,7 @@ type StoredStatus = AutoRebaseLaneStatus; 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 +23,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; @@ -50,6 +53,18 @@ function byCreatedAtAsc(a: LaneSummary, b: LaneSummary): number { return a.name.localeCompare(b.name); } +function resolveRootLaneId(laneId: string, laneById: Map): string { + let current = laneId; + const visited = new Set(); + while (!visited.has(current)) { + visited.add(current); + const lane = laneById.get(current); + if (!lane?.parentLaneId) return current; + current = lane.parentLaneId; + } + return laneId; +} + export function createAutoRebaseService(args: { db: AdeDb; logger: Logger; @@ -74,6 +89,8 @@ export function createAutoRebaseService(args: { reason: string; }; const queueByRoot = new Map(); + let sweepInFlight = false; + let lastSweepAtMs = 0; const isEnabled = (): boolean => { try { @@ -113,6 +130,7 @@ export function createAutoRebaseService(args: { }; const listStatuses = async (): Promise => { + void maybeSweepRoots("listStatuses"); const lanes = await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const nowMs = Date.now(); @@ -170,6 +188,58 @@ export function createAutoRebaseService(args: { } }; + const queueRootsFromNeeds = async (needs: RebaseNeed[], reason: string): Promise => { + if (needs.length === 0) return; + const lanes = await laneService.list({ includeArchived: false }); + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const rootLaneIds = new Set(); + + for (const need of needs) { + const lane = laneById.get(need.laneId); + if (!lane?.parentLaneId) continue; + rootLaneIds.add(resolveRootLaneId(lane.id, laneById)); + } + + for (const rootLaneId of rootLaneIds) { + queueRoot({ rootLaneId, reason: `sweep:${reason}` }); + } + }; + + const maybeSweepRoots = async (reason: string, options?: { force?: boolean }): Promise => { + if (!isEnabled()) return; + const now = Date.now(); + if (sweepInFlight) return; + if (!options?.force && now - lastSweepAtMs < SWEEP_DEBOUNCE_MS) return; + + sweepInFlight = true; + lastSweepAtMs = now; + try { + const needs = await conflictService.scanRebaseNeeds(); + await queueRootsFromNeeds(needs, reason); + } catch (error) { + logger.warn("autoRebase.sweep_failed", { reason, error: String(error) }); + } finally { + sweepInFlight = false; + } + }; + + const refreshActiveRebaseNeeds = async (reason = "external_refresh"): Promise => { + await maybeSweepRoots(reason, { force: true }); + await emit(); + }; + + const recordAttentionStatus = async (status: { + laneId: string; + parentLaneId: string | null; + parentHeadSha: string | null; + state: AutoRebaseLaneState; + conflictCount: number; + message?: string | null; + }): Promise => { + setStatus(status); + await emit(); + }; + const collectDescendantsDepthFirst = (rootLaneId: string, lanes: LaneSummary[]): string[] => { const childrenByParent = new Map(); for (const lane of lanes) { @@ -225,20 +295,12 @@ 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) { - const existing = loadStatus(lane.id); - if (existing?.state !== "autoRebased") { - clearStatus(lane.id); - } - continue; - } - const parent = laneById.get(lane.parentLaneId); if (!parent) { setStatus({ @@ -247,36 +309,37 @@ export function createAutoRebaseService(args: { parentHeadSha: null, state: "rebasePending", conflictCount: 0, - message: "Pending: parent lane is unavailable." + message: "Pending: parent lane is unavailable. Open the Rebase tab to review the lane." }); blocked = true; blockedLaneId = lane.id; continue; } - const simulation = await conflictService.simulateMerge({ laneAId: lane.id, laneBId: parent.id }); - if (simulation.outcome !== "clean") { + const need = await conflictService.getRebaseNeed(lane.id).catch((error) => { + logger.warn("autoRebase.need_lookup_failed", { laneId: lane.id, error: String(error) }); + return null; + }); + + if (!need) { + const existing = loadStatus(lane.id); + if (existing?.state !== "autoRebased") { + clearStatus(lane.id); + } + continue; + } + + if (need.conflictPredicted) { 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." - }); - } + setStatus({ + laneId: lane.id, + parentLaneId: lane.parentLaneId, + parentHeadSha: await getHeadSha(parent.worktreePath), + 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.` + }); continue; } @@ -295,23 +358,56 @@ export function createAutoRebaseService(args: { laneId: lane.id, parentLaneId: lane.parentLaneId, parentHeadSha: await getHeadSha(parent.worktreePath), - state: conflictHint ? "rebaseConflict" : "rebasePending", + 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.` - }); + try { + const pushedRun = await laneService.rebasePush({ runId: rebaseRun.runId, laneIds: [lane.id] }); + const pushedLane = pushedRun.lanes.find((entry) => entry.laneId === lane.id); + if (!pushedRun.pushedLaneIds.includes(lane.id) || pushedLane?.pushed !== true) { + throw new Error("Auto-push did not complete for the rebased lane."); + } + + setStatus({ + laneId: lane.id, + parentLaneId: lane.parentLaneId, + parentHeadSha: await getHeadSha(parent.worktreePath), + state: "autoRebased", + conflictCount: 0, + message: `Rebased and pushed automatically after '${parent.name}' advanced.` + }); + } catch (error) { + let rollbackError: string | null = null; + try { + await laneService.rebaseRollback({ runId: rebaseRun.runId }); + } 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: await getHeadSha(parent.worktreePath), + 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 }); @@ -363,7 +459,7 @@ export function createAutoRebaseService(args: { }): Promise => { 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 }); }; @@ -371,6 +467,8 @@ export function createAutoRebaseService(args: { return { listStatuses, onHeadChanged, - emit + emit, + refreshActiveRebaseNeeds, + recordAttentionStatus }; } 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..5909ab473 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts @@ -0,0 +1,72 @@ +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); + } + return targetPath; +} + +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 { + throw new Error( + `Requested cwd '${requestedCwd}' escapes lane '${laneId}'. ADE only launches work inside the selected lane worktree '${laneRoot}'.`, + ); + } + + 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..32c5e4669 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -943,3 +943,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..ac19b4439 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1809,8 +1809,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..e0234829c 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 = { @@ -121,7 +121,21 @@ 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; + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parentBranch, + }).catch(() => {}); + parentHeadSha = await readRefHeadSha(`origin/${parentBranch}`); + if (!parentHeadSha) { + parentHeadSha = await getHeadSha(parent.worktreePath); + } + } else { + parentHeadSha = await getHeadSha(parent.worktreePath); + } if (!parentHeadSha) return null; return { parentLaneId: lane.parentLaneId, 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..06a0ae117 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,186 @@ 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 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 + ? allLanes.find((lane) => lane.id === landedLane.parentLaneId) ?? null + : null; + if (!successorParent || successorParent.archivedAt) { + successorParent = allLanes.find((lane) => lane.laneType === "primary" && !lane.archivedAt) ?? null; + } + + if (!successorParent) { + for (const child of directChildren) { + await autoRebaseService?.recordAttentionStatus({ + 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.`, + }); + } + 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 = (await laneService.list({ includeArchived: true })).find((lane) => lane.id === child.id) ?? { + ...child, + parentLaneId: successorParent.id, + baseRef: successorParent.branchRef, + }; + 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) { + await autoRebaseService?.recordAttentionStatus({ + 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.`, + }); + failedLaneIds.push(child.id); + continue; + } + } + await autoRebaseService?.recordAttentionStatus({ + laneId: child.id, + parentLaneId: successorParent.id, + parentHeadSha: null, + state: "autoRebased", + conflictCount: 0, + message: `Rebased and pushed automatically after '${args.landedLaneName}' merged.`, + }); + 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, + }); + } catch (restoreError) { + rollbackError = getErrorMessage(restoreError); + logger.warn("prs.child_auto_rebase_restore_failed", { + landedLaneId: args.landedLaneId, + childLaneId: child.id, + error: rollbackError, + }); + } + } + await autoRebaseService?.recordAttentionStatus({ + 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.`, + }); + failedLaneIds.push(child.id); + } + } + + return { + updatedLaneIds, + failedLaneIds, + blockCleanup: failedLaneIds.length > 0, + }; + }; + const upsertSnapshotRow = (args: { prId: string; detail?: PrDetail | null; @@ -1884,18 +2067,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 +2077,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 +2096,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: false, + }; + }); + 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 +2167,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 +2186,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..005c2a351 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"; // --------------------------------------------------------------------------- @@ -12,7 +13,26 @@ const mocks = vi.hoisted(() => { existsSyncResults, 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) => path.resolve(p)), + { native: vi.fn((p: string) => path.resolve(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 +64,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 +73,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, @@ -247,27 +271,49 @@ 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("clamps very small dimensions to minimum values", async () => { const { service, loadPty } = createHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index c9f1251ea..87c2b51ad 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"; @@ -500,11 +501,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(); 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/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..981f6b054 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." }; } @@ -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 ? ( ) : ( + ) : status.state === "rebaseFailed" ? ( + ) : (
-
- +
diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 16c15c77c..c216b0272 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -8,6 +8,7 @@ import { } from "@phosphor-icons/react"; import type { LaneSummary, AgentChatPermissionMode } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; +import { useAppStore } from "../../state/appStore"; import { AgentChatPane } from "../chat/AgentChatPane"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; @@ -144,7 +145,13 @@ export function WorkStartSurface({ onOpenChatSession, onLaunchPtySession, }: WorkStartSurfaceProps) { - const [selectedLaneId, setSelectedLaneId] = useState(lanes[0]?.id ?? ""); + const globallySelectedLaneId = useAppStore((s) => s.selectedLaneId); + const [selectedLaneId, setSelectedLaneId] = useState(() => { + if (globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId)) { + return globallySelectedLaneId; + } + return lanes[0]?.id ?? ""; + }); const [cliProvider, setCliProvider] = useState("claude"); const [cliPermissionMode, setCliPermissionMode] = useState("default"); const [launchBusy, setLaunchBusy] = useState(false); @@ -158,9 +165,12 @@ export function WorkStartSurface({ return; } if (!selectedLaneId || !lanes.some((lane) => lane.id === selectedLaneId)) { - setSelectedLaneId(lanes[0]!.id); + const fallbackLaneId = globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId) + ? globallySelectedLaneId + : lanes[0]!.id; + setSelectedLaneId(fallbackLaneId); } - }, [lanes, selectedLaneId]); + }, [globallySelectedLaneId, lanes, selectedLaneId]); const cliPermissionOptions = useMemo( () => getPermissionOptions({ diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index f7e032890..fcab0d51e 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -278,7 +278,7 @@ export type RebaseSuggestionsEventPayload = { suggestions: RebaseSuggestion[]; }; -export type AutoRebaseLaneState = "autoRebased" | "rebasePending" | "rebaseConflict"; +export type AutoRebaseLaneState = "autoRebased" | "rebasePending" | "rebaseConflict" | "rebaseFailed"; export type AutoRebaseLaneStatus = { laneId: string; From c30a6e27dc8feac5f061028db54dd0d76d86fcb7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:46:12 -0400 Subject: [PATCH 2/6] Improve auto-rebase, pty, and chat lane handling Multiple fixes and enhancements across auto-rebase, pty, chat, and UI wiring: - main: ensure autoRebaseService is disposed on shutdown. - agentChatService: only persist lane directive keys when a directive is actually delivered; refresh and record headShaStart for the managed execution lane; centralize prepare/execute flow for queued steers; use launchContext path for initial headStart. - ptyService/tests: track boundCwd and laneWorktreePath on Pty entries and use the bound cwd for AI title/summaries (even if lane mapping changes later); preserve non-escape cwd errors instead of rewriting them; wire tests to allow aiIntegrationService overrides. - autoRebaseService: added types, disposal, timer clearing, sweep concurrency guard and promise tracking, resolveAffectedChainLaneId to restrict chain resolution to affected sets, includeAll option for listing/emitting statuses, preserve existing status when lookup fails, and keep sibling chains independent during a sweep. Also added/updated tests for timer disposal, attention status, forced refresh behavior, and other edge cases. - rebaseSuggestionService/prService: cache primary parent head SHAs by branch to avoid redundant fetches; use allLanes map to avoid repeated list lookups and update it after changes. - renderer: pass laneId when opening rebase details and use defaultTrackedCliStartupCommand for CLI startup commands. - utils: adjust default resume command for codex to include --no-alt-screen and improve laneLaunchContext error handling. Overall these changes tighten lifecycle management, reduce redundant work, improve AI summary/title reliability, and make auto-rebase behavior more robust under concurrent/edge conditions. --- apps/desktop/src/main/main.ts | 5 + .../services/chat/agentChatService.test.ts | 100 +++++ .../main/services/chat/agentChatService.ts | 68 +++- .../services/lanes/autoRebaseService.test.ts | 156 ++++++++ .../main/services/lanes/autoRebaseService.ts | 137 +++++-- .../main/services/lanes/laneLaunchContext.ts | 11 +- .../services/lanes/rebaseSuggestionService.ts | 29 +- .../src/main/services/prs/prService.ts | 13 +- .../src/main/services/pty/ptyService.test.ts | 87 ++++- .../src/main/services/pty/ptyService.ts | 43 +- .../src/main/utils/terminalSessionSignals.ts | 2 +- .../components/lanes/LaneGitActionsPane.tsx | 4 +- .../components/lanes/LaneRebaseBanner.tsx | 8 +- .../components/lanes/LaneTerminalsPanel.tsx | 3 +- .../renderer/components/lanes/LanesPage.tsx | 10 +- .../components/lanes/useLaneWorkSessions.ts | 7 +- .../renderer/components/run/CommandCard.tsx | 23 +- .../components/run/ProcessMonitor.tsx | 366 +++++++++++++----- .../src/renderer/components/run/RunPage.tsx | 99 +++-- .../settings/LaneBehaviorSection.tsx | 9 +- .../components/terminals/TerminalView.tsx | 7 +- .../components/terminals/WorkStartSurface.tsx | 34 +- .../components/terminals/WorkViewArea.tsx | 38 +- .../components/terminals/cliLaunch.ts | 45 +++ .../components/terminals/useWorkSessions.ts | 11 +- 25 files changed, 1025 insertions(+), 290 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/cliLaunch.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index dfee935f0..f465ed47c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2330,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/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 06970582f..27189ed98 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -214,6 +214,7 @@ 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"; @@ -1159,6 +1160,89 @@ describe("createAgentChatService", () => { 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(); + }); }); // -------------------------------------------------------------------------- @@ -1265,6 +1349,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", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 684cb29ed..828b8f7c8 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2984,6 +2984,13 @@ export function createAgentChatService(args: { ?? 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 }, @@ -4049,10 +4056,6 @@ export function createAgentChatService(args: { onDispatched?: () => void; }, ): void => { - if (args.laneDirectiveKey && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { - managed.lastLaneDirectiveKey = args.laneDirectiveKey; - persistChatState(managed); - } emitChatEvent(managed, { type: "user_message", text: args.text, @@ -4062,6 +4065,15 @@ 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 sendCodexMessage = async ( managed: ManagedChatSession, args: { @@ -4105,6 +4117,7 @@ export function createAgentChatService(args: { 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; @@ -4170,6 +4183,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) { @@ -4422,6 +4436,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. @@ -4920,7 +4935,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) { @@ -5571,6 +5594,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 }); @@ -5609,7 +5633,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) { @@ -7640,7 +7672,7 @@ export function createAgentChatService(args: { managedSessions.set(sessionId, managed); - const headStart = await computeHeadShaBestEffort(laneId).catch(() => null); + const headStart = await computeHeadShaBestEffort(launchContext.laneWorktreePath).catch(() => null); if (headStart) { sessionService.setHeadShaStart(sessionId, headStart); } @@ -7862,7 +7894,7 @@ export function createAgentChatService(args: { resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, - laneDirectiveKey, + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }; }; @@ -8134,7 +8166,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; } @@ -8192,7 +8231,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 => { @@ -8480,6 +8526,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(); @@ -8546,6 +8593,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; diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts index 370d16539..788112402 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -544,6 +544,35 @@ 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(); + } + }); }); // --------------------------------------------------------------------------- @@ -618,6 +647,57 @@ describe("autoRebaseService", () => { ]), }); }); + + 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); + }); }); // --------------------------------------------------------------------------- @@ -655,6 +735,82 @@ 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: [], + }); + + 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.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 diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index ff38923bf..1657af489 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -8,6 +8,31 @@ import type { AutoRebaseEventPayload, AutoRebaseLaneState, AutoRebaseLaneStatus, import { isRecord, nowIso } from "../shared/utils"; type StoredStatus = AutoRebaseLaneStatus; +type ListStatusesOptions = { + includeAll?: boolean; +}; +type AttentionStatusInput = { + laneId: string; + parentLaneId: string | null; + parentHeadSha: string | null; + state: AutoRebaseLaneState; + conflictCount: number; + message?: string | null; +}; + +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; @@ -53,13 +78,19 @@ function byCreatedAtAsc(a: LaneSummary, b: LaneSummary): number { return a.name.localeCompare(b.name); } -function resolveRootLaneId(laneId: string, laneById: Map): string { +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) return current; + if (!lane?.parentLaneId || !affectedLaneIds.has(lane.parentLaneId)) { + return current; + } current = lane.parentLaneId; } return laneId; @@ -72,7 +103,7 @@ export function createAutoRebaseService(args: { conflictService: ReturnType; projectConfigService: ReturnType; onEvent?: (event: AutoRebaseEventPayload) => void; -}) { +}): AutoRebaseService { const { db, logger, @@ -89,7 +120,8 @@ export function createAutoRebaseService(args: { reason: string; }; const queueByRoot = new Map(); - let sweepInFlight = false; + let disposed = false; + let sweepPromise: Promise | null = null; let lastSweepAtMs = 0; const isEnabled = (): boolean => { @@ -110,14 +142,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, @@ -129,7 +154,7 @@ export function createAutoRebaseService(args: { }); }; - const listStatuses = async (): Promise => { + const listStatuses = async (options?: ListStatusesOptions): Promise => { void maybeSweepRoots("listStatuses"); const lanes = await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); @@ -154,7 +179,7 @@ export function createAutoRebaseService(args: { clearStatus(lane.id); continue; } - } else if (lane.status.behind <= 0) { + } else if (!options?.includeAll && lane.status.behind <= 0) { clearStatus(lane.id); continue; } else if (status.parentLaneId && !laneById.has(status.parentLaneId)) { @@ -174,10 +199,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(), @@ -192,12 +217,13 @@ export function createAutoRebaseService(args: { if (needs.length === 0) return; const lanes = await laneService.list({ includeArchived: false }); 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?.parentLaneId) continue; - rootLaneIds.add(resolveRootLaneId(lane.id, laneById)); + rootLaneIds.add(resolveAffectedChainLaneId(lane.id, laneById, affectedLaneIds)); } for (const rootLaneId of rootLaneIds) { @@ -206,21 +232,31 @@ export function createAutoRebaseService(args: { }; const maybeSweepRoots = async (reason: string, options?: { force?: boolean }): Promise => { - if (!isEnabled()) return; + if (disposed || !isEnabled()) return; + if (options?.force && sweepPromise) { + await sweepPromise.catch(() => {}); + } + if (sweepPromise) return; const now = Date.now(); - if (sweepInFlight) return; if (!options?.force && now - lastSweepAtMs < SWEEP_DEBOUNCE_MS) return; - sweepInFlight = true; - lastSweepAtMs = now; - try { - const needs = await conflictService.scanRebaseNeeds(); - await queueRootsFromNeeds(needs, reason); - } catch (error) { - logger.warn("autoRebase.sweep_failed", { reason, error: String(error) }); - } finally { - sweepInFlight = false; - } + let currentSweep: Promise; + currentSweep = (async () => { + lastSweepAtMs = now; + try { + const needs = await conflictService.scanRebaseNeeds(); + if (disposed) return; + await queueRootsFromNeeds(needs, reason); + } catch (error) { + logger.warn("autoRebase.sweep_failed", { reason, error: String(error) }); + } + })().finally(() => { + if (sweepPromise === currentSweep) { + sweepPromise = null; + } + }); + sweepPromise = currentSweep; + await currentSweep; }; const refreshActiveRebaseNeeds = async (reason = "external_refresh"): Promise => { @@ -228,16 +264,9 @@ export function createAutoRebaseService(args: { await emit(); }; - const recordAttentionStatus = async (status: { - laneId: string; - parentLaneId: string | null; - parentHeadSha: string | null; - state: AutoRebaseLaneState; - conflictCount: number; - message?: string | null; - }): Promise => { + const recordAttentionStatus = async (status: AttentionStatusInput): Promise => { setStatus(status); - await emit(); + await emit({ includeAll: true }); }; const collectDescendantsDepthFirst = (rootLaneId: string, lanes: LaneSummary[]): string[] => { @@ -264,12 +293,14 @@ 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 }); const rootLane = lanes.find((lane) => lane.id === rootLaneId) ?? null; if (!rootLane) return; - const cascadeOrder = collectDescendantsDepthFirst(rootLaneId, lanes); + const cascadeOrder = rootLane.parentLaneId + ? [rootLaneId, ...collectDescendantsDepthFirst(rootLaneId, lanes)] + : collectDescendantsDepthFirst(rootLaneId, lanes); if (cascadeOrder.length === 0) return; let blocked = false; @@ -316,12 +347,17 @@ export function createAutoRebaseService(args: { continue; } + 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 (!need) { + if (lookupFailed) { + continue; + } const existing = loadStatus(lane.id); if (existing?.state !== "autoRebased") { clearStatus(lane.id); @@ -414,6 +450,7 @@ export function createAutoRebaseService(args: { }; const runRootQueue = async (rootLaneId: string): Promise => { + if (disposed) return; const state = queueByRoot.get(rootLaneId); if (!state || state.running) return; state.running = true; @@ -435,6 +472,7 @@ export function createAutoRebaseService(args: { }; const queueRoot = (args: { rootLaneId: string; reason: string }): void => { + if (disposed) return; const rootLaneId = args.rootLaneId.trim(); if (!rootLaneId) return; @@ -446,6 +484,7 @@ export function createAutoRebaseService(args: { } existing.timer = setTimeout(() => { existing.timer = null; + if (disposed) return; void runRootQueue(rootLaneId); }, RUN_DEBOUNCE_MS); queueByRoot.set(rootLaneId, existing); @@ -457,6 +496,7 @@ 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") || args.reason === "rebase_abort" || args.reason === "rebase_rollback") return; @@ -464,11 +504,24 @@ export function createAutoRebaseService(args: { 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, refreshActiveRebaseNeeds, - recordAttentionStatus + recordAttentionStatus, + dispose }; } diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts index 5909ab473..27235e475 100644 --- a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts @@ -54,10 +54,13 @@ export function resolveLaneLaunchContext(args: { let resolvedCwd: string; try { resolvedCwd = resolvePathWithinRoot(laneRoot, requestedTarget); - } catch { - throw new Error( - `Requested cwd '${requestedCwd}' escapes lane '${laneId}'. ADE only launches work inside the selected lane worktree '${laneRoot}'.`, - ); + } 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( diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index e0234829c..d8b1e369c 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -100,6 +100,7 @@ export function createRebaseSuggestionService(args: { 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, @@ -125,13 +126,18 @@ export function createRebaseSuggestionService(args: { if (parent.laneType === "primary") { const parentBranch = parent.branchRef.trim(); if (!parentBranch) return null; - await fetchRemoteTrackingBranch({ - projectRoot, - targetBranch: parentBranch, - }).catch(() => {}); - parentHeadSha = await readRefHeadSha(`origin/${parentBranch}`); - if (!parentHeadSha) { - parentHeadSha = await getHeadSha(parent.worktreePath); + 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); @@ -154,13 +160,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, @@ -254,7 +261,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); @@ -286,7 +294,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); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 06a0ae117..21e2258f6 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -741,6 +741,7 @@ export function createPrService({ } 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) { @@ -748,7 +749,7 @@ export function createPrService({ } let successorParent = landedLane?.parentLaneId - ? allLanes.find((lane) => lane.id === landedLane.parentLaneId) ?? null + ? allLanesById.get(landedLane.parentLaneId) ?? null : null; if (!successorParent || successorParent.archivedAt) { successorParent = allLanes.find((lane) => lane.laneType === "primary" && !lane.archivedAt) ?? null; @@ -793,11 +794,12 @@ export function createPrService({ laneId: child.id, newParentLaneId: successorParent.id, }); - const refreshedChild = (await laneService.list({ includeArchived: true })).find((lane) => lane.id === child.id) ?? { - ...child, + 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) => { @@ -843,6 +845,11 @@ export function createPrService({ 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", { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 005c2a351..1ce876354 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -154,7 +154,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(); @@ -202,6 +207,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, @@ -314,6 +320,19 @@ describe("ptyService", () => { 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({ @@ -399,6 +418,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", () => { @@ -519,6 +573,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(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 87c2b51ad..80ed6b91d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -35,6 +35,8 @@ import { type PtyEntry = { pty: IPty; laneId: string; + laneWorktreePath: string; + boundCwd: string; sessionId: string; tracked: boolean; transcriptPath: string; @@ -302,7 +304,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); @@ -326,7 +339,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.", @@ -344,7 +356,7 @@ export function createPtyService({ : undefined; const aiSummary = await aiIntegrationService.summarizeTerminal({ - cwd: lane.worktreePath, + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, prompt, ...(summaryModelId ? { model: summaryModelId } : {}), }); @@ -391,13 +403,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(() => {}) @@ -555,7 +569,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(() => {}); @@ -629,7 +643,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; } @@ -637,6 +654,8 @@ export function createPtyService({ const entry: PtyEntry = { pty, laneId, + laneWorktreePath: worktreePath, + boundCwd: cwd, sessionId, tracked, transcriptPath, @@ -768,7 +787,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.", @@ -783,7 +801,7 @@ export function createPtyService({ capturedAi .summarizeTerminal({ - cwd: lane.worktreePath, + cwd: entry.boundCwd || entry.laneWorktreePath, prompt, timeoutMs: 8_000, ...(titleModelId ? { model: titleModelId } : {}), @@ -910,7 +928,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/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 30843be30..7785f3326 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -27,7 +27,7 @@ function prefersTool(raw: string, preferredTool: TerminalToolType | null | undef export function defaultResumeCommandForTool(toolType: TerminalToolType | null | undefined): string | null { if (toolType === "claude" || toolType === "claude-orchestrated") return "claude resume"; - if (toolType === "codex" || toolType === "codex-orchestrated") return "codex resume"; + if (toolType === "codex" || toolType === "codex-orchestrated") return "codex --no-alt-screen resume"; return null; } diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 981f6b054..d47add419 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -313,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; @@ -1433,7 +1433,7 @@ export function LaneGitActionsPane({ title="View rebase details" detail="See detailed rebase history, including conflicts and timing." disabled={!laneId || busyAction != null} - onClick={() => onViewRebaseDetails?.()} + onClick={() => onViewRebaseDetails?.(laneId)} /> ) : null} void; onRebaseAndPush: (laneId: string) => void; - onViewRebaseDetails: () => void; + onViewRebaseDetails: (laneId?: string | null) => void; onDismissRebase: (laneId: string) => void; onDeferRebase: (laneId: string, minutes: number) => void; onOpenAutoRebaseSettings: () => void; @@ -102,7 +102,7 @@ export function LaneRebaseBanner({ type="button" style={outlineButton({ height: 24, padding: "0 8px", fontSize: 10 })} disabled={Boolean(rebaseBusyLaneId)} - onClick={onViewRebaseDetails} + onClick={() => onViewRebaseDetails(s.laneId)} > Details @@ -202,7 +202,7 @@ export function LaneRebaseBanner({ @@ -219,7 +219,7 @@ export function LaneRebaseBanner({ 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.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 1335f3834..48871bbc1 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -989,7 +989,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 }); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 696322b2e..877b2cc05 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -5,6 +5,7 @@ import { listSessionsCached } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { isRunOwnedSession } from "../../lib/sessions"; +import { defaultTrackedCliStartupCommand } from "../terminals/cliLaunch"; const EMPTY_WORK_STATE: WorkProjectViewState = { openItemIds: [], @@ -387,7 +388,11 @@ export function useLaneWorkSessions(laneId: string | null) { startupCommand?: string; }) => { 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/run/CommandCard.tsx b/apps/desktop/src/renderer/components/run/CommandCard.tsx index bb046989f..fe419e05b 100644 --- a/apps/desktop/src/renderer/components/run/CommandCard.tsx +++ b/apps/desktop/src/renderer/components/run/CommandCard.tsx @@ -18,7 +18,16 @@ type CommandCardProps = { }; function isActive(status: ProcessRuntimeStatus | undefined): boolean { - return status === "running" || status === "starting" || status === "stopping"; + return status === "running" || status === "starting" || status === "degraded" || status === "stopping"; +} + +function statusLabel(runtime: ProcessRuntime | null): string | null { + const status = runtime?.status; + if (!status || status === "stopped") return null; + if ((status === "crashed" || status === "exited") && runtime?.lastExitCode != null) { + return `${status}:${runtime.lastExitCode}`; + } + return status; } export function CommandCard({ @@ -33,6 +42,7 @@ export function CommandCard({ }: CommandCardProps) { const status = runtime?.status; const running = isActive(status); + const statusText = statusLabel(runtime); const [menuOpen, setMenuOpen] = React.useState(false); const menuRef = React.useRef(null); @@ -45,7 +55,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 +103,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..339715cfd 100644 --- a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx +++ b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx @@ -2,93 +2,141 @@ 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, ProcessRuntimeStatus } from "../../../shared/types"; 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 isActiveStatus(status: ProcessRuntimeStatus): boolean { + return status === "running" || status === "starting" || status === "degraded" || status === "stopping"; +} - React.useEffect(() => { - void refreshSessions(); - }, [refreshSessions, runtimes.length]); +function hasInspectableOutput(runtime: ProcessRuntime): boolean { + return runtime.status !== "stopped" || runtime.startedAt != null || runtime.lastEndedAt != null || runtime.lastExitCode != null; +} + +function formatProcessStatus(runtime: ProcessRuntime): string { + if ((runtime.status === "crashed" || runtime.status === "exited") && runtime.lastExitCode != null) { + return `${runtime.status}:${runtime.lastExitCode}`; + } + return runtime.status; +} + +function formatEndedAt(value: string | null): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +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) => isActiveStatus(runtime.status)); + const activeCount = activeRuntimes.length; + const inspectableRuntimes = React.useMemo( + () => runtimes.filter((runtime) => hasInspectableOutput(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) => isActiveStatus(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) => normalizeLog(`${prev}${event.chunk}`)); }); 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; + activeProcessIdRef.current = processId; + setPauseAutoscroll(false); + setLogError(null); + setLogLoading(true); + 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 && isActiveStatus(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 +298,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={!isActiveStatus(rt.status)} style={{ display: "inline-flex", alignItems: "center", @@ -284,11 +332,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 ${isActiveStatus(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: isActiveStatus(rt.status) ? COLORS.danger : COLORS.textDim, + cursor: isActiveStatus(rt.status) ? "pointer" : "default", + opacity: isActiveStatus(rt.status) ? 1 : 0.4, }} title="Kill process" > @@ -320,7 +368,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 +389,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 +425,153 @@ 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={{ + 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 090fbc260..9fb97b8c7 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -178,6 +178,7 @@ 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 ?? null; @@ -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]); @@ -806,6 +782,24 @@ export function RunPage() { overflow: "hidden", }} > + {actionError ? ( +
+ {actionError} +
+ ) : null} + {/* Command cards grid */}
diff --git a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx index 2c660011a..824cba722 100644 --- a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx @@ -78,7 +78,11 @@ export function LaneBehaviorSection() {
@@ -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/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 48871bbc1..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() { @@ -1075,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; @@ -1087,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`); diff --git a/apps/desktop/src/renderer/components/run/CommandCard.tsx b/apps/desktop/src/renderer/components/run/CommandCard.tsx index fe419e05b..0ea0b3a01 100644 --- a/apps/desktop/src/renderer/components/run/CommandCard.tsx +++ b/apps/desktop/src/renderer/components/run/CommandCard.tsx @@ -2,9 +2,10 @@ import React from "react"; import { Play, Stop, DotsThreeVertical } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, inlineBadge, processStatusColor } from "../lanes/laneDesignTokens"; import { formatDurationMs } from "../../lib/format"; -import type { ProcessDefinition, ProcessRuntime, ProcessRuntimeStatus } from "../../../shared/types"; +import type { ProcessDefinition, ProcessRuntime } from "../../../shared/types"; import { useClickOutside } from "../../hooks/useClickOutside"; import { commandArrayToLine } from "../../lib/shell"; +import { formatProcessStatus, isActiveProcessStatus } from "./processUtils"; type CommandCardProps = { definition: ProcessDefinition; @@ -17,19 +18,6 @@ type CommandCardProps = { stacks?: { id: string }[]; }; -function isActive(status: ProcessRuntimeStatus | undefined): boolean { - return status === "running" || status === "starting" || status === "degraded" || status === "stopping"; -} - -function statusLabel(runtime: ProcessRuntime | null): string | null { - const status = runtime?.status; - if (!status || status === "stopped") return null; - if ((status === "crashed" || status === "exited") && runtime?.lastExitCode != null) { - return `${status}:${runtime.lastExitCode}`; - } - return status; -} - export function CommandCard({ definition, runtime, @@ -41,8 +29,8 @@ export function CommandCard({ stacks, }: CommandCardProps) { const status = runtime?.status; - const running = isActive(status); - const statusText = statusLabel(runtime); + const running = status ? isActiveProcessStatus(status) : false; + const statusText = runtime && status && status !== "stopped" ? formatProcessStatus(runtime) : null; const [menuOpen, setMenuOpen] = React.useState(false); const menuRef = React.useRef(null); diff --git a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx index 339715cfd..273188c47 100644 --- a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx +++ b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx @@ -3,7 +3,8 @@ 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 { commandArrayToLine } from "../../lib/shell"; -import type { ProcessDefinition, ProcessEvent, ProcessRuntime, ProcessRuntimeStatus } from "../../../shared/types"; +import type { ProcessDefinition, ProcessEvent, ProcessRuntime } from "../../../shared/types"; +import { formatProcessStatus, hasInspectableProcessOutput, isActiveProcessStatus } from "./processUtils"; type ProcessMonitorProps = { laneId: string | null; @@ -20,26 +21,15 @@ function normalizeLog(raw: string): string { return raw.replace(/\u0000/g, ""); } -function isActiveStatus(status: ProcessRuntimeStatus): boolean { - return status === "running" || status === "starting" || status === "degraded" || status === "stopping"; -} - -function hasInspectableOutput(runtime: ProcessRuntime): boolean { - return runtime.status !== "stopped" || runtime.startedAt != null || runtime.lastEndedAt != null || runtime.lastExitCode != null; -} - -function formatProcessStatus(runtime: ProcessRuntime): string { - if ((runtime.status === "crashed" || runtime.status === "exited") && runtime.lastExitCode != null) { - return `${runtime.status}:${runtime.lastExitCode}`; - } - return runtime.status; -} - -function formatEndedAt(value: string | null): string { +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([], { hour: "numeric", minute: "2-digit" }); + return date.toLocaleTimeString(locale, options); } export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNames, onKill }: ProcessMonitorProps) { @@ -54,10 +44,10 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa const laneIdRef = React.useRef(laneId); laneIdRef.current = laneId; const logRef = React.useRef(null); - const activeRuntimes = runtimes.filter((runtime) => isActiveStatus(runtime.status)); + const activeRuntimes = runtimes.filter((runtime) => isActiveProcessStatus(runtime.status)); const activeCount = activeRuntimes.length; const inspectableRuntimes = React.useMemo( - () => runtimes.filter((runtime) => hasInspectableOutput(runtime)), + () => runtimes.filter((runtime) => hasInspectableProcessOutput(runtime)), [runtimes], ); const activeRuntime = inspectableRuntimes.find((runtime) => runtime.processId === activeProcessId) ?? null; @@ -69,7 +59,7 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa } if (activeProcessId && inspectableRuntimes.some((runtime) => runtime.processId === activeProcessId)) return; const preferred = - inspectableRuntimes.find((runtime) => isActiveStatus(runtime.status)) + inspectableRuntimes.find((runtime) => isActiveProcessStatus(runtime.status)) ?? inspectableRuntimes.find((runtime) => runtime.status === "crashed") ?? inspectableRuntimes[0] ?? null; @@ -81,7 +71,10 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa if (event.type !== "log") return; if (event.laneId !== laneIdRef.current) return; if (event.processId !== activeProcessIdRef.current) return; - setLogText((prev) => normalizeLog(`${prev}${event.chunk}`)); + setLogText((prev) => { + const next = normalizeLog(`${prev}${event.chunk}`); + return next.length > LOG_TAIL_MAX_BYTES ? next.slice(-LOG_TAIL_MAX_BYTES) : next; + }); }); return () => { try { @@ -134,7 +127,7 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa const activeCwd = activeDefinition?.cwd?.trim()?.length ? activeDefinition.cwd : "."; const logPlaceholder = logLoading ? "Loading recent output..." - : activeRuntime && isActiveStatus(activeRuntime.status) + : activeRuntime && isActiveProcessStatus(activeRuntime.status) ? "Waiting for output..." : "(no output yet)"; @@ -324,7 +317,7 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa + + ); + })()} + {/* 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/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 88f78d59f..6a5604988 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -20,6 +20,7 @@ import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, Pr import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; import { PrLaneCleanupBanner } from "../shared/PrLaneCleanupBanner"; import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; +import { describePrTargetDiff } from "../shared/laneBranchTargets"; import { usePrs } from "../state/PrsContext"; // ---- Sub-tab type ---- @@ -1144,6 +1145,10 @@ function OverviewTab(props: OverviewTabProps) { const [checksExpanded, setChecksExpanded] = React.useState(false); const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); + const laneForPr = React.useMemo( + () => lanes.find((lane) => lane.id === pr.laneId) ?? null, + [lanes, pr.laneId], + ); React.useEffect(() => { setLocalMergeMethod(mergeMethod); @@ -1219,6 +1224,48 @@ function OverviewTab(props: OverviewTabProps) { + {(() => { + const targetDiffMessage = describePrTargetDiff({ + lane: laneForPr, + lanes, + targetBranch: pr.baseBranch, + }); + if (!targetDiffMessage || pr.state !== "open") return null; + return ( +
+
+ +
+ + PR target differs from lane base + + + {targetDiffMessage} + +
+ {props.onOpenRebaseTab && ( + + )} +
+
+ ); + })()} + {/* ---- 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..04af1eb00 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts @@ -0,0 +1,39 @@ +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("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..da4ee7fea 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,10 @@ 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}`; } /* ── inline style constants ── */ @@ -95,32 +92,45 @@ 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( - () => rebaseNeeds.find((n) => n.laneId === selectedItemId) ?? null, - [rebaseNeeds, selectedItemId], - ); + const selectedNeed = React.useMemo(() => { + if (!selectedItemId) return null; + return rebaseNeeds.find((need) => rebaseNeedKey(need) === selectedItemId) + ?? rebaseNeeds.find((need) => need.laneId === selectedItemId) + ?? null; + }, [rebaseNeeds, selectedItemId]); const selectedLane = React.useMemo( () => (selectedNeed ? laneById.get(selectedNeed.laneId) ?? null : null), @@ -128,6 +138,10 @@ export function RebaseTab({ ); const hasChildren = (selectedLane?.childCount ?? 0) > 0; + const selectedNeedIsPrTarget = React.useMemo( + () => (selectedNeed ? isPrTargetNeed(selectedNeed) : false), + [isPrTargetNeed, selectedNeed], + ); // Auto-default scope based on children React.useEffect(() => { @@ -154,9 +168,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 || need.laneId === selectedItemId)) return; + const first = grouped.lane_base[0] ?? grouped.pr_target[0]; + onSelectItem(first ? rebaseNeedKey(first) : null); }, [rebaseNeeds, selectedItemId, grouped, onSelectItem]); React.useEffect(() => { @@ -242,7 +256,16 @@ export function RebaseTab({ if (!selectedNeed) return; setRebaseError(null); + 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,7 +289,8 @@ export function RebaseTab({ laneId: selectedNeed.laneId, scope: runScope, pushMode, - actor: "user" + actor: "user", + ...(selectedNeedIsPrTarget ? { baseBranchOverride: selectedNeed.baseBranch } : {}), }); setActiveRun(started.run); setRunLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] Started run ${started.runId}`].slice(-80)); @@ -370,11 +394,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 ( diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index 5ffba64b5..083e19dc9 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -39,7 +39,7 @@ export function SessionInfoPopover({ }: { popover: InfoPopoverState; onClose: () => void; - onCloseSession: (ptyId: string) => void; + onCloseSession: (args: { ptyId: string; sessionId: string }) => void; onEndChat: (sessionId: string) => void; onResume: (session: TerminalSessionSummary) => void; onGoToLane: (session: TerminalSessionSummary) => void; @@ -206,7 +206,7 @@ export function SessionInfoPopover({ variant="outline" size="sm" disabled={closingPtyIds.has(session.ptyId)} - onClick={() => { if (session.ptyId) onCloseSession(session.ptyId); }} + onClick={() => { if (session.ptyId) onCloseSession({ ptyId: session.ptyId, sessionId: session.id }); }} > {closingPtyIds.has(session.ptyId) ? "Closing..." : "Close"} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 9420ce3ec..90f00040c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -197,7 +197,7 @@ export function TerminalsPage() { setContextMenu(null)} - onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onCloseSession={({ ptyId, sessionId }) => work.closeSession(ptyId, sessionId).catch(() => {})} onEndChat={(id) => work.closeChatSession(id).catch(() => {})} onResume={(s) => work.resumeSession(s).catch(() => {})} onCopyResumeCommand={(cmd) => navigator.clipboard.writeText(cmd).catch(() => {})} @@ -208,7 +208,7 @@ export function TerminalsPage() { setInfoPopover(null)} - onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onCloseSession={({ ptyId, sessionId }) => work.closeSession(ptyId, sessionId).catch(() => {})} onEndChat={(id) => work.closeChatSession(id).catch(() => {})} onResume={(s) => work.resumeSession(s).catch(() => {})} onGoToLane={handleGoToLane} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index abe01c8da..05dc5f4ce 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -501,7 +501,7 @@ export function useWorkSessions() { }; const closeSession = useCallback( - async (ptyId: string) => { + async (ptyId: string, sessionId?: string) => { setClosingPtyIds((prev) => { const next = new Set(prev); next.add(ptyId); @@ -509,7 +509,7 @@ export function useWorkSessions() { }); markPtyClosed(ptyId); try { - await window.ade.pty.dispose({ ptyId }); + await window.ade.pty.dispose({ ptyId, ...(sessionId ? { sessionId } : {}) }); } finally { setClosingPtyIds((prev) => { const next = new Set(prev); diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index fcab0d51e..cb02f352d 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -223,6 +223,8 @@ export type RebaseRun = { error: string | null; pushedLaneIds: string[]; canRollback: boolean; + rootBaseRefBefore?: string | null; + rootBaseRefAfter?: string | null; }; export type RebaseStartArgs = { @@ -231,6 +233,7 @@ export type RebaseStartArgs = { pushMode?: PushMode; actor?: string; reason?: string; + baseBranchOverride?: string | null; }; export type RebaseStartResult = { diff --git a/docs/architecture/UI_FRAMEWORK.md b/docs/architecture/UI_FRAMEWORK.md index cc730ee3c..7a2556379 100644 --- a/docs/architecture/UI_FRAMEWORK.md +++ b/docs/architecture/UI_FRAMEWORK.md @@ -154,6 +154,7 @@ ADE uses multiple layout systems depending on surface complexity: - `PaneTilingLayout`: recursive pane trees for high-density workspaces - `SplitPane` / resizable panels: 2-pane and 3-pane structured views - Floating pane primitives for modular lane/conflict/terminal sub-surfaces +- **Work view session grid** (`WorkViewArea`): CSS Grid with `auto-fill` and `minmax` for fluid responsive session card layout that adapts to viewport width without fixed breakpoints Layout state persistence is backed by IPC calls into local SQLite (`layout`, `tilingTree`, `graphState` domains). diff --git a/docs/features/CHAT.md b/docs/features/CHAT.md index e9cae0bfb..5339b5dbe 100644 --- a/docs/features/CHAT.md +++ b/docs/features/CHAT.md @@ -33,7 +33,7 @@ Every chat creates an `AgentChatSession`: - **identityKey** -- Optional. `"cto"` for the CTO agent, `"agent:"` for named employees. - **executionMode** -- `focused | parallel | subagents | teams`. -- **interactionMode** -- `default | plan`. When `plan`, the agent operates in a read-only planning mode where it proposes changes without executing them. Plan approval flows through a dedicated `plan_approval` pending input kind. +- **interactionMode** -- `default | plan`. When `plan`, the agent operates in a read-only planning mode where it proposes changes without executing them. Plan approval flows through a dedicated `plan_approval` pending input kind. When the user approves a plan, the session automatically transitions from `plan` to `edit` permission mode so the agent can begin implementing the approved plan. In `bypassPermissions` or `full-auto` permission modes, plan approval is auto-granted without showing the approval UI since the user has opted out of all permission gates. Sessions persist their transcript and metadata to disk so they survive app restarts. The `AgentChatSessionSummary` exposes title, goal, @@ -186,6 +186,10 @@ The renderer derives pending inputs from the event stream via `derivePendingInpu The `AgentQuestionModal` renders the first pending input with Accept / Accept for Session / Decline / Cancel buttons and optional freeform text. User responses are sent back via the `respondToInput` IPC channel (which accepts `AgentChatRespondToInputArgs` with structured `answers` and optional `decision`), or the legacy `approve` channel for backward compatibility. +Plan approval requests carry the actual plan description text (extracted from the `ExitPlanMode` tool input) as the event description, so the UI can display meaningful content rather than a generic label. The message list renders plan approval cards in a scrollable container (max 288px) with pre-wrapped text to handle long multi-step plans. + +If a Claude approval is resolved more than once (e.g., due to a UI double-click, an interrupted turn, or stale state), the service logs a warning and returns silently instead of throwing. This prevents spurious errors from surfacing to the user when approvals have already been consumed. + Codex `permissions` requests and Claude `structured_question` events both flow through the same pending input abstraction. ## Chat Transcript and Work Log @@ -216,12 +220,30 @@ When a user switches model families mid-session (e.g., from Claude to Codex), th - **Run tab sidebar** -- Each lane can have one or more chat sessions. The `AgentChatPane` component renders the message list, composer, and approval modal. Users create sessions from the lane's chat panel. + When a new session is created, the pane awaits the `onSessionCreated` + callback and the session-list refresh before sending the first agent + turn. This ensures the parent surface has navigated to the chat tab + before the turn starts, preventing a blank "new chat" screen. - **CTO tab** -- The CTO's persistent chat session is embedded in the CTO surface. It uses the same `AgentChatPane` with a `persistent_identity` profile, giving it a distinct visual treatment. - **Mission threads** -- Mission-scoped views adapt chat events through `missionThreadEventAdapter` so they render in the mission feed format. +### Chat Header + +The chat header title is derived from the active session. When a session +is selected, the title is the session's own title (via +`chatSessionTitle()`). When no session exists yet, the header shows +"New chat". CTO and resolver surfaces override the title via +`ChatSurfacePresentation`. + +When the chat pane is associated with a lane, the header displays a lane +navigation button showing the lane's label with a branch icon. Clicking +it selects the lane in the app store and navigates to the Lanes tab. +The lane label is passed into `AgentChatPane` via the `laneLabel` prop +from `TilingLayout` and `WorkViewArea`. + The composer (`AgentChatComposer`) supports file/image attachments, model switching, reasoning-effort control, context-pack injection, and slash commands sourced from the active SDK session. The `@` key opens diff --git a/docs/features/LANES.md b/docs/features/LANES.md index 6c135c22f..d5feccaa6 100644 --- a/docs/features/LANES.md +++ b/docs/features/LANES.md @@ -33,6 +33,7 @@ - [Lane Proxy & Preview](#lane-proxy--preview) - [Per-Lane Hostname Isolation (Phase 5 W4)](#per-lane-hostname-isolation-phase-5-w4--done) - [Preview URLs (Phase 5 W4)](#preview-urls-phase-5-w4--done) +- [Lane Cleanup & Lifecycle](#lane-cleanup--lifecycle) - [Auth Redirect Handling (Phase 5 W5)](#auth-redirect-handling-phase-5-w5--done) - [Runtime Diagnostics (Phase 5 W6)](#runtime-diagnostics-phase-5-w6--done) @@ -190,7 +191,7 @@ The Chat view layout: +-----------------------------------------------+ ``` -Chat sessions created from the Chat view are automatically scoped to the selected lane (`cwd` = lane worktree path). The provider/model selector in the composer supports all configured models (CLI, API-key, OpenRouter, and local providers such as LM Studio/Ollama/vLLM). If a user switches model families while a chat session is active, ADE forks a new chat session with the selected model so the active thread remains internally consistent. Chat sessions are tracked as first-class sessions with the same delta computation, pack integration, and context tracking as terminal sessions. +Chat sessions created from the Chat view are automatically scoped to the selected lane (`cwd` = lane worktree path). The provider/model selector in the composer supports all configured models (CLI, API-key, OpenRouter, and local providers such as LM Studio/Ollama/vLLM). If a user switches model families while a chat session is active, ADE forks a new chat session with the selected model so the active thread remains internally consistent. Chat sessions are tracked as first-class sessions with the same delta computation, pack integration, and context tracking as terminal sessions. The chat header shows the session title (or "New chat" when no session exists yet) and includes a lane navigation button that links back to the parent lane in the Lanes tab. The lane label is passed to `AgentChatPane` via the `laneLabel` prop from `TilingLayout` and `WorkViewArea`. **Phase 2 improvements (shipped)**: The agent chat view now has polished message/composer/pane styling, Claude provider selection remains stable, and Codex reasoning effort selection is available in the model controls (persisted per lane/model and sent to Codex thread/turn starts). @@ -492,7 +493,7 @@ lanes ( | LANES-050 | Port allocation and lease manager | DONE — Phase 5 W3 (`portAllocationService.ts`, lease-based port range allocation with conflict detection) | | LANES-051 | Per-lane hostname proxy (*.localhost) | DONE — Phase 5 W4 (`laneProxyService.ts`, Host-header routing reverse proxy, 16 tests) | | LANES-052 | Preview launch service | DONE — Phase 5 W4 (`LanePreviewPanel.tsx`, one-click preview URL generation and browser launch, 8 tests) | -| LANES-053 | Lane template CRUD and storage | DONE — Phase 5 W2 (`laneTemplateService.ts`, reusable initialization recipes, template selector in CreateLaneDialog) | +| LANES-053 | Lane template CRUD and storage | DONE — Phase 5 W2 (`laneTemplateService.ts`, reusable initialization recipes, template selector in CreateLaneDialog, `resolveSetupScript` for platform-specific post-creation scripts) | | LANES-054 | Auth redirect handling per-lane: state-parameter routing (single OAuth callback URL, route by state param to correct lane), hostname-based routing (for providers supporting wildcards), setup assistant in Settings | DONE — Phase 5 W5 (`oauthRedirectService.ts`, state-parameter + hostname routing, 18+10 tests) | | LANES-055 | LaneOverlayPolicy extension for env/port/proxy | DONE — Phase 5 W1 (extended `LaneOverlayOverrides` with `portRange`, `proxyHostname`, `computeBackend`, `envInit`) | | LANES-056 | Runtime diagnostics (health checks, port conflicts) | DONE — Phase 5 W6 (`runtimeDiagnosticsService.ts`, traffic-light health checks with fallback mode, 25+11 tests) | @@ -530,6 +531,8 @@ Defined in `src/shared/types/config.ts`: - `LaneDockerConfig` — Docker Compose path and service names - `LaneDependencyInstallConfig` — command, working directory, and package manager - `LaneMountPointConfig` — mount source, target, and read-only flag +- `LaneSetupScriptConfig` — platform-specific setup script with commands/script paths and primary worktree path injection +- `LaneCleanupConfig` — auto-cleanup policy for stale lanes (max active, archive/delete thresholds, remote branch cleanup) ### IPC Channels @@ -583,11 +586,22 @@ Each `LaneTemplate` specifies: - `mountPoints` — runtime mount configurations - `portRange` — default port range for lanes using this template - `envVars` — extra environment variables +- `setupScript` — optional post-creation setup script with platform-specific command/script support + +### Setup Script (`LaneSetupScriptConfig`) + +Templates can include a setup script that runs after environment initialization completes. The script config supports platform-specific overrides: + +- `commands` / `unixCommands` / `windowsCommands` — inline shell commands (platform-specific variants take precedence) +- `scriptPath` / `unixScriptPath` / `windowsScriptPath` — path to a script file relative to the project root (platform-specific variants take precedence) +- `injectPrimaryPath` — when true, `$PRIMARY_WORKTREE_PATH` (or `%PRIMARY_WORKTREE_PATH%` on Windows) is available in commands/scripts, referencing the primary lane's worktree root + +The `laneTemplateService.resolveSetupScript(template)` method resolves the platform-appropriate commands and script path at runtime, returning `null` if no setup script is configured or if the resolved config has no commands and no script path. ### UI Components - **Template selector in CreateLaneDialog**: Users choose a template when creating a lane; the template's config is auto-applied to the new lane's environment initialization. -- **LaneTemplatesSection in Settings**: Management UI (`src/renderer/components/settings/LaneTemplatesSection.tsx`) for creating, editing, and deleting templates. Supports setting a project-level default template. +- **LaneTemplatesSection in Settings**: Management UI (`src/renderer/components/settings/LaneTemplatesSection.tsx`) for creating, editing, and deleting templates. Supports setting a project-level default template. Each template card shows feature chips summarizing its configuration (copy paths, env files, dependencies, mounts, docker, ports, env vars, setup script). ### NO_DEFAULT_LANE_TEMPLATE Sentinel @@ -605,6 +619,39 @@ The `NO_DEFAULT_LANE_TEMPLATE` sentinel value is used when a project explicitly --- +## Lane Cleanup & Lifecycle + +ADE supports automatic lane cleanup to prevent lane sprawl in long-running projects. The cleanup policy is configured via `LaneCleanupConfig` in the project config (`local.yaml` / `ade.yaml`) and managed in the Settings UI under the **Lane Behavior** section. + +### Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `maxActiveLanes` | `number` | `0` (unlimited) | Maximum active (non-archived) lanes. Oldest by access time are auto-archived when exceeded. | +| `cleanupIntervalHours` | `number` | `0` (disabled) | How often (in hours) to scan for stale lanes and run cleanup. | +| `autoArchiveAfterHours` | `number` | `0` (never) | Auto-archive lanes inactive for this many hours. | +| `autoDeleteArchivedAfterHours` | `number` | `0` (never) | Permanently delete archived lanes after this many hours. | +| `deleteRemoteBranchOnCleanup` | `boolean` | `false` | Also delete the remote branch when a lane is auto-deleted. | + +### Settings UI + +The **Lane Behavior** section in Settings (`LaneBehaviorSection.tsx`) manages both auto-rebase and cleanup configuration: + +- **Auto-rebase child lanes** — toggle to automatically rebase dependent lanes when a parent advances +- **Cleanup & limits** — primary controls for max active lanes and auto-archive inactivity threshold; secondary controls (scan interval, archived deletion period, remote branch cleanup) appear conditionally when cleanup is active + +Cleanup configuration is persisted to `local.yaml` via the project config service. + +### Types + +Defined in `src/shared/types/config.ts`: + +- `LaneCleanupConfig` — cleanup policy with all fields above +- `ProjectConfigFile.laneCleanup` — project-level cleanup config +- `EffectiveProjectConfig.laneCleanup` — effective (merged) cleanup config + +--- + ## Port Allocation (Phase 5 W3 — DONE) The `portAllocationService` provides lease-based port range allocation for lanes. Each lane that needs network ports is assigned a non-overlapping range from a configurable pool. diff --git a/docs/features/ONBOARDING_AND_SETTINGS.md b/docs/features/ONBOARDING_AND_SETTINGS.md index 659ece57d..abd8003cd 100644 --- a/docs/features/ONBOARDING_AND_SETTINGS.md +++ b/docs/features/ONBOARDING_AND_SETTINGS.md @@ -79,6 +79,9 @@ Settings owns durable configuration and infrastructure concerns, organized into - **Memory** — consolidated memory management with two sub-tabs: - *Overview* — memory health, scope summaries, promotion status, embedding progress and health monitoring (service state, queue depth, error rates) - *Browse All* — full browser for learned memory across all scopes (project, agent, mission) and tiers (Tier 1 Pinned, Tier 2 Active, Tier 3 Fading). Memories that back indexed skill files are hidden from the generic browser and managed via the Workspace skill-file surface; the Memory tab shows a summary card linking there instead. +- **Lane Templates** — lane template management and lane behavior configuration: + - *Templates* — CRUD for reusable lane initialization recipes (env files, docker, dependencies, mount points, port ranges, env vars, setup scripts). Supports setting a project-level default template for new lanes. + - *Lane Behavior* — auto-rebase toggle for stacked lanes and lane cleanup/lifecycle policy (max active lanes, auto-archive inactivity threshold, scan interval, archived lane deletion period, remote branch cleanup on auto-delete). - **Integrations** — GitHub, Linear, and related connectivity state; automation defaults and credentials. When a stored GitHub token cannot be decrypted (e.g., keychain migration or corruption), the service sets a `tokenDecryptionFailed` flag, logs a WARN-level message, and the UI displays a banner prompting the user to re-authenticate rather than silently failing GitHub operations. - **Sync & Devices** — multi-device sync management (Phase 6): - Local device identity and rename controls diff --git a/docs/features/TERMINALS_AND_SESSIONS.md b/docs/features/TERMINALS_AND_SESSIONS.md index f93aec515..5841651ac 100644 --- a/docs/features/TERMINALS_AND_SESSIONS.md +++ b/docs/features/TERMINALS_AND_SESSIONS.md @@ -28,6 +28,12 @@ Tracked sessions still feed history, lane refresh, conflict follow-up, and missi ## Current renderer behavior +### Work view session grid + +The Work tab renders active sessions in a responsive card grid (`WorkViewArea`). The grid uses CSS `auto-fill` with `minmax(min(100%, 360px), 1fr)` columns and `minmax(240px, 33vh)` row heights, so card count per row adjusts fluidly to the viewport width without fixed breakpoints. Each card wraps a `SessionSurface` (live terminal via xterm.js or agent chat pane) and supports right-click context menus for session-level actions. + +The grid view and a single-session focused view are toggled via a view mode selector in the Work tab header. + ### Shared session-list cache The renderer now deduplicates repeated `ade.sessions.list` calls through a small shared cache layer. This cache is used by multiple surfaces that previously issued overlapping requests independently. @@ -76,7 +82,17 @@ The lifecycle model remains the same: 4. finalize end state and deltas 5. notify downstream systems such as history, lane refresh, and memory hooks -The important change is that UI observers now subscribe more selectively and reuse cached list results where possible. +UI observers subscribe selectively and reuse cached list results where possible. + +### Refresh-before-activate ordering + +When a new session is created or an existing session is opened, the renderer refreshes the session list *before* activating the session tab. This ensures the new session exists in `sessionsById` when the UI resolves `activeSession` for the tab. Without this ordering, the active item ID would point to an unknown session and the view would fall back to the most recent session or display a blank pane until the next refresh cycle. + +This pattern applies across all session-creation and session-opening paths: + +- `useWorkSessions` and `useLaneWorkSessions`: `refresh()` is called and awaited before `focusSession()` and `openSessionTab()`. +- `TerminalsPage`: `work.refresh()` is awaited before `work.openSessionTab()`. +- `AgentChatPane`: The `onSessionCreated` and `refreshSessions` callbacks are awaited (not fire-and-forget) so that the parent surface navigates the user to the chat tab before the first agent turn begins. --- From 61de1f859f64e06290366d6e79e8dea2da7400c1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:54:57 -0400 Subject: [PATCH 5/6] Refine rebase flow, UI, session & conflict handling Multiple changes to improve auto-rebase behavior, UI and related services: - autoRebaseService: add disposed guards, treat parentless lanes as eligible for status display, preserve manual statuses, handle fallback rebase targets if comparison ref is missing, queue root resolution for parentless lanes, improve lookup failure handling and messaging, and ensure proper rollback/emit behavior. - RebaseTab/UI: better active-run association by computed run keys, only show drift panel when relevant, derive drift source lane correctly, fetch commit/files from the right lane, refine rendering/layout, and update CTA text for failed rebases in LaneRebaseBanner. - conflictService: preserve existing rebase-need metadata (groupContext, dismissedAt, deferredUntil), return null on lookup errors, and use fallback comparison logic when target ref is absent. - laneService: prevent persisting base branch overrides for parented lanes. - sessionService & terminalSessionSignals: more robust resumeCommand normalization (respect current toolType), canonicalize preferred tool names and prefer tool matching logic. - prs/CreatePrModal and helpers: avoid overwriting explicit user base branch edits by tracking previous default, trim integration base branch before fallback, add branchNameFromRef support for refs/remotes, and ensure PR detail uses non-archived lane for cleanup banner. - Tests and misc: normalize paths and restore mocks in cliExecutableResolver tests, adjust git push in tests to push HEAD:main, and add various test updates to reflect behavior changes. These changes tighten lifecycle handling, surface clearer statuses/messages, and make UI behavior more predictable when lanes or refs are missing. --- .../services/ai/cliExecutableResolver.test.ts | 11 +- .../main/services/chat/agentChatService.ts | 11 +- .../conflicts/conflictService.test.ts | 3 +- .../services/conflicts/conflictService.ts | 19 +- .../services/lanes/autoRebaseService.test.ts | 24 +- .../main/services/lanes/autoRebaseService.ts | 110 ++- .../src/main/services/lanes/laneService.ts | 3 + .../main/services/sessions/sessionService.ts | 9 +- .../src/main/utils/terminalSessionSignals.ts | 11 +- .../components/lanes/LaneRebaseBanner.tsx | 4 +- .../renderer/components/prs/CreatePrModal.tsx | 27 +- .../components/prs/LanePrPanel.test.ts | 10 + .../components/prs/detail/PrDetailPane.tsx | 4 +- .../prs/shared/laneBranchTargets.ts | 5 + .../components/prs/tabs/RebaseTab.tsx | 705 ++++++++++-------- .../components/run/ProcessMonitor.tsx | 1 - .../components/terminals/WorkViewArea.tsx | 61 +- 17 files changed, 577 insertions(+), 441 deletions(-) diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index 19eb751d8..bb85cebc0 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -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 }); @@ -44,13 +45,15 @@ describe("cliExecutableResolver", () => { // 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 s = String(p); - if (s.endsWith("/codex") && !s.startsWith(tempRoot!)) { + 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(s, opts); + return realStatSync(normalizedCandidate, opts); }) as typeof fs.statSync); const env = { @@ -62,8 +65,6 @@ describe("cliExecutableResolver", () => { path: path.join(prefixDir, "bin", "codex"), source: "known-dir", }); - - vi.restoreAllMocks(); }); it("augments PATH with npm-global bins discovered from ~/.npmrc", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d1eac50b5..08d002ea5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3866,6 +3866,7 @@ export function createAgentChatService(args: { managed.runtime.pendingApprovals.clear(); managed.runtime = null; } + clearLaneDirectiveKey(managed); }; const maybeGenerateSessionSummary = async ( @@ -4173,6 +4174,11 @@ export function createAgentChatService(args: { persistChatState(managed); }; + const clearLaneDirectiveKey = (managed: ManagedChatSession): void => { + managed.lastLaneDirectiveKey = null; + persistChatState(managed); + }; + const sendCodexMessage = async ( managed: ManagedChatSession, args: { @@ -5105,7 +5111,7 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); runtime.sdkSessionId = null; - managed.lastLaneDirectiveKey = null; + clearLaneDirectiveKey(managed); void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); prewarmClaudeV2Session(managed); @@ -8971,7 +8977,6 @@ export function createAgentChatService(args: { if (managed.runtime && modelChanged) { teardownRuntime(managed); - managed.lastLaneDirectiveKey = null; refreshReconstructionContext(managed, { includeConversationTail: true }); } @@ -8982,7 +8987,7 @@ export function createAgentChatService(args: { managed.session.capabilityMode = inferCapabilityMode(nextProvider); if (previousProvider !== nextProvider || previousProvider === "codex") { delete managed.session.threadId; - managed.lastLaneDirectiveKey = null; + clearLaneDirectiveKey(managed); } sessionService.updateMeta({ sessionId, diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 9526acca7..d5327026e 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -1295,12 +1295,13 @@ describe("conflictService conflict context integrity", () => { 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", "main"]); + git(remoteWorkRoot, ["push", "origin", "HEAD:main"]); git(repoRoot, ["fetch", "origin"]); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 9f3ac4e9f..889035853 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -4331,6 +4331,8 @@ export function createConflictService({ timeoutMs: 60_000 }); + const existingNeed = needs.find((need) => need.laneId === lane.id) ?? null; + needs.push({ laneId: lane.id, laneName: lane.name, @@ -4339,9 +4341,9 @@ export function createConflictService({ conflictPredicted: merge.conflicts.length > 0, conflictingFiles: merge.conflicts.map((conflict) => conflict.path), prId: String(row.id), - groupContext: null, - dismissedAt: null, - deferredUntil: null, + 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 }); @@ -4419,7 +4421,7 @@ export function createConflictService({ }; } catch (err) { logger.warn(`getRebaseNeed: failed for lane ${laneId}`, { error: err }); - throw err; + return null; } }; @@ -4512,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 1e9002d06..17849474c 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -233,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", @@ -254,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", + }); }); }); @@ -801,7 +811,9 @@ describe("autoRebaseService", () => { conflictCount: 0, message: "Pending before lookup failure.", }); - conflictService.getRebaseNeed.mockRejectedValueOnce(new Error("lookup failed")); + conflictService.getRebaseNeed + .mockImplementationOnce(async () => null) + .mockRejectedValueOnce(new Error("lookup failed")); 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 95046f0ae..faa52449b 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -7,7 +7,9 @@ import type { createLaneService } from "./laneService"; 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; }; @@ -18,6 +20,7 @@ type AttentionStatusInput = { state: AutoRebaseLaneState; conflictCount: number; message?: string | null; + source?: "auto" | "manual"; }; export type AutoRebaseService = { @@ -67,7 +70,8 @@ function sanitizeStoredStatus(value: unknown): StoredStatus | null { state, updatedAt, conflictCount, - message: messageRaw + message: messageRaw, + source: value.source === "manual" ? "manual" : "auto" }; } @@ -150,7 +154,8 @@ 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" }); }; @@ -169,6 +174,7 @@ export function createAutoRebaseService(args: { 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(); @@ -176,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)) { @@ -191,7 +192,7 @@ export function createAutoRebaseService(args: { clearStatus(lane.id); continue; } - } else if (!options?.includeAll && 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)) { @@ -228,14 +229,17 @@ 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?.parentLaneId) continue; - rootLaneIds.add(resolveAffectedChainLaneId(lane.id, laneById, affectedLaneIds)); + if (!lane) continue; + rootLaneIds.add(lane.parentLaneId + ? resolveAffectedChainLaneId(lane.id, laneById, affectedLaneIds) + : lane.id); } for (const rootLaneId of rootLaneIds) { @@ -247,6 +251,7 @@ export function createAutoRebaseService(args: { if (disposed || !isEnabled()) return; if (options?.force && sweepPromise) { await sweepPromise.catch(() => {}); + if (disposed) return; } if (sweepPromise) return; const now = Date.now(); @@ -259,6 +264,7 @@ export function createAutoRebaseService(args: { 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) }); } @@ -269,15 +275,18 @@ export function createAutoRebaseService(args: { }); 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 }); }; @@ -308,29 +317,53 @@ export function createAutoRebaseService(args: { 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 = rootLane.parentLaneId - ? [rootLaneId, ...collectDescendantsDepthFirst(rootLaneId, lanes)] - : 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, @@ -344,36 +377,23 @@ export function createAutoRebaseService(args: { continue; } - const parent = laneById.get(lane.parentLaneId); - 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; - } - - const parentHeadSha = await safeGetHeadSha(parent.worktreePath); - 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; @@ -393,13 +413,20 @@ export function createAutoRebaseService(args: { continue; } + if (!lane.parentLaneId) { + baseBranchOverride = need.baseBranch; + targetLabel = need.baseBranch || lane.baseRef || lane.branchRef || lane.name; + } + const rebaseRun = await laneService.rebaseStart({ laneId: lane.id, 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; @@ -420,6 +447,7 @@ export function createAutoRebaseService(args: { 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) { @@ -433,13 +461,16 @@ export function createAutoRebaseService(args: { parentHeadSha, state: "autoRebased", conflictCount: 0, - message: `Rebased and pushed automatically after '${parent.name}' advanced.` + 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", { @@ -477,14 +508,17 @@ export function createAutoRebaseService(args: { while (state.pending) { state.pending = false; await processRoot(rootLaneId, state.reason); + if (disposed) return; await emit({ includeAll: true }); + if (disposed) return; } } catch (error) { logger.warn("autoRebase.run_failed", { rootLaneId, error: String(error) }); + if (disposed) return; await emit({ includeAll: true }); } finally { state.running = false; - if (state.pending) { + if (state.pending && !disposed) { void runRootQueue(rootLaneId); } } diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 555938809..99bfd8d33 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1478,6 +1478,9 @@ export function createLaneService({ 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(); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 1dd3dfd91..1584c8b65 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -172,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)[] = []; @@ -201,9 +202,12 @@ export function createSessionService({ db }: { db: AdeDb }) { } if (args.resumeCommand !== undefined) { + const preferredToolType = args.toolType !== undefined + ? normalizeToolType(args.toolType) + : currentSession?.toolType ?? null; const next = normalizeResumeCommand( args.resumeCommand, - args.toolType === undefined ? undefined : normalizeToolType(args.toolType), + preferredToolType, ); sets.push("resume_command = ?"); params.push(next); @@ -309,7 +313,8 @@ export function createSessionService({ db }: { db: AdeDb }) { }, setResumeCommand(sessionId: string, resumeCommand: string | null): void { - const next = normalizeResumeCommand(resumeCommand); + const currentSession = this.get(sessionId); + const next = normalizeResumeCommand(resumeCommand, currentSession?.toolType ?? null); db.run("update terminal_sessions set resume_command = ? where id = ?", [next, sessionId]); }, diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 3bf6a1809..d6ef11813 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -19,10 +19,17 @@ 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( diff --git a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx index c74bd8997..40389b884 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx @@ -219,9 +219,9 @@ export function LaneRebaseBanner({ diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 0e63301e1..2b253afa0 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -474,6 +474,7 @@ export function CreatePrModal({ 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([]); @@ -538,6 +539,7 @@ export function CreatePrModal({ setMergeMethod("squash"); setNormalLaneId(""); setNormalBaseBranch(""); + normalBaseBranchDefaultRef.current = ""; setNormalTitle(""); setNormalDraft(false); setQueueLaneIds([]); @@ -615,16 +617,24 @@ export function CreatePrModal({ React.useEffect(() => { if (!open) return; if (!selectedNormalLane) { + normalBaseBranchDefaultRef.current = ""; setNormalBaseBranch(""); return; } - setNormalBaseBranch( - resolveDefaultBaseBranchForLane({ - lane: selectedNormalLane, - lanes, - primaryBranchRef: primaryLane?.branchRef ?? null, - }), - ); + 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 () => { @@ -633,7 +643,8 @@ export function CreatePrModal({ setExecError(null); setProposal(null); try { - const baseBranch = (integrationBaseBranch || branchNameFromRef(primaryLane?.branchRef ?? "main")).trim(); + const trimmedIntegrationBaseBranch = integrationBaseBranch.trim(); + const baseBranch = trimmedIntegrationBaseBranch || branchNameFromRef(primaryLane?.branchRef ?? "main"); const result = await window.ade.prs.simulateIntegration({ sourceLaneIds: integrationSources, baseBranch, diff --git a/apps/desktop/src/renderer/components/prs/LanePrPanel.test.ts b/apps/desktop/src/renderer/components/prs/LanePrPanel.test.ts index 893dfdaec..5c07f1018 100644 --- a/apps/desktop/src/renderer/components/prs/LanePrPanel.test.ts +++ b/apps/desktop/src/renderer/components/prs/LanePrPanel.test.ts @@ -38,6 +38,16 @@ describe("resolveDefaultBaseBranch", () => { ).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({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 6a5604988..eb4b1d4fc 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -1146,7 +1146,7 @@ function OverviewTab(props: OverviewTabProps) { const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); const laneForPr = React.useMemo( - () => lanes.find((lane) => lane.id === pr.laneId) ?? null, + () => lanes.find((lane) => lane.id === pr.laneId && !lane.archivedAt) ?? null, [lanes, pr.laneId], ); @@ -1207,7 +1207,7 @@ function OverviewTab(props: OverviewTabProps) {
{/* ---- Lane cleanup banner (shown when PR is merged/closed and lane still exists) ---- */} - lane.id === pr.laneId) ?? null} actionBusy={actionBusy} onNavigate={props.onNavigate} /> + {/* ---- Merge Status Bar ---- */}
diff --git a/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts b/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts index 04af1eb00..16eb3eb4f 100644 --- a/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts +++ b/apps/desktop/src/renderer/components/prs/shared/laneBranchTargets.ts @@ -3,6 +3,11 @@ 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; } diff --git a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx index da4ee7fea..b5e95d532 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx @@ -32,6 +32,10 @@ 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 ── */ const S = { mainBg: "#0F0D14", @@ -127,11 +131,14 @@ export function RebaseTab({ const selectedNeed = React.useMemo(() => { if (!selectedItemId) return null; - return rebaseNeeds.find((need) => rebaseNeedKey(need) === selectedItemId) - ?? rebaseNeeds.find((need) => need.laneId === selectedItemId) - ?? null; + return rebaseNeeds.find((need) => rebaseNeedKey(need) === selectedItemId) ?? null; }, [rebaseNeeds, selectedItemId]); + const selectedNeedRunKey = React.useMemo( + () => (selectedNeed ? rebaseRunKey({ laneId: selectedNeed.laneId, baseBranch: selectedNeed.baseBranch }) : null), + [selectedNeed], + ); + const selectedLane = React.useMemo( () => (selectedNeed ? laneById.get(selectedNeed.laneId) ?? null : null), [selectedNeed, laneById], @@ -143,6 +150,8 @@ export function RebaseTab({ [isPrTargetNeed, selectedNeed], ); + const activeRunNeedKeyRef = React.useRef(null); + // Auto-default scope based on children React.useEffect(() => { if (!hasChildren && runScope === "lane_and_descendants") { @@ -168,7 +177,7 @@ export function RebaseTab({ // Auto-select first item in highest-urgency group React.useEffect(() => { if (rebaseNeeds.length === 0 && selectedItemId === null) return; - if (selectedItemId && rebaseNeeds.some((need) => rebaseNeedKey(need) === selectedItemId || need.laneId === selectedItemId)) return; + 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]); @@ -179,17 +188,27 @@ export function RebaseTab({ React.useEffect(() => { activeRunIdRef.current = activeRun?.runId ?? null; + activeRunNeedKeyRef.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; + activeRunNeedKeyRef.current = incomingRunKey; + return event.run; } - if (!selectedNeed || event.run.rootLaneId !== selectedNeed.laneId) return prev; + if (!selectedNeedRunKey || incomingRunKey !== selectedNeedRunKey) return prev; + activeRunNeedKeyRef.current = incomingRunKey; return event.run; }); if (event.run.state !== "running") { @@ -206,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([]); @@ -217,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); }) @@ -237,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 }) @@ -255,6 +292,7 @@ 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."); @@ -292,6 +330,7 @@ export function RebaseTab({ actor: "user", ...(selectedNeedIsPrTarget ? { baseBranchOverride: selectedNeed.baseBranch } : {}), }); + activeRunNeedKeyRef.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); @@ -313,13 +352,20 @@ export function RebaseTab({ setRebaseBusy(false); } }; - const selectedRunIsActive = activeRun?.state === "running"; + const activeRunMatchesSelectedNeed = Boolean( + activeRun + && selectedNeedRunKey + && activeRunNeedKeyRef.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"), ); @@ -477,13 +523,13 @@ export function RebaseTab({ const resolverTargetLaneId = React.useMemo(() => { if (!selectedNeed) return null; - return lanes.find((l) => { - const ref = l.branchRef.replace(/^refs\/heads\//, "").replace(/^origin\//, ""); - const base = selectedNeed.baseBranch.replace(/^refs\/heads\//, "").replace(/^origin\//, ""); - return ref === base; - })?.id ?? null; + const baseBranch = branchNameFromRef(selectedNeed.baseBranch); + if (!baseBranch) return null; + return lanes.find((lane) => branchNameFromRef(lane.branchRef) === baseBranch)?.id ?? null; }, [lanes, selectedNeed]); + const shouldRenderDriftPanel = !selectedNeedIsPrTarget || Boolean(driftSourceLaneId); + // Compute file overlap between drift commits and the lane's own files const driftTouchedFiles = React.useMemo(() => { const files = new Set(); @@ -626,326 +672,330 @@ export function RebaseTab({
) : null} - {/* ── Drift Analysis Card ── */} -
-
- DRIFT ANALYSIS -
- -
- {/* Behind By */} -
+ {shouldRenderDriftPanel ? ( + <> + {/* ── Drift Analysis Card ── */} +
- BEHIND BY -
-
5 ? S.warning : selectedNeed.behindBy > 0 ? S.info : S.success, + fontSize: 10, + letterSpacing: "1px", + color: S.textSecondary, + marginBottom: 14, }} > - {selectedNeed.behindBy} - - commit{selectedNeed.behindBy !== 1 ? "s" : ""} - + DRIFT ANALYSIS
-
- {/* Conflict Status */} -
-
- CONFLICTS -
-
- {selectedNeed.conflictPredicted ? "PREDICTED" : "NONE"} -
-
+
+ {/* Behind By */} +
+
+ BEHIND BY +
+
5 ? S.warning : selectedNeed.behindBy > 0 ? S.info : S.success, + }} + > + {selectedNeed.behindBy} + + commit{selectedNeed.behindBy !== 1 ? "s" : ""} + +
+
- {/* Overlapping Files */} -
-
- FILE OVERLAPS -
-
0 ? S.warning : S.success, - }} - > - {selectedNeed.conflictingFiles.length > 0 - ? `${selectedNeed.conflictingFiles.length} file${selectedNeed.conflictingFiles.length !== 1 ? "s" : ""}` - : "CLEAN"} -
-
+ {/* Conflict Status */} +
+
+ CONFLICTS +
+
+ {selectedNeed.conflictPredicted ? "PREDICTED" : "NONE"} +
+
- {/* Rebase Risk */} -
-
- RISK LEVEL -
-
0 - ? S.warning - : S.success, - }} - > - {selectedNeed.conflictPredicted - ? "HIGH" - : selectedNeed.conflictingFiles.length > 0 - ? "MEDIUM" - : "LOW"} + {/* Overlapping Files */} +
+
+ FILE OVERLAPS +
+
0 ? S.warning : S.success, + }} + > + {selectedNeed.conflictingFiles.length > 0 + ? `${selectedNeed.conflictingFiles.length} file${selectedNeed.conflictingFiles.length !== 1 ? "s" : ""}` + : "CLEAN"} +
+
+ + {/* Rebase Risk */} +
+
+ RISK LEVEL +
+
0 + ? S.warning + : S.success, + }} + > + {selectedNeed.conflictPredicted + ? "HIGH" + : selectedNeed.conflictingFiles.length > 0 + ? "MEDIUM" + : "LOW"} +
+
-
-
- {/* ── New Commits on Base ── */} -
-
- {driftCommitsExpanded - ? - : } - - - {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 ── */}
{ - const isFailed = activeRun.state === "failed"; - const conflictLane = activeRun.lanes.find((l) => l.status === "conflict"); + {activeRunForSelectedNeed ? (() => { + const run = activeRunForSelectedNeed; + const isFailed = run.state === "failed"; + const conflictLane = run.lanes.find((l) => l.status === "conflict"); const conflictFiles = conflictLane?.conflictingFiles ?? []; return ( @@ -1237,7 +1288,7 @@ export function RebaseTab({ className="font-mono font-bold uppercase" style={{ fontSize: 10, letterSpacing: "1px", color: S.textSecondary }} > - {activeRun.state === "running" ? "REBASING" : activeRun.state === "completed" ? "REBASE COMPLETE" : "REBASE RUN"} + {run.state === "running" ? "REBASING" : run.state === "completed" ? "REBASE COMPLETE" : "REBASE RUN"}
- {activeRun.state.toUpperCase()} + {run.state.toUpperCase()}
- {activeRun.state === "running" && ( + {run.state === "running" && ( )} - {activeRun.canRollback && ( + {run.canRollback && ( @@ -1282,7 +1333,7 @@ export function RebaseTab({ {/* Lane statuses */}
- {activeRun.lanes.map((lane) => { + {run.lanes.map((lane) => { const statusColor = lane.status === "succeeded" ? S.success : lane.status === "running" @@ -1292,7 +1343,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 (
; } - if (session.status === "running" && session.ptyId) { + if (isRunningPtySession(session)) { return ( { - const running = displaySessions.filter( - (session) => - session.status === "running" - && Boolean(session.ptyId) - && !isChatToolType(session.toolType), - ); - if ( - activeSession - && activeSession.status === "running" - && Boolean(activeSession.ptyId) - && !isChatToolType(activeSession.toolType) - && !running.some((session) => session.id === activeSession.id) - ) { - running.push(activeSession); - } - return running; - }, - [activeSession, displaySessions], - ); + const activeRunningTerminalSession = isRunningPtySession(activeSession) ? activeSession : null; if (viewMode === "grid") { return ( @@ -368,28 +359,20 @@ export function WorkViewArea({
- {runningTerminalSessions.length > 0 ? ( + {activeRunningTerminalSession ? (
- {runningTerminalSessions.map((session) => - session.ptyId ? ( - - ) : null, - )} +
) : null} {activeSession ? ( - activeSession.status === "running" && activeSession.ptyId && !isChatToolType(activeSession.toolType) ? null : ( + activeRunningTerminalSession ? null : (
From 89d6e2280111965efa16aa665847f792e7049796 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:19:15 -0400 Subject: [PATCH 6/6] Fix session/terminal/rebase behaviors and tests Multiple fixes across main and renderer code: - agentChatService: use terminateChildProcessTree(runtime.process, null) to ensure child processes are killed safely. - sessionService: always set a fallback resume_command (removed "resume_command is null" condition) so resume commands are persisted reliably. - terminalSessionSignals: tighten RESUME_PLAIN_REGEX to avoid greedy matches that span into subsequent commands. - CreatePrModal: handle null/empty queueTargetBranch safely by trimming a default before deriving baseBranch. - RebaseTab: rename activeRunNeedKeyRef -> activeRunKeyRef, update all usages, and remove unused driftTouchedFiles memo to simplify state handling. - ProcessMonitor: clear logText when (re)loading logs to avoid showing stale content. - WorkViewArea: remove unnecessary wrapper div around TerminalView to simplify layout. - autoRebaseService.test: make scanRebaseNeeds mock filter only child lanes, switch to vi.waitFor for deterministic assertions, and add an assertion that db.setJson is updated with auto-rebase status. These changes fix process termination, session persistence, regex overmatching, UI edge cases, state tracking in the rebase UI, and make the rebase tests more robust and deterministic. --- .../main/services/chat/agentChatService.ts | 4 +++- .../services/lanes/autoRebaseService.test.ts | 14 ++++++++++-- .../main/services/sessions/sessionService.ts | 2 +- .../src/main/utils/terminalSessionSignals.ts | 2 +- .../renderer/components/prs/CreatePrModal.tsx | 3 ++- .../components/prs/tabs/RebaseTab.tsx | 22 +++++-------------- .../components/run/ProcessMonitor.tsx | 1 + .../components/terminals/WorkViewArea.tsx | 16 ++++++-------- 8 files changed, 33 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 08d002ea5..1957c1e25 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -7536,7 +7536,9 @@ export function createAgentChatService(args: { // ignore } try { - runtime?.process.kill(); + if (runtime) { + terminateChildProcessTree(runtime.process, null); + } } catch { // ignore } diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts index 17849474c..53773bc90 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -609,7 +609,10 @@ describe("autoRebaseService", () => { laneList = [root, child]; rebaseNeedOverrides.set("child-1", { behindBy: 3, conflictPredicted: false, conflictingFiles: [] }); conflictService.scanRebaseNeeds.mockResolvedValue( - laneList.map((lane) => makeRebaseNeed(lane, rebaseNeedOverrides.get(lane.id) ?? undefined)).filter((n) => n.behindBy > 0), + laneList + .filter((lane) => lane.parentLaneId) + .map((lane) => makeRebaseNeed(lane, rebaseNeedOverrides.get(lane.id) ?? undefined)) + .filter((n) => n.behindBy > 0), ); await service.refreshActiveRebaseNeeds("merge_completed"); @@ -896,7 +899,9 @@ describe("autoRebaseService", () => { }); await vi.advanceTimersByTimeAsync(1500); - await Promise.resolve(); + await vi.waitFor(() => { + expect(laneService.rebasePush).toHaveBeenCalled(); + }); expect(laneService.rebaseStart).toHaveBeenCalledWith( expect.objectContaining({ @@ -905,6 +910,11 @@ describe("autoRebaseService", () => { }), ); 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 () => { diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 1584c8b65..ea36ff74c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -225,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; } diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index d6ef11813..85bfd69e9 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -2,7 +2,7 @@ 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|-r|resume)\b)[^`\r\n]*)`/gi; -const RESUME_PLAIN_REGEX = /\b((?: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 diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 2b253afa0..155a5076f 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -684,7 +684,8 @@ export function CreatePrModal({ setResults([pr]); setNumericStep(3); } else if (mode === "queue") { - const baseBranch = (queueTargetBranch || branchNameFromRef(primaryLane?.branchRef ?? "main")).trim(); + 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({ diff --git a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx index b5e95d532..3118397c3 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx @@ -150,7 +150,7 @@ export function RebaseTab({ [isPrTargetNeed, selectedNeed], ); - const activeRunNeedKeyRef = React.useRef(null); + const activeRunKeyRef = React.useRef(null); // Auto-default scope based on children React.useEffect(() => { @@ -188,7 +188,7 @@ export function RebaseTab({ React.useEffect(() => { activeRunIdRef.current = activeRun?.runId ?? null; - activeRunNeedKeyRef.current = activeRun + activeRunKeyRef.current = activeRun ? rebaseRunKey({ laneId: activeRun.rootLaneId, baseBranch: activeRun.baseBranch }) : null; }, [activeRun]); @@ -204,11 +204,11 @@ export function RebaseTab({ setActiveRun((prev) => { if (prev?.runId) { if (event.run.runId !== prev.runId) return prev; - activeRunNeedKeyRef.current = incomingRunKey; + activeRunKeyRef.current = incomingRunKey; return event.run; } if (!selectedNeedRunKey || incomingRunKey !== selectedNeedRunKey) return prev; - activeRunNeedKeyRef.current = incomingRunKey; + activeRunKeyRef.current = incomingRunKey; return event.run; }); if (event.run.state !== "running") { @@ -330,7 +330,7 @@ export function RebaseTab({ actor: "user", ...(selectedNeedIsPrTarget ? { baseBranchOverride: selectedNeed.baseBranch } : {}), }); - activeRunNeedKeyRef.current = requestedNeedRunKey; + 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); @@ -355,7 +355,7 @@ export function RebaseTab({ const activeRunMatchesSelectedNeed = Boolean( activeRun && selectedNeedRunKey - && activeRunNeedKeyRef.current === selectedNeedRunKey, + && activeRunKeyRef.current === selectedNeedRunKey, ); const activeRunForSelectedNeed = activeRunMatchesSelectedNeed ? activeRun : null; const selectedRunIsActive = activeRunMatchesSelectedNeed && activeRun?.state === "running"; @@ -530,15 +530,6 @@ export function RebaseTab({ const shouldRenderDriftPanel = !selectedNeedIsPrTarget || Boolean(driftSourceLaneId); - // Compute file overlap between drift commits and the lane's own files - const driftTouchedFiles = React.useMemo(() => { - const files = new Set(); - for (const fileList of Object.values(commitFilesMap)) { - for (const f of fileList) files.add(f); - } - return files; - }, [commitFilesMap]); - const paneConfigs: Record = React.useMemo( () => ({ list: { @@ -1486,7 +1477,6 @@ export function RebaseTab({ driftCommitsExpanded, commitFilesMap, expandedCommitSha, - driftTouchedFiles, onSelectItem, resolverPermissionMode, onRefresh, diff --git a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx index 278a1a134..c4a6c3fc5 100644 --- a/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx +++ b/apps/desktop/src/renderer/components/run/ProcessMonitor.tsx @@ -97,6 +97,7 @@ export function ProcessMonitor({ laneId, runtimes, processDefinitions, processNa setPauseAutoscroll(false); setLogError(null); setLogLoading(true); + setLogText(""); window.ade.processes .getLogTail({ laneId, processId, maxBytes: LOG_TAIL_MAX_BYTES }) .then((log) => { diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 717c6044f..5001d86fb 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -360,15 +360,13 @@ export function WorkViewArea({
{activeRunningTerminalSession ? ( -
- -
+ ) : null} {activeSession ? (