Skip to content

回移植 PR #795:记忆 UID 解耦与召回补取至 v1.2.6 - #796

Open
Orsted290 wants to merge 8 commits into
MemTensor:v1.2.6from
Orsted290:backport-pr795-v126
Open

Orsted290 wants to merge 8 commits into
MemTensor:v1.2.6from
Orsted290:backport-pr795-v126

Conversation

@Orsted290

@Orsted290 Orsted290 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

问题定位

记忆 owner 和召回候选池此前仍受账号 UID 影响。历史记忆可能因不同账号重复写入,重复项会占用召回额度,导致去重后的有效记忆数量不足。调整 owner 时还必须保留历史行原值,以及既有 source、project、tenant 等范围约束。

修改内容

  • 新建记忆与设备级状态统一使用固定设备 owner,不再从当前账号 UID 推导。
  • 不改写历史记录;对跨账号历史副本按可验证的稳定来源身份去重。没有可靠来源证据时按记录 ID 保留。
  • 将最终 quota 放在去重之后。候选不足时继续分页补取,直到达到目标数量或候选范围耗尽,并受现有时间和工作量边界限制。
  • 保留 bearer token、scope 和原有来源、项目、租户权限检查。

具体改动与代码位置

v1.2.6 适配

本次只回移植 PR #795 的 4 个代码提交,没有合入其他分支提交。冲突解决保留了 v1.2.6 的数据库范围过滤,同时接入召回补取条件;会话服务则同时保留异步让出逻辑和设备 owner import。

测试结果

@Memtensor-AI

Memtensor-AI commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #796
Task: 992cf1b35a216589
Base: v1.2.6
Head: backport-pr795-v126

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

⚠️ Review warnings (27)

  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • tool output exceeded the byte/line budget; use narrower file ranges or paginated searches; omitted output is not reviewed
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • … 17 additional warning(s) omitted.

1. App/backend/src/adapters/outbound/skill-writer/hermes/target.ts (L1341-L1342)

After hardcoding the owner, the memory / app locals extracted from the parsed YAML at the top of _load_runtime() (the root.get("memmyMemory") / root.get("app") lines) are never read again, so the whole yaml.safe_load block now only feeds dead variables. Please remove the unused memory / app extraction (and the now-pointless root parse) to avoid dead code / F841 lint noise in the generated plugin.

💡 Suggested Change

Before:

        "userId": "__memmy_device_memory__",
        "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")),

After:

        "userId": "__memmy_device_memory__",
        "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")),

2. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L90)

pinnedOwner is now a dead parameter: void pinnedOwner only silences the unused-arg lint, while both call sites (openRuntimeSession at line ~100 and completeSourceTurn at line ~274) still pass true, implying an intent that is silently ignored. Since ownership is device-wide, drop the parameter from readRuntimeConfig and its call sites instead of keeping a no-op argument.

Also note this literal diverges from the peer copy: the App/backend skill-writer/memmy-runtime-config.ts still resolves userId to app.userId || memmyMemory.userId || "local-user", so the plugin config.json written by the very same adapter keeps recording local-user while the bridge now reports __memmy_device_memory__. Either share one owner constant or update the sibling config resolver in this PR.

💡 Suggested Change

Before:

  void pinnedOwner; // Ownership is device-wide, including pinned runtime snapshots.

After:

  // Ownership is device-wide; pinned snapshots share the same identity.

3. Memory/src/agent-source/integration/workspace-bridge/runtime.ts (L94)

This magic literal duplicates DEVICE_MEMORY_OWNER from Memory/src/service/namespace/memory-owner.ts, which is the single source of truth used across memory-service, repositories, namespace-scope, etc. Hardcoding it here (plus in memmy-runtime-config.ts and the Hermes Python templates) means a future change of the owner value will silently desynchronise the installed bridges/hooks. Prefer importing the constant (this module already imports sibling internals like ../../contracts/index.js and ../../cli/analytics.js) or introduce a shared wire constant.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_OWNER,

4. Memory/src/agent-source/integration/hermes/target.ts (L1341-L1342)

memory and app (the root.get("memmyMemory") / root.get("app") locals in _load_runtime) are no longer referenced after this change, so the YAML load now only produces unused variables in the generated plugin. Please remove them (or keep a single config read if it is still needed elsewhere) to avoid dead code in the emitted Python.

Also, this value is the same device-wide owner constant defined in Memory/src/service/namespace/memory-owner.ts; duplicating the raw string in the embedded template makes future value changes error-prone.

💡 Suggested Change

Before:

        "userId": "__memmy_device_memory__",
        "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")),

After:

        "userId": "__memmy_device_memory__",
        "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")),

5. App/memmy-agent/src/memmy-memory/config.ts (L10)

Two issues on this change:

  1. stringOrUndefined (line 15) is now dead code — the only call site was stringOrUndefined(raw?.userId), which was replaced by the literal. Unused helpers typically fail no-unused-vars/knip checks, so remove it.
  2. The owner id is now a hardcoded literal that is duplicated in discovery.ts and in the Memory/backend config readers; the server side already defines a canonical constant (DEVICE_MEMORY_OWNER in Memory/src/service/namespace/memory-owner.ts). Export a single constant here (e.g. MEMMY_DEVICE_MEMORY_OWNER) and reuse it everywhere so the scope id cannot drift.
💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: MEMMY_DEVICE_MEMORY_OWNER,

6. App/memmy-agent/src/memmy-memory/discovery.ts (L109-L110)

void options; is a no-op expression that only silences the unused-parameter warning while the function still advertises an options argument that has no effect — callers can pass env/homeDir and reasonably expect them to matter. Either drop the parameter (export function discoverMemmyMemoryUserId(): string) or rename it to _options to make the contract explicit. Also reuse the shared device-owner constant instead of re-declaring the literal string (it is duplicated in config.ts).

💡 Suggested Change

Before:

  void options;
  return "__memmy_device_memory__";

After:

export function discoverMemmyMemoryUserId(): string {
  return MEMMY_DEVICE_MEMORY_OWNER;
}

7. Memory/src/agent-source/integration/memmy-runtime-config.ts (L31)

Use the canonical constant instead of the raw literal: import { DEVICE_MEMORY_OWNER } from "../../service/namespace/memory-owner.js" and return userId: DEVICE_MEMORY_OWNER here (the rest of this package already does so, e.g. namespace-scope.ts, memory-service.ts, session-turn-service.ts). Beyond the duplication, note that the mirrored copy of this file in App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts was NOT updated and still resolves userId from app.userId / memmyMemory.userId / "local-user". The two copies are consumed by the same skill-writer targets, so they now emit different userId values into the generated agent configs — please update the App/backend copy (or share the helper) so both read paths agree.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_OWNER,

8. Memory/src/service/namespace/shared-project.ts (L12-L13)

sharedProjectIdentity issues up to six full-table scans per call: sessions WHERE project_id = ? and l3_world_model_scopes WHERE project_id = ? have no usable index (idx_sessions_user_updated / uq_l3_world_model_scopes_project both lead with user_id), and the json_extract(meta_json,'$.workspace_uri') / workspace_uri = ? predicates cannot use any index at all. There is no memoization, yet this is called repeatedly on hot paths: once per work-memory candidate (work-memory-pipeline.ts:432) and once per L3 field key (L3WorldModelRepository.fieldStateKey / effectiveFields, which itself already calls sharedProjectIds). Consider adding indexes (sessions(project_id), l3_world_model_scopes(project_id) / (workspace_uri)) and a per-db/projectId cache so the alias set is computed once per request. Also, the later scopes query re-reads the same l3_world_model_scopes rows already fetched into saved.


9. Memory/src/service/namespace/shared-project.ts (L73-L74)

This re-implements the identity/ID derivation that already lives in workspace-identity.ts (local\0hostId\0uri / remote\0uri composition at line 33 above, plus sha256Hex and the ws_ prefix in resolveWorkspaceIdentity). Two independent copies must stay in lockstep; if resolveWorkspaceIdentity ever changes the composition or prefix, alias verification here silently falls back to isolated (no error, memories just appear empty) instead of failing loudly. Please export a shared helper (e.g. workspaceIdentityString() / workspaceProjectIdFromIdentity() from workspace-identity.ts) and reuse it in both places, including the inline composition in the session loop.


10. Memory/src/service/namespace/workspace-identity.ts (L26)

The effectiveUserId parameter is now dead: it is explicitly discarded and the non-empty TypeError guard was removed, so invalid/empty owner input that previously failed fast now passes silently, and every caller still passes an argument that has no effect. Prefer removing the parameter from the signature (and dropping namespace.userId at the namespace-scope.ts call site / test call sites) so the API reflects that identity is owner-independent; keep a short doc comment instead of a void statement which leaves lint/noUnusedParameters config as the only guard.


11. Memory/src/service/memory-service.ts (L3444-L3445)

createMemoryRow now always writes DEVICE_MEMORY_OWNER while input.userId is still part of the parameter type but never read, so callers can keep passing a per-request owner that has no effect (and MemoryRepository.insert overrides it again). Same pattern at completeSourceTurn (...request.namespace, userId: DEVICE_MEMORY_OWNER), where the spread-then-override suggests ownership is still request-scoped. Suggest dropping userId from the createMemoryRow input type (or documenting it as ignored) and replacing the spread-then-override with an explicit namespace object so the device-owned invariant is unambiguous.


12. App/memmy-agent/src/memmy-memory/hook.ts (L793)

Using JSON.stringify for array equality allocates two strings on every L3 load and is order-sensitive, so a reordered-but-identical sourceMemoryIds invalidates the cache and rewrites the prompt section for no content change. A small helper (length check + element-wise compare, or a set comparison if order is not meaningful) is cheaper and expresses the intent better: sameIds(current.sourceMemoryIds, response.sourceMemoryIds).


13. App/backend/src/adapters/outbound/skill-writer/hermes/target.ts (L1452-L1453)

After hardcoding the owner, _runtime_envelope's runtime parameter is never read (it previously fed runtime.get("userId")). It is now a dead parameter, and every caller still has to pass state["runtime"] into it, which misleadingly suggests the envelope depends on runtime settings. Remove the parameter (and the state["runtime"] arguments at its call sites) to keep the contract honest.


14. Memory/src/agent-source/integration/hermes/target.ts (L1452-L1453)

Same dead-parameter issue as in the backend copy: _runtime_envelope no longer reads runtime now that the owner is a constant, yet all callers still pass state["runtime"]. Drop the parameter and its arguments so the function signature reflects that the envelope is device-wide.


15. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L94)

This change is only applied to this runtime path; the sibling backend module App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts (readMemmyMemoryServiceConfig, used by the codex/cursor/claude-code/hermes/opencode installers to write each agent's config.json) still resolves userId as app.userId || memmyMemory.userId || "local-user", and its tests still assert "local-user". The Memory mirror of that file was updated, so backend install-time config now disagrees with the runtime envelope owner. Propagate the device-wide owner to that helper (ideally via the shared constant) to avoid two different owners in the same adapter tree.


16. Memory/src/service/evolution/l3-world-model-pipeline.ts (L80-L81)

This re-derives the target-field → L3WorldModelFields property mapping with a nested ternary (prohibited by our style rules) and duplicates the mapping that already exists in repositories.ts (l3WorldModelFieldProperty) and the one used by fieldValue below. If the mapping ever gains an entry the two copies can silently diverge, and snapshot.revisions[fieldProperty] would then look up the wrong revision. Prefer exporting/reusing a single helper.

💡 Suggested Change

Before:

    const fieldProperty = payload.targetField === "general_rules_and_safety_constraints" ? "generalRulesAndSafetyConstraints"
      : payload.targetField === "project_contract" ? "projectContract" : "domainKnowledge";

After:

    const fieldProperty = fieldPropertyOf(payload.targetField);

17. Memory/src/service/evolution/l3-world-model-pipeline.ts (L279-L280)

userId is now unused, so void userId; only masks a dead parameter. Drop the parameter (and the batch.userId argument at the single call site) instead of voiding it, so the signature reflects the decoupled scope.

💡 Suggested Change

Before:

  void userId;
  if (rawTurn.sessionId !== sessionId) {

After:

  if (rawTurn.sessionId !== sessionId) {

18. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L23)

Session meta_json is parsed and workspace_uri is passed straight to isLocalWorkspaceUri, which calls new URL(uri) and throws on malformed/legacy values. There is no guard here, and the resolver is invoked from keyOf/dedup inside Promise.all, so one bad row aborts the entire recall request instead of being treated as no evidence. shared-project.ts already solves this with a per-row try/catch + WorkspaceIdentityFieldsSchema.safeParse; apply the same guard here (skip the row on parse/URI failure).

💡 Suggested Change

Before:

        const meta: unknown = JSON.parse(row.meta_json);

After:

        let meta: unknown;
        try { meta = JSON.parse(row.meta_json); } catch { continue; }

19. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L145)

message_payload_json is consumed from legacy raw-turn rows and parsed without error handling. A single malformed payload throws out of sourceKey (again uncaught inside refill/dedup) and fails the whole retrieval. Treat a parse failure as "no origin available" and fall through to the capture-based path instead.

💡 Suggested Change

Before:

      const payload: unknown = JSON.parse(raw.message_payload_json);

After:

      let payload: unknown;
      try { payload = JSON.parse(raw.message_payload_json); } catch { payload = undefined; }

20. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L128)

recallSourceKey constructs a brand-new createRecallSourceResolver (with a cold per-source session cache) on every call, and it is not referenced anywhere in the repo. If it is only a test/back-compat helper this is fine, but it is currently dead public API that re-queries sessions per memory if anyone loops over it. Either remove it or thread a shared resolver/cache through callers.


21. Memory/src/service/retrieval/indexed-candidate-pool.ts (L159)

keyOf can run two extra queries per memory (raw_turns, source_turn_captures) plus per-source session scans, but none of that work is charged to usage.rows/usage.metadataRows. With large candidate sets the actual DB work can far exceed the advertised 10k-row budget even though diagnostics report the budget respected. Consider counting source-key lookups in a separate budget dimension (or caching by turn id) so a big recall cannot trigger unbounded per-row queries.


22. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L48)

groupKeyByMemoryId is added to the result contract but is never read by any caller (the retrieval-service dedup path consumes only memories/memoryIdByMemoryId). Keeping an unused map forces callers to allocate a per-hit Map for nothing; drop it or wire it into the consumers that need the group key.


23. Memory/src/service/retrieval/retrieval-service.ts (L2048)

refill re-runs queryVector() (may throw) and the whole candidate-pool query without any try/catch. A transient embedding/DB failure during a refill now rejects this promise even though the initial successful retrievalOutput already exists, turning a recoverable degraded result into a failed recall. Wrap the refill call (and the user-memory refresh below) and fall back to previousOutput + a status flag on error.

💡 Suggested Change

Before:

      retrievalOutput = await retrievalOutput.refill(retrievalLimit - prior);

After:

      try { retrievalOutput = await retrievalOutput.refill(retrievalLimit - prior); }
      catch { refillStatuses.push("recall_refill:error"); retrievalOutput = previousOutput; ({memories, retrieval} = prepare(retrievalOutput)); mergedState = merge(); break; }

24. Memory/src/service/retrieval/retrieval-service.ts (L2546)

Each refill throws away all previous per-query progress and calls run(), which re-runs every rewritten query from scratch (Promise.all(planned.map(runQuery))) instead of continuing the existing pagination. Combined with the two independent refill loops (8 rounds each) this can re-execute the full candidate-pool/vector pipeline up to ~16 times per recall, multiplying latency and embedding/DB cost. Consider resuming each query's route state (excluded ids / accepted set) rather than restarting.


25. Memory/src/service/retrieval/retrieval-service.ts (L2287)

filteredHits.hits is reassigned to merged.hits inside the concurrent-change refill block, so by the time the logging object is built mmrDropped: filteredHits.hits.length - beforeRevalidation no longer measures MMR drops (it becomes ~merged-vs-MMR-survivors and can even be negative), and droppedByLlm computed against filteredIds is empty because filteredIds now holds the post-merge set. Capture the pre-mutation lengths/ids (or compute the stats before the second refill block) so the diagnostics stay accurate.


26. Memory/src/service/session/session-turn-service.ts (L3341)

This loop is O(n²) DB work on a hot per-turn path (repairSuggestion). Each iteration (a) materialises the whole excluded array into a growing NOT IN (SELECT value FROM json_each(?)) parameter, and (b) re-runs deduplicateCrossAccountMemoryCandidates over the entire accumulated list. That helper is not cheap: createRecallSourceResolver is recreated per call and sourceKey issues a source_turn_captures query per memory lacking native_origin_v1 (plus a sessions scan per source/profile). With up to 10,000 scanned rows across ~20 batches this can mean tens of thousands of statements per call and up to 5s of synchronous better-sqlite3 work on the event loop.

Better: page with a cursor (ORDER BY created_at DESC, id DESC + (created_at, id) < cursor or offset), accumulate the raw rows, and run the dedupe exactly once after the loop.

💡 Suggested Change

Before:

      const batch = this.deps.repos.memories.list({...filter, excludedIds: [...excluded]}, Math.min(500,10_000-excluded.size));

After:

      const batch = this.deps.repos.memories.list({...filter}, batchSize, scanned.length);

27. Memory/src/service/session/session-turn-service.ts (L3349)

The termination test re-derives the limit with excluded.size - fresh.length (the pre-batch excluded count) so it silently mirrors the Math.min(...) passed to list(). Any future change to how excluded/fresh are populated (e.g. skipping not-ready memories from excluded) will break the last-page detection and can truncate the scan or loop an extra round. Compute the limit once into a const and reuse it for both the query and the break condition; also hoist 500/10_000/5_000 into named constants (or the existing algorithm.feedback tuning config) instead of inline literals.

💡 Suggested Change

Before:

      if (batch.length < Math.min(500,10_000-(excluded.size-fresh.length))) break;

After:

      const limit = Math.min(batchSize, maxScanRows - excludedBefore);
      const batch = this.deps.repos.memories.list({...filter, excludedIds: [...excluded]}, limit);
      if (batch.length < limit) break;

28. Memory/src/service/session/session-turn-service.ts (L3362)

Serialising info/properties (arbitrarily large JSON blobs) for every retrieved and sibling memory just to detect concurrent modification is expensive and brittle: it relies on byte-identical key ordering between the list() and getMany() hydration paths. A cheap stable check (memory.version plus updatedAt) gives the same freshness guarantee if version is bumped on every write — consider that, or at least document why full serialisation is required.


29. Memory/src/service/worker/worker-work-requirements.ts (L51-L52)

Picking the lexicographically smallest alias of sharedProjectIds(project) as the permit representative is fragile. sharedProjectIdentity returns the full alias set only when workspace evidence resolves for the given input; if it falls back to the isolated branch ([projectId], e.g. the canonical ws_<hash> has no session/scope row carrying workspace_uri while a legacy alias does), the two jobs of the same project compute different scopeKeys and silently stop serialising — the exact unification the comment above intends. Use the canonical identity instead (e.g. expose sharedProjectIdentity(...).deviceProjectId on l3WorldModels and key on that), so every alias deterministically maps to one canonical project id. (slice() is also redundant here since sharedProjectIds already returns a fresh array.)


30. Memory/src/service/worker/worker-work-requirements.ts (L53)

This runs in the per-candidate admission hot path (worker-runner admit callback), which is explicitly kept cheap a few lines above ("Avoid reading long conversation bodies while skipping resource-blocked rows"). sharedProjectIds executes ~6 prepared statements plus JSON parsing for every candidate, and when project falls back to job.scopeKey (opaque hash for work_memory_extract jobs whose payload has no projectId) the resolution can never match and those queries are pure overhead per claim attempt. Consider only resolving when the value actually looks like a project id, and/or memoising the alias→canonical mapping for the duration of a claim/scan round.


31. Memory/src/types.ts (L192-L193)

MemoryFilter.userId is now a no-op: buildMemoryWhere dropped the user_id clause (repositories.ts), but the field is kept without any marker, so every remaining/future caller that passes userId for scoping silently gets unrelated rows instead of a compile error or a narrowed result. Please either remove the field, or (if it must stay for wire compatibility) deprecate it explicitly here so callers know it no longer filters.

💡 Suggested Change

Before:

  /** Request-local exclusions; applied before candidate windows and LIMIT. */
  excludedIds?: readonly string[];

After:

  /** Request-local exclusions; applied before candidate windows and LIMIT. */
  excludedIds?: readonly string[];
  /** @deprecated Legacy provenance only; no longer used as a read filter. */
  userId?: string;

32. Memory/src/storage/repositories.ts (L1318)

rank AS raw_rank is selected but never consumed — the rows are cast to Array<{ id: string }> and only row.id is used. It is a dead projection that also hides the real column shape from the type. Either drop the column or use it (and type the row accordingly) if a raw BM25 score is intended.

💡 Suggested Change

Before:

          `SELECT memories.id AS id, rank AS raw_rank

After:

          `SELECT memories.id AS id

33. Memory/src/storage/repositories.ts (L1659-L1662)

Empty-array handling is inconsistent: the excluded* clauses are guarded by ?.length, but includedSourceTurnIds is guarded by truthiness only. A caller that passes [] (meaning "no restriction") gets source_turn_id IN (SELECT value FROM json_each('[]')), which matches nothing, so recall silently returns zero user memories. Use the same ?.length guard as the neighbouring branches (or document that [] means "match none").

💡 Suggested Change

Before:

    if (options.includedSourceTurnIds) {
      clauses.push("user_memories.source_turn_id IN (SELECT value FROM json_each(?))");
      params.push(toJson(options.includedSourceTurnIds));
    }

After:

    if (options.includedSourceTurnIds?.length) {
      clauses.push("user_memories.source_turn_id IN (SELECT value FROM json_each(?))");
      params.push(toJson(options.includedSourceTurnIds));
    }

34. Memory/src/storage/repositories.ts (L1801)

This is the only user-memory lookup that still hard-filters on user_id = DEVICE_MEMORY_OWNER, while every other read path now ignores the owner and insert/update keep whatever user_id the row already has. Rows written before this change (legacy owner ids, which the change explicitly does not migrate) are therefore invisible to upsertExact's dedup check, so the same normalized_user_text_hash will be inserted a second time instead of updated. Drop the owner predicate here (match on the hash only) to keep dedup consistent with the new ownership model.

💡 Suggested Change

Before:

    ).get(DEVICE_MEMORY_OWNER, hash) as UserMemorySqlRow | undefined;

After:

    ).get(hash) as UserMemorySqlRow | undefined;

35. Memory/src/storage/repositories.ts (L6089-L6090)

In the no-marker branch, when no device-owned memory holds a value, the fallback is every non-device memory of every shared project alias, and the values below are joined with "\n\n" into a single field. For an upgraded DB that can concatenate several historical owners'/aliases' differing project_contract/project_environment_profile values into one prompt, and the same merged string is then used as the expectedFieldHash/expectedCurrentProfile base for commit checks. Consider selecting a single canonical memory (e.g. the one for deviceProjectId, else newest) rather than overlapping all historical rows.


36. Memory/src/storage/repositories.ts (L6078-L6079)

effectiveFields is now on hot paths (fields() for model input/context injection, applyTraceTarget, applyScan, applyProfile), but it rebuilds work per call and per field: a new runtime_kv statement is prepared inside the 4-iteration loop, sharedProjectIdentity issues several queries per call, and fieldsFromL3WorldModelMemory(memory) re-parses the same memory's JSON twice per field (once in the device filter, once in the value loop). Prepare the marker statement once outside the loop and derive each memory's fields once (e.g. memories.map(m => ({memory: m, fields: fieldsFromL3WorldModelMemory(m)}))) before iterating fields.


37. Memory/src/storage/repositories.ts (L6832-L6833)

This rewrite changes noop semantics: the old guard (!emptyNoop && (nextProfile !== null || existingMemory)) always persisted a non-null effective profile. Now, for noop with a non-null effective profile, the write only happens if the local device scope memory exists and already holds a non-null profile. When the effective profile comes from a shared-alias or legacy scope memory (or the local memory is absent), upsertField is skipped, yet the state row below is still updated with applied_scan_id = input.scanId. The memory actually carrying the value keeps a stale project_environment_applied_scan_id, and effectiveFields' gate (applied?.applied_scan_id !== scanId) then drops the profile from context. Also, the nested condition needs a comment explaining why noop is a no-write in the other cases.

💡 Suggested Change

Before:

      if (input.operation !== "noop" || (!emptyNoop && existingMemory &&
          fieldsFromL3WorldModelMemory(existingMemory).projectEnvironmentProfile !== null)) {

After:

      // Mirror any non-null effective profile into the local scope memory so the
      // applied scan id stays consistent with the memory that carries the value.
      if (input.operation !== "noop" || (!emptyNoop && nextProfile !== null)) {

38. Memory/src/storage/repositories.ts (L7885)

buildMemoryWhere now calls the service-layer helper sharedProjectIds(this.db, projectId), i.e. a multi-query workspace-identity resolution plus extra IN (...) parameters on every memory read (list/count/search/vector/recall). This also inverts the layering (storage importing ../service/namespace/*) and invites a service↔storage import cycle. Consider precomputing the project alias set once per request and passing it in MemoryFilter (e.g. workMemoryProjectIds), keeping storage free of service imports.


39. Memory/src/service/read-model/panel-read.ts (L374)

input.userId is now dead: the method still declares input: RequestEnvelope & { userId?: string }, and callers such as viewer-api.ts (panelOverviewSummary({ ...envelope, userId })) still pass a real account id, but it is silently ignored. The userId local also shadows the semantic of the request's user id, which makes the device-wide scope easy to misread. Suggest inlining the constant and dropping the unused userId from the signature/documenting that the summary is device-scoped.

💡 Suggested Change

Before:

    const userId = DEVICE_MEMORY_OWNER;

After:

    return {
      counts: {
        userMemories: this.deps.repos.userMemories.countForPanel({ userId: DEVICE_MEMORY_OWNER }),

40. Memory/src/service/worker/job-handlers.ts (L785-L790)

The namespace-key format (tenantId:projectId??workspaceId:source:profileId) is now duplicated verbatim in five modules (this file, embedding-job-processor.ts, memory-service.ts, feedback-experience.ts, read-model/skill.ts). Because memory_change_log.namespace_id grouping and the namespaceIdFromMemory(...) !== namespaceIdFromContext(...) scope checks depend on all copies producing byte-identical keys, any future field change must be applied in every copy or change-log rows will silently split into different namespaces. Consider exporting a single namespaceIdFromContext(namespace) from the namespace module and reusing it in all five places.


41. Memory/src/service/user-memory/user-memory.ts (L65)

After this change input.userId is never read anymore (only DEVICE_MEMORY_OWNER is written), so the userId property of the buildUserMemory input becomes dead/misleading: callers such as session-turn-service.ts (twice) and embedding-job-processor.ts still pass userId: rawTurn.userId / sourceMemory.userId believing it is honoured. Also note that Repositories.userMemories.insert() already forces userId: DEVICE_MEMORY_OWNER (repositories.ts:1580), so this override is duplicated in two places. Suggest dropping userId from the input type (and its call sites/tests) so the device-owner contract is expressed once, or at minimum documenting here that the caller-provided owner is intentionally ignored.


42. Memory/src/service/work-memory/work-memory-pipeline.ts (L432)

sharedProjectIds(...) is invoked inside the filter callback, so it re-runs for every candidate memory row (each call issues several sessions / l3_world_model_scopes queries and JSON parses). Compute the alias set once per retrieval, before the candidates.map, (and skip it entirely while filter.workMemoryProjectId is undefined) and reuse it. Note also that filter.workMemoryUserId is no longer enforced here, so passing it around is misleading.

💡 Suggested Change

Before:

      return { candidate, matches: memories.filter((memory) => isWorkMemoryInScope(memory, filter, sharedProjectIds(this.deps.repos.db, filter.workMemoryProjectId ?? null))) };

After:

      return { candidate, matches: memories.filter((memory) => isWorkMemoryInScope(memory, filter, projectIds)) };

43. Memory/src/service/work-memory/work-memory-pipeline.ts (L347)

Reassigning the input parameter silently discards the userId supplied by every caller (session.userId / batch.userId), which makes the signature misleading and hides the owner switch. Prefer a dedicated local (e.g. const ownerId = DEVICE_MEMORY_OWNER;) used in the dedupe/scope hashes and job.userId, and add a short comment explaining that local memory is account-independent.

💡 Suggested Change

Before:

    input = { ...input, userId: DEVICE_MEMORY_OWNER };

After:

    // Local work memory is account-independent; historical owners stay untouched.
    const ownerId = DEVICE_MEMORY_OWNER;

44. Memory/src/service/work-memory/work-memory-pipeline.ts (L324)

With the rawTurn.userId comparison removed, scope.userId is no longer read anywhere in qaForRawTurn (nor in the takeWindow scope object that feeds it), yet callers still pass session.userId / batch.userId. Consider narrowing the scope type to { sessionId: string } so the unused field doesn't suggest the turn is still user-scoped.

💡 Suggested Change

Before:

    if (rawTurn.sessionId !== scope.sessionId) {

After:

  private qaForRawTurn(
    rawTurnId: string,
    scope: { sessionId: string }
  ): WorkMemoryQaPair | undefined {
    const rawTurn = this.deps.repos.runtime.getRawTurn(rawTurnId);
    if (!rawTurn || rawTurn.deletedAt || rawTurn.redactedAt) return undefined;
    if (rawTurn.sessionId !== scope.sessionId) {

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

Generated by cloud-assistant via Open Code Review.

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

Comment thread Memory/src/storage/repositories.ts Outdated

listActive(userId: string, limit = 2000): UserMemoryRecord[] {
private activeWhere(userId: string, options: UserMemoryRecallOptions = {}) {
const clauses = ["user_memories.user_id = ?", "user_memories.status = 'active'", "user_memories.deleted_at IS NULL"];

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 连原账号也无法召回

问题

升级前已保存的 User Memory(用户偏好、事实等),升级后仍能在记忆面板看到,但正式搜索无法召回;即使用原来写入它的账号查询也一样。补充测试保留历史行的 user_id=account-a,并显式开启 enableUserMemoryRecall,通过 service.search() 查询同一内容:指定 base 返回该记忆,新 head 返回空结果。新增的历史跨账号测试也在此处失败。这个问题发生在 User Memory 召回开关开启时,开关关闭时 Agent 不收到 User Memory 是预期行为;修复此底层查询问题不代表要打开开关或撤销关闭设置,而是保证开关以后开启时能够正常召回。

原因

请求的账号已在 namespace 归一化时替换为固定设备 owner,而这里的 activeWhere() 仍要求 user_memories.user_id 等于传入 owner。历史行按本 PR 的约定保留旧 UID,因此全部被过滤。这个条件同时影响有效数量统计、普通列表、全文检索、关键词检索和向量检索;后续分页补取无法补回已经被查询条件排除的行。记录没有被删除,是读取规则没有完成 UID 解耦。

建议做法

让 User Memory 的所有召回入口统一使用设备共享的读取规则,移除账号 UID 过滤,同时保留有效状态、删除状态、来源范围、排除回合及向量模型匹配等条件。不要通过批量改写历史 UID 修复。补充正式搜索的旧库升级/重启测试,覆盖原账号、另一账号和 API Key 模式,以及全文、关键词、向量三条补取路线;修复后跑通当前失败的历史保留与补取测试。

});
// A read projection only: retain each historical record and its ordering.
// Never seed/merge the writable device scope with these historical fields.
const field = (key: keyof typeof records[number]["fields"]) => {

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.

修改或清空旧 L3 规则后,Agent 仍会收到旧规则

问题

例如,v9 历史项目规则是 Run tests。升级后把它改成 Use pnpm test instead of npm test,Agent 实际收到的上下文却同时包含旧规则和新规则;再清空该字段,Agent 又会收到旧的 Run tests。补充测试直接读取实际 L3 上下文:更新后预期只有新规则,head 返回新旧两条;清空后预期为空,head 返回旧规则。直接更新、执行排队演化任务、更新后清空这三个场景在指定 base 全部通过。这里不是要求删除历史数据库行;问题是旧行保留后,读取层仍把旧值当成当前有效规则。

原因

读取层把历史账号记录和设备记录的同一字段直接拼接,并过滤掉 null,没有表达“新版已覆盖旧版”或“此字段已明确删除”。同时,演化任务读取当前字段时调用的 fields() 只看设备记录,读不到仍会注入 Agent 的历史内容。于是演化可能把旧的 Run tests 当作空字段并按“新建”处理;写入新值后上下文仍拼出新旧两条;清空设备字段时,因 null 没有作为删除标记保存,历史旧值又成为唯一非空值并重新出现。

建议做法

统一演化输入与 Agent 上下文所使用的有效字段规则:没有设备更新时读取经验证的历史字段;设备明确更新或删除后,以该结果覆盖历史字段,区分“尚未写入”和“明确清空”。可以单独记录覆盖/删除状态,保持历史行原值不变。继续保留旧 batch 原始哈希及项目检查;环境画像还需携带其对应的已应用项目环境扫描编号。回归覆盖修订、清空、重启、跨账号读取及旧排队任务,直接断言 Agent 收到的内容。

if (projectId === null) return [null];
const rows = db.prepare(`
SELECT DISTINCT peer.project_id AS projectId
FROM sessions origin JOIN sessions peer

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.

历史记录缺少机器标识时,相同路径会被误认成同一项目

问题

PR 前生成的数据确实有缺少机器标识的可能。目标 base v1.2.6 的 hook 在 Memory 服务未报告支持 v2 时会走旧协议兼容分支,只传 workspacePath,不传 workspaceHostId(App/memmy-agent/src/memmy-memory/hook.ts,第 728、742 行);服务端旧版 session 写入使用清洗后的元数据(Memory/src/service/session/session-turn-service.ts,第 896 行),清洗函数会移除 workspace_host_id(同文件第 4608 行)。因此通过该路径创建的 session 不会保存机器标识。

本次复现没有运行真实导出/导入流程。测试构造的是两台机器曾各自生成不同历史项目编号、但 session 中 workspace_host_id 缺失且 workspace URI 相同的情形。测试 fixture 原先有 host 值,随后专门移除它来模拟缺失;这不能说明真实存量中缺失记录的比例,也不能证明标准导入会丢掉该字段。读取其中一个项目的 L3 时,head 会把另一个项目的规则也读进来,base 只返回本项目规则。机器标识仍存在且能区分两台机器时,该测试没有复现问题。

原因

这段 JOIN 只比较 workspace URI,并使用 SQL IS 比较 host;两个缺失值满足 NULL IS NULL,所以两条记录进入同一别名集合。结果是查询时把两个原本不同的项目范围当成别名一起读取,而不是把数据库中的项目记录物理合并。这里没有校验项目编号是否确实由该历史 owner、workspace 和 host 推导,也没有拒绝冲突证据。后面的 host/hash 校验只会追加别名,不会撤销前面已加入的错误项目。L3 读取和工作记忆范围查询都会使用这个集合。

建议做法

所有别名入口统一采用可验证的项目身份规则:本地路径必须有可靠 host 证据,并验证旧 UID 派生编号与设备编号的关系;缺失、矛盾或无法验证时保留原项目隔离,不因路径相同而扩大读取范围。补充同路径不同机器、双方缺 host、伪造或修改 workspace 元数据、冲突映射和无 session 的历史 scope 测试,同时验证正确的跨账号项目仍可共享。

@Orsted290

Copy link
Copy Markdown
Contributor Author

三条 review comment 已逐项修复,修复提交为 2f64bd5b;同步最新 v1.2.6 并解决冲突后,已推送到 87d5cf3726c79b6ddc0dfcbd3be050f661b6abbd。

1. 历史 User Memory 在召回开启时仍无法召回

  • 移除 activeWhere() 中的账号 UID 过滤,使有效数量统计、普通列表、全文、关键词、向量检索和分页补取统一按设备共享规则读取历史记录。
  • 保留有效/删除状态、排除 ID、排除回合和表达式,以及向量模型/维度匹配等条件。enableUserMemoryRecall 的开关含义保持不变:关闭时不向 Agent 返回 User Memory,开启时能够召回历史记录。
  • 同时修复向量窗口不足 2,000 条、但首批命中被重复来源占满时的提前停止条件,继续补取窗口内剩余候选。
  • 验证覆盖:保留旧 user_id 的历史行,原账号、另一账号、无 UID 和 HTTP API Key 查询;开关开启/关闭;重启;全文、关键词、向量分别作为有效路线补足 20 个唯一来源;归档行排除;无效 token 返回 401、无读取权限的 key 返回 403。

代码:统一读取条件、窗口内继续补取。

2. 修改或清空旧 L3 后仍注入旧规则

  • fields()、Agent 上下文、演化输入和提交校验统一使用 effectiveFields()。每个字段分别区分“尚未写入”“明确覆盖”“明确清空”:尚未写入时读取经验证的历史字段,新值覆盖该字段旧值,明确清空后不再回退到历史值。
  • 使用现有 runtime_kv 独立保存字段覆盖/清空状态和修订号,与新字段写入在同一事务内提交;历史 L3 行保持原值。修订号还能识别模型调用期间“清空后又恢复原值”的并发修改,旧结果被拒绝后从当前有效字段重试。
  • 保留旧 batch 的 owner、scope、原始 payload 哈希、session/项目与证据校验。环境画像按其源记录对应的 applied_scan_id 验证;历史画像的 noop 不会把旧内容复制进设备 scope。
  • 验证直接断言实际 L3 上下文:更新后只保留新规则,清空后为空;覆盖字段独立性、重启、跨账号、新旧排队 batch 演化、并发失效与重试、历史行快照保留,以及画像扫描编号失效和明确清空。

代码:有效字段与独立状态、演化输入和修订号校验、Agent 上下文。

3. 历史记录缺少 host 时同路径项目串读

  • 所有项目别名入口统一先验证身份,再纳入共享集合。本地路径要求有效 host,并校验旧 owner、规范化 URI、host 与旧 UID 派生项目编号/设备项目编号的关系。
  • 缺失、矛盾或无法验证的证据保持原项目隔离;不再因双方 host 都为 NULL 且 URI 相同而扩大读取范围。冲突映射按批查询。
  • 无 session 的历史 scope 也必须能通过可靠 peer host 与旧 owner/hash 验证,才允许关联。
  • 验证覆盖:双方/单方缺 host、错误 host、修改 URI、伪造项目编号、冲突 session 映射、无 session 历史 scope,以及正确跨账号共享和另一台机器隔离。

代码:统一项目身份验证。

测试结果与验收边界

验证范围 结果
同步及冲突解决后的 87d5cf37:向量维护 reader、历史 UID 维护、UID/L3、召回补取和项目画像定向回归 201 项通过,20 个测试文件
87d5cf37:Memory TypeScript 检查、git diff --check 通过
同步前修复提交 2f64bd5b:npm run memory:lint(含 Memory/viewer 类型检查) 通过
2f64bd5b:Memory 全量回归 2,735 通过、2 项原有跳过、1 项工具链失败

全量回归的唯一失败是 build-standalone.test.ts:本地缓存的 pkg-fetch 无法识别要求的 Node v24.22.1。同一失败已在原始 PR 提交 f0b163ef 上独立复现,不将该套件列为全部通过。同步目标分支后保留了新增的异步 reader、分页和按目标集合查询,并验证历史 UID 行能够完成向量维护且不改写原 UID。

新增回归集中在 uid-review-regressions.test.ts,并复跑了既有 UID、L3、召回补取、画像与向量维护测试。

上述验证使用独立 SQLite fixture、隔离的 Memory home 和本地 HTTP 服务;未进行 DMG 安装/真实桌面 GUI 验收、真实模型调用或真实历史库导入导出。缺 host 的复现证明该数据形态下存在串读风险,不据此推断真实导入会丢失 host 或存量发生比例。

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: GIT PREPARATION FAILED

Git preparation did not complete. Tests were not executed; inspect the recorded command evidence.

Details: Executor error: Git clone failed (timeout, attempt 1/2, timeout 300000ms). The command deadline expired; this alone does not identify a network or authentication root cause.
Branch: backport-pr795-v126

const before = this.memories.get(memoryId);
if (!before) throw new Error(`corrupt L3 World Model scope memory: ${memoryId}`);
validateL3WorldModelMemory(before, scope.userId, scope.projectId);
for (const field of L3_FIELD_NAMES) {

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.

删除旧 L3 记录会同时屏蔽其他未删除记录的有效字段

问题

同一项目保留历史 L3 记录,并且升级后已经生成新的设备级 L3 时,删除旧记录会连带清空其他记录的注入内容。例如旧记录只有项目约定,新设备记录保存了独立的领域知识;通过正式 deleteMemory() 删除旧记录后,新记录仍在数据库中,正文和状态都没变,但 Agent 上下文里的领域知识变成了空。相同场景在指定 base 通过、当前 head 失败。

原因

这里不检查被删记录实际包含哪些字段,也不检查哪个记录提供当前有效值,而是把该项目的画像、约定、领域知识全部写成 cleared: true。这些标记按共享项目编号保存,effectiveFields() (line 6081) 看到标记就不再读取任何候选,因此一次按记录 ID 的删除实际变成了整个共享项目的字段清空。

建议做法

让按 ID 删除只影响选中的记录。写清空标记前,逐字段确认当前有效内容是否由该记录提供;来自其他未删除记录的有效值和已有覆盖标记应保留。显式清空字段仍需防止旧内容重新生效,但不能借此覆盖无关字段。补充“旧记录仅有约定、新记录另有知识或画像,删除旧记录后其他字段继续注入”的正式服务测试,并验证重启后的结果一致。

Comment thread Memory/src/storage/repositories.ts Outdated
) {}

getScope(userId: string, projectId?: string | null): L3WorldModelScopeRecord | undefined {
userId = DEVICE_MEMORY_OWNER;

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.

升级前排队的项目环境扫描无法正确完成

关联位置:project-environment-service.ts (line 70)、repositories.ts (line 6834)。

问题

升级前已有排队的项目环境扫描任务时,旧任务和旧 scope 仍保存账号 UID。升级后继续执行该任务,会报 project_environment_workspace_uri_missing,尽管旧 scope 中确实保存了 workspace URI。补充测试从 v9 数据库恢复任务,通过正式 processProfileJob() 执行:base 成功,新 head 失败。这里是生成项目环境画像的目录扫描,不是 agent-source 对话扫描。

原因

processProfileJob() 用旧任务的 UID 读取 scope,但 getScope() 把传入 UID 强制替换为设备 owner,因此找不到历史 scope。只补这个读取也不够:下游 applyProfile() 将新画像写成设备 owner,却仍按旧 UID 更新已应用扫描记录;读取画像时又按画像的设备 owner 校验扫描编号。单独执行画像生成阶段的补充测试已确认:状态变成 clean、正文保存成功,但上下文仍返回空。

建议做法

为旧任务保留可验证的历史 scope 读取,并统一任务执行后的设备级画像与扫描完成记录的关联。旧任务的 owner、项目和扫描编号仍需校验,历史行保持原值;不要只放宽读取校验或批量改写历史 UID。覆盖“升级前排队→升级后完成→Agent 读到画像→重启后仍可读”的完整流程,同时验证过期任务不能覆盖较新的扫描结果。

@Orsted290

Copy link
Copy Markdown
Contributor Author

已补充修复“旧会话恢复后,新建的设备扫描任务被误拒绝”,本次更新包含以下两条修复提交:

  • a6f2c67f:修复按 ID 删除旧 L3 时误清空其他记录字段,以及历史 UID 排队扫描的 scope 读取与设备画像扫描编号关联。
  • 0213c0c4:修正上述返修中新增的 session owner 相等限制。

本次新增修复

历史 session 保留原账号 UID,新扫描任务使用 __memmy_device_memory__,两者不同是合法的升级兼容状态。此前新增的 session.userId === payload.userId 要求会拒绝恢复会话和 token-compaction 创建的设备任务。

这次仅移除该相等要求,保留任务 owner/payload/持久化任务一致性、session 项目、精确 owner 的 scope_key、workspace URI、扫描编号、字段修订号和 worker lease 校验。没有改写历史 session、scope 或 Memory,也没有新增 UID 分支、扫描版本或修改队列排序。

代码:processProfileJob、回归测试。

测试及本地 auto-cr

  • 新增两个正式流程回归:openSession(sessionId) 恢复扫描,以及 completeTurn + l3WorldModelBoundary(token_compaction) 压缩扫描;验证画像与 applied_scan_id、跨账号读取、重启,以及历史记录保持原值。
  • 主任务定向回归:105 项通过;Memory 全量:2,782 通过、2 跳过、1 项既有工具链失败。唯一失败是 pkg-fetch 无法解析 Node v24.22.1,不计为全量通过。
  • npm run lint、Memory 类型检查通过;仓库级 typecheck 仍受桌面模块缺少 Electron 类型依赖影响。
  • 使用本地 auto-cr(review-branch.sh),独立比较 a6f2c67f → 0213c0c4:未发现有证据的新增缺陷。head 14 个文件 152 项通过;父提交同命令 150 项通过。另用完全相同 fixture 对照:head 两项通过,父提交两项均在原 owner 校验处失败。
  • auto-cr 的 Memory/viewer 类型检查、相关依赖构建和 diff check 均通过。执行器退出码为 2,因为本轮按范围未运行全仓检查和远端 CI,不代表整个 PR 全绿。

范围与边界

本次新增修复只处理 session UID 相等限制。另两项本地 CR(删除较新画像后旧任务重新生效、导入旧任务后 enqueue_seq 顺序判断)未在本轮调整。验证使用隔离 SQLite、临时目录真实扫描及模型 mock;未进行真实模型、生产旧库、DMG/GUI 或远端 CI 验收。

请按以上范围复审。

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