diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index 99753ad..0e7d1c9 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -12,6 +12,7 @@ ## Scope +- On 2026-08-15, resume the existing Integration owner to merge reviewed Robot configuration-loading fix `1bcd519` into local `main`, preserving the page error/retry state and Main response-body deadline. - On 2026-08-15, resume the existing Integration owner to merge reviewed source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` for the Codex-style AI Programming context-compaction timeline into local `main` and promote its accepted lifecycle facts. - On 2026-08-14, resume the existing Integration owner to merge reviewed AI Canvas source commit `22378efcee07e7fb80b651e65e3202f1a1dfea1d` into local `main` and promote its accepted project-memory candidates. - Resume the existing integration owner to fast-forward the completed Robot / AI hardware source commit into local `main` after verifying the current remote `main` tip. @@ -104,6 +105,14 @@ Gate result: - The related threshold and UI-audit tasks agree with the source: automatic compaction remains OpenCode/model-limit-owned, while this source changes only the persistent Renderer interaction and run lifecycle. No semantic conflict requires human resolution. - Gate result: Passed for the local context-compaction merge. Remote push remains outside this resumption. +### 2026-08-15 Robot Configuration Loading Integration Resume + +- Reused this Integration owner because it still exclusively owns `main` and the repository integration lock; `task_context.py touch` refreshed the reservation. +- Verified source task `20260815-robot-config-loading-4f8c2d` is ready for integration, its final Sol review returned PASS, and source commit `1bcd519` is a direct descendant of current `main` at merge base `a4050f0`. +- A fresh `git fetch origin main --prune` succeeded and confirmed local `main` and `origin/main` are both exactly `a4050f0`, with no remote-only commit or divergence. +- The source changes only the Robot page, Main-owned AI hardware route, focused tests, and its own source task record. It preserves Main-owned auth/idempotency/ETag and introduces no semantic overlap with registered peer work. +- Gate result: Passed for the local Robot configuration-loading merge. This request authorizes a local `main` merge, not an automatic remote push. + ## Plan 1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology. @@ -120,8 +129,20 @@ Gate result: 4. Re-run focused/full unit checks, typecheck, lint, production build, Electron E2E, document drift, and whitespace checks on the merged tree. 5. Obtain an independent read-only Sol PASS/FAIL review, record the result, and keep remote push outside this request. +### 2026-08-15 Robot Configuration Loading Integration Plan + +1. Merge reviewed source commit `1bcd519` into local `main` with a normal no-ff merge while preserving source history. +2. Exclude the source-owned task record from the final `main` tree so the integration task does not own or rewrite another task's project document. +3. Run the 7-file Robot/Main regression suite, typecheck, scoped lint, build, project-document gates, and whitespace checks. +4. Obtain an independent read-only Sol PASS/FAIL review, record exact topology and verification, and keep remote push outside this request. + ## Outcome +- On 2026-08-15, formed the reviewed Robot configuration-loading fix as source commit `1bcd51964da04e5d80b461547d6bcccfafc0bec9` on its isolated feature branch. +- Fetched `origin/main` and verified both local and remote-tracking `main` were exactly `a4050f0a6567e8203630bf5d16b57f00c45caab8` with zero ahead/behind before integration. +- Started a normal `--no-ff --no-commit` merge of `1bcd519`; Git reported no textual conflicts. The source task record remains reachable on source commit `1bcd519` and its feature branch and is excluded from the `main` result to preserve project-document ownership boundaries. +- The merged code gives Robot configuration reads an explicit terminal error/retry state and keeps the same upstream deadline active through bounded response-body read and parse. Strict configuration DTO, ETag/revision, Main-owned authentication/idempotency, size limits, and error redaction remain unchanged. +- Independent final integration review returned `PASS` after validating merge topology, remote baseline, staged-tree ownership, behavior, tests, build, and project-document gates. - On 2026-08-13, resumed this integration task for the user's local-main merge request. `git fetch origin main --prune` succeeded and confirmed `origin/main=22add3f01f2b7cb6318495e8294db006f6f18abf`. - Verified source commit `aba5cae286807093cf4ef643fe9f498050985c31` is a direct descendant of that remote tip and that local `main` is its ancestor, then fast-forwarded local `main` to `aba5cae` without rebase, reset, stash use, or conflict. - The existing shared `stash@{0}` was not applied, popped, or dropped. @@ -192,6 +213,12 @@ Gate result: ## Verification +- 2026-08-15 Robot configuration-loading merged-tree regression selection — 7 files / 74 tests passed. +- Robot merged-tree `pnpm run typecheck` — passed. +- ESLint on the four merged TypeScript/TSX files — passed. +- `pnpm run build:vite` — Renderer, Electron Main, and Preload production builds passed; only the existing chunk-size and mixed-import warnings remain. +- Staged/unstaged whitespace checks and task-aware project-document drift — passed before final review. +- Independent Robot configuration-loading integration review — `PASS`, no blocking findings. - Robot source closeout before integration: 8 focused files / 72 tests passed; TypeScript and task-aware document drift passed. - Post-fast-forward `pnpm test`: 156 files / 1681 tests passed. - `pnpm run typecheck` and `pnpm run build:vite`: passed; build retains only existing chunk/dynamic-import warnings. diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index f011e5b..76b9fda 100644 --- a/electron/api/routes/ai-hardware.ts +++ b/electron/api/routes/ai-hardware.ts @@ -172,6 +172,42 @@ async function readBoundedResponse(response: Response): Promise { } } +type TimedResponse = { + response: Response; + signal: AbortSignal; + finish: () => void; +}; + +async function readTimedResponse(call: TimedResponse): Promise { + try { + return await readBoundedResponse(call.response); + } catch (error) { + if (call.signal.aborted) { + throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true); + } + throw error; + } finally { + call.finish(); + } +} + +async function readSafeErrorPayload(call: TimedResponse): Promise { + try { + return await readTimedResponse(call); + } catch (error) { + if (error instanceof SafeRouteError && error.code === 'AI_HARDWARE_TIMEOUT') throw error; + return null; + } +} + +async function cancelTimedResponse(call: TimedResponse): Promise { + try { + await call.response.body?.cancel().catch(() => undefined); + } finally { + call.finish(); + } +} + function ensureExactKeys(body: Record, allowed: Set): void { if (Object.keys(body).some((key) => !allowed.has(key))) { throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); @@ -431,11 +467,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend return true; } const idempotencyKey = operationId ? `makelore-${operationId}` : undefined; - const call = async (accessToken: string): Promise => { + const call = async (accessToken: string): Promise => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + }; try { - return await fetchImpl(`${apiBaseUrl}${upstreamPath}`, { + const response = await fetchImpl(`${apiBaseUrl}${upstreamPath}`, { method, headers: { Accept: 'application/json', @@ -448,24 +490,26 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend signal: controller.signal, redirect: 'manual', }); + return { response, signal: controller.signal, finish }; } catch (error) { + finish(); if (controller.signal.aborted) { throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true); } throw error; - } finally { - clearTimeout(timer); } }; let currentToken = token; - let response = await call(currentToken); + let activeCall = await call(currentToken); + let response = activeCall.response; if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (refreshed) { currentToken = refreshed; - response = await call(currentToken); + activeCall = await call(currentToken); + response = activeCall.response; } } @@ -473,11 +517,12 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend if (response.status === 409 && operationId) { const retryDelay = retryAfterSeconds(response, MAX_RETRY_AFTER_SECONDS); if (retryDelay !== undefined) { - cachedErrorPayload = isJsonContentType(response) ? await readBoundedResponse(response).catch(() => null) : null; + cachedErrorPayload = isJsonContentType(response) ? await readSafeErrorPayload(activeCall) : null; const safe = safeUpstreamError(cachedErrorPayload, response.status); if (safe.code === 'ai_hardware_operation_in_progress') { await new Promise((resolve) => setTimeout(resolve, retryDelay * 1000)); - response = await call(currentToken); + activeCall = await call(currentToken); + response = activeCall.response; cachedErrorPayload = undefined; } } @@ -486,14 +531,14 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend if (!response.ok) { const status = response.status; if (status === 404 && upstreamPath === UPSTREAM_ROOT) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); sendFailure(res, 200, 404, 'AI_HARDWARE_DISABLED', 'AI hardware module is not enabled', false); return true; } const retryAfter = retryAfterSeconds(response); const payload = cachedErrorPayload ?? (isJsonContentType(response) - ? await readBoundedResponse(response).catch(() => null) - : (await response.body?.cancel().catch(() => undefined), null)); + ? await readSafeErrorPayload(activeCall) + : (await cancelTimedResponse(activeCall), null)); const safe = safeUpstreamError(payload, status); sendFailure( res, @@ -509,11 +554,17 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend } if (response.status !== expectedStatus || !isJsonContentType(response)) { - await response.body?.cancel().catch(() => undefined); + await cancelTimedResponse(activeCall); throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); } - const revision = requireEtag ? strongRevisionFrom(response) : undefined; - const projected = project(await readBoundedResponse(response)); + let revision: number | undefined; + try { + revision = requireEtag ? strongRevisionFrom(response) : undefined; + } catch (error) { + await cancelTimedResponse(activeCall); + throw error; + } + const projected = project(await readTimedResponse(activeCall)); if (!projected) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true); if (revision !== undefined) { const dtoRevision = 'config_revision' in projected diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index fbfc525..7ea1d90 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -152,6 +152,8 @@ export function AiHardware() { const [config, setConfig] = useState(null); const [configRevision, setConfigRevision] = useState(null); const [configLoading, setConfigLoading] = useState(false); + const [configLoadFailed, setConfigLoadFailed] = useState(false); + const [configReloadKey, setConfigReloadKey] = useState(0); const [notice, setNotice] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [bindOpen, setBindOpen] = useState(false); @@ -220,19 +222,30 @@ export function AiHardware() { useEffect(() => { void loadOverview(); }, [loadOverview]); useEffect(() => { - if (!selectedAgentId) { setConfig(null); setConfigRevision(null); return; } + if (!selectedAgentId) { + setConfig(null); + setConfigRevision(null); + setConfigLoading(false); + setConfigLoadFailed(false); + return; + } let active = true; setConfig(null); setConfigRevision(null); + setConfigLoadFailed(false); setNotice(null); setConfigLoading(true); void getAiHardwareAgentConfiguration(selectedAgentId).then((result) => { if (!active) return; setConfig(result.data); setConfigRevision(result.revision); - }).catch(() => { if (active) setNotice('无法读取智能体配置,请刷新后重试。'); }) + }).catch(() => { + if (!active) return; + setConfigLoadFailed(true); + setNotice('无法读取智能体配置,请刷新后重试。'); + }) .finally(() => { if (active) setConfigLoading(false); }); return () => { active = false; }; - }, [selectedAgentId]); + }, [configReloadKey, selectedAgentId]); const selectedAgent = overview?.agents.find((item) => item.id === selectedAgentId) ?? null; const devices = useMemo(() => overview?.devices ?? [], [overview]); @@ -365,7 +378,7 @@ export function AiHardware() {
智能体{overview.agents.length} 个
{overview.agents.map((agent) => )}
-
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: }
+
{selectedAgent?.name ?? '智能体配置'}基础对话和语音设置
{config ?
语言
{config.language || config.lang_code || '未设置'}
语音
{config.tts_voice_id || '未设置'}
系统提示
{config.system_prompt || '未设置'}
: configLoading ? : configLoadFailed ? setConfigReloadKey((value) => value + 1)}>重试读取配置} /> : null}
设备{devices.length} 台已绑定设备
{devices.length ?
{devices.map((device) =>
设备 {shortId(device.id)}指派 r{device.assignment_revision}
)}
: }
diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index 72a8249..d94f4a3 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -366,6 +366,32 @@ describe('AI hardware page', () => { expect(screen.getByText('正在读取配置')).toBeInTheDocument(); }); + it('stops loading and lets the user retry after configuration loading fails', async () => { + api.getAiHardwareOverview.mockResolvedValueOnce({ + status: 'active', + agents: [agentOne], + devices: [device], + }); + api.getAiHardwareAgentConfiguration + .mockRejectedValueOnce(new AiHardwareApiError({ + status: 502, + code: 'xiaozhi_hardware_unavailable', + message: 'private provider details', + retryable: true, + })) + .mockResolvedValueOnce({ data: configuration, revision: 4 }); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent('无法读取智能体配置'); + expect(screen.queryByText('正在读取配置')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '重试读取配置' })); + await waitFor(() => expect(api.getAiHardwareAgentConfiguration).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('保持简洁')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '编辑配置' })).toBeEnabled(); + }); + it('loads the current assignment revision before reassigning a device', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '重新指派' })); diff --git a/tests/unit/ai-hardware-routes.test.ts b/tests/unit/ai-hardware-routes.test.ts index bd76fb2..9e36692 100644 --- a/tests/unit/ai-hardware-routes.test.ts +++ b/tests/unit/ai-hardware-routes.test.ts @@ -326,6 +326,41 @@ describe('AI hardware Host API route', () => { expect(hugeResult.payload).toMatchObject({ success: false, status: 502, code: 'AI_HARDWARE_RESPONSE_TOO_LARGE' }); }); + it('keeps the upstream deadline active while reading a stalled configuration body', async () => { + let bodyController: ReadableStreamDefaultController | undefined; + const fetchImpl = vi.fn((_input, init) => { + const body = new ReadableStream({ + start(controller) { + bodyController = controller; + controller.enqueue(new TextEncoder().encode('{"id":"a-1"')); + init?.signal?.addEventListener('abort', () => { + controller.error(new DOMException('secret stalled body', 'AbortError')); + }, { once: true }); + }, + }); + return Promise.resolve(new Response(body, { + headers: { 'content-type': 'application/json', etag: '"0"' }, + })); + }); + const { handler } = setup(fetchImpl); + const pending = invoke(handler, 'GET', '/api/works/ai-hardware/agents/a-1'); + const result = await Promise.race([ + pending, + new Promise((resolve) => setTimeout(() => resolve(null), 100)), + ]); + + if (result === null) { + bodyController?.close(); + await pending; + } + expect(result?.payload).toMatchObject({ + success: false, + status: 504, + code: 'AI_HARDWARE_TIMEOUT', + retryable: true, + }); + }); + it('bounds request bodies and distinguishes unrelated and unknown hardware routes', async () => { const { handler, fetchImpl } = setup(); const unrelated = response();