Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3d24b5c
fix(desktop): recover Pi after offline startup (#3382)
qsoyq Aug 25, 2026
2ddab1f
fix(device-link): sync recovered Pi roster (#3394)
qsoyq Aug 25, 2026
cf8c5b3
fix(mobile): refresh recovered Pi roster (#3394)
qsoyq Aug 25, 2026
b7c46e7
fix(pi): preserve memory setting after recovery (#3394)
qsoyq Aug 25, 2026
51703a7
fix(pi): stop retries for permanent prepare errors (#3394)
qsoyq Aug 25, 2026
75a6d0d
fix(mobile): subscribe to Pi roster updates (#3394)
qsoyq Aug 25, 2026
b7ed4d8
fix(pi): stop retries after permanent recovery failure (#3394)
qsoyq Aug 25, 2026
73f674a
fix(agents): refresh capabilities after Pi recovery (#3394)
qsoyq Aug 25, 2026
c4de0d0
fix(mobile): retain roster subscription across reconnects (#3394)
qsoyq Aug 25, 2026
6f491bc
fix(mobile): cancel stale roster resubscriptions (#3394)
qsoyq Aug 25, 2026
72850c5
Merge origin/main into fix-issue-3382-pi-network-recovery
qsoyq Aug 26, 2026
32884c2
fix(mobile): refresh Pi capabilities after roster changes
qsoyq Aug 26, 2026
2f26745
Merge remote-tracking branch 'origin/main' into qsoyq/fix-issue-3382-…
qsoyq Aug 26, 2026
7465b5d
test(pi): tolerate slow Windows PowerShell cleanup
qsoyq Aug 26, 2026
2fad4b0
Merge remote-tracking branch 'origin/main' into qsoyq/fix-issue-3382-…
qsoyq Aug 26, 2026
b8c598d
fix(desktop): ignore stale agent roster responses (#3394)
qsoyq Aug 26, 2026
be6464d
fix(device-link): refresh agent rosters after reconnect (#3394)
qsoyq Aug 26, 2026
14662e8
fix(desktop): deduplicate agent roster refreshes (#3394)
qsoyq Aug 26, 2026
1321b7c
fix(desktop): centralize agent roster subscriptions (#3394)
qsoyq Aug 26, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest';

import { createPiRuntimeRecovery } from '../pi-runtime-recovery.js';

describe('Pi runtime recovery', () => {
it('retries after the network returns and registers Pi once', async () => {
let online = false;
let prepareCalls = 0;
let registered = false;
const onRegistered = vi.fn();
const recovery = createPiRuntimeRecovery({
isOnline: () => online,
prepare: async () => {
prepareCalls += 1;
return { ready: true, path: '/tmp/pi' };
},
register: () => {
if (registered) return false;
registered = true;
return true;
},
onRegistered,
retryDelayMs: 60_000,
setTimeout: (() => 0) as unknown as typeof setTimeout,
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
});

recovery.markUnavailable('manifest_failed');
expect(await recovery.retryNow('offline')).toBe(false);
online = true;
expect(await recovery.retryNow('online')).toBe(true);
expect(await recovery.retryNow('duplicate')).toBe(false);
expect(prepareCalls).toBe(1);
expect(onRegistered).toHaveBeenCalledOnce();
expect(recovery.isDisabled()).toBe(false);
recovery.dispose();
});

it('deduplicates concurrent recovery and keeps retryable failure disabled', async () => {
let resolvePrepare!: (value: { ready: boolean; path?: string; error?: string }) => void;
const prepare = vi.fn(
() => new Promise<{ ready: boolean; path?: string; error?: string }>((resolve) => {
resolvePrepare = resolve;
}),
);
const recovery = createPiRuntimeRecovery({
isOnline: () => true,
prepare,
register: () => true,
onRegistered: vi.fn(),
retryDelayMs: 60_000,
setTimeout: (() => 0) as unknown as typeof setTimeout,
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
});

recovery.markUnavailable('manifest_failed');
const first = recovery.retryNow();
const second = recovery.retryNow();
expect(first).toBe(second);
expect(prepare).toHaveBeenCalledOnce();
resolvePrepare({ ready: false, error: 'still_offline' });
expect(await first).toBe(false);
expect(recovery.isDisabled()).toBe(true);
recovery.dispose();
});

it('does not schedule retries for permanent prepare errors', async () => {
const prepare = vi.fn(async () => ({ ready: true, path: '/tmp/pi' }));
const schedule = vi.fn(() => 0);
const recovery = createPiRuntimeRecovery({
isOnline: () => true,
prepare,
register: () => true,
onRegistered: vi.fn(),
setTimeout: schedule as unknown as typeof setTimeout,
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
});

recovery.markUnavailable('asset_missing');
expect(schedule).not.toHaveBeenCalled();
expect(await recovery.retryNow('permanent')).toBe(false);
expect(prepare).not.toHaveBeenCalled();
recovery.dispose();
});

it('stops an existing retry loop when a later prepare becomes permanent', async () => {
const prepare = vi.fn(async () => ({ ready: false, error: 'asset_missing' }));
const schedule = vi.fn(() => 0);
const cancel = vi.fn();
const recovery = createPiRuntimeRecovery({
isOnline: () => true,
prepare,
register: () => true,
onRegistered: vi.fn(),
setTimeout: schedule as unknown as typeof setTimeout,
clearTimeout: cancel as unknown as typeof clearTimeout,
});

recovery.markUnavailable('manifest_failed');
expect(schedule).toHaveBeenCalledOnce();
expect(await recovery.retryNow('permanent-after-transient')).toBe(false);
expect(cancel).toHaveBeenCalledOnce();
expect(schedule).toHaveBeenCalledOnce();
expect(recovery.isDisabled()).toBe(true);
recovery.dispose();
});
});
145 changes: 145 additions & 0 deletions apps/desktop/src/main/agent-binaries/pi-runtime-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { PrepareResult } from './types.js';

/** Retry delay for an optional Pi runtime that missed the startup network. */
export const PI_RUNTIME_RECOVERY_RETRY_MS = 30_000;

/** Only errors that may change when connectivity returns are worth retrying. */
export function isRetryablePiPrepareError(error?: string): boolean {
return error === 'manifest_failed'
|| error === 'NETWORK'
|| error === 'HTTP_5XX'
|| error === 'ABORTED';
}

export interface PiRuntimeRecoveryOptions {
isOnline: () => boolean;
prepare: () => Promise<PrepareResult>;
register: () => boolean;
onRegistered: () => void;
logWarn?: (message: string, error?: unknown) => void;
retryDelayMs?: number;
setTimeout?: typeof globalThis.setTimeout;
clearTimeout?: typeof globalThis.clearTimeout;
}

export interface PiRuntimeRecovery {
/** Mark the startup prepare as unavailable and begin background recovery. */
markUnavailable(error?: string): void;
/** Try recovery immediately; returns true only when Pi was registered. */
retryNow(reason?: string): Promise<boolean>;
/** Stop future retries during app shutdown or test cleanup. */
dispose(): void;
isDisabled(): boolean;
}

/**
* Owns the small recovery state machine for an optional Pi runtime.
*
* The CDN policy remains unchanged: every retry calls the managed prepare path,
* so a local runtime is accepted only after the manifest and verification rules
* have succeeded. Concurrent focus/timer signals share one prepare promise.
*/
export function createPiRuntimeRecovery(options: PiRuntimeRecoveryOptions): PiRuntimeRecovery {
const retryDelayMs = options.retryDelayMs ?? PI_RUNTIME_RECOVERY_RETRY_MS;
const schedule = options.setTimeout ?? globalThis.setTimeout;
const cancel = options.clearTimeout ?? globalThis.clearTimeout;
let disabled = false;
let disposed = false;
let retryable = false;
let retryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
let inFlight: Promise<boolean> | null = null;

const logWarn = (message: string, error?: unknown): void => {
options.logWarn?.(message, error);
};

const scheduleRetry = (): void => {
if (disposed || !disabled || retryTimer !== null) return;
retryTimer = schedule(() => {
retryTimer = null;
void recovery.retryNow('timer');
}, retryDelayMs);
const unref = (retryTimer as unknown as { unref?: () => void }).unref;
unref?.call(retryTimer);
};

const recovery: PiRuntimeRecovery = {
markUnavailable(error) {
disabled = true;
retryable = isRetryablePiPrepareError(error);
if (!retryable && retryTimer !== null) {
cancel(retryTimer);
retryTimer = null;
}
if (error) {
logWarn(
retryable
? 'Pi runtime unavailable; scheduling recovery'
: 'Pi runtime unavailable; recovery not scheduled for permanent prepare error',
error,
);
}
if (retryable) scheduleRetry();
},

retryNow(reason = 'manual') {
if (disposed || !disabled || !retryable) return Promise.resolve(false);
if (inFlight) return inFlight;

let online = false;
try {
online = options.isOnline();
} catch (error) {
logWarn('Pi runtime network state probe failed', error);
}
if (!online) {
scheduleRetry();
return Promise.resolve(false);
}

const attempt = (async (): Promise<boolean> => {
Comment thread
qsoyq marked this conversation as resolved.
try {
const result = await options.prepare();
if (!result.ready || !result.path) {
logWarn(`Pi runtime recovery prepare failed (${reason})`, result.error);
recovery.markUnavailable(result.error);
return false;
}
if (!options.register()) {
scheduleRetry();
return false;
Comment thread
qsoyq marked this conversation as resolved.
}
disabled = false;
retryable = false;
options.onRegistered();
return true;
} catch (error) {
logWarn(`Pi runtime recovery threw (${reason})`, error);
recovery.markUnavailable(error instanceof Error ? error.message : String(error));
return false;
}
})();
inFlight = attempt;
void attempt.then(() => {
if (inFlight === attempt) inFlight = null;
}, () => {
if (inFlight === attempt) inFlight = null;
});
return attempt;
},

dispose() {
disposed = true;
if (retryTimer !== null) {
cancel(retryTimer);
retryTimer = null;
}
},

isDisabled() {
return disabled;
},
};

return recovery;
}
77 changes: 44 additions & 33 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import {
waitForTurnChangeSetPersistence,
} from './turn-change-set/store.js';

let retryPiRuntimeAfterNetworkRecovery: (() => void) | null = null;
let disposePiRuntimeRecovery: (() => void) | null = null;
// Official Linux binaries total hundreds of MB. Keep one shared deadline for
// both downloads, but allow normal consumer connections to finish while the
// splash displays real byte progress.
Expand Down Expand Up @@ -192,7 +194,6 @@ import {
prepare as binaryPrepare,
peekNeedsDownload as binaryPeekNeedsDownload,
broadcastResetForStep as binaryBroadcastResetForStep,
getCachedBinaryStatus,
type AgentBinaryKind,
type PrepareResult,
} from './agent-binaries';
Expand Down Expand Up @@ -489,7 +490,9 @@ import {
setProviderAccessRuntimeRefreshListener,
restartCodexAfterAuthModeChange,
waitForInitialCustomMcpRefresh,
registerPiAgentIfAvailable,
} from './maker-host/index.js';
import { createPiRuntimeRecovery } from './agent-binaries/pi-runtime-recovery.js';
import { createDynamicMaker } from './maker-host/dynamic-maker.js';
import { ensureBundledRipgrepReady } from './maker-host/runtime-configs.js';
import {
Expand Down Expand Up @@ -3146,6 +3149,9 @@ const windowsClosePromptFallback = createWindowsClosePromptFallbackController(

app.on('before-quit', () => {
isQuitting = true;
disposePiRuntimeRecovery?.();
retryPiRuntimeAfterNetworkRecovery = null;
disposePiRuntimeRecovery = null;
windowsClosePromptFallback.dispose();
destroyWindowsTray();
disposeUpdatePresentationRecovery();
Expand Down Expand Up @@ -3204,6 +3210,7 @@ function scheduleAppFocusSync(): void {
}

app.on('browser-window-focus', (_event, win) => {
retryPiRuntimeAfterNetworkRecovery?.();
if (win === mainWindowRef) updatePresentationRecovery?.onWindowFocused();
if (appFocusSyncTimer) {
clearTimeout(appFocusSyncTimer);
Expand Down Expand Up @@ -5388,9 +5395,28 @@ const registerIpcHandlers = () => {
// getMaker() 在构造期就读 binary path, 早于 splash 调用会抛错; 第一次 splash 成功后置 true,
// 后续 retry 走 check-environment 不重复注册 (重复 ipcMain.handle 会覆盖同名 handler)。
let makerIpcsRegistered = false;
// Pi 是“本次启动可选”的能力:一旦首次准备失败,就算后续清单/CDN恢复,
// 也不再把 Pi 动态塞回已经构造好的 Maker,避免返回状态与实际能力不一致。
let piDisabledForLaunch = false;
// Pi 是“本次启动可选”的能力:准备失败不阻塞主界面,交给 recovery 在网络恢复后
// 重新走 managed prepare,并在成功后动态注册到当前 Maker。
const piRuntimeRecovery = createPiRuntimeRecovery({
isOnline: () => net.isOnline(),
prepare: async () => {
const result = await binaryPrepare('pi', {
broadcastFailure: false,
broadcastProgress: false,
signal: AbortSignal.timeout(PI_AGENT_INSTALL_STARTUP_DEADLINE_MS),
});
return result;
},
register: () => registerPiAgentIfAvailable(),
onRegistered: () => {
console.info('[bootstrap-electron] Pi runtime recovered and agent registered');
},
logWarn: (message, error) => console.warn(`[bootstrap-electron] ${message}`, error ?? ''),
});
retryPiRuntimeAfterNetworkRecovery = () => {
void piRuntimeRecovery.retryNow('window-focus');
};
disposePiRuntimeRecovery = () => piRuntimeRecovery.dispose();
const registerMakerIpcsAfterSplash = async (): Promise<void> => {
if (makerIpcsRegistered) return;
// 模型供应商目录(providers.json)按「OSS 真源 / bundled 兜底」加载一次存内存:必须在第一次
Expand Down Expand Up @@ -5751,41 +5777,26 @@ const registerIpcHandlers = () => {
resetBeforeSegment('pi', claudeRes.downloaded === true || codexRes.downloaded === true);

let piInfo: { status: 'passed' | 'failed'; path?: string; error?: string };
// 轮 27 LOW-4:首次准备失败后账号切换(同一进程),二进制可能已由后台
// 下载/手动放置变得可用 —— 轻量重试:意外可用则清除标志继续准备。
if (piDisabledForLaunch) {
const cached = getCachedBinaryStatus('pi');
if (cached?.binaryPath && cached.binaryPath.length > 0) {
piDisabledForLaunch = false;
}
}
if (piDisabledForLaunch) {
try {
const piRes = await binaryPrepare('pi', {
...stepOptsFor('pi'),
broadcastFailure: false,
signal: piInstallSignal,
});
piInfo =
piRes.ready && piRes.path
? { status: 'passed' as const, path: piRes.path }
: { status: 'failed' as const, error: piRes.error ?? 'pi binary not available' };
} catch (err: unknown) {
piInfo = {
status: 'failed' as const,
error: 'pi disabled for this launch after an earlier prepare failure',
error: err instanceof Error ? err.message : String(err),
};
} else {
try {
const piRes = await binaryPrepare('pi', {
...stepOptsFor('pi'),
broadcastFailure: false,
signal: piInstallSignal,
});
piInfo =
piRes.ready && piRes.path
? { status: 'passed' as const, path: piRes.path }
: { status: 'failed' as const, error: piRes.error ?? 'pi binary not available' };
} catch (err: unknown) {
piInfo = {
status: 'failed' as const,
error: err instanceof Error ? err.message : String(err),
};
}
}
if (piInfo.status === 'failed') {
piDisabledForLaunch = true;
piRuntimeRecovery.markUnavailable(piInfo.error);
Comment thread
qsoyq marked this conversation as resolved.
console.warn(
`[bootstrap-electron] pi binary prepare failed (non-fatal, pi disabled for this launch): ${piInfo.error}`,
`[bootstrap-electron] pi binary prepare failed (non-fatal, recovery scheduled): ${piInfo.error}`,
);
}

Expand Down
Loading