From 2efc740f8f64eec04251e59baca3ae768472d57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 17 Jul 2026 17:13:17 +0800 Subject: [PATCH] =?UTF-8?q?centralize=20mud=20balance=20state=20and=20fix?= =?UTF-8?q?=20=E6=99=9A=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 145 ++++++-------- .../src/stores/useWalletStore.ts | 100 ++++++++++ .../ai-game-creator-shell/src/view/layout.tsx | 41 ++-- .../tests/appSurface.test.ts | 24 ++- .../tests/walletStore.test.ts | 182 ++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 6 files changed, 379 insertions(+), 115 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/stores/useWalletStore.ts create mode 100644 apps/ai-game-creator-shell/tests/walletStore.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2b650855d..4c28bed3a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -75,8 +75,6 @@ import { selectGameCreationAppReadyTasks, } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { - ProfileDashboardSummary, - ProfileMudPointBalance, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileWalletLedgerResponse, @@ -89,10 +87,10 @@ import { import { confirmClientWechatProfileRechargeOrder, createClientProfileRechargeOrder, - getClientProfileDashboard, getClientProfileRechargeCenter, getClientProfileWalletLedger, } from './services/clientApi'; +import { useWalletStore } from './stores/useWalletStore'; import HomeView, { type HomeAgentMode, type HomeAgentModeItem, @@ -108,6 +106,10 @@ const seedManifest = createGameCreationAppManifest( ); const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; +type RechargeContent = Omit< + ProfileRechargeCenterResponse, + 'walletBalance' | 'mudPointBalance' +>; const RECENT_WORKSPACES_STORAGE_KEY = 'genarrative-ai-game-creator.recent-workspaces.v1'; const AGENT_RUN_HISTORY_MAX_COUNT = 100; @@ -5360,14 +5362,14 @@ export function WorkspaceLauncher({ const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); const [pendingNonEmptyProject, setPendingNonEmptyProject] = useState(null); - const [profileDashboard, setProfileDashboard] = - useState(null); - const [mudPointBalance, setMudPointBalance] = - useState(null); - const [mudPointBalanceStatus, setMudPointBalanceStatus] = useState< - 'idle' | 'loading' | 'ready' | 'error' - >('idle'); - const [mudPointBalanceError, setMudPointBalanceError] = useState(''); + const { + mudPointBalance, + mudPointBalanceStatus, + mudPointBalanceError, + applyWalletBalanceSnapshot, + onWalletBalanceMayHaveChanged, + resetWalletBalance, + } = useWalletStore(); const [walletLedgerOpen, setWalletLedgerOpen] = useState(false); const [walletLedger, setWalletLedger] = useState(null); @@ -5376,8 +5378,8 @@ export function WorkspaceLauncher({ null, ); const [rechargeOpen, setRechargeOpen] = useState(false); - const [rechargeCenter, setRechargeCenter] = - useState(null); + const [rechargeContent, setRechargeContent] = + useState(null); const [rechargeLoading, setRechargeLoading] = useState(false); const [rechargeError, setRechargeError] = useState(null); const [submittingRechargeProductId, setSubmittingRechargeProductId] = @@ -5385,6 +5387,14 @@ export function WorkspaceLauncher({ const [nativeRechargePayment, setNativeRechargePayment] = useState(null); const rechargeLifecycleRef = useRef(0); + const rechargeModalCenter = + rechargeContent && mudPointBalance + ? { + ...rechargeContent, + walletBalance: mudPointBalance.totalPoints, + mudPointBalance, + } + : null; const [launcherNotice, setLauncherNotice] = useState<{ title: string; message: string; @@ -5472,6 +5482,23 @@ export function WorkspaceLauncher({ setAgentChatRunSubmitMode('steer'); }, [agentChatSelectedAgentId, agentChatSelectedSessionId]); + useEffect(() => { + resetWalletBalance(); + void onWalletBalanceMayHaveChanged(); + + const refreshWalletBalance = () => { + void onWalletBalanceMayHaveChanged(); + }; + window.addEventListener('focus', refreshWalletBalance); + return () => { + window.removeEventListener('focus', refreshWalletBalance); + }; + }, [ + currentUser.id, + onWalletBalanceMayHaveChanged, + resetWalletBalance, + ]); + function setAgentChatPendingRuntimeRun( pendingRun: AgentChatPendingRuntimeRun | null, ) { @@ -5828,29 +5855,6 @@ export function WorkspaceLauncher({ }; }, [agentChatPendingRuntimeRun]); - useEffect(() => { - let disposed = false; - void getClientProfileDashboard() - .then((dashboard) => { - if (disposed) { - return; - } - setProfileDashboard(dashboard); - }) - .catch((error) => { - if (disposed) { - return; - } - setProfileDashboard(null); - setMudPointBalanceError( - error instanceof Error ? error.message : '泥点读取失败', - ); - }); - return () => { - disposed = true; - }; - }, []); - useEffect(() => { if (launcherView !== 'agent-chat') { return; @@ -6189,28 +6193,6 @@ export function WorkspaceLauncher({ ); } - async function loadMudPointBalance() { - if (mudPointBalanceStatus === 'loading') { - return; - } - setMudPointBalanceStatus('loading'); - setMudPointBalanceError(''); - try { - const center = await getClientProfileRechargeCenter(); - setMudPointBalance(center.mudPointBalance ?? null); - setProfileDashboard((current) => - current ? { ...current, walletBalance: center.walletBalance } : current, - ); - setMudPointBalanceStatus('ready'); - } catch (error) { - setMudPointBalance(null); - setMudPointBalanceStatus('error'); - setMudPointBalanceError( - error instanceof Error ? error.message : '泥点明细读取失败', - ); - } - } - async function loadWalletLedger() { setWalletLedgerLoading(true); setWalletLedgerError(null); @@ -6231,14 +6213,13 @@ export function WorkspaceLauncher({ void loadWalletLedger(); } - function applyRechargeCenter(center: ProfileRechargeCenterResponse) { - setRechargeCenter(center); - setMudPointBalance(center.mudPointBalance ?? null); - setMudPointBalanceStatus('ready'); - setMudPointBalanceError(''); - setProfileDashboard((current) => - current ? { ...current, walletBalance: center.walletBalance } : current, - ); + function applyRechargeContent(center: ProfileRechargeCenterResponse) { + const { walletBalance, mudPointBalance, ...content } = center; + void walletBalance; + if (mudPointBalance) { + applyWalletBalanceSnapshot(mudPointBalance); + } + setRechargeContent(content); } async function loadRechargeCenter() { @@ -6250,7 +6231,7 @@ export function WorkspaceLauncher({ if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeCenter(center); + applyRechargeContent(center); } catch (error) { if (rechargeLifecycleRef.current === rechargeLifecycle) { setRechargeError( @@ -6271,6 +6252,13 @@ export function WorkspaceLauncher({ void loadRechargeCenter(); } + function closeRecharge() { + rechargeLifecycleRef.current += 1; + setRechargeOpen(false); + setNativeRechargePayment(null); + setSubmittingRechargeProductId(null); + } + async function buyRechargeProduct(product: ProfileRechargeProduct) { if (submittingRechargeProductId) { return; @@ -6285,7 +6273,7 @@ export function WorkspaceLauncher({ if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeCenter(response.center); + applyRechargeContent(response.center); const nativePayment = response.wechatNativePayment; const codeUrl = nativePayment?.codeUrl?.trim(); const expiresAt = nativePayment?.expiresAt?.trim(); @@ -6327,9 +6315,10 @@ export function WorkspaceLauncher({ if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeCenter(response.center); + applyRechargeContent(response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); + void onWalletBalanceMayHaveChanged(); return; } const confirmMessage = @@ -8654,6 +8643,7 @@ export function WorkspaceLauncher({ currentUser={currentUser} onLogout={() => { resetLauncherHomeDraft(); + resetWalletBalance(); onLogout(); }} onNoticeRequest={showLauncherNotice} @@ -8682,11 +8672,11 @@ export function WorkspaceLauncher({
void loadMudPointBalance()} + onRequestDetails={() => void onWalletBalanceMayHaveChanged()} onRecharge={openRecharge} onOpenLedger={openWalletLedger} /> @@ -9447,17 +9437,12 @@ export function WorkspaceLauncher({ ) : null} {rechargeOpen ? ( { - rechargeLifecycleRef.current += 1; - setRechargeOpen(false); - setNativeRechargePayment(null); - setSubmittingRechargeProductId(null); - }} + onClose={closeRecharge} onRetry={() => void loadRechargeCenter()} onBuy={(product) => void buyRechargeProduct(product)} onConfirmNativePayment={() => void confirmNativeRechargePayment()} @@ -9467,9 +9452,7 @@ export function WorkspaceLauncher({ {walletLedgerOpen ? ( setWalletLedgerOpen(false)} diff --git a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts new file mode 100644 index 000000000..eba4d1b48 --- /dev/null +++ b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts @@ -0,0 +1,100 @@ +import { create } from 'zustand'; + +import type { ProfileMudPointBalance } from '../../../../packages/shared/src'; +import { getClientProfileRechargeCenter } from '../services/clientApi'; + +export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; + +type WalletStore = { + mudPointBalance: ProfileMudPointBalance | null; + mudPointBalanceStatus: MudPointBalanceStatus; + mudPointBalanceError: string; + applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void; + onWalletBalanceMayHaveChanged: () => Promise; + resetWalletBalance: () => void; +}; + +const initialWalletState: Pick< + WalletStore, + 'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError' +> = { + mudPointBalance: null, + mudPointBalanceStatus: 'idle', + mudPointBalanceError: '', +}; + +let refreshVersion = 0; +let requestGeneration = 0; +let activeRefresh: Promise | null = null; + +export const useWalletStore = create((set) => ({ + ...initialWalletState, + applyWalletBalanceSnapshot: (balance) => { + requestGeneration += 1; + refreshVersion = 0; + activeRefresh = null; + set({ + mudPointBalance: balance, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + }, + onWalletBalanceMayHaveChanged: () => { + refreshVersion += 1; + if (activeRefresh) { + return activeRefresh; + } + + const refreshRequestGeneration = requestGeneration; + const refresh = (async () => { + while (refreshRequestGeneration === requestGeneration) { + const requestedVersion = refreshVersion; + set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' }); + try { + const center = await getClientProfileRechargeCenter(); + if (!center.mudPointBalance) { + throw new Error('充值中心响应缺少泥点余额'); + } + if (refreshRequestGeneration !== requestGeneration) { + return; + } + if (requestedVersion !== refreshVersion) { + continue; + } + set({ + mudPointBalance: center.mudPointBalance, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + return; + } catch (error) { + if (refreshRequestGeneration !== requestGeneration) { + return; + } + if (requestedVersion !== refreshVersion) { + continue; + } + set({ + mudPointBalanceStatus: 'error', + mudPointBalanceError: + error instanceof Error ? error.message : '泥点明细读取失败', + }); + return; + } + } + })(); + activeRefresh = refresh; + void refresh.finally(() => { + if (activeRefresh === refresh) { + activeRefresh = null; + } + }); + return refresh; + }, + resetWalletBalance: () => { + requestGeneration += 1; + refreshVersion = 0; + activeRefresh = null; + set(initialWalletState); + }, +})); diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 296658c6d..2aba6972f 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -10,7 +10,7 @@ import { import { useEffect, useRef, useState } from 'react'; import BRAND_ICON from '../../../../packages/shared/src/icons/taonier-product-ip.png'; -import { getClientProfileDashboard } from '../services/clientApi'; +import { useWalletStore } from '../stores/useWalletStore'; function cx(...classNames: Array) { return classNames.filter(Boolean).join(' '); @@ -70,33 +70,18 @@ function SidebarAccountMenu({ onNoticeRequest, onRechargeRequest, }: SidebarAccountMenuProps) { - const [walletBalanceLabel, setWalletBalanceLabel] = useState('--'); - const [walletBalanceStatus, setWalletBalanceStatus] = - useState('正在读取泥点余额'); - - useEffect(() => { - let disposed = false; - void getClientProfileDashboard() - .then((dashboard) => { - if (disposed) { - return; - } - setWalletBalanceLabel(formatMudPoints(dashboard.walletBalance)); - setWalletBalanceStatus('泥点余额已读取'); - }) - .catch((error) => { - if (disposed) { - return; - } - setWalletBalanceLabel('--'); - setWalletBalanceStatus( - error instanceof Error ? error.message : '泥点余额读取失败', - ); - }); - return () => { - disposed = true; - }; - }, []); + const walletBalance = useWalletStore( + (state) => state.mudPointBalance?.totalPoints ?? null, + ); + const walletBalanceError = useWalletStore( + (state) => state.mudPointBalanceError || null, + ); + const walletBalanceLabel = formatMudPoints(walletBalance); + const walletBalanceStatus = walletBalanceError + ? walletBalanceError + : walletBalance !== null + ? '泥点余额已读取' + : '正在读取泥点余额'; function showNotice(title: string) { onClose(); diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 845a2ba89..81f83ec5f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1630,18 +1630,29 @@ describe('AI 游戏创作 App 界面边界', () => { hasPointsRecharged: true, }; let rechargeOrderRequestCount = 0; + let rechargePaid = false; let releaseLateRechargeOrder: (() => void) | null = null; const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); - if (url === '/api/profile/dashboard') { - return new Response(JSON.stringify({ walletBalance: 120 }), { - status: 200, - }); - } if (url === '/api/profile/recharge-center') { - return new Response(JSON.stringify(rechargeCenter), { status: 200 }); + return new Response( + JSON.stringify( + rechargePaid + ? { + ...rechargeCenter, + walletBalance: 180, + mudPointBalance: { + ...rechargeCenter.mudPointBalance, + totalPoints: 180, + permanentPoints: 160, + }, + } + : rechargeCenter, + ), + { status: 200 }, + ); } if (url === '/api/profile/recharge/orders') { rechargeOrderRequestCount += 1; @@ -1678,6 +1689,7 @@ describe('AI 游戏创作 App 界面边界', () => { if ( url === '/api/profile/recharge/orders/order-native-1/wechat/confirm' ) { + rechargePaid = true; return new Response( JSON.stringify({ order: { diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts new file mode 100644 index 000000000..7a573f945 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -0,0 +1,182 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const clientApi = vi.hoisted(() => ({ + getClientProfileRechargeCenter: vi.fn(), +})); + +vi.mock('../src/services/clientApi', () => clientApi); + +import { useWalletStore } from '../src/stores/useWalletStore'; + +function detailedBalance(totalPoints: number) { + return { + totalPoints, + permanentPoints: Math.max(0, totalPoints - 20), + limitedPoints: 20, + limitedExpiresAt: '2026-08-01T00:00:00Z', + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-18T00:00:00Z', + }; +} + +describe('useWalletStore', () => { + beforeEach(() => { + useWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('keeps mudPointBalance as the only balance state', async () => { + const mudPointBalance = detailedBalance(120); + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 120, + mudPointBalance, + }); + const { result } = renderHook(() => useWalletStore()); + + await act(async () => { + await result.current.onWalletBalanceMayHaveChanged(); + }); + + expect(result.current.mudPointBalance).toEqual(mudPointBalance); + expect(result.current.mudPointBalanceStatus).toBe('ready'); + expect(result.current.mudPointBalanceError).toBe(''); + expect(Object.keys(result.current).sort()).toEqual([ + 'applyWalletBalanceSnapshot', + 'mudPointBalance', + 'mudPointBalanceError', + 'mudPointBalanceStatus', + 'onWalletBalanceMayHaveChanged', + 'resetWalletBalance', + ]); + }); + + test('recovers from a failed refresh with a recharge-center snapshot', async () => { + clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce( + new Error('首次读取失败'), + ); + await useWalletStore.getState().onWalletBalanceMayHaveChanged(); + + const mudPointBalance = detailedBalance(120); + useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + + expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); + expect(useWalletStore.getState().mudPointBalanceError).toBe(''); + }); + + test('does not let an older refresh overwrite an applied snapshot', async () => { + let rejectRequest: ((reason: Error) => void) | undefined; + clientApi.getClientProfileRechargeCenter.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectRequest = reject; + }), + ); + + const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); + const mudPointBalance = detailedBalance(160); + useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + rejectRequest?.(new Error('过期请求失败')); + await refresh; + + expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); + expect(useWalletStore.getState().mudPointBalanceError).toBe(''); + }); + + test('rejects a response that omits mudPointBalance instead of inventing a breakdown', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 120, + }); + + await useWalletStore.getState().onWalletBalanceMayHaveChanged(); + + expect(useWalletStore.getState().mudPointBalance).toBeNull(); + expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); + expect(useWalletStore.getState().mudPointBalanceError).toBe( + '充值中心响应缺少泥点余额', + ); + }); + + test('uses mudPointBalance from recharge-center as the authoritative snapshot', async () => { + const mudPointBalance = detailedBalance(180); + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 999, + mudPointBalance, + }); + + await useWalletStore.getState().onWalletBalanceMayHaveChanged(); + + expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + }); + + test('discards an outdated response and performs one trailing refresh', async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + clientApi.getClientProfileRechargeCenter + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ + walletBalance: 80, + mudPointBalance: detailedBalance(80), + }); + + const firstRefresh = + useWalletStore.getState().onWalletBalanceMayHaveChanged(); + const trailingRefresh = + useWalletStore.getState().onWalletBalanceMayHaveChanged(); + resolveFirst?.({ + walletBalance: 120, + mudPointBalance: detailedBalance(120), + }); + await Promise.all([firstRefresh, trailingRefresh]); + + expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2); + expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(80); + }); + + test('retains the last balance when a refresh fails', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ + walletBalance: 90, + mudPointBalance: detailedBalance(90), + }); + await useWalletStore.getState().onWalletBalanceMayHaveChanged(); + clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce( + new Error('刷新失败'), + ); + + await useWalletStore.getState().onWalletBalanceMayHaveChanged(); + + expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90); + expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); + expect(useWalletStore.getState().mudPointBalanceError).toBe('刷新失败'); + }); + + test('does not restore a late response after reset', async () => { + let resolveRequest: ((value: unknown) => void) | undefined; + clientApi.getClientProfileRechargeCenter.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); + useWalletStore.getState().resetWalletBalance(); + resolveRequest?.({ + walletBalance: 75, + mudPointBalance: detailedBalance(75), + }); + await refresh; + + expect(useWalletStore.getState().mudPointBalance).toBeNull(); + expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle'); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dbab77143..428adae83 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -88,6 +88,8 @@ V1.18 开发窗口把模式扩展为 `执行 / 聊天 / 目标`,Goal 创建和 以下能力清单保留 Runtime V1 的演进记录;其中“App 进程内 tokio task”“跨进程同项目写入不作为支持目标”和“恢复到当前 App 进程”的旧描述均已由 V1.1 替代。当前边界是 App / CLI 只落账并唤醒同一发布二进制的独立 Runner,append-only JSONL 使用进程内锁加 OS 文件锁,恢复继续由 Runner 接管同一 run / session。 +客户端泥点余额统一由 `apps/ai-game-creator-shell/src/stores/useWalletStore.ts` Zustand store 持有,唯一余额真相是 `mudPointBalance`。`ProfileDashboardSummary.walletBalance` 不作为余额来源;所有可能改变余额的动作统一调用 `onWalletBalanceMayHaveChanged()` 从 recharge-center 完整刷新,不在客户端本地增减余额。账单请求、充值产品内容、下单 / 支付确认和弹窗状态留在使用它们的组件,侧栏账户菜单直接订阅该 store,不通过布局 props 传递余额。 + Agent Runtime 负责: - 当前执行边界:App / CLI 只负责 durable 入队、查询和唤醒;同一发布二进制的独立 Runner 取得项目 owner 与 per-Agent OS 锁后执行 loop。Agent DB、conversation、events、tasks、activity 和 output 的 append-only JSONL 同时使用进程内互斥与 OS 文件锁,恢复由 Runner 接管原 run / session,不再重接到当前 App 进程。