Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ function createRuntime() {
resumed: true,
reusedExistingRuntime: false,
})),
dispose: vi.fn(),
dispose: vi.fn(() => ({ disposed: true, reason: "disposed" })),
writeBySessionId: vi.fn((sessionId: string, data: string): boolean => {
void sessionId;
void data;
Expand Down Expand Up @@ -990,7 +990,7 @@ describe("adeRpcServer", () => {
id: 7,
method: "pty.dispose",
params: { args: { ptyId: "pty-1", sessionId: "session-1" } },
})).resolves.toBeNull();
})).resolves.toEqual({ disposed: true, reason: "disposed" });
expect(runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" });

const listed = await handler({
Expand Down
3 changes: 1 addition & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5448,8 +5448,7 @@ export function createAdeRpcRequestHandler(args: {
}
if (method === "pty.dispose") {
ensurePtyTargetAuthorized(runtime, session, method, ptyArgs);
runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]);
return null;
return runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]);
}
if (method === "pty.list") {
return { sessions: listAuthorizedPtySessions(runtime, session, method, ptyArgs) };
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1899,7 +1899,7 @@ export function createHeadlessLinearServices(
"PTY-backed run commands are unavailable in headless Linear services.",
);
},
dispose: () => {},
dispose: () => ({ disposed: false as const, reason: "missing" as const }),
onData: () => () => {},
onExit: () => () => {},
};
Expand Down
20 changes: 15 additions & 5 deletions apps/desktop/src/main/services/config/projectConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ function asComputeBackend(value: unknown): "local" | "vps" | "daytona" | undefin
return COMPUTE_BACKEND_SCHEMA.parse(value);
}

function asNewLaneBaseSource(value: unknown): "local" | "remote" | undefined {
return value === "local" || value === "remote" ? value : undefined;
}

function coerceOrchestratorHookConfig(value: unknown): { command: string; timeoutMs?: number } | null {
if (typeof value === "string") {
const command = value.trim();
Expand Down Expand Up @@ -2009,10 +2013,15 @@ function coerceConfigFile(value: unknown): ProjectConfigFile {

const github = coerceGithubConfig(value.github);

const git =
isRecord(value.git) && asBool(value.git.autoRebaseOnHeadChange) != null
? { autoRebaseOnHeadChange: asBool(value.git.autoRebaseOnHeadChange) }
: undefined;
const git = (() => {
if (!isRecord(value.git)) return undefined;
const autoRebaseOnHeadChange = asBool(value.git.autoRebaseOnHeadChange);
const newLaneBaseSource = asNewLaneBaseSource(value.git.newLaneBaseSource);
const out: NonNullable<ProjectConfigFile["git"]> = {};
if (autoRebaseOnHeadChange != null) out.autoRebaseOnHeadChange = autoRebaseOnHeadChange;
if (newLaneBaseSource) out.newLaneBaseSource = newLaneBaseSource;
return Object.keys(out).length ? out : undefined;
})();

const providersRaw = isRecord(value.providers)
? { ...(value.providers as Record<string, unknown>) }
Expand Down Expand Up @@ -2515,7 +2524,8 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF
providerMode,
...(mergedGithub ? { github: mergedGithub } : {}),
git: {
autoRebaseOnHeadChange: mergedGit?.autoRebaseOnHeadChange ?? false
autoRebaseOnHeadChange: mergedGit?.autoRebaseOnHeadChange ?? false,
newLaneBaseSource: mergedGit?.newLaneBaseSource ?? "remote",
},
...(effectiveAi ? { ai: effectiveAi } : {}),
...(mergedProviders ? { providers: mergedProviders } : {}),
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/main/services/git/gitOperationsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1569,10 +1569,10 @@ describe("gitOperationsService.listBranches annotations", () => {
}
});

it("dedupes a remote ref when its local counterpart already exists", async () => {
it("dedupes a remote ref when its local counterpart tracks it", async () => {
mockGit.runGitOrThrow.mockResolvedValue(
[
"refs/heads/feature/dup\tfeature/dup\t*\t",
"refs/heads/feature/dup\tfeature/dup\t*\torigin/feature/dup",
"refs/remotes/origin/feature/dup\torigin/feature/dup\t \t",
].join("\n"),
);
Expand All @@ -1583,6 +1583,20 @@ describe("gitOperationsService.listBranches annotations", () => {
expect(branches.find((b) => b.name === "origin/feature/dup")).toBeUndefined();
});

it("keeps a remote counterpart when the local branch has no upstream", async () => {
mockGit.runGitOrThrow.mockResolvedValue(
[
"refs/heads/feature/untracked\tfeature/untracked\t*\t",
"refs/remotes/origin/feature/untracked\torigin/feature/untracked\t \t",
].join("\n"),
);
const { service } = makeServiceWithLanes({});

const branches = await service.listBranches({ laneId: "lane-1" });
expect(branches.find((b) => b.name === "feature/untracked")).toBeDefined();
expect(branches.find((b) => b.name === "origin/feature/untracked")).toBeDefined();
});

it("filters refs/remotes/.../HEAD entries out of the result", async () => {
mockGit.runGitOrThrow.mockResolvedValue(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,9 @@ export function createGitOperationsService({
const localNames = new Set(localBranches.keys());
const dedupedRemotes = remoteBranches.filter((branch) => {
const localCandidate = localBranchNameFromRemoteRef(branch.name);
return !localNames.has(localCandidate);
if (!localNames.has(localCandidate)) return true;
const local = localBranches.get(localCandidate);
return local?.upstream !== branch.name;
});

const sortedLocals = Array.from(localBranches.values()).sort((a, b) => {
Expand Down
17 changes: 14 additions & 3 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ import type {
RecentProjectSummary,
PtyCreateArgs,
PtyCreateResult,
PtyDisposeResult,
PtyResumeSessionArgs,
PtyResumeSessionResult,
PtySendToSessionArgs,
Expand Down Expand Up @@ -6062,10 +6063,20 @@ export function registerIpc({
});
}
}
return ptyService.enrichSessions([session])[0] ?? {
let enriched = ptyService.enrichSessions([session])[0] ?? {
...session,
runtimeState: ptyService.getRuntimeState(session.id, session.status)
};
if (enriched.status === "running" && isChatToolType(enriched.toolType)) {
try {
const chat = await ctx.agentChatService?.getSessionSummary(enriched.id);
if (chat) enriched = projectChatOntoSession(enriched, chat);
} catch {
// Detail reads should still return the persisted session if chat state
// hydration fails during runtime restart/recovery.
}
}
return enriched;
});

ipcMain.handle(IPC.sessionsDelete, async (_event, arg: DeleteSessionArgs): Promise<void> => {
Expand Down Expand Up @@ -7566,8 +7577,8 @@ export function registerIpc({
requirePtyService().resize(arg);
});

ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise<void> => {
requirePtyService().dispose(arg);
ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise<PtyDisposeResult> => {
return requirePtyService().dispose(arg);
});

ipcMain.handle(IPC.terminalList, async (_event, arg) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function makeEffective(overrides: Partial<EffectiveProjectConfig> = {}): Effecti
testSuites: [],
laneOverlayPolicies: [],
automations: [],
git: { autoRebaseOnHeadChange: false },
git: { autoRebaseOnHeadChange: false, newLaneBaseSource: "remote" },
...overrides,
};
}
Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/src/main/services/pty/ptyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3669,7 +3669,7 @@ describe("ptyService", () => {
});
});

it("does not end persisted agent chat rows just because there is no PTY", () => {
it("keeps persisted agent chat rows resumable but idle when there is no PTY", () => {
const { service } = createHarness();
const enriched = service.enrichSessions([{
id: "chat-session",
Expand All @@ -3682,7 +3682,7 @@ describe("ptyService", () => {
expect(enriched[0]).toMatchObject({
id: "chat-session",
status: "running",
runtimeState: "running",
runtimeState: "idle",
});
});

Expand Down Expand Up @@ -3730,6 +3730,19 @@ describe("ptyService", () => {
);
});

it("does not kill a live PTY when the supplied session id belongs to another session", async () => {
const { service, mockPty, sessionService, broadcastExit } = createHarness();
const first = await service.create({ laneId: "lane-1", title: "first", cols: 80, rows: 24 });
const second = await service.create({ laneId: "lane-1", title: "second", cols: 80, rows: 24 });

const result = service.dispose({ ptyId: second.ptyId, sessionId: first.sessionId });

expect(result).toEqual({ disposed: false, reason: "session-mismatch" });
expect(mockPty.kill).not.toHaveBeenCalled();
expect(sessionService.end).not.toHaveBeenCalled();
expect(broadcastExit).not.toHaveBeenCalled();
});

it("handles disposing an already-disposed PTY gracefully", async () => {
const { service } = createHarness();
const { ptyId } = await service.create({ laneId: "lane-1", title: "d", cols: 80, rows: 24 });
Expand Down
33 changes: 23 additions & 10 deletions apps/desktop/src/main/services/pty/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
PtyExitEvent,
PtyCreateArgs,
PtyCreateResult,
PtyDisposeResult,
PtyResumeSessionArgs,
PtyResumeSessionResult,
PtySendToSessionArgs,
Expand Down Expand Up @@ -4690,6 +4691,10 @@ export function createPtyService({
const runningWithoutReachablePty = !live
&& row.status === "running"
&& !isPersistedChatToolType(row.toolType ?? null);
const idlePersistedChatRuntime = !live
&& row.status === "running"
&& isPersistedChatToolType(row.toolType ?? null)
&& computeRuntimeState(row.id, row.status) === "running";
const isDetachedFromThisRuntime = ownedByLivePeer || runningWithoutReachablePty;
const fallbackStatus = live ? "running" : row.status;
return {
Expand All @@ -4707,22 +4712,26 @@ export function createPtyService({
status: "detached" as const,
}
: {}),
runtimeState: isDetachedFromThisRuntime ? "exited" : computeRuntimeState(row.id, fallbackStatus),
runtimeState: isDetachedFromThisRuntime
? "exited"
: idlePersistedChatRuntime
? "idle"
: computeRuntimeState(row.id, fallbackStatus),
chatSessionId: live
? terminalChatSessions.get(row.id) ?? live[1].chatSessionId ?? row.chatSessionId ?? null
: terminalChatSessions.get(row.id) ?? row.chatSessionId ?? null,
};
});
},

dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): void {
dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): PtyDisposeResult {
const entry = ptys.get(ptyId);
if (!entry) {
if (!sessionId) return;
if (!sessionId) return { disposed: false, reason: "missing" };
const session = sessionService.get(sessionId);
if (!session) return;
if (session.status && session.status !== "running") return;
if (session.ptyId && session.ptyId !== ptyId) return;
if (!session) return { disposed: false, reason: "missing" };
if (session.status && session.status !== "running") return { disposed: false, reason: "not-running" };
if (session.ptyId && session.ptyId !== ptyId) return { disposed: false, reason: "session-mismatch" };
if (
ownerPid != null
&& session.ownerPid != null
Expand All @@ -4735,7 +4744,7 @@ export function createPtyService({
ownerPid: session.ownerPid,
currentPid: ownerPid,
});
return;
return { disposed: false, reason: "owned-by-peer" };
}
// The renderer can outlive the pty map (for example after app restart). Allow closing by session id
// so stale sessions do not get stuck in a "running" state forever.
Expand Down Expand Up @@ -4766,9 +4775,12 @@ export function createPtyService({
}
}
logger.warn("pty.dispose_orphaned", { ptyId, sessionId });
return;
return { disposed: true, reason: "orphaned" };
}
if (entry.disposed) return;
if (sessionId && entry.sessionId !== sessionId) {
return { disposed: false, reason: "session-mismatch" };
}
if (entry.disposed) return { disposed: false, reason: "already-disposed" };
entry.disposed = true;
if (entry.aiTitleTimer) {
clearTimeout(entry.aiTitleTimer);
Expand Down Expand Up @@ -4808,14 +4820,15 @@ export function createPtyService({
ptys.delete(ptyId);

if (!entry.tracked) {
return;
return { disposed: true, reason: "disposed" };
}

try {
onSessionEnded?.({ laneId: entry.laneId, sessionId: entry.sessionId, exitCode: null });
} catch {
// ignore
}
return { disposed: true, reason: "disposed" };
},

disposeAll(): void {
Expand Down
Loading
Loading