From f1d46d595a0e6b0205670ae350a52f46a7481359 Mon Sep 17 00:00:00 2001 From: asher <82265836+bytelazy@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:35:20 +0800 Subject: [PATCH 1/3] fix(desktop): use config-relative glob so desktop stories load on Windows The desktop stories entry was built with resolve(REPO_ROOT, ...), which produces a backslash absolute path on Windows. Glob matchers treat backslashes as escape characters, so the pattern matched nothing and the desktop stories were silently dropped from the index (53 entries instead of 251). The neighboring packages/ui entry is a forward-slash, config-relative glob, which is why only the UI stories kept working. Use the same config-relative form for the desktop entry so the glob matches on all platforms. Fixes #4516 Generated-by: Claude (Claude Code) --- apps/desktop/.storybook/main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/.storybook/main.ts b/apps/desktop/.storybook/main.ts index 8aa012a7dd..b1355ffd84 100644 --- a/apps/desktop/.storybook/main.ts +++ b/apps/desktop/.storybook/main.ts @@ -33,7 +33,11 @@ const STORYBOOK_NODE_CRYPTO_BOUNDARY = resolve( const config: StorybookConfig = { stories: [ '../../../packages/ui/stories/**/*.stories.@(ts|tsx)', - resolve(REPO_ROOT, 'apps/desktop/stories/**/*.stories.@(ts|tsx)'), + // Config-relative glob (forward slashes) like the UI entry above. A + // `resolve(REPO_ROOT, ...)` absolute path produces backslashes on Windows, + // which glob matchers treat as escape characters, so no desktop story ever + // matches there. + '../stories/**/*.stories.@(ts|tsx)', ], framework: { name: '@storybook/react-vite', From 66d0398e64499fd32ac679036c45fc0e1659c4f6 Mon Sep 17 00:00:00 2001 From: asher <82265836+bytelazy@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:33:23 +0800 Subject: [PATCH 2/3] fix(desktop): treat unavailable collaboration authority as an empty inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-request inbox polls every owner Runtime Host on a 2s interval. A Host with no collaboration authority (e.g. the default Local Host) rejects each query with operation_unavailable, and when every Host rejected, collectAvailablePendingTurnRequests threw an AggregateError. The renderer caught it and retried, so Electron logged the same rejected IPC handler call every two seconds after startup — an unbounded stream of identical errors that buries real diagnostics. An unavailable collaboration capability is a valid Host composition, not a failure, so resolve the inbox as empty when no Host answered instead of throwing. The poller then keeps a quiet, empty inbox and repopulates it as soon as a capable Host appears. Mixed-capability setups are unchanged: a Host that still answers keeps contributing its requests. Fixes #4522 Generated-by: Claude (Claude Code) --- ...me-host-turn-request-inbox-preload.test.ts | 20 +++++++++++-------- .../runtime-host-turn-request-inbox.ts | 11 +++++----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts index 267504c866..b188c23f6a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts @@ -38,12 +38,16 @@ test('keeps available collaboration inboxes when another Owner Host rejects', as assert.deepEqual(requests.map(({ requestId }) => requestId), ['earlier', 'later']); }); -test('retains the previous inbox projection when every Owner Host rejects', async () => { - await assert.rejects( - collectAvailablePendingTurnRequests([ - Promise.reject(new Error('first unavailable')), - Promise.reject(new Error('second unavailable')), - ]), - /Every Runtime Host collaboration inbox request failed/, - ); +test('returns an empty inbox when every Owner Host rejects', async () => { + // A Host without a collaboration authority (e.g. the default Local Host) + // rejects each query with `operation_unavailable`. That is a valid + // composition, not an error, so the inbox resolves empty rather than + // throwing — the poller keeps the quiet, empty inbox instead of logging an + // IPC failure every interval. + const requests = await collectAvailablePendingTurnRequests([ + Promise.reject(new Error('first unavailable')), + Promise.reject(new Error('second unavailable')), + ]); + + assert.deepEqual(requests, []); }); diff --git a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts index 86199b9187..6da5ed1667 100644 --- a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts +++ b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts @@ -26,12 +26,11 @@ export async function collectAvailablePendingTurnRequests( const available = results.flatMap( (result) => result.status === 'fulfilled' ? [result.value] : [], ); - if (queries.length > 0 && available.length === 0) { - throw new AggregateError( - results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), - 'Every Runtime Host collaboration inbox request failed', - ); - } + // A Host with no collaboration authority rejects every inbox query with + // `operation_unavailable`. That is a valid composition (e.g. the default + // Local Host), not a failure, so when no Host answered we surface an empty + // inbox instead of throwing — the next poll repopulates it once a capable + // Host appears. return available .flat() .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); From e55130f9865de4a5cddc5068483504ba512b11f8 Mon Sep 17 00:00:00 2001 From: asher <82265836+bytelazy@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:17:58 +0800 Subject: [PATCH 3/3] fix(i18n): make resume-park toast copy locale-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resumeParkToastCopy() exposed no UiLocale parameter and returned hardcoded Chinese, so an English-locale Desktop user who resumed a parked session saw a Chinese toast title, a Chinese description, and the same Chinese text again in the inline detail. The locale was already in scope at the call site — the success and error branches of the same try block are localized; only the park branch skipped it. Restructure the copy module into a per-locale UiLocale table (zh keeps the existing strings; en adds an English translation of every reason, the two resume_candidate_missing strings, and the title/fallback), and pass uiLocale at the apps/desktop call site. Add a unit test that sweeps every documented reason key in both locales so the tables cannot drift apart. Scope: this addresses the user-visible resumeParkToastCopy half of the issue. The connection-error-copy.ts contract cleanup it also describes is left for a follow-up. Refs #4489 Generated-by: Claude (Claude Code) --- apps/desktop/src/renderer/use-shell-resume.ts | 2 +- .../src/__tests__/runtime-resume-copy.test.ts | 73 ++++++++ packages/ui/src/runtime-resume-copy.ts | 164 ++++++++++++++---- 3 files changed, 202 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/__tests__/runtime-resume-copy.test.ts diff --git a/apps/desktop/src/renderer/use-shell-resume.ts b/apps/desktop/src/renderer/use-shell-resume.ts index b682ff6220..4feb64d0a5 100644 --- a/apps/desktop/src/renderer/use-shell-resume.ts +++ b/apps/desktop/src/renderer/use-shell-resume.ts @@ -64,7 +64,7 @@ export function useShellResume(options: { try { const result = await window.maka.sessions.resumeLatest(sessionId); if (result.disposition === 'park') { - const parkCopy = resumeParkToastCopy(result.rejectionReasons); + const parkCopy = resumeParkToastCopy(result.rejectionReasons, uiLocale); setResumeParkDescriptionBySession((current) => ({ ...current, [sessionId]: parkCopy.description, diff --git a/packages/ui/src/__tests__/runtime-resume-copy.test.ts b/packages/ui/src/__tests__/runtime-resume-copy.test.ts new file mode 100644 index 0000000000..7c311aa9c5 --- /dev/null +++ b/packages/ui/src/__tests__/runtime-resume-copy.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { RESUME_PARK_REASON_KEYS, resumeParkToastCopy } from '../runtime-resume-copy.js'; + +test('renders Chinese copy for the zh locale', () => { + const copy = resumeParkToastCopy(['pending_permission'], 'zh'); + assert.equal(copy.title, '暂时无法继续这一轮'); + assert.equal(copy.description, '上次执行仍在等待权限确认。'); +}); + +test('renders English copy for the en locale', () => { + const copy = resumeParkToastCopy(['pending_permission'], 'en'); + assert.equal(copy.title, "This turn can't continue yet"); + assert.equal(copy.description, 'The last run is still waiting for permission approval.'); +}); + +test('localizes the resume_candidate_missing branch', () => { + assert.deepEqual(resumeParkToastCopy(['resume_candidate_missing'], 'zh'), { + title: '没有可恢复的任务', + description: '任务已是最新状态。', + }); + assert.deepEqual(resumeParkToastCopy(['resume_candidate_missing'], 'en'), { + title: 'Nothing to resume', + description: 'This task is already up to date.', + }); +}); + +test('falls back to a locale-aware message when no reason is recognized', () => { + assert.equal( + resumeParkToastCopy(['not_a_real_reason'], 'en').description, + 'This task does not currently meet the conditions to continue.', + ); + assert.equal( + resumeParkToastCopy(['not_a_real_reason'], 'zh').description, + '当前任务不满足继续的条件。', + ); +}); + +test('keeps every documented reason key translated in both locales', () => { + // Every documented reason must resolve to a distinct, non-empty string in + // both locales, and neither locale may fall back for a known key. + for (const reason of RESUME_PARK_REASON_KEYS) { + const zh = resumeParkToastCopy([reason], 'zh'); + const en = resumeParkToastCopy([reason], 'en'); + assert.notEqual(zh.description, '当前任务不满足继续的条件。', `zh missing: ${reason}`); + assert.notEqual( + en.description, + 'This task does not currently meet the conditions to continue.', + `en missing: ${reason}`, + ); + assert.ok(zh.description.length > 0 && en.description.length > 0); + } + assert.equal(RESUME_PARK_REASON_KEYS.length, 29); +}); diff --git a/packages/ui/src/runtime-resume-copy.ts b/packages/ui/src/runtime-resume-copy.ts index d9156ee7f5..9939d389b6 100644 --- a/packages/ui/src/runtime-resume-copy.ts +++ b/packages/ui/src/runtime-resume-copy.ts @@ -17,61 +17,153 @@ * under the License. */ +import type { UiLocale } from '@maka/core/ui-locale'; + export interface ResumeParkToastCopy { title: string; description: string; } -const RESUME_PARK_REASON_COPY: Readonly> = { - dangling_tool_state: '上次工具执行中断,记录已保留,暂时不能自动继续。', - pending_permission: '上次执行仍在等待权限确认。', - background_operation_pending: '仍有后台操作没有结束,暂时不能继续。', - workspace_identity_mismatch: '当前工作区与中断时不一致。', - workspace_identity_missing: '无法确认中断时的工作区。', - workspace_cwd_mismatch: '当前工作目录与中断时不一致。', - workspace_ref_missing: '中断时的工作区已不可用。', - tool_catalog_mismatch: '可用工具已发生变化,无法安全继续。', - checkpoint_restore_failed: '工作区检查点恢复失败。', - source_run_unreadable: '上次运行记录无法完整读取。', - runtime_ledger_unreadable: '上次运行账本无法完整读取。', - runtime_ledger_empty: '上次运行没有可回放的记录。', - terminal_repair_failed: '上次运行记录修复失败。', - provider_resume_head_unsupported: '当前模型不支持这个恢复起点。', - provider_resume_boundary_unsupported: '当前模型不支持这个恢复边界。', - provider_replay_non_suffix_gap: '上次模型输出的中断位置无法安全裁剪。', - provider_replay_unsupported: '上次运行历史无法按当前模型协议安全回放。', - runtime_lineage_cycle: '续跑链存在循环引用,已停止恢复。', - runtime_lineage_depth_exceeded: '续跑链过长,已停止自动恢复。', - runtime_lineage_missing: '续跑链缺少必要的历史记录。', - runtime_lineage_start_mismatch: '续跑链的起点记录不一致,已停止恢复。', - runtime_lineage_replay_mismatch: '续跑链记录的模型上下文与当前重建结果不一致。', - runtime_lineage_claim_mismatch: '续跑链缺少匹配的恢复所有权记录,已停止恢复。', - source_prefix_digest_mismatch: '上次运行的不可变边界已发生变化。', - continuation_already_exists: '该中断任务已经创建过续跑。', - continuation_claim_repair_required: '恢复所有权已保留,但续跑记录需要先修复。', - continuation_started_indeterminate: '续跑已经开始,但尚未形成可证明的终态。', - continuation_authority_unavailable: '当前存储不支持安全的续跑所有权。', - resume_feature_disabled: '继续中断任务的功能尚未启用。', +interface ResumeParkCopyTable { + readonly reasons: Readonly>; + readonly resumeCandidateMissingTitle: string; + readonly resumeCandidateMissingDescription: string; + readonly title: string; + readonly fallbackDescription: string; +} + +const RESUME_PARK_COPY: Record = { + zh: { + reasons: { + dangling_tool_state: '上次工具执行中断,记录已保留,暂时不能自动继续。', + pending_permission: '上次执行仍在等待权限确认。', + background_operation_pending: '仍有后台操作没有结束,暂时不能继续。', + workspace_identity_mismatch: '当前工作区与中断时不一致。', + workspace_identity_missing: '无法确认中断时的工作区。', + workspace_cwd_mismatch: '当前工作目录与中断时不一致。', + workspace_ref_missing: '中断时的工作区已不可用。', + tool_catalog_mismatch: '可用工具已发生变化,无法安全继续。', + checkpoint_restore_failed: '工作区检查点恢复失败。', + source_run_unreadable: '上次运行记录无法完整读取。', + runtime_ledger_unreadable: '上次运行账本无法完整读取。', + runtime_ledger_empty: '上次运行没有可回放的记录。', + terminal_repair_failed: '上次运行记录修复失败。', + provider_resume_head_unsupported: '当前模型不支持这个恢复起点。', + provider_resume_boundary_unsupported: '当前模型不支持这个恢复边界。', + provider_replay_non_suffix_gap: '上次模型输出的中断位置无法安全裁剪。', + provider_replay_unsupported: '上次运行历史无法按当前模型协议安全回放。', + runtime_lineage_cycle: '续跑链存在循环引用,已停止恢复。', + runtime_lineage_depth_exceeded: '续跑链过长,已停止自动恢复。', + runtime_lineage_missing: '续跑链缺少必要的历史记录。', + runtime_lineage_start_mismatch: '续跑链的起点记录不一致,已停止恢复。', + runtime_lineage_replay_mismatch: '续跑链记录的模型上下文与当前重建结果不一致。', + runtime_lineage_claim_mismatch: '续跑链缺少匹配的恢复所有权记录,已停止恢复。', + source_prefix_digest_mismatch: '上次运行的不可变边界已发生变化。', + continuation_already_exists: '该中断任务已经创建过续跑。', + continuation_claim_repair_required: '恢复所有权已保留,但续跑记录需要先修复。', + continuation_started_indeterminate: '续跑已经开始,但尚未形成可证明的终态。', + continuation_authority_unavailable: '当前存储不支持安全的续跑所有权。', + resume_feature_disabled: '继续中断任务的功能尚未启用。', + }, + resumeCandidateMissingTitle: '没有可恢复的任务', + resumeCandidateMissingDescription: '任务已是最新状态。', + title: '暂时无法继续这一轮', + fallbackDescription: '当前任务不满足继续的条件。', + }, + en: { + reasons: { + dangling_tool_state: 'The last tool call was interrupted; its record is kept, so this turn cannot resume automatically yet.', + pending_permission: 'The last run is still waiting for permission approval.', + background_operation_pending: 'A background operation is still running, so this turn cannot continue yet.', + workspace_identity_mismatch: 'The current workspace does not match the one that was interrupted.', + workspace_identity_missing: 'The workspace from the interrupted run cannot be confirmed.', + workspace_cwd_mismatch: 'The current working directory differs from the one that was interrupted.', + workspace_ref_missing: 'The workspace from the interrupted run is no longer available.', + tool_catalog_mismatch: 'The available tools have changed, so it is not safe to continue.', + checkpoint_restore_failed: 'Failed to restore the workspace checkpoint.', + source_run_unreadable: 'The last run record could not be read in full.', + runtime_ledger_unreadable: 'The last run ledger could not be read in full.', + runtime_ledger_empty: 'The last run has no records to replay.', + terminal_repair_failed: 'Failed to repair the last run record.', + provider_resume_head_unsupported: 'The current model does not support this resume point.', + provider_resume_boundary_unsupported: 'The current model does not support this resume boundary.', + provider_replay_non_suffix_gap: "The last model output can't be safely trimmed at the interruption point.", + provider_replay_unsupported: "The last run's history can't be safely replayed under the current model protocol.", + runtime_lineage_cycle: 'The resume chain has a circular reference, so recovery stopped.', + runtime_lineage_depth_exceeded: 'The resume chain is too long, so automatic recovery stopped.', + runtime_lineage_missing: 'The resume chain is missing required history.', + runtime_lineage_start_mismatch: 'The resume chain start records disagree, so recovery stopped.', + runtime_lineage_replay_mismatch: "The resume chain's recorded model context does not match the rebuilt result.", + runtime_lineage_claim_mismatch: 'The resume chain has no matching recovery-ownership record, so recovery stopped.', + source_prefix_digest_mismatch: "The last run's immutable boundary has changed.", + continuation_already_exists: 'A continuation was already created for this interrupted task.', + continuation_claim_repair_required: 'Recovery ownership is kept, but the continuation record needs repair first.', + continuation_started_indeterminate: 'The continuation started but has no provable final state yet.', + continuation_authority_unavailable: 'The current store does not support safe continuation ownership.', + resume_feature_disabled: 'Resuming interrupted tasks is not enabled yet.', + }, + resumeCandidateMissingTitle: 'Nothing to resume', + resumeCandidateMissingDescription: 'This task is already up to date.', + title: "This turn can't continue yet", + fallbackDescription: 'This task does not currently meet the conditions to continue.', + }, }; -export function resumeParkToastCopy(reasons: readonly string[]): ResumeParkToastCopy { +// Exported for tests so the locale tables cannot drift apart in key coverage. +export const RESUME_PARK_REASON_KEYS = [ + 'dangling_tool_state', + 'pending_permission', + 'background_operation_pending', + 'workspace_identity_mismatch', + 'workspace_identity_missing', + 'workspace_cwd_mismatch', + 'workspace_ref_missing', + 'tool_catalog_mismatch', + 'checkpoint_restore_failed', + 'source_run_unreadable', + 'runtime_ledger_unreadable', + 'runtime_ledger_empty', + 'terminal_repair_failed', + 'provider_resume_head_unsupported', + 'provider_resume_boundary_unsupported', + 'provider_replay_non_suffix_gap', + 'provider_replay_unsupported', + 'runtime_lineage_cycle', + 'runtime_lineage_depth_exceeded', + 'runtime_lineage_missing', + 'runtime_lineage_start_mismatch', + 'runtime_lineage_replay_mismatch', + 'runtime_lineage_claim_mismatch', + 'source_prefix_digest_mismatch', + 'continuation_already_exists', + 'continuation_claim_repair_required', + 'continuation_started_indeterminate', + 'continuation_authority_unavailable', + 'resume_feature_disabled', +] as const; + +export function resumeParkToastCopy( + reasons: readonly string[], + locale: UiLocale, +): ResumeParkToastCopy { + const copy = RESUME_PARK_COPY[locale]; if (reasons.length === 1 && reasons[0] === 'resume_candidate_missing') { return { - title: '没有可恢复的任务', - description: '任务已是最新状态。', + title: copy.resumeCandidateMissingTitle, + description: copy.resumeCandidateMissingDescription, }; } const descriptions = [...new Set( reasons - .map((reason) => RESUME_PARK_REASON_COPY[reason]) + .map((reason) => copy.reasons[reason]) .filter((description): description is string => description !== undefined), )]; return { - title: '暂时无法继续这一轮', + title: copy.title, description: descriptions.length > 0 ? descriptions.join(' ') - : '当前任务不满足继续的条件。', + : copy.fallbackDescription, }; }