Skip to content

fix: 优化记忆管理加载与分页,统一展示本机记忆 - #777

Open
Orsted290 wants to merge 35 commits into
MemTensor:v1.2.6from
Orsted290:fix-memory-panel-performance-v126
Open

Orsted290 wants to merge 35 commits into
MemTensor:v1.2.6from
Orsted290:fix-memory-panel-performance-v126

Conversation

@Orsted290

@Orsted290 Orsted290 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

问题与结果

记忆管理在首次打开、切页和刷新时会读取完整向量或大段 JSON,概览与分析重复计算;后台新轮次首次归属还会使正在浏览的分页失效。管理页按登录 UID 过滤的部分入口,也会遗漏历史账号记录或 NULL UID 新记录。

本 PR 改为轻量展示读取、共享统计来源和固定分页视图,并保留原页面布局及聊天展示。管理页统一展示、搜索和统计本机新旧记忆,历史 UID 不改写;管理页的读取范围不用于放宽召回、演化或删除权限。

主要修改

  • 减少无用读取:列表与详情使用展示投影,排除向量及无关大字段;批量查询关联和处理状态,耗时读取放入独立 worker。正式 runtime bundle 保留 worker 入口及动态依赖。
  • 共享轻量统计:概览和分析共用一次统计读取;来源、日期、状态及工具指标持久化为可重建派生数据,源表写入后按脏 ID 更新,重启后复用,避免重复解析完整记忆和日志 JSON。首次历史数据仍有一次构建成本,完整数据就绪才视为加载完成。
  • 稳定分页与请求生命周期:固定当前浏览成员,后台普通新增及合法首次归属不重建旧视图;原刷新按钮显式重建视图。保留共享请求合并、独立订阅取消、隐藏页面暂停、迟到结果隔离及 worker 停止后的调度保护。
  • 管理页读取本机全部记录:统一列表、全文搜索、统计、任务对话/字段、来源关系和独立管理详情入口,兼容历史 UID 与 NULL UID。只切换登录 UID 时保留当前页面和视图;连接变化仍释放旧视图。本地认证、租户和有效来源关系检查保留,云端及 scoped 请求沿用原访问范围。
  • 统一来源可见性:对声明原始来源的记忆,在形成列表成员、搜索计数及分页前检查来源是否存在、是否已删除/脱敏及会话关系是否有效;详情、日志与统计保持一致。来源 ID 和会话关系缓存为轻量字段,当前隐私状态按主键检查,避免恢复全量 JSON 校验。
  • 修正 NULL UID 首次归属:合法首次归属使用 NULL-safe 判断,第 2/3 页及已读内容保持有效;已有归属重绑、所有者变化、删除、脱敏和会话关系变化仍撤销旧视图。任务关联清理继续核验当前 raw/父 trace 归属,避免使用过期关系。
  • 回退无关界面及功能:恢复基线聊天气泡、角色、Markdown、时间和工具状态/耗时;移除整页 Gate、统计时间行及新增更新提示。按本轮回退要求,新的前端分段对话交互整体撤回,列表摘要与取消保留。移除主动数据库降级命令、专用实现和 Linux CUDA 下载配置,并清理相关代码、文案、样式和测试残留。
  • 保留升级保护:业务数据库保留必要的 13→14 增量升级、备份、事务回滚及租约 fencing/重试计数断言。旧库测试使用独立 v13 夹具;同步适配旧租约测试的最终版本、备份路径及失败注入点。仅派生缓存格式升版并重建,不回填或改写历史源表。

验证

已合入最新目标 81a86226e8cf9ac1c793e5f13b2b4aac005e5fe8,以下为同步后的本地检查;远端 CI 状态以当前提交的 Checks 为准。

范围 结果
Memory 存储、读取、关联查找及迁移 34 文件、274 项通过(含数据库启动及演化冲突回归)
Memory HTTP 契约 5 文件、8 项通过,覆盖 NULL UID 首次归属、原正常捕获入口、分页、来源撤销及访问控制
前端 API、记忆页面及交互 41 文件、365 项通过
Backend 路由、客户端及跨层集成 7 文件、47 项通过
Runtime bundler 6 项通过
静态检查与构建 完整工作区 typecheck、lint、Memory 完整构建通过
UI 基线对照 同步前 25 组同数据 DOM 对照一致、16 个展示/转换函数保持原样;同步后 styles.css 与最新目标一致,前端交互回归通过

本次新增针对性断言在修复前有 5 项失败,修复后通过。来源撤销测试同时覆盖列表、全文搜索、总数、完整第 2/3 页、新建视图、统计和重启;保留未修改的 memory 行及普通仓储读取结果,逐行核对管理读取未改写源表。NULL UID 测试同时覆盖正常首次归属、重复绑定、重绑、错误 owner 及隐私失效。

同步前 3e4719b4 的隔离浏览器使用生产子页组件、真实 Backend、Memory HTTP 和编译后的 JS worker:53 条记录的第 2 页在 NULL UID 首次归属后保留原 20 个成员和视图;来源脱敏后,通过原刷新按钮重新搜索不再命中旧标题,重新翻页不复现,概览同步变为 52。临时入口和样例仅用于本地验收,不纳入提交。

性能与验收边界

以下性能及浏览器证据来自同步前的 3e4719b4 阶段,不等同于最终合并提交的桌面/性能验收。

同一隔离合成库包含 46,891 条记忆、12,000 条日志,源库约 1.30 GB。最终构建单次 HTTP 实测:

  • 服务打开至概览/分析完整数据约 3.21 秒;重启后约 310 ms。
  • 日志第 2/3 页约 1.72/1.40 ms,刷新约 1.03 ms;所选工具/来源范围内的完整 payload 搜索约 66 ms。
  • 另为 12,000 条合成来源配置 4 KB 工具 payload,原始记忆第 2/3 页约 5.20/5.16 ms;来源隐私失效后的计数和页面约 5.11 ms。查询计划确认来源校验走主键。

这些是单机描述性样例,未清空 OS 文件缓存,不作为跨版本因果百分比。首次迁移/派生数据构建仍有成本,不将快速返回 preparing 算作完成。最终版本尚未完成 DMG 安装后的验收,也未覆盖真实账号、Hook/召回竞争、受控冷盘及完整跨平台发行矩阵。

分支与范围

沿用现有 PR #777:目标 MemTensor:v1.2.6,修复分支 Orsted290:fix-memory-panel-performance-v126,已同步目标 81a86226e8cf9ac1c793e5f13b2b4aac005e5fe8。本轮管理读取与视图补修未修改历史数据、召回、演化或删除执行逻辑,未纳入原有未提交的 lockfile 改动。

本轮目标分支同步

通过 merge 同步目标分支,保留上游数据库启动阶段、后台准备和公平调度改进。冲突处理同时保留面板只读配置、来源投影函数,以及 storage-startup-worker 与 panel/reader-worker 两个 runtime 入口;演化测试保留双方新增断言。另补数据库准备完成后面板读取与只读写入拒绝的串联回归。

@Orsted290
Orsted290 marked this pull request as ready for review October 28, 2026 13:10
@Orsted290
Orsted290 requested a review from wustzdy as a code owner October 28, 2026 13:10
@Memtensor-AI

Memtensor-AI commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #777
Task: 81c3647328e67d6f
Base: v1.2.6
Head: fix-memory-panel-performance-v126

🔍 OpenCodeReview found 115 issue(s) in this PR.

⚠️ Review warnings (3)

  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • Filtered 3 low-confidence OCR finding(s) before posting/fix-loop.

1. App/backend/local-api-contracts/src/memory-runtime.ts (L909)

This new ~130-line block (task summaries/detail/field, browse, stats, panel logs) is a verbatim copy of the block added to Memory/src/contracts/memory-runtime.ts; there is no codegen step that derives one from the other (no script references either file). Since the Memory service responses are validated against this copy by http-memory-client, any future edit applied to only one side turns into a runtime validation failure. Consider generating one of the two files from a single source, or at least add a marker comment pointing at the mirrored file so edits stay in lockstep.


2. App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts (L1-L3)

Four separate import statements pull from the same module (@memmy/local-api-contracts, also line 5), and they mix two brace-spacing styles. Sibling route files (e.g. memory.ts) consolidate to a single named import with the file's usual spacing. Merging them keeps the import list readable as more panel schemas are added.

💡 Suggested Change

Before:

import {PanelLogsInputSchema} from "@memmy/local-api-contracts";
import { PanelBrowseInputSchema } from "@memmy/local-api-contracts";
import { PanelTaskDetailInputSchema, PanelTaskFieldInputSchema } from "@memmy/local-api-contracts";

After:

import {
  PanelBrowseInputSchema,
  PanelItemsInputSchema,
  PanelLogsInputSchema,
  PanelTaskDetailInputSchema,
  PanelTaskFieldInputSchema,
  PanelTasksInputSchema
} from "@memmy/local-api-contracts";

3. App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts (L6)

FastifyReply and FastifyRequest are imported but never referenced in this file (no explicit annotation uses them), so they are dead imports. Only FastifyInstance is needed.

💡 Suggested Change

Before:

import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";

After:

import type { FastifyInstance } from "fastify";

4. App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts (L77-L78)

New routes inline the same param schemas repeatedly (z.object({ id: z.string().min(1) }) at lines 14, 70, 74, and this one). memory.ts already establishes the pattern of declaring module-level schemas (MemoryParamsSchema, RecallParamsSchema); defining TaskParamsSchema / TaskTurnParamsSchema / MemoryParamsSchema once here avoids drift when a param rule changes.

💡 Suggested Change

Before:

  app.get("/api/v1/panel/task-details/:id/turns/:turnId/field", {preHandler: deps.authenticateRuntimeToken}, withErrorEnvelope(async (request, reply) => {
    const {id, turnId} = z.object({id: z.string().min(1), turnId: z.string().min(1)}).parse(request.params);

After:

const TaskParamsSchema = z.object({ id: z.string().min(1) });
const TaskTurnParamsSchema = z.object({ id: z.string().min(1), turnId: z.string().min(1) });

5. App/backend/src/adapters/outbound/memory-client/http-memory-client.ts (L1-L2)

Six separate import statements from the same module were added above the existing @memmy/local-api-contracts import block (lines 8-40). This duplicates the same specifier six times and breaks the import grouping used by the rest of the file. Merge them into one statement (ideally the existing block below).

💡 Suggested Change

Before:

import { PanelTaskCheckOutputSchema } from "@memmy/local-api-contracts";
import {PanelLogsOutputSchema,PanelLogDetailOutputSchema} from "@memmy/local-api-contracts";

After:

import {
  PanelTaskCheckOutputSchema,
  PanelLogsOutputSchema,
  PanelLogDetailOutputSchema,
  PanelStatsOutputSchema,
  PanelOverviewStatsOutputSchema,
  PanelBrowseOutputSchema,
  PanelTaskSummariesOutputSchema,
  PanelTaskDetailOutputSchema,
  PanelTaskFieldOutputSchema
} from "@memmy/local-api-contracts";

6. App/backend/src/adapters/outbound/memory-client/http-memory-client.ts (L319-L322)

These four new methods are written as single-line one-liners (and without spaces after commas), while every neighbouring method uses the multi-line, spaced style. Expanding them keeps the file readable and makes future diffs on a single argument comprehensible.

💡 Suggested Change

Before:

    async panelLogs(input,context) {return request("POST","panelLogs",PanelLogsOutputSchema,{body:input,context,maxRetries:0});},
    async panelLogDetail(id,context) {return request("GET","panelLogDetail",PanelLogDetailOutputSchema,{params:{id:String(id)},context});},
    async panelStats(context) {return request("GET","panelStats",PanelStatsOutputSchema,{context,maxRetries:0});},
    async panelOverviewStats(context) {return request("GET","panelOverviewStats",PanelOverviewStatsOutputSchema,{context,maxRetries:0});},

After:

    async panelLogs(input, context) {
      return request("POST", "panelLogs", PanelLogsOutputSchema, { body: input, context, maxRetries: 0 });
    },
    async panelLogDetail(id, context) {
      return request("GET", "panelLogDetail", PanelLogDetailOutputSchema, { params: { id: String(id) }, context });
    },
    async panelStats(context) {
      return request("GET", "panelStats", PanelStatsOutputSchema, { context, maxRetries: 0 });
    },
    async panelOverviewStats(context) {
      return request("GET", "panelOverviewStats", PanelOverviewStatsOutputSchema, { context, maxRetries: 0 });
    },

7. App/backend/src/adapters/outbound/memory-client/http-memory-client.ts (L111-L112)

The expression requestOptions.signal ?? requestOptions.context?.signal is now repeated four times inside request (fetch init, fetchWithMappedNetworkErrors, the JSON catch, and the retry options). Hoist it into a local const callerSignal so the caller-abort precedence is defined once and cannot drift between call sites.

💡 Suggested Change

Before:

          signal: combineAbortSignals(timeoutSignal, requestOptions.signal ?? requestOptions.context?.signal)
        }, requestOptions.signal ?? requestOptions.context?.signal);

After:

          signal: combineAbortSignals(timeoutSignal, callerSignal)
        }, callerSignal);

8. App/backend/src/adapters/outbound/memory-client/retry.ts (L21)

throwIfAborted() rethrows the signal's raw reason (a DOMException named AbortError), not a MemoryLayerError. Callers only map MemoryLayerError/MemoryLayerNetworkError (toErrorEnvelope -> internal/500 otherwise), and the client tests assert caller cancellation surfaces as {code:"memory_layer_unavailable", message:"memory layer request cancelled"}. When the caller signal is already aborted before the first attempt (e.g. cancellableRuntimeContextFromRequest aborts immediately for a destroyed reply), the route now answers 500 internal with "This operation was aborted" instead of the intended cancellation response. Map the abort reason to the same cancellation error before propagating (in request()'s try/catch around retryWithBackoff, or via an error factory injected into RetryOptions).


9. App/backend/src/adapters/outbound/memory-client/retry.ts (L48-L51)

abort is referenced inside the timeout callback before its const declaration. It happens to work because the callback runs asynchronously, but it reads as a TDZ hazard and is flagged by no-use-before-define-style lint rules. Declare abort first, then the timeout.

💡 Suggested Change

Before:

    const timeout = setTimeout(() => {
      signal?.removeEventListener("abort", abort);
      resolve();
    }, delayMs);

After:

    let timeout: ReturnType<typeof setTimeout>;
    const abort = () => {
      clearTimeout(timeout);
      reject(signal?.reason);
    };
    timeout = setTimeout(() => {
      signal?.removeEventListener("abort", abort);
      resolve();
    }, delayMs);

10. App/backend/src/adapters/outbound/memory-client/types.ts (L1-L2)

Same pattern as the HTTP client: six separate import type statements from @memmy/local-api-contracts were added above the existing consolidated block (lines 8-46). Merge them into that block to avoid duplicated specifiers.

💡 Suggested Change

Before:

import type { PanelTaskCheckOutput } from "@memmy/local-api-contracts";
import type {PanelLogsInput,PanelLogsOutput,PanelLogDetailOutput} from "@memmy/local-api-contracts";

After:

import type {
  PanelTaskCheckOutput,
  PanelLogsInput,
  PanelLogsOutput,
  PanelLogDetailOutput,
  PanelStatsOutput,
  PanelOverviewStatsOutput,
  PanelBrowseInput,
  PanelBrowseOutput,
  PanelTaskSummariesOutput,
  PanelTaskDetailOutput,
  PanelTaskFieldInput,
  PanelTaskFieldOutput
} from "@memmy/local-api-contracts";

11. App/backend/src/adapters/outbound/memory-client/types.ts (L119)

The new panel methods are added as required members of MemoryClient, but the existing in-repo implementations were not updated. For example App/backend/src/tests/support/mock-memory-client.ts (createMockMemoryClient(): MemoryClient), App/backend/src/services/tests/agent-source-service.test.ts (createReadyMemoryClient(): MemoryClient) and the literals in src/tests/index.test.ts / src/services/tests/agent-runtime-services.test.ts implement none of panelLogs, panelLogDetail, panelStats, panelOverviewStats, panelBrowse, panelTaskSummaries, panelTaskCheck, panelTaskDetail, panelTaskField. TypeScript now reports them as missing properties (editors/tsc on test projects) and any test that reaches a panel route through such a mock will fail with xxx is not a function. Either add the implementations to the shared mock or declare the new members optional like panelMemory.


12. App/backend/src/adapters/outbound/memory-client/types.ts (L115)

panelMemory is declared optional while all sibling panel reads are required. The only real implementation (createHttpMemoryClient) always provides it, so the optionality exists solely to keep older/incomplete clients compiling; it also drives the fallback in panel-service.ts (panelMemory ? panelMemory(id) : getMemory({memoryId:id})), which silently switches from the owner-agnostic /api/v1/panel/memory/:id to the user-scoped /api/v1/memory/:id and would 404 for another owner's local library item. Consider making it required (the endpoint is validated by the contract tests) or documenting explicitly why the fallback is needed.


13. App/backend/src/services/bootstrap-service.ts (L41)

Three positional arguments make it easy to swap userId/timeZone, and the current expression silently drops timeZone whenever userId is absent (the else-branch is just undefined). Build the context independently for each field so a configured time zone still reaches the memory layer. Also note {timeZone} is missing a space after {.

💡 Suggested Change

Before:

        getMemoryHealth(options.memoryClient, options.getMemoryUserId?.(), options.timeZone),

After:

        getMemoryHealth(options.memoryClient, { userId: options.getMemoryUserId?.(), timeZone: options.timeZone }),

14. App/backend/src/services/index.ts (L335)

resolveMemoryUserId is the single source of the Memory namespace user, but it is injected under two different names (getMemoryUserId for bootstrap, getUserId for memoryDetail/panel). Same-ish semantics with different names makes it easy for a future endpoint to wire a different resolver (e.g. the analytics one) by mistake. Align on one dep name across the services.


15. App/backend/src/services/memory-detail-service.ts (L30)

Only getById is account-scoped now: add and delete still call the memory layer without a userId. Since the Memory service scopes reads by the x-memmy-user-id header (a scoped read of another owner's memory returns 404), a memory added through add under the device namespace may not be readable through this now-scoped getById. Please confirm the ownership flow is consistent across add/get/delete (or pass getUserId() to all three).


16. App/backend/src/services/panel-service.ts (L46-L47)

The context helper now silently drops the account identity (userId: undefined), but only browse/tasks carry a comment explaining why. Since both context and writeContext look similar yet have opposite security semantics (device-scoped read vs. account-scoped write), a short doc comment on each (or renaming to readContext/writeContext) is needed; otherwise a future caller can easily pick the wrong one for a mutation.

💡 Suggested Change

Before:

  const context = (ctx: RuntimeContext): RuntimeContext => ({ ...ctx, userId: undefined });
  const writeContext=(ctx:RuntimeContext):RuntimeContext=>({...ctx,userId:deps.getUserId()});

After:

  /** Management reads are device-scoped: the desktop shows the local library. */
  const readContext = (ctx: RuntimeContext): RuntimeContext => ({ ...ctx, userId: undefined });
  /** Mutations stay account-scoped so ownership checks still apply. */
  const writeContext = (ctx: RuntimeContext): RuntimeContext => ({ ...ctx, userId: deps.getUserId() });

17. App/backend/src/services/panel-service.ts (L73)

taskSummaries / taskCheck / taskDetail / taskField (and tasks) repeat the exact {...ctx, userId: undefined} expression that the context() helper already implements. Reuse context(ctx) so the device-scope rule lives in one place and cannot drift between endpoints.

💡 Suggested Change

Before:

      return deps.memoryClient.panelTaskSummaries(input, {...ctx, userId: undefined});

After:

      return deps.memoryClient.panelTaskSummaries(input, context(ctx));

18. App/backend/src/services/panel-service.ts (L64-L67)

The newly added one-liner methods (logs, logDetail, stats, overviewStats, taskCheck) pack a whole method body onto a single line with no spaces inside braces/commas, which is inconsistent with the rest of this file and hard to read in review/diff. Please use the multi-line formatting used by the neighbouring methods.

💡 Suggested Change

Before:

    async logs(input,ctx) {return deps.memoryClient.panelLogs(input,context(ctx));},
    async logDetail(id,ctx) {return deps.memoryClient.panelLogDetail(id,context(ctx));},
    async stats(ctx) {return deps.memoryClient.panelStats(context(ctx));},
    async overviewStats(ctx) {return deps.memoryClient.panelOverviewStats(context(ctx));},

After:

    async logs(input, ctx) {
      return deps.memoryClient.panelLogs(input, context(ctx));
    },

    async logDetail(id, ctx) {
      return deps.memoryClient.panelLogDetail(id, context(ctx));
    },

    async stats(ctx) {
      return deps.memoryClient.panelStats(context(ctx));
    },

    async overviewStats(ctx) {
      return deps.memoryClient.panelOverviewStats(context(ctx));
    },

19. App/backend/src/services/panel-service.ts (L1-L6)

These six one-off import type statements import from @memmy/local-api-contracts, which is already imported by the grouped import type { ... } block a few lines below — three separate declarations from the same module in one file. They also push the module doc comment /** Panel service module. */ down between imports, so the file no longer starts with its header. Please fold the new names into the existing grouped import and keep the module comment at the top.

💡 Suggested Change

Before:

import type { PanelTaskCheckOutput } from "@memmy/local-api-contracts";
import type {PanelLogsInput,PanelLogsOutput,PanelLogDetailOutput} from "@memmy/local-api-contracts";
import type {PanelStatsOutput} from "@memmy/local-api-contracts";
import type {PanelOverviewStatsOutput} from "@memmy/local-api-contracts";
import type { PanelBrowseInput, PanelBrowseOutput } from "@memmy/local-api-contracts";
import type { PanelTaskSummariesOutput, PanelTaskDetailOutput, PanelTaskFieldInput, PanelTaskFieldOutput } from "@memmy/local-api-contracts";

After:

// move the module doc comment back to line 1 and merge these names into the
// existing `import type { ... } from "@memmy/local-api-contracts"` block below

20. App/frontend/desktop/src/api/http.ts (L70)

The route-not-found classification only recognizes Route GET:/Route POST: bodies, but the panel/evidence routes in the same client also use DELETE (DELETE /api/v1/panel/tasks/:id) and PATCH (PATCH /api/v1/evidence/skills/:id). Fastify emits the same Route <METHOD>:... not found body for those methods, so a 404 on them never gets code === "not_found" and cannot be recognized as an unsupported route. Include PATCH/DELETE (or any method token) in the pattern.

💡 Suggested Change

Before:

    && typeof rawBody.message === "string" && /^Route (GET|POST):.* not found$/i.test(rawBody.message)) {

After:

    && typeof rawBody.message === "string" && /^Route (GET|POST|PATCH|DELETE):.* not found$/i.test(rawBody.message)) {

21. App/frontend/desktop/src/api/memory-runtime-client.ts (L458)

Nested ternary here is forbidden by the project checklist, and the inner signal ? branch is pointless because getMemory(id, signal?) already accepts an optional signal. Flatten it to a single conditional. Also note getPanelMemory relies on this for its own fallback, so keeping the call as a member invocation (not a destructured reference) is required.

💡 Suggested Change

Before:

  return client.getPanelMemory ? client.getPanelMemory(id,signal) : signal ? client.getMemory(id,signal) : client.getMemory(id);

After:

  return client.getPanelMemory ? client.getPanelMemory(id,signal) : client.getMemory(id,signal);

22. App/frontend/desktop/src/api/memory-runtime-client.ts (L161-L162)

prefetchPanel reports success unconditionally after browse.list, even when the list resolved undefined because the browse route is unsupported, or when hasView() was already true only because a selection exists but no page has been fetched yet. Callers treat true as "this section is warmed" (use-panel-prefetch drops the section from its queue), so these cases silently skip prefetching. Return whether an actual page is available.

💡 Suggested Change

Before:

      await browse.list(section,input,signal);
      return true;

After:

      const page = await browse.list(section,input,signal);
      return page !== undefined;

23. App/frontend/desktop/src/api/memory-runtime-client.ts (L151)

The event name is a hardcoded transport constant duplicated in every listener (panel-view-status.tsx, logs-sub-page.tsx, use-panel-stats.ts). If either side changes, invalidation silently stops working. Extract and export a shared constant and reuse it in the listeners. The queue concurrency 2 (also in PanelBrowseClient) and the "L1" default layer are likewise magic values worth naming.

💡 Suggested Change

Before:

    if (typeof window !== "undefined") window.dispatchEvent(new Event("memmy-panel-invalidated"));

After:

    if (typeof window !== "undefined") window.dispatchEvent(new Event(PANEL_INVALIDATED_EVENT));

24. App/frontend/desktop/src/api/panel-browse-client.ts (L135-L136)

check() only returns the server's updated flag; it never invalidates/refreshes the retained view when the server reports the underlying data changed (updated: true). The only consumer (usePanelViewStatus) ignores updated and only reacts to thrown errors, so a view whose backing data changed keeps serving cached pages indefinitely and the 60s poll has no effect. Consider dropping the section's view (or triggering refresh) when updated is true so the next list re-reads.

💡 Suggested Change

Before:

    if (this.views.get(section) !== view) throw abortError();
    return checked;

After:

    if (this.views.get(section) !== view) throw abortError();
    if (checked.updated) this.reset(section);
    return checked;

25. App/frontend/desktop/src/i18n/messages.ts (L1906)

This key sits in the memory.tasks.* block, but its only consumer is the skills sub-page's timeline footer button (skills-sub-page.tsx:817, "load more / retry"). Nothing in the tasks pages uses it, so the namespace no longer describes ownership and will make the string hard to find when the task list later needs its own "load more" label. Prefer a memory.skills.* key placed in the skills block below.

💡 Suggested Change

Before:

  "memory.tasks.loadContent": "继续读取内容",

After:

  "memory.skills.loadContent": "继续读取内容",

26. App/frontend/desktop/src/pages/memory/logs-sub-page.tsx (L112)

The log list is now filtered server-side via loadLogsData(..., query), but the cache key (logsCacheKeys(page, tool, sourceAgent)) still ignores query. Consequences: (1) typing a search immediately renders the previously cached unfiltered page from readMemoryPanelCacheFirst; (2) the filtered response is written under the query-agnostic key (and under memoryPanelLatestCacheKey), so a later visit with an empty query shows a filtered list as if it were complete. Include the normalized query in the cache key derivation.

💡 Suggested Change

Before:

    const cacheKeys = logsCacheKeys(nextPage, nextTool, nextSourceAgent);

After:

    const cacheKeys = logsCacheKeys(nextPage, nextTool, nextSourceAgent, query);

27. App/frontend/desktop/src/pages/memory/logs-sub-page.tsx (L130-L134)

This listener closes over the refresh of the render in which the effect ran, and the effect only re-subscribes on props.client change. refresh() is called with the mount-time defaults for page, tool, sourceAgent and query, so after any memmy-panel-invalidated event the page refetches the wrong page/filter/search combination. Either add the captured values to the dependency list or, better, rely on the same usePanelViewStatus hook used by the memories/tasks sub-pages (which also handles expired view (401/403/409) recovery that the logs page currently lacks).

💡 Suggested Change

Before:

  useEffect(()=>{
    const invalidate=()=>{generation.current++;setState({status:"loading"});void refresh().catch(()=>undefined);};
    window.addEventListener("memmy-panel-invalidated",invalidate);
    return()=>window.removeEventListener("memmy-panel-invalidated",invalidate);
  },[props.client]);

After:

  useEffect(()=>{
    const invalidate=()=>{generation.current++;setState({status:"loading"});void refresh().catch(()=>undefined);};
    window.addEventListener("memmy-panel-invalidated",invalidate);
    return()=>window.removeEventListener("memmy-panel-invalidated",invalidate);
  },[props.client,page,tool,sourceAgent,query]);

28. App/frontend/desktop/src/pages/memory/logs-sub-page.tsx (L406)

ExpandedLog can render the loading state forever: when detailAvailable is true but the client implementation has no getPanelLogDetail, nothing is ever fetched and data stays undefined. Also the effect omits props.log.detailAvailable (and t) from its dependencies even though the body branches on them. Fall back to props.log when the detail endpoint is unavailable, and complete the dependency list. Note the panel log list rows already carry the full inputJson/outputJson, so this extra request only pays off if the list payload is later truncated.

💡 Suggested Change

Before:

  },[props.client,props.log.id]);

After:

  },[props.client,props.log.id,props.log.detailAvailable,props.client?.getPanelLogDetail]);

29. App/frontend/desktop/src/pages/memory/memories-sub-page.tsx (L137-L140)

This cleanup-only effect runs on every visible transition (not just unmount/client change). When the window is hidden while a detail request is in flight, the abort fires and detailRequestIdRef is bumped, so openDetailById's rejection handlers both bail out (requestId mismatch and controller.signal.aborted) and detail stays at {status:"loading"} forever — nothing re-triggers the load when the panel becomes visible again. Use a real unmount cleanup ([]/[props.client]) or reset detail/selectedMemoryId when aborting so a visible drawer is not stuck on a spinner.

💡 Suggested Change

Before:

  useEffect(() => () => {
    detailRequestIdRef.current++;
    detailAbortRef.current?.abort();
  }, [props.client, visible]);

After:

  useEffect(() => () => {
    detailRequestIdRef.current++;
    detailAbortRef.current?.abort();
    setDetail(null);
    setSelectedMemoryId(null);
  }, [props.client, visible]);

30. App/frontend/desktop/src/pages/memory/memories-sub-page.tsx (L3-L4)

canPreservePanelResult and usePanelViewStatus are imported through two separate statements from the same module (the same duplication exists in tasks-sub-page.tsx). Merge them into one import for readability.

💡 Suggested Change

Before:

import { canPreservePanelResult } from "./panel-view-status.js";
import { usePanelViewStatus } from "./panel-view-status.js";

After:

import { canPreservePanelResult, usePanelViewStatus } from "./panel-view-status.js";

31. App/frontend/desktop/src/pages/memory/memories-sub-page.tsx (L144-L145)

This effect has no body other than a cleanup path and depends on visible, so it runs on every visibility transition, not only on client change/unmount. Consequences: (1) retryRequestIdRef is bumped, which makes the retryMemoryProcessing polling loop bail out at its next canRead()/requestId check and never resume — the server keeps retrying but the UI stops tracking it; (2) setRetryFeedback(null) drops the running/succeeded/error feedback permanently, so a user who starts a retry and switches away (window hidden) comes back to no feedback at all. Consider only cancelling the polling (keep it resumable) and clearing feedback on genuine teardown, e.g. depend on props.client only and pause/resume rather than abort on visible.


32. App/frontend/desktop/src/pages/memory/memories-sub-page.tsx (L427)

Change detection relies on JSON.stringify equality between two values produced by different endpoints (detail.data.item.processing from readMemoryPanelDetail and the record from getMemoryProcessingStatus). If field sets/ordering differ (they are built by separate mappers), the strings never match and the full detail is reloaded every 5 s for any failed memory. Compare a stable field instead (e.g. state / updatedAt / errorMessage).


33. App/frontend/desktop/src/pages/memory/panel-view-status.tsx (L24)

client! / checkPanelView! bypass the nullable typing of this hook's first parameter. The guard that makes them safe (Boolean(client?.checkPanelView) passed as usePanelPoll's enabled) lives in another module and is only applied when the poll effect starts; latest.current is refreshed on every render, so a render where client became null can hand a client === null closure to an already-scheduled run (passive-effect cleanup is not synchronous with commit). The resulting TypeError is not an ApiRequestError, so it is re-thrown and counted as a poll failure instead of being treated as "no client". Note the same function already uses optional calls for resetPanelView.

💡 Suggested Change

Before:

      await client!.checkPanelView!(section);

After:

      const checkView = client?.checkPanelView;
      if (!checkView) return;
      await checkView(section);

34. App/frontend/desktop/src/pages/memory/panel-view-status.tsx (L26)

The awaited result of checkPanelView ({updated: boolean}) is discarded. The server computes updated: view.change !== String(this.deps.change()) (Memory/src/service/panel/browse.ts:177), i.e. it is the freshness signal for this poll. Since sub-pages disable their legacy poll when checkPanelView exists (e.g. memories-sub-page.tsx:134), a panel whose underlying data changed is never refreshed here — only 401/403/404/409 trigger work — so the freshness half of the contract is dead code. Either consume the flag or drop it from the client contract.

💡 Suggested Change

Before:

        setExpired(false);

After:

        setExpired(false);
        if (expired || checked?.updated) void latest.current.reload?.().catch(() => undefined);

35. App/frontend/desktop/src/pages/memory/panel-view-status.tsx (L30-L31)

Two problems on this path: (1) after the throwing guard on the previous line, id === generation.current (and the scope equality) is always true, so the first conjunct is dead and obscures the invariant; (2) a stale error is re-thrown, which is indistinguishable from a real failure. More importantly, re-throwing an expiry error that was just handled feeds usePanelPoll's exponential backoff (failures grows to 4 → next check in 60_000 * 2^4 = 16 min). Because the legacy refresh poll is disabled when checkPanelView exists, an expired panel would then stay empty for up to 16 minutes after the service recovers. Handle the expiry and return normally so the 60s cadence is kept.

💡 Suggested Change

Before:

      if (id !== generation.current || checkingScope !== currentScope.current) throw error;
      if (id === generation.current && error instanceof ApiRequestError && [401, 403, 404, 409].includes(error.status)) {

After:

      if (id !== generation.current || checkingScope !== currentScope.current) return;
      if (error instanceof ApiRequestError && [401, 403, 404, 409].includes(error.status)) {

36. App/frontend/desktop/src/pages/memory/panel-view-status.tsx (L71-L73)

The panel-expiry status set [401, 403, 404, 409] is duplicated verbatim here and in check() above, and a third copy exists in use-panel-stats.ts:68. A future change to the expiry contract can be applied to one site and silently missed in the others, desynchronizing expiry detection from result-preservation. Extract a single predicate and derive canPreservePanelResult from it.

💡 Suggested Change

Before:

export function canPreservePanelResult(error: unknown): boolean {
  return !(error instanceof ApiRequestError && [401, 403, 404, 409].includes(error.status));
}

After:

export function isPanelViewExpired(error: unknown): boolean {
  return error instanceof ApiRequestError && [401, 403, 404, 409].includes(error.status);
}
export function canPreservePanelResult(error: unknown): boolean {
  return !isPanelViewExpired(error);
}

37. App/frontend/desktop/src/pages/memory/panel-view-status.tsx (L44-L47)

The expired flag survives a service/section switch. If a check recorded an expiry (401/403/404/409) for the previous client, this reset effect clears the panel state (via invalidate) but leaves expired === true, so the very first successful check() against the new client hits line 27 and fires reload(). That reload aborts the fresh initial load started by usePanelLoad (requestAbortRef.current?.abort() inside the sub-page refresh) and re-issues the same request, i.e. an unnecessary duplicate fetch right after a reconnect. usePanelStats already resets its per-client refs (oldService, overviewStatsUnsupported) in the equivalent effect, so this hook should reset its per-client expiry flag too.

💡 Suggested Change

Before:

  useLayoutEffect(() => {
    generation.current++;
    latest.current.invalidate();
  }, [client, section]);

After:

  useLayoutEffect(() => {
    generation.current++;
    setExpired(false);
    latest.current.invalidate();
  }, [client, section]);

38. App/frontend/desktop/src/pages/memory/policies-sub-page.tsx (L219-L220)

refreshPanelView marks the browse selection as refreshing, and PanelBrowseClient.list then forces page = 1 for the recreated view. So refreshing while on page N fetches page 1, data.page !== normalizedPage, and the setPage(data.page) in refresh silently sends the user back to page 1 (and triggers a second request via the page identity). Same pattern exists in skills-sub-page and world-model-sub-page. Either refresh the current page explicitly (after the view is recreated) or request page 1 intentionally instead of page.


39. App/frontend/desktop/src/pages/memory/skills-sub-page.tsx (L175)

The .catch(() => []) guard on the auxiliary timeline read was dropped. loadSkillTimeline calls listMemoryLogs, which is unrelated to the skill body; if it fails, the whole Promise.all rejects and the drawer shows {status:"error"} even though the detail loaded fine. This only affects clients without listPanelLogs, but there the regression is user-visible (previously the timeline was simply empty). Restore the fallback.

💡 Suggested Change

Before:

      props.client.listPanelLogs ? Promise.resolve([]) : loadSkillTimeline(props.client, skillId)

After:

      props.client.listPanelLogs ? Promise.resolve([]) : loadSkillTimeline(props.client, skillId).catch(() => [])

40. App/frontend/desktop/src/pages/memory/skills-sub-page.tsx (L296-L298)

This extra loadSkillDetail round-trip is only used for an id equality check — current body/version are never consumed — and it runs before the mutation, doubling the latency of every approval (and making approve fail when only this pointless pre-read fails). The current selection id is already available in selectedSkillIdRef, so validate against that instead.

💡 Suggested Change

Before:

    const generation=detailGeneration.current;
    const current=await loadSkillDetail(props.client,id);
    if(generation!==detailGeneration.current||current.item.id!==id)return;

After:

    const generation=detailGeneration.current;
    if(selectedSkillIdRef.current!==id)return;

41. App/frontend/desktop/src/pages/memory/skills-sub-page.tsx (L303)

Publishing timeline: [] here discards the timeline entries already rendered in the drawer, so on legacy clients the evolution timeline visibly empties right after an approve. updateObservedSkill below preserves current.data.timeline; do the same for consistency.

💡 Suggested Change

Before:

    if(canRead() && generation === detailGeneration.current && selectedSkillIdRef.current===id)setDetail({status:"ready",data:{detail:skillDetail,timeline:[]}});

After:

    if(canRead() && generation === detailGeneration.current && selectedSkillIdRef.current===id)setDetail((current) => ({status:"ready",data:{detail:skillDetail,
      timeline: current?.status === "ready" ? current.data.timeline : []}}));

42. App/frontend/desktop/src/pages/memory/skills-sub-page.tsx (L295)

approveObservedSkill/updateObservedSkill now bail out silently when canRead() is false (panel hidden, tab backgrounded, or client swapped mid-flight). The returned promise resolves successfully, so SkillDrawer's onApprove/save handlers clear approving/saving and show no error, while the candidate was never approved and the body never saved — the UI silently drops the user's action. Either throw an error so the drawer's error path renders, or guard only the follow-up detail read and still surface the skip to the caller.


43. App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx (L560-L561)

summaryOnly is a plain boolean, so it does not narrow the PanelTaskItem | PanelTaskSummary union. PanelTaskItem only declares {id, episode, memoryIds, turns, updatedAt} — firstUserText, firstAssistantText and toolCallCount exist only on PanelTaskSummary. Therefore summaryOnly ? item.firstUserText : ... (and the same at lines 580, 583, 597) is a TS2339 compile error, and even if it compiled the fields would be undefined at runtime for legacy full items. Narrow with an in check on the fields themselves and reuse the narrowed values.

💡 Suggested Change

Before:

function taskFromPanelItem(item: PanelTaskItem | PanelTaskSummary, t: Translate): MemoryTask {
  const summaryOnly = !("turns" in item);

After:

function taskFromPanelItem(item: PanelTaskItem | PanelTaskSummary, t: Translate): MemoryTask {
  const summaryOnly = !("turns" in item);
  const summaryTitle = "firstUserText" in item ? item.firstUserText : undefined;
  const summaryReply = "firstAssistantText" in item ? item.firstAssistantText : undefined;
  const summaryToolCalls = "toolCallCount" in item ? item.toolCallCount : undefined;

44. App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx (L166-L168)

A failed detail lookup replaces the whole panel with {status:"error"}, wiping an already rendered task list (and closeTask() has already cleared the selection), so one failed/ambiguous lookup blanks the page. openTaskDetail is also now reached from the normal row click path for summary items, which makes this much easier to hit. Keep detail failures local (e.g. a separate detailError state shown in the drawer) and leave state untouched.


45. App/frontend/desktop/src/pages/memory/use-panel-load.ts (L56)

The dependency array is assembled as [input.query, ...input.identity, visible], so its length depends on the caller-supplied identity. React requires a stable dep-array size and throws (The final argument passed to useEffect changed size between renders) as soon as a caller passes a variable-length identity (e.g. an optional filter appended). Also, entries are compared with Object.is, so any freshly created object/array inside identity re-runs the effect on every render, calling invalidate() and re-firing load(). All current call sites pass fixed-length literal identities, so this is latent rather than active, but the hook's contract is fragile. Deriving a single scalar key keeps the array length constant and makes comparison value-based.

💡 Suggested Change

Before:

  }, [input.query, ...input.identity, visible]);

After:

  }, [input.query, input.identity.map((value) => String(value)).join("|"), visible]);

46. App/frontend/desktop/src/pages/memory/use-panel-load.ts (L12-L13)

Writing latest.current during render is a render-phase side effect: under concurrent rendering a discarded/aborted render still overwrites the ref, so submit() (and the debounced run) can read the query/identity of a render that never committed. Move the assignment into a layout effect declared before the load effect (layout effects run before passive effects, so the load effect still sees the committed input).

💡 Suggested Change

Before:

  const latest = useRef(input);
  latest.current = input;

After:

  const latest = useRef(input);
  useLayoutEffect(() => {
    latest.current = input;
  });

47. App/frontend/desktop/src/pages/memory/use-panel-poll.ts (L7)

Reassigning the enabled parameter is easy to misread and conflicts with the enabled entry in the dependency list below (readers cannot tell whether the dep refers to the caller's value or the masked one). Derive a local value instead and use it consistently in the guard and the deps.

💡 Suggested Change

Before:

  enabled = enabled && visible;

After:

  const active = enabled && visible;

48. App/frontend/desktop/src/pages/memory/use-panel-prefetch.ts (L46)

Background work here is uncapped in two places:

  1. prefetchPanel returns false whenever the request queue/browse cache is busy (if (!panelQueue.idle || !browse.idle) return false; in memory-runtime-client.ts), and the section is not shifted off remaining, yet next() is rescheduled after previewInterval (250 ms when getPanelOverviewStats exists). During sustained foreground activity this loop runs forever at 4 ticks/second.
  2. The snapshot.status === "preparing" branch re-polls every 500 ms with no attempt limit, so a projection that never reaches ready keeps hitting the backend for the whole session.
    Add an attempt/backoff budget (and a larger floor interval) so a section that never becomes prefetchable stops instead of polling indefinitely.

49. App/frontend/desktop/src/pages/memory/use-panel-prefetch.ts (L12)

sections and the mapped table duplicate the sub-page → panel-layer knowledge that already lives in memoryNavSections in memory-page.tsx. A new/renamed section will silently stop being prefetched (and mapped[current] will be undefined, so the current page is prefetched too). Consider extracting the section list next to the nav definition and importing it instead of re-declaring the mapping here.


50. App/frontend/desktop/src/pages/memory/use-panel-prefetch.ts (L52-L55)

next() has no re-entrancy guard (unlike usePanelPoll, which tracks active). If the window is hidden and shown again while a getPanelStats/prefetchPanel await is in flight, visibility schedules a new next(); the in-flight chain then resumes with document.hidden === false and overwrites the same timer handle with a second one, so two chains run concurrently against the shared stats/remaining arrays — issuing duplicate prefetch requests and double shift()ing entries.

Add a running flag: return early from next() when set, and reset it in a finally.


51. App/frontend/desktop/src/pages/memory/use-panel-stats.ts (L48-L54)

This state selection is a nested ternary (forbidden by the style guide) and it mixes two problems:

  • the selectedData ? truthiness test would treat a valid falsy payload as "not ready" (parking the panel on the loading state until the next poll); !== undefined is the intended check,
  • "Statistics failed" is a hardcoded English message that bypasses the i18n table, so zh-CN users see raw English while the rest of the panel is localised (the hook can call useTranslation() or accept the message from the caller).

Prefer an explicit if/else chain:

💡 Suggested Change

Before:

            setState(
              selectedData
                ? { status: "ready", data: selectedData as T }
                : result.status === "error"
                  ? { status: "error", message: result.error ?? "Statistics failed" }
                  : { status: "loading" }
            );

After:

            if (selectedData !== undefined) {
              setState({ status: "ready", data: selectedData as T });
            } else if (result.status === "error") {
              setState({ status: "error", message: result.error ?? t("memory.panel.statsFailed") });
            } else {
              setState({ status: "loading" });
            }

52. App/frontend/desktop/src/pages/memory/use-panel-stats.ts (L81-L85)

visible is in this effect's dependency list, so the reset above (setState({status:"loading"}) + setSnapshot(undefined)) also runs every time the panel becomes inactive. MemoryPageView now keeps visited sub-pages mounted and only flips PanelVisibilityContext, so simply switching away from overview/analytics and back discards the already-loaded statistics and shows the loading spinner again (and forces a full refetch), which defeats the retained-page design.

Visibility should only gate whether the request is issued, not wipe committed data. Keep the reset keyed on [client, section] and add a separate effect that loads when visible turns true.

💡 Suggested Change

Before:

    if (visible) void load().catch(() => undefined);
    return () => {
      generation.current++;
    };
  }, [client, section, visible]);

After:

    if (visible) void load().catch(() => undefined);
    return () => {
      generation.current++;
    };
  }, [client, section]);
  // Visibility only gates the request; it must not discard already-loaded data.
  useLayoutEffect(() => {
    if (visible) void load().catch(() => undefined);
  }, [visible]);

53. App/frontend/desktop/src/pages/memory/use-panel-stats.ts (L96)

While the runtime reports preparing, this polls every 100 ms — i.e. sustained 10 requests/second on /api/v1/panel/stats, which recomputes all panel statistics server-side. Depending on how long preparation takes (large panels), that is a lot of background load for a status transition.

A 250–500 ms interval keeps the transition feeling immediate; note the prefetch path in use-panel-prefetch.ts already waits 500 ms for the same preparing state, so 100 ms is also inconsistent. (The stats interaction test asserts the 100 ms tick, so it would need updating.)

💡 Suggested Change

Before:

  const preparingInterval = 100;

After:

  const preparingInterval = 500;

54. App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx (L87-L88)

The rethrow sits outside the requestId === requestIdRef.current guard, so a superseded/aborted request (abort at the top of refresh, page change, invalidate()) also rejects. With preserve the caller is the refresh button, which then reports a failure via its error feedback even though a newer request succeeded (or no error is relevant at all). Only rethrow when this request is still the current one.

💡 Suggested Change

Before:

        if(preserve)throw error;
        return undefined;

After:

        if (requestId !== requestIdRef.current) return undefined;
        if (preserve) throw error;
        return undefined;

55. App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx (L203)

On error/loading the card replaces the list-provided selected.summary entirely, so the user loses the only text they can still read, and the error box is rendered without tone="error" (the convention used in the rest of this page and in skills-sub-page). Keep the summary and render the error state with the proper tone.

💡 Suggested Change

Before:

                {detailStatus ? <MemoryStateBox message={detailStatus.status === "error" ? detailStatus.message : t("common.loading")} /> : <div className="memory-detail-text">{selected.summary}</div>}

After:

                {detailStatus?.status === "error" ? <MemoryStateBox message={detailStatus.message} tone="error" /> : null}
                {detailStatus?.status === "loading" ? <MemoryStateBox message={t("common.loading")} /> : <div className="memory-detail-text">{selected.summary}</div>}

56. App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx (L62)

The other three sub pages expose this as refresh(page, { useCache, preserve }); here it is a positional boolean, and the caller reads refresh(page, true) which is meaningless without knowing the signature. Align it with the siblings (options object) so the call sites stay self-documenting and future flags do not turn into boolean soup.


57. Memory/src/cli/commands.ts (L87)

new MemoryDb({path}) opens the database with fileMustExist: false, so a mistyped --db path silently creates a brand-new empty database, runs the full migration (plus a pre-migration backup) on it, and then reports ok: true with scanned: 0 — the user sees success while nothing was repaired. It also runs migrate() against whatever file is given, which is unsafe if that is the live service database. Suggest validating the file exists and avoiding implicit migrations for a maintenance-only command.

💡 Suggested Change

Before:

    const db = new MemoryDb({path});

After:

    if (!existsSync(path)) throw new Error(`database does not exist: ${path}`);
    const db = new MemoryDb({path, skipMigrations: true});

58. Memory/src/contracts/memory-runtime.ts (L896)

filter reuses PanelItemsInputSchema, which now carries userSummary and page, but the browse path ignores both: panelBrowse.page(viewId, input.page ?? 1, ...) only ever uses the top-level page, and panelMembers/panelPageByIds never forward userSummary to summaryOnly (panelPageByIds hard-codes fullContentAvailable: true instead). So filter.page silently yields page 1 and filter.userSummary is a no-op in browse, which is confusing now that the UserMemories page sends userSummary: true through browse. Also viewId/filter are accepted for every op even though check/release/page only use viewId+namespace, so contradictory inputs validate.

Suggest omitting page from the browse filter and, ideally, modelling the ops as a discriminated union so viewId is required for page/check/release.

💡 Suggested Change

Before:

  filter: PanelItemsInputSchema.extend({section:z.enum(["items","tasks"]).optional()}).optional()

After:

  filter: PanelItemsInputSchema.omit({ page: true }).extend({section:z.enum(["items","tasks"]).optional()}).optional()

59. Memory/src/contracts/memory-runtime.ts (L948)

This schema inherits limit (up to 500) and offset from MemoryApiLogsInputSchema and then adds cursor, but PanelLogReader.list rewrites those values: limit is clamped to 20 (Math.min(Math.max(input.limit ?? 20, 1), 20)), offset is forced to 0 when skillId is set, and tools is overridden when skillId is present. The contract therefore accepts combinations the server silently ignores (e.g. {skillId, offset: 20} returns page 1 forever), and callers cannot tell which pagination mode is authoritative.

Also, unlike the neighbouring cursor (max 2048) / toolId (max 512), the new free-text q has no length cap, yet it drives a full instr(lower(input_json), lower(?)) scan that is cached per distinct query (32 entries). Please cap q and make the cursor/offset relationship explicit.

💡 Suggested Change

Before:

export const PanelLogsInputSchema=MemoryApiLogsInputSchema.extend({skillId:z.string().optional(),cursor:z.string().max(2048).optional(),q:z.string().optional()});

After:

export const PanelLogsInputSchema=MemoryApiLogsInputSchema.extend({skillId:z.string().optional(),cursor:z.string().max(2048).optional(),q:z.string().max(256).optional()});

60. Memory/src/contracts/memory-runtime.ts (L880)

The new schemas are written as dense one-liners with no spacing and no doc comments, which breaks the file's convention (every other exported schema has a /** ... */ line) and makes shapes like PanelTaskDetailOutputSchema, PanelTaskFieldInputSchema and PanelBrowsePageSchema hard to verify against the read model. PanelBrowseCheckSchema also lacks the matching export type PanelBrowseCheck = z.infer<...> that all sibling schemas provide. Please reformat multi-line and add the missing docs/type export.


61. Memory/src/server/http.ts (L788)

The response-close AbortController signal is now threaded into a durable write (service.completeSourceTurn(..., signal)). Internally that signal reaches PanelWriteCoordinator.run(operation, signal), which rejects with an AbortError while waiting for admission and calls signal.throwIfAborted() right before the operation. Two consequences: (1) any client disconnect, app shutdown or fetch abort can silently drop a source-turn capture that would otherwise have been persisted; (2) the AbortError is not a MemoryServiceError, so it is reported as HTTP 500 / request.failed. response.once("close") also fires on normal completion, so the abort is not a reliable proxy for "client gave up on this write". Suggest limiting the request-close signal to read-only paths (panel reads / browse) and not wiring it into capture/write operations.


62. Memory/src/server/http.ts (L613)

panelStatsSnapshot is synchronous but executes real work inline: PanelStats.get() calls the safety token/revision SQL queries before scheduling the background read. Any throw here (busy DB, closed connection during drain, safety-token query) propagates out of the health branch and turns /health into a 500, which typically triggers readiness failures and restart loops in supervisors. Since this is only a best-effort cache warm-up, wrap it so health stays reliable. Additionally the warm-up key is derived from the merged namespace, which includes request-supplied x-memmy-* headers, so a token holder polling /health with varying header values creates a new stats entry (and a persisted <db>.panel-statistics/*.json file that is never evicted) each time.

💡 Suggested Change

Before:

      service.panelStatsSnapshot(input,localPanel);

After:

      try { service.panelStatsSnapshot(input,localPanel); } catch { /* Warm-up must never fail health. */ }

63. Memory/src/server/http.ts (L3-L5)

Three separate import statements from ../contracts/memory-runtime.js are interleaved with the node built-in imports; they should be a single grouped statement. The surrounding new code is also written as compressed one-liners (const localPanel=principal.kind==="local"||..., if(method==="GET"&&logDetail) {requirePanelRead(principal);return ...}), while the rest of the routing file uses the expanded style — this hurts readability of the panel authorization/routing block.

💡 Suggested Change

Before:

import {PanelLogsInputSchema} from "../contracts/memory-runtime.js";
import { PanelBrowseInputSchema } from "../contracts/memory-runtime.js";
import { PanelTaskDetailInputSchema, PanelTaskFieldInputSchema } from "../contracts/memory-runtime.js";

After:

import {
  PanelBrowseInputSchema,
  PanelLogsInputSchema,
  PanelTaskDetailInputSchema,
  PanelTaskFieldInputSchema
} from "../contracts/memory-runtime.js";

64. Memory/src/server/viewer-api.ts (L141)

The viewer overview payload now mixes two independent read snapshots: body comes from the direct service.panelOverview(...) call while summary comes from a separate readPanel("overview", ...) worker read. They can disagree, and the new path can also throw conflict ("Panel changed during read; refresh") where the previous direct call never did, surfacing a 409 to the viewer page. Consider serving both fields from a single read or retrying once on conflict.


65. Memory/src/service/evolution/policy-induction.ts (L842-L843)

isTraceEligibleForL2 is evaluated per trace inside hot loops (induction bucketing at lines ~175/189/475/675, evolution-job-processor, reward-pipeline), so adding a synchronous getRawTurn lookup here makes it an N+1 query against raw_turns on large trace pools. Consider passing a pre-fetched raw-turn map (or only checking the raw turn once per bucket in promote) instead of querying per predicate call.


66. Memory/src/service/evolution/policy-induction.ts (L399)

This throw happens inside the for (const signature of signatures) loop, so a single stale bucket aborts the whole induction job. Every remaining, perfectly valid signature in the same run is discarded and its pending candidate-pool entries stay unpromoted until the job is retried (with backoff), and the raw error gives the caller no way to distinguish "retry later" from a real failure. Prefer skipping just this bucket: log the decision (logEvolutionDecision(job, "l2_induction", "evidence_changed", ...)) and return/continue from the transaction callback instead of throwing. Note also that a concurrent maintenance write bumping updatedAt/version on any single trace makes the whole bucket unretryable until it settles.


67. Memory/src/service/memory-service.ts (L2493-L2494)

getMemory is a general read entry point (panel detail, HTTP GET /memory/:id, internal/delete flows) and previously performed no search-enabled gate. Making the assertion unconditional means deployments with memory search disabled now get a hard forbidden for detail reads, unlike getPanelMemory (local path) which is the intended panel read. Suggest gating this only on read paths that actually need search, or leaving the check to the callers that already assert it.


68. Memory/src/service/memory-service.ts (L2496-L2497)

The new UserMemory branch requires request.namespace.userId to be present and equal to the row owner. Internal callers that build a request without a namespace (or with a derived/default namespace) previously fell through to episodeReadModel.getMemory and succeeded; they now get not_found for user memories. Consider only enforcing the comparison when request.namespace?.userId is provided (and handling the default local user explicitly), to avoid a silent behavior regression for non-HTTP callers.


69. Memory/src/service/memory-service.ts (L356)

The in-memory fallback dispatches methods through a long chained ternary, which the style guide forbids (no nested ternaries) and is also unsafe: any unknown/unlisted method silently resolves to data: undefined, so callers receive a {safety, data: undefined} "success" instead of an error. Additionally the hand-written args[n] as ... casts can drift from the worker implementation in panel/reader-worker.ts (parameter order/arity). Prefer a handler map keyed by method and throw MemoryServiceError("invalid_argument", ...) for unknown methods.


70. Memory/src/service/memory-service.ts (L323-L326)

The warmup callback runs outside of any request/promise chain, so any synchronous throw aborts the whole process: PanelStats.get() synchronously calls safety()/revision()/store.load(), which hit SQLite and can throw (busy/locked, schema mismatch, closing DB). Wrap the warmup body in try/catch so a failed warmup stays a cache miss instead of a crash.

💡 Suggested Change

Before:

    this.panelWarmupTimer = setTimeout(() => {
      this.panelWarmupTimer = undefined;
      if (!this.closing) { this.panelOverviewStatsSnapshot({}, true); this.panelStatsSnapshot({}, true); }
    }, 0);

After:

    this.panelWarmupTimer = setTimeout(() => {
      this.panelWarmupTimer = undefined;
      if (this.closing) return;
      try { this.panelOverviewStatsSnapshot({}, true); this.panelStatsSnapshot({}, true); }
      catch { /* Warmup is best-effort; requests will recompute later. */ }
    }, 0);

71. Memory/src/service/memory-service.ts (L1697)

this.budgetAbort is a service-lifetime controller (only aborted in stop()), while AbortSignal.any registers an abort listener on every source signal that is only removed when that source aborts. Creating a composite per completeSourceTurn call therefore accumulates listeners on the long-lived budgetAbort.signal for the lifetime of the service (hot capture path over HTTP), which both leaks the composite signals and can emit MaxListenersExceededWarning. Prefer a request-scoped controller that is linked to budgetAbort once per request and disposed in a finally, or explicitly remove the listener after the capture completes.


72. Memory/src/service/memory-service.ts (L2482-L2483)

The UserMemory detail projection (item + memoryLayer: "UserMemory", body, sourceMemoryIds, metadata, refs, etag) is duplicated verbatim in this new getPanelMemory and in the modified getMemory. Duplicated response shaping will drift as soon as one side changes (e.g. adding refs/etag fields). Extract a private userMemoryDetail(userMemory) helper and use it in both branches.

💡 Suggested Change

Before:

    if(userMemory){
      const item=userMemoryPanelItem(userMemory);

After:

    if(userMemory){
      return this.userMemoryDetailResponse(userMemory);

73. Memory/src/service/panel/browse.ts (L87)

The page size 20 is a business constant duplicated four times in this file (Math.ceil(total / 20), view.ids.slice((page - 1) * 20, page * 20), and pageSize: 20 as const), and it also duplicates PANEL_ITEMS_PAGE_SIZE in read-model/panel-read.ts and PAGE_SIZE in storage/panel-task-read.ts. If any of these drift, hasNext/totalPages will disagree with the actual slice. Extract one exported constant and reuse it everywhere.

💡 Suggested Change

Before:

    const totalPages = Math.max(1, Math.ceil(total / 20));

After:

    const totalPages = Math.max(1, Math.ceil(total / PANEL_BROWSE_PAGE_SIZE));

74. Memory/src/service/panel/browse.ts (L44)

Idle/expired views are only detected when a caller touches them; nothing sweeps the map. A view that is never requested again keeps its ids array and every cached pages entry in memory until another view's limit() happens to exceed the budget, or clear() runs at shutdown — so up to the full 64 MB budget can be held indefinitely by abandoned panel sessions, and idleMs is effectively unenforced for them. Consider disposing expired views from limit() (or on a periodic sweep) rather than only on access.


75. Memory/src/service/panel/browse.ts (L37-L39)

View identity is derived from JSON.stringify(namespace), so it depends on property insertion order of two independently built objects. get() compares this string against the value stored at create(); if the same logical namespace is materialized with a different key order on a later request (create/page/check build their input through different spreads in browsePanel), the comparison fails and a perfectly valid view is reported as expired — and disposed — with "Panel view expired; refresh". Compare the security-relevant fields explicitly (or serialize with sorted keys) instead of relying on JSON key order.

💡 Suggested Change

Before:

  private scope(input: Input) {
    return JSON.stringify(input.namespace ?? {});
  }

After:

  private scope(input: Input) {
    const namespace = input.namespace ?? {};
    return JSON.stringify(Object.keys(namespace).sort().map(key => [key, namespace[key as keyof typeof namespace]]));
  }

76. Memory/src/service/panel/local-read.ts (L4)

local defaults to true, i.e. the fail-open direction: any future caller that forgets the second argument silently strips userId from both the envelope and the namespace, turning a user-scoped read into a cross-owner read. Both current call sites pass the flag explicitly, so making it required (or defaulting to false) costs nothing and removes a foot-gun from a security-relevant helper.

💡 Suggested Change

Before:

export function localPanelRequest<T extends RequestEnvelope>(input:T,local=true):T {

After:

export function localPanelRequest<T extends RequestEnvelope>(input:T,local:boolean):T {

77. Memory/src/service/panel/reader-worker.ts (L67-L71)

This branch is dead code: prepare() already returned early when backgroundId !== undefined (line 50-61), so inside this loop backgroundId is always undefined. The condition therefore always short-circuits to true, batches is never incremented, and the else block that posts {yield:true} can never run — as can resumePreparation and the resume message handler below. Consequently the intended "let the service report background activity every four batches" behaviour never happens during foreground cache preparation. Either drop backgroundId from this signature/condition and the now-unused batches/resumePreparation, or make the condition real (e.g. count batches unconditionally and compare against a threshold).


78. Memory/src/service/panel/reader.ts (L158)

retire() is also invoked from the worker's own exit/error handlers (via fail()). Once the thread has exited, Worker.postMessage() throws ERR_WORKER_NOT_RUNNING synchronously. Because the internal Node exit listener nulls the handle before user listeners run, this throw escapes the exit/error callback as an uncaught exception (process crash), and this.stopping = this.retire(...) is never assigned — so dispatch() is never re-run, all queued run() calls stay pending until they time out, and the captured releaseWrite lease is never released, permanently blocking PanelWriteCoordinator acquisitions (i.e. foreground captures). Guard the postMessage (or skip retire when worker.threadId === -1).

💡 Suggested Change

Before:

      worker.postMessage({stop:true});

After:

      try { worker.postMessage({ stop: true }); }
      catch { clearTimeout(timer); void worker.terminate().then(resolve); }

79. Memory/src/service/panel/reader.ts (L104)

eval: true executes a string as JavaScript in the worker, which the project's code-injection guideline forbids. The template is currently constant (only import.meta.url is interpolated), so there is no injection source, but this dev-only TS path should at least be explicitly gated (e.g. only when not packaged / NODE_ENV !== "production") or replaced with a normal new Worker(url, { execArgv: ["--import", "tsx"] }) so no code is ever passed as a string.


80. Memory/src/service/panel/reader.ts (L88-L90)

This worker-detach sequence (abort pending write admission, capture/release the write lease, retire(worker), clear stopping and re-dispatch) is duplicated verbatim in fail(). Keeping two copies means a fix in one path (for example the retire()/postMessage handling of an already-exited worker) can silently leave the other path broken. Extract a single private detach(worker: Worker) helper and call it from both fail() and yieldStatistics().


81. Memory/src/service/panel/stats-store.ts (L30)

Each distinct key (namespace/userId/timeZone) hashes to its own <section>-<hash>.json file, and nothing ever deletes them. Over time the <db>.panel-statistics/ directory grows without bound (every user/time zone that ever hit the panel leaves a file), even though PanelStats only keeps 16 entries in memory. Consider pruning superseded snapshots for the same key (e.g. include day in the file name and remove older files, or cap the directory) so derived data does not accumulate forever.


82. Memory/src/service/panel/stats.ts (L127-L128)

Nested/chained ternary (3 levels) for the retry delay. This violates the no-nested-ternary rule and makes the refresh policy hard to read/verify (same pattern on the status: line a few lines above: entry.error ? "error" : entry.data ? "ready" : "preparing"). Prefer an explicit helper or if/else chain, and name the TTLs instead of inline magic numbers.

💡 Suggested Change

Before:

          const delay = generation !== entry.generation ? 0 : entry.error ? 60_000
            : entry.revision !== this.revision() ? 1000 : 5 * 60_000;

After:

          const delay = this.nextDelay(entry, generation);

83. Memory/src/service/panel/stats.ts (L114)

The cache key is built twice with the same JSON.stringify expression (here in generate() and in get()). If the key derivation ever changes in one place only, persisted snapshots would be saved under a different key than they are loaded with (load(key) hashes the key into the file name in stats-store.ts), silently disabling persistence. Extract a single keyOf(input) helper and use it in both places.

💡 Suggested Change

Before:

        const key = JSON.stringify([entry.input.namespace ?? {}, entry.input.userId ?? null, resolveTimeZone(entry.input.timeZone)]);

After:

        const key = statsKey(entry.input);

84. Memory/src/service/panel/stats.ts (L70)

The refresh policy thresholds (5 min refresh / 60 s error retry) are written inline here and again in the .finally() block below, and the same class also hardcodes the 1 s revision debounce and the 16-entry cap. Because get() decides whether a refresh is due while generate()/finally() decide when to schedule it, the two sets of literals must stay in sync — if one is changed, the scheduler either fires useless early scans or delays refreshes by minutes, and there is nothing tying them together. Please lift them into named constants (e.g. REFRESH_INTERVAL_MS, ERROR_RETRY_MS, REVISION_DEBOUNCE_MS, MAX_ENTRIES) and use them in both places.

💡 Suggested Change

Before:

      (entry.lastAttempt === undefined || Date.now() - entry.lastAttempt >= (entry.error ? 60_000 : 5 * 60_000))

After:

      (entry.lastAttempt === undefined || Date.now() - entry.lastAttempt >= (entry.error ? ERROR_RETRY_MS : REFRESH_INTERVAL_MS))

85. Memory/src/service/panel/stats.ts (L57)

Map iteration is insertion-ordered and get() never re-inserts an entry, so this cap evicts the oldest created scope instead of the least recently used one. A scope that is actively polled can therefore be dropped (discarding its data and any in-flight generation) while idle scopes survive; with more than 16 distinct scopes each poll then restarts a full panelStatistics scan. Consider tracking recency (e.g. this.entries.delete(key); this.entries.set(key, entry) on hit, or an lastAccess field) so eviction is LRU. Note also that retained entries reschedule themselves every 5 minutes indefinitely, so an evicted-but-recreated scope keeps paying that background cost after clients stop polling.


86. Memory/src/service/panel/write-coordinator.ts (L37)

Two issues with this retry path:

  1. The backoff timer is not unref()ed (all other panel timers in this change are, e.g. stats.ts entry.timer.unref() and reader.ts timeout.unref()), so a pending retry keeps the event loop alive for up to ~200ms during shutdown.
  2. Because release() in finally runs before this backoff sleep, the retry re-enters acquire() at the tail of the FIFO queue. Under sustained SQLITE_BUSY contention from worker batches (which also go through the same coordinator) a foreground capture can keep losing admission on every retry and be starved for the full backoff window. Either hold the lease across the short backoff, or requeue the retry ahead of non-retrying waiters.
💡 Suggested Change

Before:

        const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, 25 * 2 ** attempt);

After:

        const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, 25 * 2 ** attempt);
        timer.unref();

87. Memory/src/service/panel/write-coordinator.ts (L33)

This busy-retry policy (max 3 retries, 25 * 2 ** attempt backoff) is duplicated in reader-worker.ts writeBatch(), which even imports isSqliteBusy from this file. If one copy is tuned (attempt budget or backoff) the two writer paths will drift apart, making SQLite busy handling inconsistent between worker batches and foreground captures. Extract a shared helper such as retryOnSqliteBusy(operation) that both call sites use.


88. Memory/src/service/read-model/episode.ts (L368)

The non-panel branch of this predicate (candidate.userId === memory.userId) changes the public get-memory behaviour: previously the raw turn was returned unconditionally, now it is dropped (making refs.rawTurn undefined and, for the equivalent check in getMemory's spanDetail, toolCalls an empty array) whenever raw_turns.user_id is null/legacy or differs from the memory owner, since RawTurnRecord.userId is mapped straight from row.user_id with no default. If legacy rows are possible, compare with candidate.userId ? candidate.userId === memory.userId : true (or backfill user_id) so existing clients do not silently lose data.


89. Memory/src/service/read-model/panel-read.ts (L242)

logs is now filtered down to the visible subset, but total/nextOffset (and the client's page math) still come from the unfiltered listApiLogs result. A page can therefore contain fewer rows than limit while nextOffset is still returned, and the client will keep paging through phantom rows (including empty pages at the tail). The count and the page should be derived from the same visibility filter, e.g. reuse PanelLogReader (which already computes a visibility-aware total) instead of post-filtering the legacy query, or expose a visible-count query.


90. Memory/src/service/read-model/panel-read.ts (L262)

withCurrentTraceSummaries now uses a nested ternary for id extraction, which is both hard to read and against the project rule that forbids nested ternaries. Extract a small helper (e.g. detailMemoryId(detail)) that returns the span id when present, else the trace id, and reuse it in withTraceSummaries as well (line ~281 already has a similar duplicated expression).

💡 Suggested Change

Before:

          const id = detail.role === "span" ? detail.spanId ?? detail.traceId : detail.role === "trace" ? detail.traceId : undefined;

After:

          const id = detailIdFor(detail);

91. Memory/src/service/read-model/panel-read.ts (L591)

displayItems defaults the security-relevant local flag to true (widest visibility: any raw turn matches when the memory has no sessionId). Every current call site passes an explicit value, but a future caller that forgets the argument silently gets local/unscoped semantics. Make the parameter required (or default it to false) so the permissive branch has to be opted into explicitly.

💡 Suggested Change

Before:

  private displayItems(memories: ReturnType<Repositories["memories"]["getManyForPanel"]>,local=true) {

After:

  private displayItems(memories: ReturnType<Repositories["memories"]["getManyForPanel"]>, local: boolean) {

92. Memory/src/storage/db.ts (L143-L145)

The temp name is ${backupPath}.${process.pid}.partial, and VACUUM INTO refuses to write to an existing file. If a previous run crashed after creating the partial file and the OS reuses that PID, VACUUM INTO fails and the surrounding catch rethrows, aborting startup (the error is only cleared for the next launch). Also, renameSync(partial, backupPath) silently overwrites a valid backup that another process may have produced between the existsSync check and here. Remove any stale partial before vacuuming and re-check the destination before renaming.

💡 Suggested Change

Before:

    const partial = `${backupPath}.${process.pid}.partial`;
    try {
      this.db.pragma("wal_checkpoint(FULL)");

After:

    const partial = `${backupPath}.${process.pid}.partial`;
    try {
      rmSync(partial, {force:true});
      this.db.pragma("wal_checkpoint(FULL)");

93. Memory/src/storage/memory-relations.ts (L22-L23)

The raw_turns update derives rawId from the memory's own JSON but never checks that the raw turn belongs to the same user/session, unlike the L1 branch below which joins on user_id. A memory or import whose internal_info.source_raw_turn_id/trace.raw_turn_id points at another user's turn will write this memory id into that victim's captured_memory_ids, contaminating cross-tenant data. Add an ownership/session guard on the target raw turn.


94. Memory/src/storage/panel-cache.ts (L42-L43)

DROP TABLE ${table} has no IF EXISTS. If any cache table is absent while panel_cache_meta still reports an old version (partially applied install or manual cleanup), schema initialization throws here and startup is blocked. Use DROP TABLE IF EXISTS.

💡 Suggested Change

Before:

    for (const table of ["panel_cache_meta", "panel_memory_dirty", "panel_log_dirty", "panel_metric_dirty", "panel_visibility_dirty", "panel_memory_facts", "panel_memory_visibility", "panel_log_facts", "panel_log_refs", "panel_log_metrics"])
      db.exec(`DROP TABLE ${table}`);

After:

    for (const table of ["panel_cache_meta", "panel_memory_dirty", "panel_log_dirty", "panel_metric_dirty", "panel_visibility_dirty", "panel_memory_facts", "panel_memory_visibility", "panel_log_facts", "panel_log_refs", "panel_log_metrics"])
      db.exec(`DROP TABLE IF EXISTS ${table}`);

95. Memory/src/storage/panel-cache.ts (L139)

When a row's dirty_revision no longer matches, the loop uses continue and leaves the dirty row in place, yet the function still returns rows.length (> 0). Callers do while (fillPanelCacheBatch(db, ...)), so they immediately re-select the same skipped rows. With a concurrent writer bumping revisions this becomes a hot spin rather than making progress. Return the number of rows actually processed/deleted (or 0 when nothing progressed).


96. Memory/src/storage/panel-cache.ts (L282)

Loose equality ref != null violates the project rule banning !=; use an explicit strict check. Also note scalar(ref) may return null, and String(null) is filtered earlier by this same condition, so the strict form preserves behavior.

💡 Suggested Change

Before:

    refs: [...new Set(refs.filter(ref => ref != null).map(ref => String(scalar(ref)).split("::").at(-1)!.trim()))]

After:

    refs: [...new Set(refs.filter(ref => ref !== null && ref !== undefined).map(ref => String(scalar(ref)).split("::").at(-1)!.trim()))]

97. Memory/src/storage/panel-log-read.ts (L63-L64)

keys().next().value! hides a possible undefined (empty map) behind a non-null assertion, and this exact eviction block is duplicated for both the matches and counts caches. Extract a small eviction helper with an explicit undefined guard so the two caches cannot drift.

💡 Suggested Change

Before:

        if (cached.values.size > 32)
          cached.values.delete(cached.values.keys().next().value!);

After:

        if (cached.values.size > 32) {
          const oldest = cached.values.keys().next().value;
          if (oldest !== undefined) cached.values.delete(oldest);
        }

98. Memory/src/storage/panel-log-read.ts (L61)

For every distinct q this runs instr(lower(...)) over the full api_logs table (not the panel cache/facts table), then materializes every matching id into an array held in the in-memory matches cache (up to 32 entries). On a large log this is a full-table scan per query plus potentially huge retained arrays, defeating the bounded-cache design. Bound the search to a recent window (as panelAnalysisLogs does) or maintain normalized searchable columns in panel_log_facts.


99. Memory/src/storage/panel-projection.ts (L19)

PANEL_MEMORY_COLUMNS strips all raw-turn provenance: internal_info.trace.raw_turn_id/episode_id are not in nestedFields.trace, internal_info.span.raw_turn_id is not in nestedFields.span, and source_raw_turn_id/raw_turn_id are absent from internalFields/infoFields. However panel task detail resolves raw turn and episode through rawTurnIdFromMemory (episode.ts/refsForMemory). Rows read via getManyForPanel/listForPanel therefore lose their raw-turn/episode references. Either include these provenance fields or confirm panel detail always uses unprojected rows.

💡 Suggested Change

Before:

  trace: ["title", "summary", "userText", "user_text", "value", "alpha", "reflection"],

After:

  trace: ["title", "summary", "userText", "user_text", "value", "alpha", "reflection", "raw_turn_id", "episode_id"],

100. Memory/src/storage/panel-source-sql.ts (L2)

fallback is interpolated verbatim into the generated SQL. Both current call sites pass literals, but nothing in the signature/contract says the argument must be a trusted literal — a config- or user-derived value here would be SQL injection. Please document the trusted-literal precondition (and that the generated SQL relies on unqualified properties_json / memory_layer / agent_id columns, so it only works in a single-table scope).


101. Memory/src/storage/panel-source-sql.ts (L6)

This GLOB re-encodes the canonical History-ID rule (isHistoryRecordId in Memory/src/service/external-evidence-source.ts:108) and the surrounding origin rules mirror externalEvidenceSourceMatchesMemory / panelSourceForMemory (read-model/panel.ts:91). Nothing keeps the three in sync: adding a window (e.g. 1h-summary) or changing the id shape here vs. there would break exactly the "panel filters and cached statistics identical" invariant this file promises. Recommend deriving the window list from one shared constant and cross-referencing the canonical helper in the comment.


102. Memory/src/storage/panel-task-read.ts (L190)

total_changes() is a connection-wide counter, so any write on this connection (background job handlers, stats workers, etc. all write through the same DB) invalidates the per-connection field cache. The only thing the cache buys is the offset === cached.nextOffset fast path, so in practice chunked reads will almost always fall through to advance(0, offset) and re-read + re-hash the whole field. Consider deriving the generation from the row itself (e.g. SELECT updated_at/revision FROM raw_turns WHERE id=?) so unrelated writes don't evict this entry.


103. Memory/src/storage/panel-task-read.ts (L212)

The cache is skipped for fields larger than 8M chars, i.e. exactly the payloads chunking exists for. For those, every nextOffset request re-reads the full column and recomputes SHA-256 over it, so streaming a multi-MB tool output becomes O(n²) in bytes hashed. Suggest keeping the cache (with a checkpoint map offset→index) and bounding it by an eviction budget instead of dropping large fields entirely.


104. Memory/src/storage/panel-task-read.ts (L117-L120)

This issues two tools() calls per turn, and each call does its own db.prepare() plus a json_each scan of the whole JSON column, so one page costs up to 40 statement prepares + JSON scans — including for turns where toolCallCount/toolResultCount are 0. Suggest gating on those counts and preparing the two statements once (per reader) instead of re-preparing per row.


105. Memory/src/storage/panel-task-read.ts (L180)

revision: "tools" is a magic sentinel in a field that otherwise carries a hex SHA-256, and in this branch offset/nextOffset mean a tool index while in the text branch they mean a character offset. A caller cannot tell the two modes apart, and a client that echoes revision back or compares revisions will silently treat "tools" as a valid revision. Suggest an explicit discriminator (e.g. { mode: "tools", offset, tools } vs { mode: "text", text, revision, offset }) or a distinct toolOffset cursor name.


106. Memory/src/storage/repositories.ts (L1741)

The summary projection is derived by string-replacing " content," inside DISPLAY_USER_MEMORY_COLUMNS, and the same expression is duplicated in listForPanel (line 1728) and here. If the projection's column order or spacing in panel-projection.ts ever changes (e.g. content, gets moved/renamed), String.replace silently returns the projection unchanged and full (potentially very large) user-memory content is loaded, defeating summaryOnly without any error. Export a dedicated summary projection constant instead of deriving it by text substitution.

💡 Suggested Change

Before:

    const columns = DISPLAY_USER_MEMORY_COLUMNS.replace(" content,", " substr(content,1,180) AS content,");

After:

    const columns = DISPLAY_USER_MEMORY_SUMMARY_COLUMNS;

107. Memory/src/storage/repositories.ts (L2859-L2860)

This bulk rebind rewrites info_json/properties_json, bumps version and updated_at for every L1 memory that references the raw turn, but the selection has no deleted_at IS NULL / status != 'deleted' guard (unlike the sibling helpers added in this change). Tombstoned or soft-deleted memories are therefore re-versioned and re-timestamped by an unrelated turn move, which can make them look freshly updated in time-ordered listings and history/provenance reads. Add the liveness guard unless re-pointing tombstones is explicitly required.

💡 Suggested Change

Before:

          THEN json_extract(m.properties_json,'$.internal_info.span.source_trace_id') END
        WHERE m.memory_layer='L1' AND m.user_id=?

After:

          THEN json_extract(m.properties_json,'$.internal_info.span.source_trace_id') END
        WHERE m.memory_layer='L1' AND m.user_id=? AND m.deleted_at IS NULL AND m.status!='deleted'

108. Memory/src/utils/panel-source.ts (L30-L32)

This is a second copy of the agent-family list and alias rules that already live in Memory/src/utils/agent-source-id.ts (AGENT_SOURCE_FAMILIES, AGENT_SOURCE_FAMILY_ALIASES, agentSourceFamily), only with a different canonical spelling (claude-code vs claude_code) and an extra manual entry. Adding/renaming a family in one module will silently desync the panel_source values used by filters/stats from the agent list consumed by the panel. Suggest deriving the families from agentSourceFamily and mapping to the display spelling in one place.


109. scripts/internal/shared/bundle-runtime.mjs (L86)

dynamicPackages only registers the package as an externalRoot so it gets copied into the runtime; it does not externalize it. Every agent-side entry here (isomorphic-git, @playwright/mcp, open-computer-use) is also listed in EXTERNAL_PACKAGES, so esbuild leaves it as a bare import that needs the copied package. ignore is not in EXTERNAL_PACKAGES, and it is statically imported (via local-scanner.ts), so esbuild inlines it and the copied package is never referenced — dead weight in the artifact and inconsistent with the comment above (“resolved by strings passed to workers or child processes”). If the intent was to externalize it, "ignore" must also be added to EXTERNAL_PACKAGES; otherwise drop this entry. Also note the filter silently skips instead of failing like the loop below, which makes a missing dependency undiagnosable.

💡 Suggested Change

Before:

    : ["ignore"].filter(name => manifest.dependencies?.[name]);

After:

    : [];

110. App/frontend/desktop/src/pages/memory-page.tsx (L292)

retainedPages is re-created on every render, and its semantics drive the visited effect whose dependency array only lists [activePage]. This is both unnecessary allocation on each render and a stale-closure / exhaustive-deps hazard (the set is a constant, but the linter cannot know that). Hoist it to a module-level constant next to MEMORY_SUB_PAGE_STORAGE_KEY.

💡 Suggested Change

Before:

  const retainedPages=new Set<MemorySubPageId>(["memories","tasks","policies","world-model","skills","user-memories","overview","analytics"]);

After:

  const retainedPages = RETAINED_MEMORY_SUB_PAGES;

111. App/frontend/desktop/src/pages/memory-page.tsx (L407)

This single line hides several non-obvious decisions: (1) every visited page stays mounted forever (no eviction of the visited list), (2) their background loading is paused only because each child consumes PanelVisibilityContext through usePanelPoll/usePanelLoad, and (3) the visited ∪ activePage union is recomputed inline on every render. Please extract a named helper / render function and add a short comment explaining the visibility-gating contract, so a future sub-page that polls without usePanelVisible does not silently keep running while hidden. Also consider deriving the rendered list from state (append activePage in the same effect) instead of recomputing a Set in JSX.


112. App/frontend/desktop/src/pages/memory/overview-sub-page.tsx (L67)

The local panel cache read was removed here, but pages/memory/memory-panel-prefetch.ts (panelOverviewTask, invoked via scheduleMemoryPanelCachePrefetch from onboarding) still calls getPanelOverview() and writes memoryPanelCacheKey("overview"). Nothing reads that key any more, so the prefetch is now dead work that issues a duplicate overview request during onboarding. Either drop the overview prefetch task or keep reading the cache as a first paint fallback. Same applies to analytics-sub-page.tsx / panelAnalysisTask.


113. App/frontend/desktop/src/pages/memory/analytics-sub-page.tsx (L49)

Analytics now goes through usePanelStats(client, "analysis", ...), which first calls /panel/stats. That endpoint is a combined snapshot (overview + analysis, computed by a single PanelStats entry on the server), so it stays preparing until the overview part is also computed and returns error for the whole payload if the overview computation fails. As a result the analytics screen can now be blocked or error out because of an unrelated overview failure, even though the dedicated /panel/analysis endpoint (getPanelAnalysis() / loadAnalyticsData) is still served and used to be this page's only dependency. Consider falling back to loadAnalyticsData(...) when the combined snapshot carries no usable analysis data (or treat result.status === "error" as a reason to try the legacy path) to keep the analytics view's failure domain isolated.


114. App/frontend/desktop/src/pages/memory-page.tsx (L123)

usePanelPrefetch restarts its whole warm-up chain on every activePage change: it re-issues /panel/overview-stats + /panel/stats and then walks the five panel sections. Because the prefetch runs for every sub-page (including logs, sources and computer-history, which never render panel data), each nav click now costs two statistics requests plus a layer page, duplicating what the retained overview/analytics pages already fetch and poll themselves via usePanelStats. Consider skipping the stats warm-up when the current page owns those endpoints (or when they are already warm) and only prefetching for pages that actually consume panel lists.


115. App/frontend/desktop/src/pages/memory-page.tsx (L125)

This is a cleanup-only effect, so the reset fires only when client changes or the whole memory page unmounts — not when the active sub page changes, which is what the comment ("Release the old scope before newly mounted children start passive loads") describes. Since sub pages are now retained instead of remounted on page switches, nothing releases the previous page's browse view on navigation, so the comment and the behaviour are inconsistent. Either move the reset to the page-switch path or reword the comment to state that it only releases the panel view scope when leaving the memory page, so the next reader does not assume a per-page reset.


🧹 Filtered 3 low-confidence OCR finding(s) before posting/fix-loop (existing_code_mismatch: 2, duplicate: 1).

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Command failed with exit code 1: /opt/memos-test/.venv/bin/python -m pip install -e /data/test-workspaces/dbf35637908714e1/repo
stderr: ERROR: file:///data/test-workspaces/dbf35637908714e1/repo does not appear to be a Python project: neither 'setup.py' nor 'pyproject.toml' found.
Branch: fix-memory-panel-performance-v126

Comment thread Memory/src/storage/panel-security.ts Outdated
user_memories:
"OLD.deleted_at IS NOT NEW.deleted_at OR (OLD.status='deleted') != (NEW.status='deleted') OR OLD.user_id IS NOT NEW.user_id",
raw_turns:
"OLD.deleted_at IS NOT NEW.deleted_at OR OLD.redacted_at IS NOT NEW.redacted_at OR OLD.user_id IS NOT NEW.user_id OR OLD.episode_id IS NOT NEW.episode_id",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

后台完成新轮次归属会使正在浏览的分页失效

问题: 用户停留在记忆管理第2页时,另一个正常会话完成一轮问答,后台把这条新轮次首次归入任务,已有浏览视图就会失效。下一次页面检查或翻页返回409;前端会清空详情并重新建立视图,翻页也可能先显示错误。这与本 PR 约定的“普通新增仅提示有更新,由用户点击原刷新按钮重建视图”不符。本轮用合成数据、模型替身和真实本地 HTTP 路由复现:已有25条记录的第2页先返回200,新轮次归属完成后,同一视图返回 Panel view expired; refresh。这不是桌面人工验收结果。

原因: 这里把所有 episode_id 变化都当成需要撤销旧内容访问的事件。现有异步采集流程先保存 episode_id=NULL 的原始轮次,后台 SessionTurnService 调用 bindRawTurnEpisode 完成首次归属时也会触发它,从而更换全局安全标记。PanelBrowse.get 随即拒绝所有旧视图;usePanelViewStatus 收到409后清理详情、重置视图并重新读取。正常新增因此走进了隐私失效流程。

建议做法: 区分新轮次首次归属与已有记录的迁移、删除、脱敏及所有者变化。首次归属应保留既有列表成员、页码和已读内容,只更新“有更新”提示;真正影响已有内容可见性或归属的操作仍须立即清理缓存。补充通过正常轮次完成入口、后台归属、页面检查和第2/3页切换的回归,同时验证删除、脱敏、跨用户变更及已有轮次重新绑定仍会失效,不能直接取消所有 episode_id 变化的保护。


export const SCHEMA_VERSION = 13;
export const SCHEMA_MIGRATION_ID = "013_embedding_retry_lease_counters";
export const SCHEMA_VERSION = 14;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

升到 schema14 后,旧租约迁移测试的三个用例失效

问题: 本次将数据库版本升为14后,现有 Memory/tests/repository/embedding-retry-lease-counters.test.ts 有3项失败,准确 base 上对应3项通过。两项旧库升级测试仍期望13和 .pre-v13.bak;另一项失败回滚测试仍用 WHEN NEW.version=13 注入错误,当前迁移写入14后不再触发,无法验证失败时保留租约和原库。正常执行 Memory 测试因此会失败,也不能据此声称现有租约迁移回归已全部通过。

原因: 该迁移实现最终记录的是当前 SCHEMA_VERSION,不是依次插入每个历史版本;升版同时改变了最终版本、备份名和失败注入点,但这份已有回归没有同步适配。本轮仅在诊断副本中更新这三个契约点,保留租约、重试计数、备份和事务回滚断言后,文件8项全部通过;原提交中的3项失败仍然存在。

建议做法: 同步更新该测试,按当前版本构造预期备份路径和失败触发条件,继续验证历史租约计数恢复、旧持有者不能完成任务、失败迁移不留下半成品以及重试保留原备份。修正后运行原始测试文件和13→14迁移/回滚套件,不能删除失败注入或弱化已有数据断言。

Comment thread Memory/src/storage/panel-log-read.ts Outdated
params.push(id, id.length + 2, `::${id}`);
}
const base = conditions.join(" AND ");
const total = Number(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

日志每次翻页都重新校验全量记录,导致约九秒等待

问题: 同一份本机数据的隔离副本中,日志接口首页由基线的54 ms变为9794 ms;隔离浏览器页面第2/3页分别等待8665/8807 ms,刷新等待8325 ms。返回内容因新增隐私过滤有所不同,但每次只看20条日志也需要约九秒,已经违背此次加载优化目标。

原因: 每页先对所有候选日志展开 JSON 引用并逐一检查关联记录是否可见,再执行带同样条件的分页查询。SQL 拆解中第2页计数耗时4652 ms,列表查询另耗时4314 ms;执行计划还需要临时排序,LIMIT 并未把前置校验限制在20条。

建议做法: 保留删除、脱敏和归属校验,把日志引用关系及列表摘要做成可索引的轻量数据,按隐私失效规则维护计数或缓存;分页读取避免重复全量 JSON 校验。保留用户已确认的“在当前工具/来源范围内搜索全部日志”的语义,不能退回只筛当前页来换取速度。用相同规模数据覆盖首页、第2/3页、筛选和刷新,同时验证删除/脱敏后旧日志内容不能重新出现。

}
return [];
}
async function prepare(parts: PanelCachePart[]) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首次建立统计缓存仍需约 8.6–10.2 秒

关联:同文件 55–58 行、http.ts:607–615、panel-cache.ts:101。

问题: 概览和分析现在共用统计结果,持久缓存也改善了再次启动;但首次统计缓存尚未准备好时,用户仍可能长时间等待。在约 2.74 GB、47,482 条记忆的隔离副本上,从首次概览 HTTP 请求到统计真正 ready,三次独立初建测得 8640、9730、10214 ms,其中第三次来自真实 schema13→14 升级副本。缓存已建成后的重启约 1050 ms。这些初建测量没有先用 health 触发预热,证明的是初建成本,不能据此断言真实桌面一定到打开页面才开始。基线本身也慢,本项属于首次加载优化目标尚未完成。

原因: worker 在读取统计快照前,必须补齐全部待处理的 memories/metrics 缓存,历史 JSON 提取和派生数据写入仍在首个结果的等待路径上。当前已有异步预热,但由带有效认证、UID 和时区的 health 请求触发,而不是服务可用后主动启动;触发条件未满足,或者用户在预热完成前进入页面,仍需等待。worker 避免了在 HTTP 主线程执行这些重查询,但不能消除 CPU、磁盘及数据库写锁竞争,快速返回 preparing 也不代表加载完成。

建议做法: 数据库正常打开、服务开始接收请求后,主动在后台准备本地面板缓存,不等待用户打开页面,不依赖登录 UID;日期统计使用设备已确认的时区配置。复用持久缓存,只补缺失和变化的数据;普通重启不重做全量历史回填,页面请求与预热共享同一份工作。

继续使用 worker 分批处理,缓存写入采用短事务;前台读取、采集和召回繁忙时让出资源,保留已完成批次,恢复后继续,服务停止时取消并等待退出。统计完成后一次替换快照,普通新增由后续增量处理,避免持续写入使整轮计算反复重来;删除、脱敏等可见性变化仍立即使旧结果失效。本 PR 保持原有 UI,不增加更新红字、提示块或统计时间行。

验收覆盖升级后立即打开页面、预热完成后打开、缓存已建立后的重启及原刷新按钮,计时到实际内容可用;另在缓存建立期间并发执行 health、采集和召回,核对响应时间与成功率。若立即打开仍需等待近 10 秒,还须减少首个完整统计所需的历史正文/JSON 处理量,不能仅把计算提前或移到后台就宣布解决。现有性能证据禁用了模型执行,不替代这一并发验收。

serverTime: string;
} {
return this.panelReadModel.panelItems(this.withTimeZone(input));
return this.panelReadModel.panelItems(this.panelInput(input));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

设备级读取已实现,但遗漏了旧账号隔离测试

应同步修改:user-memory.test.ts:942–973,重点 964–973 行。

问题: 本地面板已经按约定改为同一设备跨账号读取同一份记忆,但现有 User Memory 测试仍要求换账号后列表为空、概览数量为 0。当前 head 的 Memory 全量测试因此有 1 项失败;准确 base 的原文件 37 项通过。前端 UID 绑定和原 Computer History 导航失败已修复,这条剩余意见针对遗漏的单测,不是要求恢复本地账号隔离。

原因: 本地 panelInput() 现在会移除读取条件中的 userId,列表和概览都按设备范围读取;旧测试的名称及“另一个账号不可见”断言没有随这次语义变化更新。列表断言失败后,同一用例后面的概览断言也没有执行。

建议做法: 把该用例改为验证本地设备读取契约:切换账号后仍可见同一条记忆,列表、搜索和概览数量一致;保留 NULL UID 新记录和历史 UID 记录的回归。云端按账号授权、写入归属及服务访问认证继续由独立用例验证,不能为了变绿删掉这些保护。运行原测试文件及 Memory 全量套件,确认没有其他旧断言继续依赖已移除的本地 UID 过滤。

Copy link
Copy Markdown
Contributor Author

本轮更新已推送到本 PR,提交:9f31fbab(fix: 启动后异步预热记忆统计并恢复短暂读取失败)。

修改内容

  • Memory 本机服务开始接收请求后即异步启动统计预热,不再依赖 health 请求、登录 UID 或用户先打开记忆管理页面;页面请求复用同一份统计任务。
  • 统计 worker 按小批次构建缓存,并在前台任务、embedding 或资源操作繁忙时让出执行机会;服务退出时清理预热和 reader。
  • 记忆管理读取对网络错误及 HTTP 502/503/504 做有上限的退避重试(250/750/1500 ms);页面隐藏或请求取消时停止重试,认证和视图失效错误不重试。
  • 补充启动预热、worker 让行、首次短暂失败恢复及分页/刷新交互回归。

验证与性能边界

Memory 全量 2,790 项通过、2 项跳过;Desktop frontend 468 项通过;定向恢复及交互回归 15 项、runtime/startup 回归 7 项通过;typecheck、lint、Memory 构建及 DMG 校验通过。打包 UI 使用 46,891 条记忆、12,000 条日志的合成库,没有使用真实用户数据或调用模型。打包测试中的 503 恢复采用组件级 fetch 注入,没有对已打包应用注入真实 503。

同一测试库上,首次概览 UI 中位数从基线 2,861.6 ms 降到最终包 377.9 ms;但最终包的首次点击发生在服务 ready 后约 11.1 秒,后台预热已有时间完成。隔离统计测试中,冷缓存完整快照为基线 2,002 ms、最终 2,090 ms;已有派生缓存后约 233 ms 和 232 ms。因此本提交改善的是预热完成后的页面等待,冷快照计算本身没有变快。该测量库约 1.30 GB,与 review 中约 2.74 GB 的样本不同;目前没有“服务刚 ready 就立即打开页面”的打包计时,不能据此认定该冷路径已解决。

若验收要求是服务 ready 后立即打开也能快速显示,需要继续减少概览冷路径的实际计算,并在同一大库上补做即时打开计时。

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: App/backend/local-api-contracts/src/memory-runtime.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/health.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts, App/backend/src/adapters/inbound/local-api/tests/panel-display-routes.test.ts, App/backend/src/adapters/outbound/memory-client/http-memory-client.ts, App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts, App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts, App/backend/src/adapters/outbound/memory-client/types.ts, App/backend/src/index.ts, App/backend/src/services/bootstrap-service.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: fix-memory-panel-performance-v126

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: App/backend/local-api-contracts/src/memory-runtime.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/health.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts, App/backend/src/adapters/inbound/local-api/tests/panel-display-routes.test.ts, App/backend/src/adapters/outbound/memory-client/http-memory-client.ts, App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts, App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts, App/backend/src/adapters/outbound/memory-client/types.ts, App/backend/src/index.ts, App/backend/src/services/bootstrap-service.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: fix-memory-panel-performance-v126

async function prepare(parts: PanelCachePart[], backgroundId?: number) {
let batches = 0;
for (const part of parts)
while (fillPanelCacheBatch(cacheWriter, part, part === "logs" ? 64 : 128)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

缓存预热与轮次采集争写数据库,导致采集失败

关联:session-turn-service.ts (line 1567)。

问题: 服务启动后回填面板缓存时,原生轮次采集接口可能返回 500 database is locked,本次轮次没有保存。在约 4.75 万条记忆的隔离库上,当前 head 开启预热时 8 次采集全部失败,新增原始轮次为 0;关闭预热后 8 次全部保存,准确 base 同样 8 次全部保存。验证使用实际 HTTP、SQLite 和缓存 worker,关闭了后台任务执行,不等同于真实 Hook 客户端验收。

原因: 新增缓存连接与采集事务写入同一个数据库。采集先读取去重和会话状态,再写入会话时发生锁冲突,实际错误为 RuntimeRepository.createSession 抛出 SQLITE_BUSY。现有繁忙判断只看任务和模型计数,没有协调原生采集事务与缓存写入;仅缩小缓存批次或在写入后检查繁忙状态,不能防止冲突。

建议做法: 在事务开始前统一协调缓存与采集的写入,等待发生在事务外,缓存以短批次让出写入机会,避免同步等锁阻塞 HTTP 主线程。遇到仍可能发生的瞬时锁冲突,完整回滚后有上限地重试整个可幂等事务,保留原生轮次去重,不能吞错后返回成功。覆盖首次预热、增量回填期间持续采集,以及重复请求、取消和停机;验证每个成功响应都对应已保存且不重复的轮次,健康检查保持响应。

Comment thread Memory/src/service/panel/reader.ts Outdated
if (message.yield && isStatisticsMethod(active.method)) {
const resume = () => {
if (this.closed || this.worker !== worker || this.active !== active) return;
if (this.backgroundBusy()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

后台持续有任务时,概览统计可能一直等待,切到列表后才恢复

关联:memory-service.ts (line 345)。

问题: 概览已经加载过,统计因删除或归属变化失效后,如果后台持续有任务,再次进入概览可能一直加载。真实 HTTP/worker 的合成测试中,加入约 600 条记忆、使旧统计失效,并持有一个调度许可模拟等待远端模型的任务:连续 32 秒没有统计结果,健康检查正常。随后读取原始记忆列表耗时 252 ms,概览再过 30 ms 就绪,此时后台许可仍未释放。测试证明调度缺陷,不等同于逐请求还原本机那次现场。

原因: 任意后台任务运行都会暂停统计,等待远端模型也被算作繁忙。暂停时清除了读取超时计时器,之后每 25 ms 检查一次,却没有保证统计继续执行的机制。概览请求只查询准备状态;列表请求优先级更高,能中断统计并通过另一条路径补齐缓存,所以切页会改变结果。

建议做法: 保留后台预热和已完成批次,但用户正在等待概览或分析时,应保证同一份统计工作能够持续推进,不能要求后台任务全部结束。按实际数据库资源协调短批次执行,区分写入占用与等待模型,兼顾采集优先和统计的执行机会;将等待纳入总耗时控制,并支持取消和停机。统计完整生成后一次发布,删除和脱敏后不显示失效旧内容。覆盖后台任务连续运行、慢模型、统计失效后重新进入及并发采集,验证用户停留在概览也能自动显示结果,保持原有 UI。

},

async panelMemory(id,context){
return request("GET","panelMemory",GetMemoryOutputSchema,{params:{memoryId:id},context,maxRetries:0});

@origami-chen origami-chen Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

原始记忆详情丢失原有超时重试,短暂失败后必须重新点击

关联:memory-runtime-client.ts (line 223)。

问题: 打开原始记忆详情遇到一次短暂超时后,即使服务已经恢复,页面仍显示 memory layer timeout,需要重新点击。实际 React 组件和前端客户端测试中,注入一次 503 后恢复正常响应,等待 60 秒仍未重试,再点一次才恢复。后端对照中,相同一次超时,原详情调用自动重试成功,新详情调用只请求一次就失败。

原因: 本 PR 将详情改为调用新接口 panelMemory,其后端显式设置 maxRetries:0,移除了原 getMemory 的有界重试;前端又直接请求,没有经过已有的共享重试队列。详情组件捕获异常后只显示错误,两端都没有自动恢复。

建议做法: 保持原有 UI,在统一读取层为详情加入有上限、带退避的瞬时超时/503恢复,避免前后端叠加重试。关闭详情、切换记忆、隐藏或离开页面后停止不再需要的请求与重试,迟到结果不能覆盖新详情;授权失败、删除和失效结果仍按原规则处理,不能展示失效正文。覆盖单次超时后自动恢复、持续失败后的有界退出、取消及迟到响应。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本轮最终实现已调整为统一由前端共享队列承担新详情接口的瞬时失败恢复(2986df70、9e783083),更新此前“新详情在后端重试”的说明。panelMemory、panelLogDetail 后端均保持 maxRetries: 0,前端最多 4 次请求、250/750/1500 ms 退避,避免两端重试相乘。旧服务不支持面板路由时的 getMemory 兼容回退仍使用原后端有界重试,并放在前端重试包装之外。

授权失败、删除和失效结果不作为瞬时故障重试。关闭、改选或离开记忆子页会取消对应订阅,页面重新可见后按仍有效的选择恢复读取,旧响应不能回填正文。用户记忆等详情同样复用共享队列;没有重新加入手动重试按钮。已覆盖单次 503 恢复、持续失败达到上限、取消和迟到响应;前后端相关测试通过,最终前端全量 3421 项通过、3 项跳过。

let resumePreparation: (() => void) | undefined;
async function prepare(parts: PanelCachePart[], backgroundId?: number) {
let batches = 0;
for (const part of parts)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首次概览仍需约 10 秒,重启未复用完整统计快照

关联:panel-cache.ts (line 156)、stats.ts (line 26)、index.ts (line 209)。

问题: 服务已经能主动预热,但用户在服务可用后立即进入概览,仍需等待首次统计完成。在约 2.74 GB、47,482 条记忆的独立升级副本上,统计就绪耗时 10,763 ms,实际 Chromium 页面显示内容耗时 9533 ms;预热完成后请求仅需 10 ms,已有底层缓存的服务重启约 1293 ms。本组关闭后台任务并使用模型替身,证明首次准备成本仍未解决;不能将首次升级的约 10 秒外推成每次重启的耗时。

原因: 首份统计返回前,worker 仍须补齐全部待处理的记忆和指标缓存,历史 JSON 提取和派生数据写入都在等待路径上。数据库中的底层派生缓存会跨重启保留,但最终概览/分析统计快照只存在服务内存里,重启仍需重新生成。当前预热在服务可用后启动,启动加载阶段没有以面板必要数据就绪作为协调条件。提前启动或快速返回 preparing,都不能代替减少计算量、复用有效结果。

建议做法: 保持原有 UI,按以下方案完成首次准备及重启复用:

  1. 减少首次工作量。 概览首次显示只依赖必要的轻量统计数据,避免等待不相关的历史缓存全部准备完成。后续只处理变化记录,详情正文按需读取;缓存采用短批次写入,与采集事务协调,同时保证用户等待的统计能够推进。
  2. 复用完整统计快照。 每次完整生成后原子保存小的概览/分析统计结果及其对应的数据版本,重启时验证兼容性、数据库归属、日期/时区和隐私有效性,再直接恢复并增量更新。按缓存字段及统计规则决定兼容性,不按每次 App 版本变化清空;普通重启、兼容升级及保留数据目录的覆盖安装应继续复用。删除、脱敏后禁止展示失效旧结果,普通新增不应不断触发全量重建。
  3. 利用现有启动阶段准备。 数据库完成必要迁移、服务可响应后,立即在 worker 中恢复或更新缓存,与其他启动工作并行;缓存规则不兼容时优先准备首屏必需部分,保留已完成批次以便中断后续做。启动页如需等待,应有明确上限及失败处理,其余历史回填继续后台执行,不能让整个 App 等待全量缓存,也不能把缓存未就绪判成服务不可用。

if(props.log.detailAvailable&&props.client?.getPanelLogDetail) void props.client.getPanelLogDetail(props.log.id).then(result=>{if(active)setData(result.log);}).catch(error=>{if(active)setError(toErrorMessage(error));});
return()=>{active=false;};
},[props.client,props.log.id,retry]);
if(error)return <div role="alert">{error}<button type="button" onClick={()=>{setError(undefined);setRetry(value=>value+1);}}>{t("memory.tasks.loadContent")}</button></div>;

@origami-chen origami-chen Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

删除日志和用户记忆详情新增的“继续读取内容”按钮

关联:user-memories-sub-page.tsx (line 201)。

问题: 展开日志详情或打开用户记忆详情时,如果内容读取失败,页面会新增“继续读取内容”按钮。日志中按钮紧贴错误文字,用户记忆中按钮位于错误框下方。这两处新增操作不符合本次保留原有 UI 的要求。

原因: 将详情改为按需读取时,两个错误分支分别新增了手动重试按钮;它们不是原有界面的控件。

建议做法: 删除这两处新增按钮,保留原有布局和操作入口。按需读取及瞬时失败恢复仍需正常工作,不能仅移除按钮后让内容停留在无法恢复的错误状态。覆盖详情成功、单次超时恢复及关闭后重新打开,确认两处按钮均不再出现。

return <>{(logs.length > 0 || legacy !== undefined || (!busy && !error)) && <SkillTimelineSection entries={legacy??skillTimelineFromLogs(logs,props.skillId)} />}
{busy&&<MemoryStateBox message={t("common.loading")} />}
{error&&<p role="alert">{error}</p>}
{(cursor||error)&&<button type="button" disabled={busy} onClick={()=>void load(cursor)}>{t("memory.tasks.loadContent")}</button>}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

技能时间线的“继续读取内容”按钮应统一样式并放入时间线容器

关联:时间线容器:824 (line 824)。

问题: 技能演化时间线正常加载前 20 条且还有后续记录时,“继续读取内容”按钮显示在时间线卡片外、SKILL.MD 内容卡片之前,样式近似普通文字,既不符合 Memmy 整体按钮风格,也不清楚属于哪个内容区域。

原因: 当前按钮没有应用项目既有按钮样式,并与时间线组件并列渲染,落在时间线容器之外。

建议做法: 保留该按钮及分批读取功能,复用 Memmy 既有同类按钮样式,将按钮放在时间线容器内部、当前记录末尾。加载中的禁用状态及失败后的再次读取保持有效;验证超过 20 条、加载中、读取失败和全部加载完成时的位置、样式与行为,避免影响下方 SKILL.MD 内容区域。

@Orsted290

Copy link
Copy Markdown
Contributor Author

@origami-chen 本轮返修已推送,当前提交为 1f6b006e,包含 27849db 和 3c05edc,并保留远端已有的技能统计和空列表分页修复。以下按六条意见说明实际进展。

  1. 首次概览与重启复用:部分完成,首次工作量问题尚未完全关闭。 概览准备与分析所需的指标历史回填分开,概览先准备记忆统计所需的轻量事实;详情正文保持按需读取,缓存按短批次处理。完整概览/分析统计生成后原子保存,服务重启时校验缓存格式、数据库归属、数据版本、日期/时区及隐私有效性后恢复;删除、脱敏等使旧结果失效时不会继续展示。这里的重启收益来自保留并复用有效磁盘快照,不能用于说明首次无缓存已足够快。目前首次仍需准备必要的轻量事实,普通新增后的汇总仍会遍历全部轻量统计行;进一步的增量汇总提交 22ba23d8 尚未推送,不计入本次修复。启动仍使用服务可响应后的异步预热,没有增加等待全量缓存的启动屏障。

  2. 原始记忆详情短暂失败后自动恢复: 在后端统一读取层恢复有上限、带退避的瞬时超时/503 重试,前端不叠加自动重试。关闭、切换详情及卸载时取消订阅/请求,并用请求代次防止迟到结果覆盖当前详情;授权失败、删除和失效结果仍按错误处理,不回退展示失效正文。已覆盖单次失败恢复、持续失败有界退出、取消和迟到响应。

  3. 后台任务导致概览持续等待: 统计不再以“存在任何后台任务/模型调用”为暂停条件,改为按实际数据库写入协调执行。准备过程保留已完成批次并固定本轮待处理集合,后台持续新增不会让本轮准备无限延长;排队和等待计入总超时,支持取消及停机,完成后一次发布统计。已覆盖后台许可持续占用、统计失效后重新进入及并发采集,用户停留概览也能得到结果。

  4. 缓存回填与轮次采集争写: 缓存批次和原生轮次采集在进入数据库事务前取得同一写入许可,等待发生在事务外;缓存每批释放许可,让采集获得执行机会。遇到剩余 SQLITE_BUSY 时,释放许可后按 25/50/100 ms 有界退避,重新执行保留原有去重事务的采集操作,不吞错返回成功。已用真实 HTTP、SQLite worker 验证回填期间并发采集、重复请求及健康检查响应;这仍不等同于真实 Hook 客户端验收。

  5. 日志/用户记忆详情新增按钮: 已移除两处“继续读取内容”手动重试按钮,保留原布局与入口;按需读取及后端瞬时失败恢复保留,并覆盖关闭后重新打开。

  6. 技能时间线分页按钮: 保留分批加载,将按钮放入时间线容器内部、当前记录末尾,复用现有 memory-skill-editor__button 样式。加载时禁用、失败后可再次读取、全部加载完成后隐藏;已覆盖超过 20 条记录的交互,下方 SKILL.MD 内容区域不变。

另外确认:此次新增的前端共享自动重试逻辑已撤回;现有请求合并和订阅取消机制保留,不能将其描述为删除整个共享请求队列。

验证情况:最终合并提交完成 typecheck、lint 和 Memory build。相关回归首轮覆盖 80 个文件、704 项测试,703 项通过、1 项 HTTP 大样本采集测试超时;该文件单独复跑 2/2 通过。没有将这描述成一次完整全绿运行,也尚未完成最终打包桌面验收。

同数据副本的性能对照是 9f31fba → 3c05edc(远端合并前),使用约 1.30 GB、46,891 条记忆和 12,000 条日志的合成库,10 轮 AB/BA 交替、相同依赖和条件,关闭后台任务与自动预热,统一预热文件系统缓存,测量 HTTP 统计完整就绪:

场景(中位数) 修复前 修复后
首次无面板缓存 944.17 ms 908.17 ms
服务进程重启,保留有效缓存/快照 374.42 ms 7.57 ms

40 次统计结果一致。首次改善约 3.8%,主要收益在有效快照的重启复用;这组数据不是 Chromium 首屏耗时,也不是对原评论 2.74 GB 数据副本约 10 秒问题的复现,不能据此宣称首次慢的问题已完全解决。

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: App/backend/local-api-contracts/src/memory-runtime.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/health.ts, App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts, App/backend/src/adapters/inbound/local-api/tests/panel-display-routes.test.ts, App/backend/src/adapters/outbound/memory-client/http-memory-client.ts, App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts, App/backend/src/adapters/outbound/memory-client/retry.ts, App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts, App/backend/src/adapters/outbound/memory-client/types.ts, App/backend/src/index.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: fix-memory-panel-performance-v126

Comment thread Memory/src/service/memory-service.ts Outdated
// Native scans have no Hook envelope. Use the configured owner only when
// the request (including authenticated scope) did not provide one.
const response = await this.acceptCapture(() => this.withModelTaskContext(() => this.sessionTurns.withEpisodeSelection(
const response = await this.panelWrites.run(() => this.acceptCapture(() => this.withModelTaskContext(() => this.sessionTurns.withEpisodeSelection(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

缓存写入协调遗漏 Memmy 的轮次完成接口,预热仍会导致采集失败

状态:部分修复,仍有效。

关联:completeTurn:1714 (line 1714)、reader-worker.ts (line 34)、hook.ts (line 447)。

问题: 服务启动回填面板缓存时,Memmy 自身通过 POST /api/v1/turns/:id/complete 保存轮次,仍可能返回 500 database is locked,失败请求没有保存对应轮次。用 6,000 条合成记忆、实际 HTTP/SQLite/worker、明确的模型替身补测,开启预热时 32 次请求中 20 次锁冲突失败,仅 12 条落库;同一 head、相同模型替身关闭预热后,32 次全部保存。这里验证的是服务接口;实际 Agent 还有有限重试,不能将单次接口失败直接写成客户端最终永久丢失,但持续回填会反复触发失败。

原因: 新增 PanelWriteCoordinator 只包住了 completeSourceTurn,即原生来源轮次入口。旁边的 completeTurn 仍直接进入先读后写的事务,没有与缓存 worker 协调写入;Memmy 的记忆 Hook 实际调用的正是这个入口。缓存连接与该事务竞争 SQLite 写锁时,服务返回失败。原生来源接口的并发测试通过,并不能覆盖 Memmy 自身的轮次保存。

建议做法: 将缓存回填与所有受影响的轮次写入入口纳入同一套事务前协调,至少补齐 completeTurn,并检查开始轮次、工具观察、子任务等共享采集路径。等待必须发生在事务外,缓存每个短批次释放写入机会;瞬时锁冲突完整回滚后再有界重试可幂等事务,保留原有去重和取消、停机行为。使用原生来源接口与 Memmy 轮次接口分别覆盖首次预热、增量回填、重复提交、取消和停机,核对成功响应对应真实落库、不重复,health 保持响应,不能只靠客户端重试掩盖服务端冲突。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 9e2e5c8、9e783083 补齐。写入许可放在同步写事务之前获取,completeTurn 与 completeSourceTurn 共用协调;同时检查并纳入 startTurn、observeTool、subagentStart/subagentComplete、compact 等相关写事务。等待许可发生在事务外,等待模型时释放许可;模型返回后的纠错持久化也重新取得许可,并以完整事务原子提交,瞬时锁冲突完整回滚后有界重试,保留取消及停机退出。

验证使用真实 HTTP、SQLite 和缓存 worker,模型使用替身:6,000 条初始合成记忆及新增 600 条回填阶段,分别覆盖原生来源每阶段 8 次、Memmy 轮次每阶段 32 次请求;不依赖客户端重试,核对成功响应实际落库、重复轮次返回同一 ID、health 返回 200。另有事务准入/回滚、取消、停机及模型等待释放许可测试。本地独立复核的并发采集与后端 HTTP 客户端两套测试共 38 项通过。

边界说明:这里验证的是服务接口与事务协调,并非真实 Agent Hook/桌面端验收。auto-cr 另发现旧有 observeTool 整体调用幂等问题:原始观察已提交,而后续纠错持续失败后整次重发可能重复观察;本轮未扩展修复这一既有问题,不能据此声称整条工具观察调用已完全幂等。

usePanelPoll(Boolean(props.client) && !props.client?.checkPanelView, () => refresh(page, sourceAgent, { useCache: false }));
const detailRequestIdRef = useRef(0);
const detailAbortRef = useRef<AbortController | null>(null);
useEffect(() => () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

原始记忆详情加载中切走再返回,会一直停在加载中

关联:详情读取:246 (line 246)、memory-page.tsx。

问题: 点开原始记忆详情,在请求完成前切到其他记忆子页,再返回原始记忆页,详情抽屉仍然显示“加载中”,不会自动恢复,需要关闭后重新点击。补充组件测试中,请求已被取消,返回后推进 60 秒仍只有第一次调用,抽屉保持加载;相同操作在上一轮 head 能显示已完成的详情。

原因: 子页面切换后仍保留挂载和状态。新增 effect 在可见性变化时取消详情请求并递增请求编号,却没有清理加载中的详情状态,也没有在重新可见时恢复选中记忆的读取。原请求的成功和失败都会因编号或取消检查被丢弃,因此保留下来的加载状态没有任何后续请求能够结束。

建议做法: 保持原有抽屉 UI,并补齐中断后的状态恢复:切走时停止请求,返回后为仍选中的记忆重新读取,并重新核验内容可见性;如果用户已经关闭抽屉或选择其他记忆,不再恢复旧请求。覆盖加载中切换子页、离开后返回、连续选择不同记忆及删除/脱敏后的返回,确认不会永久加载,也不会显示迟到或失效正文。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 2986df7、9e783083 补齐这条恢复链,保留原抽屉 UI。切到其他记忆子页时取消当前详情订阅、使旧请求失效并清理旧正文/加载状态;保留当前选择,返回后仅为仍选中的记忆重新读取并重新校验可见性。关闭抽屉、改选其他记忆或切换 client 后,不会恢复旧请求,迟到结果不能覆盖新选择。

这不是只恢复共享队列就能解决的问题:原缺陷还包括页面取消后没有重新发起读取,本轮同时修复了页面状态和请求生命周期。组件回归覆盖加载中离开再返回、连续改选、关闭、迟到响应及返回后读取失败/不可见的处理;最终前端全量 3421 项通过、3 项跳过。

if (task.started || task.subscribers.size === 0) continue;
task.started = true;
this.active++;
void Promise.resolve()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

删除额外按钮不应撤回列表的自动恢复能力

本次要求是删除日志详情和用户记忆正文读取失败时新增的手动重试按钮,保留原有 UI 和自动恢复能力。但同一轮修改还撤回了前端共享请求队列的自动重试,导致打开经验等列表、翻页或点击原刷新按钮时,只要第一次请求短暂返回 503,即使服务随后恢复,页面也不会自动再读取:首开停在错误状态,翻页或刷新无法完成本次操作。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 2986df7 恢复前端共享请求队列的有界自动重试,首开、翻页及原刷新操作遇到瞬时网络错误或 502/503/504 后可自动恢复;持续失败最多请求 4 次,退避为 250/750/1500 ms。日志详情和用户记忆详情新增的手动重试按钮仍保持删除,原有 UI 不变。

9e78308 将新详情读取也统一交给前端共享队列,避免前后端叠加重试;7876950a 补齐视图检查的取消链:切到其他记忆子页后撤销该页订阅及后续重试,返回后重新检查,不影响仍有效的其他订阅,也不接受迟到结果。

已覆盖首开/翻页/刷新单次 503 恢复、持续失败退出、切页取消、返回恢复和共享订阅隔离。最终 head 的相关 4 个测试文件共 30 项通过,前端全量 3421 项通过、3 项跳过。

@Orsted290

Copy link
Copy Markdown
Contributor Author

本轮修复已推送,当前 head:7876950aad22e0987dbaab278b8f7451cb201e72。

  • 2986df7:恢复前端共享请求队列的自动重试,并恢复原始记忆详情切走再返回后的读取。
  • 9e2e5c8:将 Memmy completeTurn 及相关采集写事务纳入缓存写入协调,覆盖事务外等待、回滚重试、取消和停机。
  • 9e78308:新详情统一使用前端共享队列,关闭 panelMemory/panelLogDetail 后端重试以避免叠加;补齐详情生命周期恢复和模型返回后的纠错写事务协调。
  • 7876950:修复本地 auto-cr 发现的子页离开后仍继续视图检查重试的问题,取消只作用于该页订阅,返回重新检查。

更新此前回复中的两点:共享队列自动重试现已恢复;新详情接口的瞬时失败重试现由前端统一承担。旧 getMemory 兼容回退保留原后端重试,不叠加前端重试。保持原有 UI,日志/用户记忆详情的额外重试按钮未恢复;增量统计仍暂不修复,本轮未包含该优化。

验证:最终 head 相关 4 个文件 30 项测试通过;前端全量 307 个文件、3421 项通过、3 项跳过;根目录 lint、typecheck 及前端生产构建通过。9e783083 上另经独立运行的真实 HTTP/SQLite/worker 并发采集及后端 HTTP 客户端测试共 38 项通过(模型替身)。这不等同于真实桌面/Agent Hook 验收,本轮未重跑 DMG 验收,也未重新测量首次概览性能。

本地审查另外识别出的既有技能编辑器迟到正文、工具观察整次调用重发幂等、同 ID 日志刷新旧详情问题,按本轮范围未扩展修复;不将这些或首次概览慢问题一并宣称关闭。已在对应评论线程补充修复和验证说明,保留线程供复审。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants