Compare commits

..
Author SHA1 Message Date
alvinfeiandCursor fadb705781 fix(core): park writes until a model is active
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:34:04 +08:00
fdbcd9934d fix(web): distinguish scanned and saved imports (#60)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:10:10 +08:00
3171007b3c fix(core): keep initial sync behind bounded history (#59)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:00:54 +08:00
a04fcb50d5 docs: announce MemHub and CLI releases (#56)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:56:32 +08:00
a28ade2faf fix(release): hash Runtime symlinks consistently (#55)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:56:07 +08:00
1c424928a2 fix(cli): trust GitHub release asset redirects (#54)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:49:40 +08:00
kristinhe1111 688878171e chore(release): bump MemHub build number to 13 (#52) 2026-10-18 18:03:08 +08:00
a3f09f4cac release: merge MemHub 1.0.4 development fixes (#50)
* feat(memhub): add model usage audit and cost controls

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): typed source authorization state and managed-key registry ownership

Closes #29

Issue #29 surfaced as "WorkBuddy plugin cannot be uninstalled / source sync
cannot be turned on", but it is two independent failure domains that were both
being reported as a single opaque `desktop_action_failed`:

1. Source sync domain: the UI could not tell whether a source failed to sync
   because the grant was missing, revoked, or because the source identity or
   root path changed. Every failure collapsed into one error, so the UI could
   only say "failed".

2. Host plugin install domain: WorkBuddy shares three registry files
   (`known_marketplaces.json`, `installed_plugins.json`, `settings.json`) with
   the host. MemHub recorded a whole-file hash at install time and then refused
   to uninstall whenever the host (or a reformat) changed any byte anywhere in
   the file, even in entries MemHub does not own.

What changes

Domain errors (new `packages/host-bridge/src/domain-error.ts`)
- Closed allowlist `DOMAIN_CODES`: grant_required, source_identity_changed,
  host_uninstall_modified_managed_value, host_uninstall_unknown_owned_file,
  host_uninstall_needs_review, host_install_journal_invalid,
  host_install_recovery_required, host_permission_storage_unsafe.
- `DomainError` + `domainCodeOf()` so callers stop string-matching on
  `Error.message`. `diagnosticDomainError()` emits only the code and a
  repo-relative safe stack, never message text, so diagnostics cannot leak
  paths or payloads.
- Re-exported from `runtime-control` (host-bridge owns the type to avoid a
  dependency cycle); desktop `observeDesktopAction` now uses
  `domainCodeOf(error) ?? fallbackCode`.

Source authorization state
- `SourceAuthorizationState` enum: ok / missing_grant / revoked_grant /
  source_identity_changed / source_root_unavailable / candidate_changed, plus
  `SourceGrantStore.authorization_state()` which is read-only and fails closed.
- `/api/v1/sources` now returns `authorization_state` per source; contracts and
  the web app render a concrete next action next to the sync switch instead of
  a generic failure.
- `_change_source_sync` distinguishes sync_prepare_failed,
  sync_config_write_failed, sync_resume_failed, sync_watcher_failed (409 with
  the code as detail) while keeping the existing rollback behaviour.
- Capture refuses silently but now records a fail-open rejection marker
  (`sync_disabled` / grant_missing / grant_revoked) that counts occurrences in a
  time window and carries no payload, path, or session content.

WorkBuddy registry ownership (journal v1 -> v2)
- Ownership is now the value at MemHub's own key, not the whole file. Added
  `canonicalJson`, `registryManagedDigest`, `registryWithoutOurs`,
  `registryHoldsOnlyOurs`.
- Install: the plan records `keyPath` + managed digest; apply compares the
  managed value (CAS) instead of the whole file; the journal writes both the v1
  `installed_hash` and the v2 `installed_managed`.
- Uninstall: `resolveRegistryOwnership()` compares the v2 digest, falls back to
  the v1 hash and `isManagedRegistryEntry`, and otherwise produces a review
  finding instead of throwing. `managedRegistryRemoval()` deletes a file MemHub
  created when it still holds only our entry; removes just our key when the host
  has added its own content; and restores recorded bytes only when the rest of
  the document is unchanged, otherwise patches the current document.
- v1 journals migrate: a whole-file hash mismatch no longer blocks uninstall.

Tests
- `packages/host-bridge/src/domain-error.test.ts` (5): code is carried, message
  never reaches diagnostics, no guessing on unclassified errors, refuses to
  parse codes out of free text, allowlist is closed.
- `workbuddy-plugin-installer.test.ts` (34 total): uninstall survives a host
  reformat, removes only our key when the host changed something else, asks for
  review when a managed entry was changed, migrates a v1 journal, keeps a
  registry the host now uses, installs over a post-plan change.
- `apps/core/tests/test_source_authorization_state.py` (12): every state,
  fail-closed ordering, capture rejection names, marker contains no secret or
  path, recording never raises.
- `apps/web`: authorization note rendered for revoked grant and hidden when ok.

Verification
- host-bridge WorkBuddy suite 34/34; domain-error 5/5.
- apps/web 1906 tests across 105 files pass.
- pnpm -r typecheck clean; biome check clean on touched files.
- core-fast: 232 failed / 4131 passed, identical failure count to the clean
  baseline (232 failed / 4119 passed) with +12 from the new tests; the failures
  are pre-existing and unrelated (confirmed by stashing and re-running).

* feat(memhub): add a start/stop switch for model sedimentation

Shipped builds gave the user no way to stop model calls: the model content
permission switch is gone (removed with its endpoint in #20) and there is no
pause or remove entry either, so a configured model keeps turning
conversations into memories and keeps costing money.

Adds a user switch in settings: stopping keeps the model configuration and
the already imported conversations, leaves queued work pending instead of
failing it, and stops new work, enqueueing and backfill so nothing further
reaches the model. Starting again resumes the same work without re-entering
a key.

The flag lives in Core's own user config (config.json), so no schema
migration is needed, and the supervisor takes it as an optional callback so
every existing caller and test keeps its current behaviour.

* test(desktop): keep host-bridge mocks whole and assert the review contract

The memhub CI job failed four desktop tests after the issue #29 change. Both
were test-side contracts that the new ownership model intentionally moves, not
regressions in the fix itself.

1. `host-control-ownership.test.ts` mocked `@one-memory/host-bridge` with a
   whitelist that carried only `RecallBroker`. Main now imports `domainCodeOf`,
   which runtime-control re-exports from host-bridge, so the mock handed back
   undefined and every error path that called it threw a TypeError instead of
   the error the test was asserting on. The mock now spreads the real module
   and overrides only `RecallBroker`, which is the part it was written for.

2. `host-permissions.test.ts` asserted the pre-#29 contract, where a change
   anywhere in a shared registry made uninstall throw
   `host_uninstall_modified_files`. Ownership is now the value at our own key,
   so a change to that value is reported instead of guessed at: the plan
   schedules no removal, names the file in a review finding, and applying it
   refuses with `host_uninstall_needs_review`. The test keeps the guarantee it
   existed for, that those bytes are never overwritten, and now also asserts
   that apply refuses, so the safety is checked at both steps.

Verification: full desktop suite 38 files / 436 tests pass, `pnpm -r typecheck`
clean, biome reports only two pre-existing warnings on lines this change does
not touch.

* fix(logging): keep info diagnostics actionable

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(memhub): register the sedimentation switch test in the core test inventory

New core test files must appear in TEST_INDEX.md and in the classification
inventory's expected count and digest, and the backfill case needs a settled
turn because an unanswered user tail is not backfill material.

* feat(diagnostics): expose build info and fullscreen logs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(settings): align help controls and tighten spacing

Co-authored-by: Cursor <cursoragent@cursor.com>

* release: prepare MemHub 1.0.4 build 8

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): clear pre-existing dev lint errors and flaky SDK dedup tests

Three things blocking CI that came in with dev and are unrelated to the
sedimentation switch itself:

- biome errors in DebugLogPanel.tsx (import order) and ModelUsageView.tsx
  (refreshToken is an intentional refresh trigger, annotated like the
  existing modelVersion trigger in R6Settings).
- test_extractor_prompt_override.py's fake LLM now accepts keyword args the
  real pipeline passes since the usage audit landed (audit_stage).
- the fake embeddings in the intention dedup tests used the process-random
  hash(), so two different texts collided roughly one run in 32 and were
  misread as duplicates; switched to a deterministic md5 index.

* fix(cli): count recall characters as code points

A recall whose hits contain an emoji outside the BMP was reported as
response_invalid even though the data and the search were fine: the CLI
cross-checked the response by summing String.prototype.length, which counts
UTF-16 code units, against Core's used_characters, which Python's len()
counts in code points. One emoji is two units here and one character there,
so any hit set with an emoji failed the check.

The sibling validator in host-bridge (context-protocol.ts) already counted
code points, so the CLI was the odd one out rather than a deliberate choice.

- validateContextResponse now sums through characterCount(), which counts
  code points, matching Core.
- Two regression tests: an emoji hit whose used_characters follows code
  points must stay grounded, and the same response counted as UTF-16 units
  must still be refused, so the cross-check is not simply loosened.
- The contract now states used_characters counts Unicode code points and is
  not an encoding length, so a client in another language does not repeat
  the comparison.

Verified by reverting the source change: the first test fails on the old
implementation and passes with it.

Closes #30

* fix(web): keep the model recovery entry reachable while a probe is pending

Closes #40

A pending model probe fenced the configuration editor, and the only
recovery entry ("核对测试结果") lived inside the area the fence kept
shut. Reaching it required opening the form; opening the form required
having recovered. People with a stuck probe could not change or repair
their model at all.

The fence itself was doing its job and still is. What was wrong is that
refusal also hid the way out.

R6Settings
- openConfiguration() now opens the model area whether or not the editor
  accepts, so the recovery entry inside it is reachable. editProfile()
  and add() still return false while the fence is up; nothing about the
  protection changed.
- The refusal notice is reworded to match: the configuration cannot be
  changed yet, and the next step is down in the area that just opened.
  Saying "cannot be opened" was both untrue now and unhelpful before.

ModelSettings
- A refused open is remembered instead of dropped. Once the fence clears,
  the intent is replayed, so the person lands in the form they asked for.
  Without this they would get a blank new-configuration form the moment
  the probe reconciled, which invites a duplicate entry.
- Replaying only fills fields in. It never sends a request, so no test is
  re-sent implicitly; "核对测试结果" remains the only thing that does.

Tests
- R6Settings: with a current model and without one, the recovery entry is
  reachable while the editor is fenced, and the refusal is still reported.
- ModelSettings: after reconciling, the form reopens on the profile that
  was asked for, and the probe is sent exactly once.
- Both new R6Settings cases and the ModelSettings case fail on the
  previous code.

* perf(runtime-control): decide runtime reuse before staging the payload

Every App launch copied the whole 373MB bundle into a temp directory and
hashed it twice before asking whether the selected runtime could be reused.
On a machine that already runs this build the answer is yes and all of that
work is thrown away: measured on a release Mac bundle, 6.9s to copy and
about 3.8s of hashing (fileDigests, then verifyInstalledDistribution over
the same tree).

installPackagedRuntime now describes the payload in place first. The staged
tree is a byte for byte copy of resources/{core,host-bridge,runtime,web}
plus two generated launchers, so the same relative paths and digests can be
read without copying anything. reuseSelectedSameBuild takes that description
instead of a staged directory, and only a payload that is going to be
installed is ever staged.

The decision itself is unchanged: it still compares every file digest except
bin/node, still repairs the packaged launcher when the App moved, and still
raises installed_version_conflict or distribution_hash_mismatch exactly as
before. Nothing was replaced by a cheaper fingerprint, because a wrong reuse
is what those errors exist to prevent.

Hashing also runs through a small worker pool now; the digests are identical
and the manifest keeps its sorted key order.

New test: describeBundlePayload must equal the manifest the staged copy
produces, so moving the decision ahead of the copy cannot decide something
different. Verified read-only against the installed release bundle:
1534 files, identical file sets, zero digest mismatches.

* perf(desktop): raise a startup window before the runtime and Core come up

Opening the App currently shows nothing at all until initialize finishes.
On this machine that is 55.6s from desktop.initializing to desktop.ready
(about 19s installing and starting the managed runtime, then about 36s for
Core to become ready), and window.requested is logged after desktop.ready.
A launch that slow with no window reads as a launch that failed, and people
click the icon again.

startDesktopLifecycle now takes two optional hooks. showStartupWindow runs
before initialize and loads a placeholder; revealStartupWindow runs after
initialize and swaps that same window onto the real app. Because the window
is reused rather than replaced, there is no second window and no flash.

The placeholder is served from the app protocol as /__startup, in memory,
not as a file in the web bundle. It performs no IPC: it renders before
initialize has registered a single handler, so anything it asked for would
fail and leave the page in an error state for the whole wait. That route is
also reserved, so a file of the same name inside the bundle can never be
reached through it.

Raising the placeholder is best effort. It only fills a wait that would
otherwise be blank; failing to draw it must not stop the services or the
real window.

Tests: five lifecycle cases cover the ordering (placeholder before
initialize, reveal after, reuse of the same window, nothing for a background
launch, and a real window when the placeholder was closed or could not be
raised). The four suites that mock ./app-protocol now spread the real module
instead of whitelisting it, which is what broke when the protocol gained
exports. host-control-ownership gains the BrowserWindow methods the
placeholder path uses, and its "no window" assertion now names the
placeholder, since a placeholder window is expected there by design.

* fix(core): stop the sedimentation switch failing its own request

Turning the switch off answered "停止未能确认,模型可能仍在整理记忆,请重试"
every time. The value was saved and the error was a lie the endpoint told
afterwards: `put_sedimentation` logged `memory_sedimentation.toggled` with
`details={"enabled": ...}`, `enabled` is not in the diagnostic allow-list, and
`_details` raises. That ValueError escaped the endpoint, so the request is
recorded in this machine's log three times as `api.request.failed` /
`core.request.unhandled` with `diagnostic_detail_key_invalid`, and the UI took
the 500 as an unconfirmed stop.

Because the raise sits between the save and the resume, starting again was
broken the same way: `resume_allowed()` never ran, so work parked by a stop
stayed parked.

Three changes, in the order they matter:

1. `enabled` is now a legal detail key. It is a bounded boolean, the same
   shape as `spawned` and `index_complete`.
2. `diagnostic_event` no longer lets a rejected payload reach the caller. It
   already swallowed `OSError` with the comment "Diagnostics must not become a
   new product outage"; an unlisted key is the same failure seen from the
   caller, and it is exactly what turned every external recall into a 500 and
   then killed the process last time. The payload is still wrong and still
   caught in CI — now by every module rather than five.
3. `test_diagnostic_payload_keys.py` walks every module instead of a
   hand-picked list. The two payloads that caused real outages were in files
   nobody thought to add to that list; the only shape of the check that holds
   the line is "every module, always". This file was already failing on dev
   with `api.py:3673: unlisted details key 'enabled'` — the bug was known to
   the suite and shipped anyway.

`DiagnosticLog.write` still raises, so the rejection itself is unchanged and
still tested; only the callers are protected.

* fix(core): let the drain claim a manual note's job

A memory added by hand stayed on "原话已保存,等待整理" indefinitely, with
`task_id: null`: the note's durable job was never claimed, so no task was ever
created for it. On this machine the job is still `pending` with `attempts=0`
more than eight hours after it was saved, while the write gate admits its
source without complaint.

The drain had two different answers to one question. `source_writes_permitted`
is `_source_writes_on`, which admits `one-memory-manual` by its grant. The
claim gate was `_config_store(app).sync_enabled`, the per-app toggle, and
`one-memory-manual` is not a connected app: it never appears in the
configuration document, so that predicate answers False for it. The job
survived the `enabled_sources` prefilter and was then dropped row by row, on
every tick, forever. Nothing ever failed and nothing ever ran.

The claim gate now asks the same question the write gate asks. Admission is
one decision; giving it two predicates is what parked the work.

Two tests pin it. One builds the drain as the composition root does and shows
the job is claimed, and shows the shipped wiring stranding the same job, so the
regression is visible if the toggle comes back. The other records the
asymmetry itself: `_source_writes_on` is True for the manual source while
`sync_enabled` is False, which is why the pair could disagree at all.

No migration and no reinstall: existing pending jobs are picked up on the next
drain tick.

* fix(memhub): make sedimentation pause stop model dispatch

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(memhub): schedule memory writes by event time

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): answer the sedimentation switch before the backlog walk

Turning "用模型沉淀记忆" back on wrote the choice to the store and then
awaited `resume_allowed()` inside the PUT handler. That call is not a nudge:
it runs maintenance over every unfinished task and then a whole-queue scan,
and the bounded scan waited -- holding the scan lock -- for the queue to drop
under its cohort ceiling. With a real backlog the queue never drops, so the
request never returned: one toggle in the field is still open 20+ minutes
later with no `api.request.completed`, while the renderer gave up at its 30s
timeout and showed "启用未能确认,仍显示上次确认的状态;请重试。" for a switch
that had in fact been saved.

The choice is the durable fact; the walk is follow-up work:

- The handler now stores the choice and returns it, and starts the resume as a
  watched background task (same idiom as the other Core background walks).
  Repeated toggles reuse the running walk instead of stacking.
- A bounded scan now stops at its ceiling instead of sleeping under the scan
  lock waiting for capacity, which also un-starves the periodic scan that was
  queued behind it. It reports `queue_saturated` rather than `no_tasks`, so
  the status stops claiming there is nothing to do while hundreds wait.

Verified on the field log first: the failing toggle is the only request in
the window with `api.request.started` and no `api.request.completed`, and its
trace shows `memory_sedimentation.toggled` succeeded 6ms in at
`pending_count` ~500. New tests fail on the old code (timeout) and pass now.

* fix(core): stop a blank author from erasing a whole conversation

A user turn whose host did not say who wrote it carries
`written_by=unknown`. The write binding's allow-list had every other
role/author pair but not that one, so verifying the context raised
`hy_memory_write_verification_context_scope` and the task ended as
`model_content_outcome_unknown` after two attempts: a memory the model may
well have written, that can never be confirmed, and a conversation that is
never sedimented again.

This is not a rare shape. On this machine 1078 user records across 1002
WorkBuddy conversations are `unknown`, and the failures were 42 per 15
minutes in the field log — every one of them a task reporting
`result_count=0` while 402 sat pending.

The author is missing, not the turn: the record is still inside the same
conversation, workspace and source, which is what the binding actually
checks, and the turn assembly already sent it. Accepting it here puts the
verification back in agreement with what was sent, the same reasoning that
added ("user", AGENT) for sub agent questions.

New test fails on the old code at hy_source_context.py:76 — the same frame
as the field stack — and passes now.

* fix(core): stop an abandoned read reporting a failure nobody owns

`in_read_pool` shields the wrapped worker, so a lane that runs out of budget
gives up on the answer without yanking a query out from under SQLite. The
caller cancels; the read runs on. When such an abandoned read then failed,
nothing awaited its future any more: `shield` drops its inner callback as soon
as the outer future is cancelled, so the inner future finished with an
exception that nobody retrieved. asyncio reports that at GC, and the loop
handler files it as `core.loop.task_exception` -- a red error sitting next to a
lane that had already degraded on its own budget and already said so.

On this machine that is the `raw_context_timeout` pair in the field log: a
Future, not a Task, carrying TimeoutError('raw_context_timeout'), reported
three times in half an hour. The lane result itself was right; only the
diagnostics were arguing with themselves.

Retrieving the outcome on completion keeps the diagnostics honest without
hiding anything: awaiting the read still raises for a caller who waited, which
the second case pins down.

The new case fails on the previous code (it reports the TimeoutError) and
passes now.

* fix(core): one task with no inputs no longer strands every unknown task

`MemoryTaskRunner._request` falls back to `chunk[0]` when no record is a
person-written user turn. A task whose inputs are gone yields an empty chunk,
so that fallback raised IndexError -- an error no caller can read. A reconcile
pass catches domain errors per task and moves on, so IndexError did not skip
the task: it ended the pass, and every unknown task behind it stayed unknown
while the retry backed off 1, 2, 4 ... 128 and gave up.

On this machine 68 of 303 unknown tasks had no inputs, which is exactly the
`reconcile_unknown / error_type=IndexError` backing off in the field log. Those
68 cannot be reconciled either way -- there is nothing to ask HY about -- but
the 235 that can must not be held hostage to them, and this is what stands
between a task whose model write may already have landed and its confirmation.

An empty chunk is now a domain error, so the pass skips the task and continues.
Also folds in one formatting hunk left over in this file from the previous
change, so the formatter is clean again.

The new case fails on the previous code at memory_processing.py:1036 with the
same IndexError the log shows, and passes now.

* chore(memhub): bump release build number to 10

The installer refuses a package whose bytes differ while its build number
stays the same, so the four Core fixes on dev need their own number to be
installable over the 1.0.4 / build 9 package already on the machine.

* fix(core): retire a window whose remaining rows FIFO refuses

The event-time window asked only whether its ten-minute range still held
*some* pending row before deciding the anchor was still active. A pending
row that FIFO holds back is not claimable: it may be blocked by an earlier
sibling of the same Harness whose own event time sits outside the range, and
that sibling can never be claimed from inside this window. The anchor then
stayed pinned forever and the whole queue starved.

Live shape on one machine: the anchor sat on 2026-10-07T03:24Z while the four
pending rows inside it were blocked by 929/930/1243/2347 earlier workbuddy
jobs, the earliest of them dated 2026-03-12. Completions stopped at 09:59 and
3.8k queued turns sat at zero output; restarting does not help because the
anchor is persisted.

The contract (docs/contracts/memory-event-time-scheduling-v1.md) retires a
window once every *eligible* pending/running row in the range has settled or
parked, and re-anchors on the next oldest eligible job. Reuse the claim's own
predicate — free lease, no busy Harness, no earlier unsent sibling — so "the
window is busy" and "the window can still be worked" finally agree. Anchor
selection and in-window selection already used it; only the liveness check
did not.

* fix(core): a refused source context is a failure, not an unknown write

Issue #44: an OpenCode import carried `role=user, written_by=unknown`. The
role/author set now admits it, but the row still never moved: `HyWriteBindings`
is built inside `ingest`, and the invocation marker is recorded *before* that
call, so the refusal arrived with the marker already set. The classifier
checked `hy_invoked` first and parked the task as `unknown` — a state that
means "HY may hold a write we cannot confirm" about a request that provably
never reached HY. The reason stayed off the row, the task could not be
re-driven, and the queue behind it stopped.

`hy_memory_write_verification_context_scope` is raised from that construction
alone, and `_check_source_context` (which does run after the call) raises
`hy_memory_evidence_changed` instead, so the code is unambiguous: nothing was
sent. It joins `PRE_SEND_REFUSAL_CODES`, and both classifiers now test that set
before the marker: the durable path settles `failed` with the marker cleared so
reclaim does not treat a row HY never saw as possibly-sent, and the task path
fails with the code instead of marking unknown.

Regression covers both directions: a pre-call refusal must end `failed` with
`hy_invoked=0`, and a post-call refusal (`authority_invalid`) must still be
`unknown` — the fix narrows the unknown set, it does not widen the failure set.

* chore(memhub): bump release build number to 11

Carries the window starvation fix (dc0ba49) and the pre-send refusal
fix (0df84c0). A new build number keeps the package distinguishable from
build 10 and lets it install over the current one.

* refactor(memhub): simplify durable memory scheduling

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(memhub): bump release build number to 12

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(security): parse diagnostic stack paths linearly

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): sync sedimentation switch status to home (#51)

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kristinhe <kristinhe@tencent.com>
2026-10-18 17:52:59 +08:00
28 changed files with 522 additions and 124 deletions

No files matched your search

+2 -1
View File
@@ -32,7 +32,8 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.3`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.3/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **Released** **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)** (submitted to **ICLR 2027**).
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **Accepted** at **EMNLP 2026**: **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)**!
+2 -1
View File
@@ -32,7 +32,8 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.3`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.3/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **基准与方法发布** 推出长周期与交错式编程记忆评测基准 **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)**(已投稿 **ICLR 2027**)。
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **论文录用** 双系统认知记忆架构论文 **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)** 被 **EMNLP 2026** 接收!
@@ -12,7 +12,6 @@
* It is generated rather than hand-written so the two cannot drift: this reads
* the real package.json and only strips what is provably not needed at runtime.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
@@ -20,12 +19,14 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "..");
const repositoryRoot = resolve(root, "../..");
const require = createRequire(import.meta.url);
const { resolveBuildVersion } = require("../../../scripts/release-version.cjs");
const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
const { assertSemver } = require("../../../scripts/release-version.cjs");
const pkgPath = resolve(root, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const publishVersion = assertSemver(
process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version,
"MEMHUB_NPM_VERSION",
);
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
throw new Error(
@@ -53,7 +54,7 @@ mkdirSync(outDir, { recursive: true });
const published = { ...pkg };
published.name = publishName;
published.version = releaseVersion;
published.version = publishVersion;
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
if (!published[section]) continue;
published[section] = Object.fromEntries(
+6 -5
View File
@@ -331,7 +331,7 @@ import {
renderVersion,
renderVersionDocument,
} from "./usage.js";
import { CLI_VERSION, PRODUCT } from "./version.js";
import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js";
import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js";
export interface RunControls {
@@ -417,7 +417,8 @@ async function selectInstallSource(
throw new Error("release_manifest_url_unavailable");
}
const manifestUrl =
explicitManifestUrl ?? template.replaceAll("{version}", CLI_VERSION);
explicitManifestUrl ??
template.replaceAll("{version}", RUNTIME_RELEASE_VERSION);
const allowedHosts = parseAllowedReleaseHosts(
environment.MEMHUB_RELEASE_ALLOWED_HOSTS,
);
@@ -462,7 +463,7 @@ async function resolveRemoteInstallPreview(
): Promise<ResolvedRuntimeRelease> {
return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({
manifestUrl: selection.manifestUrl,
expectedVersion: CLI_VERSION,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
@@ -476,7 +477,7 @@ async function acquireRemoteInstallSource(
): Promise<AcquiredRuntimeDistribution> {
return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({
manifestUrl: selection.manifestUrl,
expectedVersion: CLI_VERSION,
expectedVersion: RUNTIME_RELEASE_VERSION,
channel: selection.channel,
platform: process.platform,
arch: process.arch,
@@ -6520,7 +6521,7 @@ function parseInstallOptions(
...(releaseManifestUrl ? { releaseManifestUrl } : {}),
releaseChannel:
releaseChannel ??
(CLI_VERSION.includes("-") ? "prerelease" : "stable"),
(RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"),
installRoot: resolvedInstallRoot,
home: resolvedHome,
uid: uid ?? process.getuid?.(),
+6 -1
View File
@@ -48,7 +48,11 @@ describe("publish manifest is production-safe", () => {
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
cwd: root,
stdio: "pipe",
env: { ...process.env, MEMHUB_NPM_PACKAGE: "@example/memhub-cli" },
env: {
...process.env,
MEMHUB_NPM_PACKAGE: "@example/memhub-cli",
MEMHUB_NPM_VERSION: "1.0.5",
},
});
const published = JSON.parse(
readFileSync(path.join(root, "dist-publish", "package.json"), "utf8"),
@@ -56,6 +60,7 @@ describe("publish manifest is production-safe", () => {
expect(published.dependencies).toBeUndefined();
expect(published.private).toBe(false);
expect(published.name).toBe("@example/memhub-cli");
expect(published.version).toBe("1.0.5");
expect(published.bin).toMatchObject({
memhub: "dist/one-memory.cjs",
"memhub-cli": "dist/one-memory.cjs",
+15 -6
View File
@@ -1,5 +1,9 @@
declare const __ONE_MEMORY_VERSION__: string;
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __MEMHUB_DEFAULT_RUNTIME_VERSION__: string;
const SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const embeddedVersion =
typeof __ONE_MEMORY_VERSION__ === "string"
@@ -9,13 +13,18 @@ const embeddedBuildNumber =
typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
? __ONE_MEMORY_BUILD_NUMBER__.trim()
: "";
const embeddedRuntimeVersion =
typeof __MEMHUB_DEFAULT_RUNTIME_VERSION__ === "string"
? __MEMHUB_DEFAULT_RUNTIME_VERSION__.trim()
: "";
export const CLI_VERSION =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
embeddedVersion,
)
? embeddedVersion
: "development";
export const CLI_VERSION = SEMVER.test(embeddedVersion)
? embeddedVersion
: "development";
export const RUNTIME_RELEASE_VERSION = SEMVER.test(embeddedRuntimeVersion)
? embeddedRuntimeVersion
: CLI_VERSION;
export const CLI_BUILD_NUMBER = /^\d+$/.test(embeddedBuildNumber)
? Number(embeddedBuildNumber)
+14 -2
View File
@@ -2,11 +2,22 @@ import { createRequire } from "node:module";
import { defineConfig } from "tsup";
const require = createRequire(import.meta.url);
const { resolveBuildVersion } =
const { assertSemver, resolveBuildVersion } =
require("../../scripts/release-version.cjs") as {
assertSemver(value: string, label?: string): string;
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
};
const releaseVersion = resolveBuildVersion(process.env);
const packageVersion = (require("./package.json") as { version: string })
.version;
const cliVersion = assertSemver(
process.env.MEMHUB_NPM_VERSION?.trim() || packageVersion,
"MEMHUB_NPM_VERSION",
);
const runtimeReleaseVersion = assertSemver(
process.env.MEMHUB_DEFAULT_RUNTIME_VERSION?.trim() || releaseVersion,
"MEMHUB_DEFAULT_RUNTIME_VERSION",
);
export default defineConfig({
entry: { "one-memory": "src/entry.ts" },
@@ -15,7 +26,7 @@ export default defineConfig({
outDir: "dist",
clean: true,
define: {
__ONE_MEMORY_VERSION__: JSON.stringify(releaseVersion),
__ONE_MEMORY_VERSION__: JSON.stringify(cliVersion),
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
),
@@ -25,6 +36,7 @@ export default defineConfig({
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
),
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(runtimeReleaseVersion),
},
// The shared operations package ships TypeScript sources, so it must be
// inlined into the CJS bundle the same way runtime-control is; otherwise the
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify("unknown"),
__ONE_MEMORY_COMMIT__: JSON.stringify("unknown"),
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(""),
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(version),
},
test: {
testTimeout: 120_000,
+8 -1
View File
@@ -2793,6 +2793,12 @@ def create_app(
# D75: work queued for the previous model continues with this one.
# Only tasks that never reached the model are re-bound.
await asyncio.to_thread(app.state.memory_task_store.rebind_unstarted_profile, profile)
# A missing profile is an event-driven wait, not a provider outage.
# Release those durable rows only now that activation has changed the
# condition; the ordinary drain poll will claim them without a restart.
await asyncio.to_thread(
app.state.memory_write_queue.readmit_model_profile_waiters
)
# Not awaited: the catch-up reads every conversation ever imported, and
# the answer to "which profile is active" does not depend on it.
_start_backfill(app)
@@ -5062,10 +5068,11 @@ def _source_writes_on(app: FastAPI, source_id: str) -> bool:
def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
"""Claim queued model work only while both global and source gates allow it."""
"""Claim queued model work only when policy, source and model all admit it."""
return (
_config_store(app).sedimentation_enabled()
and _model_profile_store(app).active_identity() is not None
and _history_import_gate_released(app)
and _source_writes_on(app, source_id)
)
@@ -282,11 +282,24 @@ class PollingLiveSyncSupervisor:
and job.status
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
]
bounded_history_incomplete = any(
job.source_id == source_id
and job.operation == "history"
and job.history_after is not None
and job.status is not ImportStatus.COMPLETED
for job in source_jobs
)
# Establish the realtime EOF independently even while history owns
# this source. Otherwise the P1 job starts from the P2 checkpoint
# (or from zero) and becomes a second full-history scan.
adapter = self.import_supervisor.adapters[source_id]
await self._live_cursors(source_id, adapter)
live_cursors = await self._live_cursors(source_id, adapter)
# A bounded onboarding history is the authority for everything up
# to its EOF. An adapter without a native live baseline has no safe
# realtime cursor until that history finishes; starting a sync from
# zero here would bypass `history_after` and import the old corpus.
if bounded_history_incomplete and not live_cursors:
return None
# Only one scan of a source may be in flight: they share the
# cursor, and two readers means every file read twice. With a
# catch-up still holding the source there is no free moment to ask
@@ -1404,11 +1404,12 @@ class DurableWriteQueue:
) -> int:
"""Re-admit `waiting_for_model` jobs whose wait has elapsed.
A model outage clears on its own, so this state is retried on a timer
A model outage clears on its own, so transient waiters retry on a timer
rather than waiting for a grant event like `waiting_for_permission`.
Keyed on `updated_at` so repeated calls while a job is still parked do
not reset its own clock; the attempt consumed by the parking claim is
returned, the same way `readmit_parked` does.
`model_profile_not_active` is excluded: only explicit activation can
change that condition. Keyed on `updated_at` so repeated calls while a
job is still parked do not reset its own clock; the attempt consumed by
the parking claim is returned, the same way `readmit_parked` does.
"""
now = now or datetime.now(UTC)
threshold = (now - after).isoformat()
@@ -1417,8 +1418,33 @@ class DurableWriteQueue:
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
"lease_expires_at=NULL, attempts=CASE WHEN attempts>0 "
"THEN attempts-1 ELSE 0 END, last_error=NULL, updated_at=? "
"WHERE state=? AND updated_at <= ? AND hy_invoked=0",
(PENDING, now.isoformat(), WAITING_FOR_MODEL, threshold),
"WHERE state=? AND updated_at <= ? AND hy_invoked=0 "
"AND (last_error IS NULL OR last_error<>?)",
(
PENDING,
now.isoformat(),
WAITING_FOR_MODEL,
threshold,
"model_profile_not_active",
),
)
return changed.rowcount
def readmit_model_profile_waiters(self) -> int:
"""Re-admit no-profile work only after model activation changes the condition."""
now = datetime.now(UTC).isoformat()
with main_database(self.database_path).write() as connection, connection:
changed = connection.execute(
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
"lease_expires_at=NULL, attempts=CASE WHEN attempts>0 "
"THEN attempts-1 ELSE 0 END, last_error=NULL, updated_at=? "
"WHERE state=? AND last_error=? AND hy_invoked=0",
(
PENDING,
now,
WAITING_FOR_MODEL,
"model_profile_not_active",
),
)
return changed.rowcount
@@ -2071,7 +2097,12 @@ async def execute_job(
# Never `logger.exception` here: it prints the exception's message,
# which may be a provider response or the user's own text. Log the
# already-sanitised code instead, matching `hy-failure-classification`.
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
if code == "model_profile_not_active":
logger.debug("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
elif state in PARKED_STATES:
logger.info("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
else:
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
return
await in_the_background(queue.settle, job_id, state=COMPLETED, owner=owner)
+111
View File
@@ -94,6 +94,67 @@ class SettledFixtureAdapter(IncrementalFixtureAdapter):
}
class BoundedOnboardingAdapter:
"""No native baseline: bounded history must establish the live cursor."""
descriptor = IncrementalFixtureAdapter.descriptor
def __init__(self) -> None:
self.include_new = False
async def detect(self) -> bool:
return True
async def scan(self, options: ScanOptions):
current = options.cursors.get("e" * 64)
checkpoint = current.checkpoint if current is not None else None
if checkpoint not in {"byte:10", "byte:20"}:
yield ScannedEvidence(
evidence=EvidenceInput(
source_id="fixture",
conversation_id="old-conversation",
message_id="old-message",
role="user",
written_by=WrittenBy.PERSON,
created_at=datetime(2020, 1, 1, tzinfo=UTC),
verbatim="old history",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
),
cursor=SourceCursor(
file_key="e" * 64,
file_identity="bounded",
checkpoint="byte:10",
checkpoint_anchor="f" * 64,
observed_size=20,
observed_modified_ns=1,
),
)
checkpoint = "byte:10"
if self.include_new and checkpoint != "byte:20":
yield ScannedEvidence(
evidence=EvidenceInput(
source_id="fixture",
conversation_id="new-conversation",
message_id="new-message",
role="user",
written_by=WrittenBy.PERSON,
created_at=datetime(2026, 10, 18, tzinfo=UTC),
verbatim="new append",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
),
cursor=SourceCursor(
file_key="e" * 64,
file_identity="bounded",
checkpoint="byte:20",
checkpoint_anchor="1" * 64,
observed_size=20,
observed_modified_ns=2,
read_to_end=True,
),
)
yield ScanProgressEvent(phase=ScanPhase.DONE)
class HoldingHistoryAdapter:
"""First scan blocks until cancelled; later scans yield one new record."""
@@ -328,6 +389,56 @@ def test_live_sync_does_not_resume_a_history_import_the_user_paused(tmp_path) ->
asyncio.run(scenario())
def test_bounded_onboarding_history_establishes_cursor_before_first_sync(tmp_path) -> None:
async def scenario() -> None:
database = tmp_path / "one-memory.sqlite3"
evidence = EvidenceStore(database)
jobs = ImportJobStore(database)
adapter = BoundedOnboardingAdapter()
imports = ImportSupervisor(
adapters={"fixture": adapter},
evidence_store=evidence,
job_store=jobs,
)
live_sync = PollingLiveSyncSupervisor(
source_ids=("fixture",),
import_supervisor=imports,
job_store=jobs,
)
history = imports.create_job("fixture", operation="history")
with sqlite3.connect(database) as connection, connection:
connection.execute(
"UPDATE import_jobs SET history_after=? WHERE job_id=?",
(datetime(2026, 10, 15, tzinfo=UTC).isoformat(), history.job_id),
)
# With no native baseline, sync must wait instead of scanning from zero
# and bypassing the onboarding history window.
assert await live_sync.trigger("fixture") is None
assert not [job for job in jobs.list_jobs() if job.operation == "sync"]
imports.enqueue(history.job_id)
completed = await imports.wait(history.job_id)
assert completed.status is ImportStatus.COMPLETED
assert completed.stored == 0
assert completed.skipped == 1
assert evidence.count() == 0
# Once bounded history has advanced the cursor to EOF, normal realtime
# appends are admitted without retaining the three-day cutoff forever.
adapter.include_new = True
synced = await live_sync.trigger("fixture")
assert synced is not None
assert synced.status is ImportStatus.COMPLETED
assert synced.stored == 1
records = evidence.list_extractable()
assert [record.verbatim for record in records] == ["new append"]
await live_sync.shutdown()
await imports.shutdown()
asyncio.run(scenario())
def test_a_sync_left_running_after_its_worker_died_is_started_again(tmp_path) -> None:
"""A running row with no live worker used to be returned as 'already syncing'.
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sqlite3
import threading
import time
@@ -1369,24 +1370,30 @@ def test_a_machine_error_code_is_preserved(tmp_path) -> None:
assert safe_error_code(ValueError("some sentence with spaces")) == "ValueError"
def test_an_inactive_profile_parks_for_model_before_any_send(tmp_path) -> None:
def test_an_inactive_profile_parks_without_error_logging(tmp_path, caplog) -> None:
"""A pre-bridge model-not-ready code parks and keeps the intent."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
_claim_through_drain(queue, record.job_id)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(error=RuntimeError("model_profile_not_active")),
evidence=_EvidenceStore({"e1"}),
with caplog.at_level(
logging.DEBUG,
logger="one_memory.memory_server.durable_queue",
):
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(error=RuntimeError("model_profile_not_active")),
evidence=_EvidenceStore({"e1"}),
)
)
)
assert _state(path, record.job_id) == WAITING_FOR_MODEL
assert _rows(path)[0]["hy_invoked"] == 0
assert "durable_write_job_failed" not in caplog.text
assert "durable_write_job_parked" in caplog.text
def test_an_authorization_stop_cancels_instead_of_failing(tmp_path) -> None:
@@ -1546,7 +1553,7 @@ def test_parking_does_not_burn_the_retry_budget(tmp_path) -> None:
assert queue.get("idem-1").state == PENDING
def test_a_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
def test_a_transient_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
"""A model outage clears on its own; nothing re-admits it otherwise.
`waiting_for_permission` needs a grant event, but `waiting_for_model` is
@@ -1564,7 +1571,7 @@ def test_a_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
queue.settle(
record.job_id,
state=WAITING_FOR_MODEL,
error_code="model_profile_not_active",
error_code="model_secret_unavailable",
hy_invoked=False,
)
@@ -1581,8 +1588,8 @@ def test_a_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
assert _rows(path)[0]["attempts"] == 0
def test_readmitting_a_model_waiter_clears_its_stale_detail(tmp_path) -> None:
"""The timer readmission path must clear the old blocking reason too."""
def test_no_profile_waiter_is_readmitted_only_after_activation(tmp_path) -> None:
"""No timer can create a model profile; activation owns this transition."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
@@ -1594,13 +1601,50 @@ def test_readmitting_a_model_waiter_clears_its_stale_detail(tmp_path) -> None:
)
now = datetime.now(UTC)
assert queue.readmit_model_waiters(now=now + timedelta(minutes=2)) == 1
assert queue.readmit_model_waiters(now=now + timedelta(minutes=2)) == 0
parked = queue.get("idem-1")
assert parked.state == WAITING_FOR_MODEL
assert parked.detail == "model_profile_not_active"
assert queue.readmit_model_profile_waiters() == 1
readmitted = queue.get("idem-1")
assert readmitted.state == PENDING
assert readmitted.detail == ""
def test_no_profile_backlog_stays_parked_across_maintenance_cycles(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
for index in range(100):
record = queue.submit(_request(key=f"no-profile-{index}"))
queue.settle(
record.job_id,
state=WAITING_FOR_MODEL,
error_code="model_profile_not_active",
hy_invoked=False,
)
with sqlite3.connect(path) as connection, connection:
connection.execute(
"UPDATE memory_write_jobs SET attempts=1,"
"updated_at='2000-01-01T00:00:00+00:00'"
)
first = datetime.now(UTC)
assert queue.readmit_model_waiters(now=first, after=timedelta(0)) == 0
assert (
queue.readmit_model_waiters(
now=first + timedelta(minutes=2),
after=timedelta(0),
)
== 0
)
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT state,attempts,COUNT(*) FROM memory_write_jobs "
"GROUP BY state,attempts"
).fetchall() == [(WAITING_FOR_MODEL, 1, 100)]
def test_readmit_parked_cannot_resurrect_a_terminal_row(tmp_path) -> None:
"""Only parked states may be re-admitted, never a finished write.
@@ -259,12 +259,17 @@ def test_activation_survives_a_corpus_already_imported(tmp_path) -> None:
),
secrets=secrets,
)
readmissions: list[bool] = []
app.state.memory_write_queue.readmit_model_profile_waiters = (
lambda: readmissions.append(True) or 0
)
activated = await client.post(f"/api/v1/model-profiles/{profile_id}/activate")
assert activated.status_code == 200
assert activated.json()["is_active"] is True
assert API_KEY not in activated.text
assert readmissions == [True]
# Started, not waited for: the walk reads every conversation.
assert not app.state.memory_backfill.done()
@@ -111,6 +111,9 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
app = SimpleNamespace(
state=SimpleNamespace(
config_store=config,
model_profile_store=SimpleNamespace(
active_identity=lambda: ("profile", 1)
),
source_grant_store=SimpleNamespace(
receipt=lambda **_kwargs: SimpleNamespace(revoked_at=None)
),
@@ -125,6 +128,21 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
def test_claim_gate_waits_without_polling_when_no_model_is_active(tmp_path) -> None:
config = ConfigStore(tmp_path)
app = SimpleNamespace(
state=SimpleNamespace(
config_store=config,
model_profile_store=SimpleNamespace(active_identity=lambda: None),
source_grant_store=SimpleNamespace(
receipt=lambda **_kwargs: SimpleNamespace(revoked_at=None)
),
)
)
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
def test_first_import_gate_waits_for_every_selected_history_job() -> None:
state = SimpleNamespace(
status="importing",
@@ -18,7 +18,6 @@ import * as api from "./api";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { queryClient, resetClient } from "./query-client";
import { commitSedimentation } from "./sedimentation-state";
const {
summary,
@@ -701,28 +700,6 @@ it("shows a paused switch immediately even when task rows still say running", as
expect(screen.queryByText("新的对话正在整理")).toBeNull();
});
it("updates an already mounted home as soon as settings commits the switch receipt", async () => {
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("整理中")).toHaveLength(2);
act(() => {
commitSedimentation({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
});
await waitFor(() => expect(screen.getAllByText("已暂停")).toHaveLength(2));
});
it("keeps the import-in-progress empty state stable without a progress link", async () => {
summary.mockResolvedValue({
memory_count: 0,
@@ -174,20 +174,20 @@ it("continues to step3 while sample GET is pending without calling completion AP
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
});
it.each([
"pending",
"paused",
"interrupted",
"failed",
"cancelled",
"completed",
["pending", "已扫描 100 条,正在筛选"],
["paused", "已扫描 100 条,尚未保存"],
["interrupted", "已扫描 100 条,尚未保存"],
["failed", "已扫描 100 条,尚未保存"],
["cancelled", "已扫描 100 条,尚未保存"],
["completed", "已扫描 100 条,本次没有可保存的对话"],
] as const)(
"shows %s without mutating jobs or fetching missing content",
(status) => {
(status, expected) => {
setup({
jobs: [{ ...job, status, stored: 0 }],
sources: [{ ...source, imported_messages: 0 }],
});
expect(screen.getByText("已扫描 100 条,正在保存")).toBeTruthy();
expect(screen.getByText(expected)).toBeTruthy();
expect(api.receipt).not.toHaveBeenCalled();
},
);
@@ -38,6 +38,71 @@ it("uses the selected job's message count, never the source total or fake percen
expect(container.textContent).not.toContain("%");
expect(container.textContent).not.toContain("持续导入中");
});
it("does not describe scanned-but-skipped history as saved", () => {
const { container } = render(
<R6ImportProgress
sourceIds={["cursor"]}
sources={[source]}
jobs={[
{
...job,
status: "completed",
discovered: 12_375,
stored: 0,
skipped: 12_375,
history_after: "2026-10-15T00:00:00Z",
},
]}
/>,
);
expect(
screen.getByText("已扫描 12,375 条,本次没有符合时间范围的对话"),
).toBeTruthy();
expect(screen.getByText("导入完成")).toBeTruthy();
expect(container.textContent).not.toContain("正在保存");
expect(container.textContent).not.toContain("已保存 12,375");
});
it("calls an active bounded scan filtering rather than saving", () => {
render(
<R6ImportProgress
sourceIds={["cursor"]}
sources={[source]}
jobs={[
{
...job,
discovered: 3_522,
stored: 0,
history_after: "2026-10-15T00:00:00Z",
},
]}
/>,
);
expect(screen.getByText("已扫描 3,522 条,正在筛选最近 3 天")).toBeTruthy();
});
it("reports only stored rows as saved after a completed partial import", () => {
const { container } = render(
<R6ImportProgress
sourceIds={["cursor"]}
sources={[source]}
jobs={[
{
...job,
status: "completed",
discovered: 25_177,
stored: 20_826,
skipped: 4_351,
},
]}
/>,
);
expect(screen.getByText("已保存 20,826 条消息")).toBeTruthy();
expect(screen.getByText("导入完成")).toBeTruthy();
expect(container.textContent).not.toContain("25,177");
});
it.each(["paused", "interrupted", "failed", "completed", "cancelled"] as const)(
"does not animate %s as active work",
(status) => {
+19 -5
View File
@@ -3,6 +3,21 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import { AppMark } from "./app-mark";
import "./r6-import-progress.css";
function progressText(job: ImportJob): string | null {
if (job.stored > 0) return `已保存 ${job.stored.toLocaleString()} 条消息`;
if (job.discovered <= 0) return null;
const scanned = `已扫描 ${job.discovered.toLocaleString()} 条`;
if (job.status === "completed")
return job.history_after
? `${scanned},本次没有符合时间范围的对话`
: `${scanned},本次没有可保存的对话`;
if (job.status === "pending" || job.status === "running")
return job.history_after
? `${scanned},正在筛选最近 3 天`
: `${scanned},正在筛选`;
return `${scanned},尚未保存`;
}
export function R6ImportProgress({
sourceIds,
sources,
@@ -86,8 +101,9 @@ export function R6ImportProgress({
interrupted: "已中断",
failed: "导入遇到问题",
cancelled: "已取消",
completed: "已保存",
completed: "导入完成",
};
const progress = job ? progressText(job) : null;
return (
<article
className="r6-import-progress-card"
@@ -102,10 +118,8 @@ export function R6ImportProgress({
</div>
</div>
<h2>{source?.display_name ?? id}</h2>
{job && job.stored > 0 ? (
<p>{`已保存 ${job.stored.toLocaleString()} 条消息`}</p>
) : job && job.discovered > 0 ? (
<p>{`已扫描 ${job.discovered.toLocaleString()} 条,正在保存`}</p>
{progress ? (
<p>{progress}</p>
) : noImportedHistory ? (
<p>没有可保存的历史对话</p>
) : null}
@@ -39,23 +39,3 @@ it("keeps a newer switch receipt when an older read lands later", async () => {
expect(await olderRead).toEqual(paused);
expect(queryClient.getQueryData(["sedimentation"])).toEqual(paused);
});
it("keeps the newest read when concurrent reads finish out of order", async () => {
let finishFirst: ((value: typeof enabled) => void) | undefined;
getSedimentation
.mockImplementationOnce(
() =>
new Promise<typeof enabled>((resolve) => {
finishFirst = resolve;
}),
)
.mockResolvedValueOnce(paused);
const first = refreshSedimentation();
const second = refreshSedimentation();
expect(await second).toEqual(paused);
finishFirst?.(enabled);
expect(await first).toEqual(paused);
expect(queryClient.getQueryData(["sedimentation"])).toEqual(paused);
});
@@ -11,12 +11,8 @@ queryClient.setQueryDefaults(SEDIMENTATION_KEY, {
});
let revision = 0;
let readsStarted = 0;
let readsLanded = 0;
onClientReset(() => {
revision = 0;
readsStarted = 0;
readsLanded = 0;
});
/**
@@ -24,14 +20,11 @@ onClientReset(() => {
* newer PUT receipt committed by the settings page.
*/
export async function refreshSedimentation(): Promise<SedimentationState> {
const order = ++readsStarted;
const startedAtRevision = revision;
const next = await getSedimentation();
const current =
queryClient.getQueryData<SedimentationState>(SEDIMENTATION_KEY);
if (startedAtRevision !== revision && current) return current;
if (order < readsLanded && current) return current;
readsLanded = order;
queryClient.setQueryData(SEDIMENTATION_KEY, next);
return next;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.4",
"release": {
"build_number": 12,
"build_number": 13,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
@@ -312,6 +312,48 @@ describe("remote Runtime acquisition", () => {
});
});
it("allows the exact GitHub Release asset redirect hosts by default", async () => {
const value = await fixture();
const manifestUrl =
"https://github.com/example/project/releases/download/v1.2.3/release-manifest.json";
const artifactUrl =
"https://github.com/example/project/releases/download/v1.2.3/runtime.tar.gz";
const manifestAssetUrl =
"https://release-assets.githubusercontent.com/manifest-download";
const runtimeAssetUrl =
"https://release-assets.githubusercontent.com/runtime-download";
const release = {
...value.release,
artifacts: [{ ...value.artifact, url: artifactUrl }],
};
const temporaryParent = await mkdtemp(
path.join(os.tmpdir(), "memhub-github-release-parent-"),
);
roots.push(temporaryParent);
const acquired = await acquireRuntimeDistribution({
manifestUrl,
expectedVersion: "1.2.3",
channel: "stable",
platform: "linux",
arch: "x64",
temporaryParent,
httpGet: httpFixture({
[manifestUrl]: response(new Uint8Array(), 302, {
location: manifestAssetUrl,
}),
[manifestAssetUrl]: response(Buffer.from(JSON.stringify(release))),
[artifactUrl]: response(new Uint8Array(), 302, {
location: runtimeAssetUrl,
}),
[runtimeAssetUrl]: response(value.archiveBytes),
}),
});
expect(acquired.release.version).toBe("1.2.3");
await acquired.cleanup();
});
it("rejects an archive whose internal commit drifts from the Release", async () => {
const value = await fixture();
const release = { ...value.release, git_commit: "b".repeat(40) };
@@ -368,7 +410,6 @@ describe("remote Runtime acquisition", () => {
);
it("rejects a redirect to an untrusted host", async () => {
const value = await fixture();
await expect(
acquireRuntimeDistribution({
manifestUrl: "https://downloads.example.test/release-manifest.json",
@@ -38,6 +38,10 @@ const SHA256 = /^[a-f0-9]{64}$/;
const COMMIT = /^[0-9a-f]{40}$/;
const SAFE_FILE_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,255}$/;
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
const GITHUB_RELEASE_REDIRECT_HOSTS = [
"release-assets.githubusercontent.com",
"objects.githubusercontent.com",
] as const;
export type ReleaseChannel = "stable" | "prerelease";
export type RuntimeReleasePlatform = "darwin" | "linux";
@@ -330,8 +334,12 @@ export async function resolveRuntimeRelease(options: {
maxRedirects?: number;
}): Promise<ResolvedRuntimeRelease> {
const manifestUrl = trustedUrl(options.manifestUrl);
const defaultHosts =
normalizedHost(manifestUrl.hostname) === "github.com"
? [manifestUrl.hostname, ...GITHUB_RELEASE_REDIRECT_HOSTS]
: [manifestUrl.hostname];
const allowedHosts = new Set(
(options.allowedHosts ?? [manifestUrl.hostname]).map(normalizedHost),
(options.allowedHosts ?? defaultHosts).map(normalizedHost),
);
if (!allowedHosts.has(normalizedHost(manifestUrl.hostname))) {
throw new Error("release_url_untrusted");
+7
View File
@@ -8,6 +8,7 @@ import {
mkdir,
readdir,
readFile,
readlink,
realpath,
rm,
stat,
@@ -320,6 +321,12 @@ async function distributionBytes(source) {
async function sha256File(file) {
const digest = createHash("sha256");
const info = await lstat(file);
if (info.isSymbolicLink()) {
digest.update("symlink\0");
digest.update(await readlink(file));
return digest.digest("hex");
}
for await (const chunk of createReadStream(file)) digest.update(chunk);
return digest.digest("hex");
}
@@ -6,6 +6,7 @@ import {
readdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import os from "node:os";
@@ -121,6 +122,35 @@ test("builds versioned Runtime archives, checksums and a downloader-compatible m
}
});
test("verifies Runtime symlinks with the same identity rule as the installer", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-release-assets-"));
t.after(() => rm(root, { recursive: true, force: true }));
const source = await distribution(root, "linux", "x64");
const link = "memhub";
await symlink(link, path.join(source, "bin", "node"));
const manifestPath = path.join(source, "manifest.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
manifest.files["bin/node"] = sha256(`symlink\0${link}`);
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const release = await buildReleaseAssets({
output: path.join(root, "release"),
version: "0.1.0-internal.1",
channel: "prerelease",
assetBaseUrl: "https://git.example.test/release/downloads/",
distributions: [source],
});
assert.ok(
release.artifacts.some(
(artifact) =>
artifact.kind === "runtime" &&
artifact.platform === "linux" &&
artifact.arch === "x64",
),
);
});
test("refuses version, build and duplicate target drift", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-release-assets-"));
t.after(() => rm(root, { recursive: true, force: true }));
+13 -5
View File
@@ -23,7 +23,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
const statImpl = dependencies.statImpl ?? stat;
const buildEnvironment = {
...environment,
ONE_MEMORY_VERSION: options.version,
MEMHUB_NPM_VERSION: options.version,
MEMHUB_DEFAULT_RUNTIME_VERSION: options.runtimeVersion,
ONE_MEMORY_BUILD_NUMBER: String(options.buildNumber),
ONE_MEMORY_COMMIT: options.gitCommit,
};
@@ -31,7 +32,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
const versionReceipt = await verifyVersion(
[
"--version",
options.version,
options.runtimeVersion,
"--channel",
options.channel,
"--tag",
@@ -43,7 +44,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
);
const releaseReceipt = await verifyRelease({
manifestUrl: options.manifestUrl,
version: options.version,
version: options.runtimeVersion,
gitCommit: options.gitCommit,
channel: options.channel,
platform: options.platform,
@@ -106,7 +107,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
return {
schema_version: 1,
product: "memhub-cli-publication",
version: versionReceipt.version,
version: options.version,
runtime_version: releaseReceipt.version,
build_number: versionReceipt.build_number,
tag: versionReceipt.tag,
release: releaseReceipt,
@@ -196,6 +198,11 @@ function parseArgs(arguments_) {
options.manifestUrl = value;
else if (flag === "--version" && options.version === undefined)
options.version = value;
else if (
flag === "--runtime-version" &&
options.runtimeVersion === undefined
)
options.runtimeVersion = value;
else if (flag === "--git-commit" && options.gitCommit === undefined)
options.gitCommit = value;
else if (flag === "--channel" && options.channel === undefined)
@@ -219,6 +226,7 @@ function parseArgs(arguments_) {
if (
!options.manifestUrl ||
!options.version ||
!options.runtimeVersion ||
!options.gitCommit ||
!options.channel ||
!options.tag ||
@@ -230,7 +238,7 @@ function parseArgs(arguments_) {
!options.npmTag
) {
throw new Error(
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <CLI SemVer> --runtime-version <Runtime SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
);
}
return options;
@@ -6,6 +6,9 @@ import test from "node:test";
import { publishCliAfterRelease } from "./publish-cli-after-release.mjs";
const CLI_VERSION = "0.1.1";
const RUNTIME_VERSION = "0.1.0-internal.1";
async function fixture(t) {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-publish-gate-"));
t.after(() => rm(root, { recursive: true, force: true }));
@@ -15,10 +18,10 @@ async function fixture(t) {
await mkdir(output, { recursive: true });
await writeFile(
path.join(publish, "package.json"),
`${JSON.stringify({ name: "@example/memhub-cli", version: "0.1.0-internal.1", private: false })}\n`,
`${JSON.stringify({ name: "@example/memhub-cli", version: CLI_VERSION, private: false })}\n`,
);
await writeFile(
path.join(output, "example-memhub-cli-0.1.0-internal.1.tgz"),
path.join(output, `example-memhub-cli-${CLI_VERSION}.tgz`),
"tarball",
);
return root;
@@ -26,10 +29,11 @@ async function fixture(t) {
const options = {
manifestUrl: "https://releases.example.test/release-manifest.json",
version: "0.1.0-internal.1",
version: CLI_VERSION,
runtimeVersion: RUNTIME_VERSION,
gitCommit: "a".repeat(40),
channel: "prerelease",
tag: "v0.1.0-internal.1",
tag: `v${RUNTIME_VERSION}`,
buildNumber: 1,
platform: "linux",
arch: "x64",
@@ -49,15 +53,24 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
{
verifyVersion: async () => {
events.push("version");
return { version: options.version, build_number: 1, tag: options.tag };
return {
version: options.runtimeVersion,
build_number: 1,
tag: options.tag,
};
},
verifyRelease: async (verifyOptions) => {
events.push("release");
assert.equal(verifyOptions.gitCommit, options.gitCommit);
return { version: options.version, sha256: "a".repeat(64) };
assert.equal(verifyOptions.version, options.runtimeVersion);
return { version: options.runtimeVersion, sha256: "a".repeat(64) };
},
runCommand: (command, arguments_, commandOptions) => {
assert.equal(commandOptions.env.ONE_MEMORY_VERSION, options.version);
assert.equal(commandOptions.env.MEMHUB_NPM_VERSION, options.version);
assert.equal(
commandOptions.env.MEMHUB_DEFAULT_RUNTIME_VERSION,
options.runtimeVersion,
);
assert.equal(
commandOptions.env.ONE_MEMORY_BUILD_NUMBER,
String(options.buildNumber),
@@ -74,6 +87,8 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
assert.match(events[3], /--access public/);
assert.match(events[3], /--provenance/);
assert.equal(receipt.npm_package, "@example/memhub-cli");
assert.equal(receipt.version, CLI_VERSION);
assert.equal(receipt.runtime_version, RUNTIME_VERSION);
assert.equal(receipt.dry_run, true);
assert.equal(receipt.npm_access, "public");
assert.equal(receipt.provenance, true);
@@ -87,7 +102,7 @@ test("never builds or invokes npm when the Runtime release is unavailable", asyn
{ ...options, root },
{
verifyVersion: async () => ({
version: options.version,
version: options.runtimeVersion,
build_number: 1,
tag: options.tag,
}),
@@ -115,11 +130,11 @@ test("rejects a package identity that differs from the configured publish name",
},
{
verifyVersion: async () => ({
version: options.version,
version: options.runtimeVersion,
build_number: 1,
tag: options.tag,
}),
verifyRelease: async () => ({ version: options.version }),
verifyRelease: async () => ({ version: options.runtimeVersion }),
runCommand: () => undefined,
},
),