Skip to content

Commit 810dc75

Browse files
authored
Fix ADE pressure and session recovery
* Fix ADE pressure and session recovery * Address PR review feedback * Fix autoreview follow-ups * Address runtime stop review notes * Keep already-gone runtimes stopped * Fix runtime mismatch and lane config saves * Fix usage retry and live PTY disposal guards * Fix TerminalView renderer test portability * Fix lane behavior source toggle save
1 parent 27551ae commit 810dc75

39 files changed

Lines changed: 3088 additions & 396 deletions

apps/ade-cli/src/adeRpcServer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ function createRuntime() {
289289
resumed: true,
290290
reusedExistingRuntime: false,
291291
})),
292-
dispose: vi.fn(),
292+
dispose: vi.fn(() => ({ disposed: true, reason: "disposed" })),
293293
writeBySessionId: vi.fn((sessionId: string, data: string): boolean => {
294294
void sessionId;
295295
void data;
@@ -990,7 +990,7 @@ describe("adeRpcServer", () => {
990990
id: 7,
991991
method: "pty.dispose",
992992
params: { args: { ptyId: "pty-1", sessionId: "session-1" } },
993-
})).resolves.toBeNull();
993+
})).resolves.toEqual({ disposed: true, reason: "disposed" });
994994
expect(runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" });
995995

996996
const listed = await handler({

apps/ade-cli/src/adeRpcServer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5448,8 +5448,7 @@ export function createAdeRpcRequestHandler(args: {
54485448
}
54495449
if (method === "pty.dispose") {
54505450
ensurePtyTargetAuthorized(runtime, session, method, ptyArgs);
5451-
runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]);
5452-
return null;
5451+
return runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]);
54535452
}
54545453
if (method === "pty.list") {
54555454
return { sessions: listAuthorizedPtySessions(runtime, session, method, ptyArgs) };

apps/ade-cli/src/headlessLinearServices.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1899,7 +1899,7 @@ export function createHeadlessLinearServices(
18991899
"PTY-backed run commands are unavailable in headless Linear services.",
19001900
);
19011901
},
1902-
dispose: () => {},
1902+
dispose: () => ({ disposed: false as const, reason: "missing" as const }),
19031903
onData: () => () => {},
19041904
onExit: () => () => {},
19051905
};

apps/desktop/src/main/services/config/projectConfigService.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ function asComputeBackend(value: unknown): "local" | "vps" | "daytona" | undefin
152152
return COMPUTE_BACKEND_SCHEMA.parse(value);
153153
}
154154

155+
function asNewLaneBaseSource(value: unknown): "local" | "remote" | undefined {
156+
return value === "local" || value === "remote" ? value : undefined;
157+
}
158+
155159
function coerceOrchestratorHookConfig(value: unknown): { command: string; timeoutMs?: number } | null {
156160
if (typeof value === "string") {
157161
const command = value.trim();
@@ -2009,10 +2013,15 @@ function coerceConfigFile(value: unknown): ProjectConfigFile {
20092013

20102014
const github = coerceGithubConfig(value.github);
20112015

2012-
const git =
2013-
isRecord(value.git) && asBool(value.git.autoRebaseOnHeadChange) != null
2014-
? { autoRebaseOnHeadChange: asBool(value.git.autoRebaseOnHeadChange) }
2015-
: undefined;
2016+
const git = (() => {
2017+
if (!isRecord(value.git)) return undefined;
2018+
const autoRebaseOnHeadChange = asBool(value.git.autoRebaseOnHeadChange);
2019+
const newLaneBaseSource = asNewLaneBaseSource(value.git.newLaneBaseSource);
2020+
const out: NonNullable<ProjectConfigFile["git"]> = {};
2021+
if (autoRebaseOnHeadChange != null) out.autoRebaseOnHeadChange = autoRebaseOnHeadChange;
2022+
if (newLaneBaseSource) out.newLaneBaseSource = newLaneBaseSource;
2023+
return Object.keys(out).length ? out : undefined;
2024+
})();
20162025

20172026
const providersRaw = isRecord(value.providers)
20182027
? { ...(value.providers as Record<string, unknown>) }
@@ -2515,7 +2524,8 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF
25152524
providerMode,
25162525
...(mergedGithub ? { github: mergedGithub } : {}),
25172526
git: {
2518-
autoRebaseOnHeadChange: mergedGit?.autoRebaseOnHeadChange ?? false
2527+
autoRebaseOnHeadChange: mergedGit?.autoRebaseOnHeadChange ?? false,
2528+
newLaneBaseSource: mergedGit?.newLaneBaseSource ?? "remote",
25192529
},
25202530
...(effectiveAi ? { ai: effectiveAi } : {}),
25212531
...(mergedProviders ? { providers: mergedProviders } : {}),

apps/desktop/src/main/services/git/gitOperationsService.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,10 +1569,10 @@ describe("gitOperationsService.listBranches annotations", () => {
15691569
}
15701570
});
15711571

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

1586+
it("keeps a remote counterpart when the local branch has no upstream", async () => {
1587+
mockGit.runGitOrThrow.mockResolvedValue(
1588+
[
1589+
"refs/heads/feature/untracked\tfeature/untracked\t*\t",
1590+
"refs/remotes/origin/feature/untracked\torigin/feature/untracked\t \t",
1591+
].join("\n"),
1592+
);
1593+
const { service } = makeServiceWithLanes({});
1594+
1595+
const branches = await service.listBranches({ laneId: "lane-1" });
1596+
expect(branches.find((b) => b.name === "feature/untracked")).toBeDefined();
1597+
expect(branches.find((b) => b.name === "origin/feature/untracked")).toBeDefined();
1598+
});
1599+
15861600
it("filters refs/remotes/.../HEAD entries out of the result", async () => {
15871601
mockGit.runGitOrThrow.mockResolvedValue(
15881602
[

apps/desktop/src/main/services/git/gitOperationsService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,9 @@ export function createGitOperationsService({
16791679
const localNames = new Set(localBranches.keys());
16801680
const dedupedRemotes = remoteBranches.filter((branch) => {
16811681
const localCandidate = localBranchNameFromRemoteRef(branch.name);
1682-
return !localNames.has(localCandidate);
1682+
if (!localNames.has(localCandidate)) return true;
1683+
const local = localBranches.get(localCandidate);
1684+
return local?.upstream !== branch.name;
16831685
});
16841686

16851687
const sortedLocals = Array.from(localBranches.values()).sort((a, b) => {

apps/desktop/src/main/services/ipc/registerIpc.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ import type {
389389
RecentProjectSummary,
390390
PtyCreateArgs,
391391
PtyCreateResult,
392+
PtyDisposeResult,
392393
PtyResumeSessionArgs,
393394
PtyResumeSessionResult,
394395
PtySendToSessionArgs,
@@ -6062,10 +6063,20 @@ export function registerIpc({
60626063
});
60636064
}
60646065
}
6065-
return ptyService.enrichSessions([session])[0] ?? {
6066+
let enriched = ptyService.enrichSessions([session])[0] ?? {
60666067
...session,
60676068
runtimeState: ptyService.getRuntimeState(session.id, session.status)
60686069
};
6070+
if (enriched.status === "running" && isChatToolType(enriched.toolType)) {
6071+
try {
6072+
const chat = await ctx.agentChatService?.getSessionSummary(enriched.id);
6073+
if (chat) enriched = projectChatOntoSession(enriched, chat);
6074+
} catch {
6075+
// Detail reads should still return the persisted session if chat state
6076+
// hydration fails during runtime restart/recovery.
6077+
}
6078+
}
6079+
return enriched;
60696080
});
60706081

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

7569-
ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise<void> => {
7570-
requirePtyService().dispose(arg);
7580+
ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise<PtyDisposeResult> => {
7581+
return requirePtyService().dispose(arg);
75717582
});
75727583

75737584
ipcMain.handle(IPC.terminalList, async (_event, arg) =>

apps/desktop/src/main/services/lanes/laneTemplateService.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function makeEffective(overrides: Partial<EffectiveProjectConfig> = {}): Effecti
3434
testSuites: [],
3535
laneOverlayPolicies: [],
3636
automations: [],
37-
git: { autoRebaseOnHeadChange: false },
37+
git: { autoRebaseOnHeadChange: false, newLaneBaseSource: "remote" },
3838
...overrides,
3939
};
4040
}

apps/desktop/src/main/services/pty/ptyService.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,7 +3669,7 @@ describe("ptyService", () => {
36693669
});
36703670
});
36713671

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

@@ -3730,6 +3730,19 @@ describe("ptyService", () => {
37303730
);
37313731
});
37323732

3733+
it("does not kill a live PTY when the supplied session id belongs to another session", async () => {
3734+
const { service, mockPty, sessionService, broadcastExit } = createHarness();
3735+
const first = await service.create({ laneId: "lane-1", title: "first", cols: 80, rows: 24 });
3736+
const second = await service.create({ laneId: "lane-1", title: "second", cols: 80, rows: 24 });
3737+
3738+
const result = service.dispose({ ptyId: second.ptyId, sessionId: first.sessionId });
3739+
3740+
expect(result).toEqual({ disposed: false, reason: "session-mismatch" });
3741+
expect(mockPty.kill).not.toHaveBeenCalled();
3742+
expect(sessionService.end).not.toHaveBeenCalled();
3743+
expect(broadcastExit).not.toHaveBeenCalled();
3744+
});
3745+
37333746
it("handles disposing an already-disposed PTY gracefully", async () => {
37343747
const { service } = createHarness();
37353748
const { ptyId } = await service.create({ laneId: "lane-1", title: "d", cols: 80, rows: 24 });

apps/desktop/src/main/services/pty/ptyService.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
PtyExitEvent,
2525
PtyCreateArgs,
2626
PtyCreateResult,
27+
PtyDisposeResult,
2728
PtyResumeSessionArgs,
2829
PtyResumeSessionResult,
2930
PtySendToSessionArgs,
@@ -4690,6 +4691,10 @@ export function createPtyService({
46904691
const runningWithoutReachablePty = !live
46914692
&& row.status === "running"
46924693
&& !isPersistedChatToolType(row.toolType ?? null);
4694+
const idlePersistedChatRuntime = !live
4695+
&& row.status === "running"
4696+
&& isPersistedChatToolType(row.toolType ?? null)
4697+
&& computeRuntimeState(row.id, row.status) === "running";
46934698
const isDetachedFromThisRuntime = ownedByLivePeer || runningWithoutReachablePty;
46944699
const fallbackStatus = live ? "running" : row.status;
46954700
return {
@@ -4707,22 +4712,26 @@ export function createPtyService({
47074712
status: "detached" as const,
47084713
}
47094714
: {}),
4710-
runtimeState: isDetachedFromThisRuntime ? "exited" : computeRuntimeState(row.id, fallbackStatus),
4715+
runtimeState: isDetachedFromThisRuntime
4716+
? "exited"
4717+
: idlePersistedChatRuntime
4718+
? "idle"
4719+
: computeRuntimeState(row.id, fallbackStatus),
47114720
chatSessionId: live
47124721
? terminalChatSessions.get(row.id) ?? live[1].chatSessionId ?? row.chatSessionId ?? null
47134722
: terminalChatSessions.get(row.id) ?? row.chatSessionId ?? null,
47144723
};
47154724
});
47164725
},
47174726

4718-
dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): void {
4727+
dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): PtyDisposeResult {
47194728
const entry = ptys.get(ptyId);
47204729
if (!entry) {
4721-
if (!sessionId) return;
4730+
if (!sessionId) return { disposed: false, reason: "missing" };
47224731
const session = sessionService.get(sessionId);
4723-
if (!session) return;
4724-
if (session.status && session.status !== "running") return;
4725-
if (session.ptyId && session.ptyId !== ptyId) return;
4732+
if (!session) return { disposed: false, reason: "missing" };
4733+
if (session.status && session.status !== "running") return { disposed: false, reason: "not-running" };
4734+
if (session.ptyId && session.ptyId !== ptyId) return { disposed: false, reason: "session-mismatch" };
47264735
if (
47274736
ownerPid != null
47284737
&& session.ownerPid != null
@@ -4735,7 +4744,7 @@ export function createPtyService({
47354744
ownerPid: session.ownerPid,
47364745
currentPid: ownerPid,
47374746
});
4738-
return;
4747+
return { disposed: false, reason: "owned-by-peer" };
47394748
}
47404749
// The renderer can outlive the pty map (for example after app restart). Allow closing by session id
47414750
// so stale sessions do not get stuck in a "running" state forever.
@@ -4766,9 +4775,12 @@ export function createPtyService({
47664775
}
47674776
}
47684777
logger.warn("pty.dispose_orphaned", { ptyId, sessionId });
4769-
return;
4778+
return { disposed: true, reason: "orphaned" };
47704779
}
4771-
if (entry.disposed) return;
4780+
if (sessionId && entry.sessionId !== sessionId) {
4781+
return { disposed: false, reason: "session-mismatch" };
4782+
}
4783+
if (entry.disposed) return { disposed: false, reason: "already-disposed" };
47724784
entry.disposed = true;
47734785
if (entry.aiTitleTimer) {
47744786
clearTimeout(entry.aiTitleTimer);
@@ -4808,14 +4820,15 @@ export function createPtyService({
48084820
ptys.delete(ptyId);
48094821

48104822
if (!entry.tracked) {
4811-
return;
4823+
return { disposed: true, reason: "disposed" };
48124824
}
48134825

48144826
try {
48154827
onSessionEnded?.({ laneId: entry.laneId, sessionId: entry.sessionId, exitCode: null });
48164828
} catch {
48174829
// ignore
48184830
}
4831+
return { disposed: true, reason: "disposed" };
48194832
},
48204833

48214834
disposeAll(): void {

0 commit comments

Comments
 (0)