Skip to content

feat(storage): activate Postgres and Azure profiles with async agent persistence - #185

Merged
cxxxxxn (cxxxxxn) merged 13 commits into
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-6
Sep 21, 2026
Merged

cxxxxxn (cxxxxxn) merged 13 commits into
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-6

Conversation

@ultmaster

@ultmaster Yuge Zhang (ultmaster) commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Migrates Agenetes thread, event, and turn persistence to async operations and activates Postgres and Azure Blob across the application. With #184's adapter foundation merged, every pairing of implemented backends becomes selectable — six of them, including Postgres records with Azure Blob bytes.

Which six. Structured records and blob bytes are independent configuration axes (proposal §6.3): records go to the structured backend, bytes go to a file system, and the two share nothing. So the profiles are the product of the two axes, not a list of blessed combinations — sqlite records with disk bytes is an ordinary profile, not a special case:

HUABU_STRUCTURED_BACKEND HUABU_BLOB_BACKEND Also needs Loses
disk (default) disk (default) — nothing
disk azure connection string + container the 4 byte-plane features
sqlite disk — all 9 folder features
sqlite azure connection string + container all 9
postgres disk HUABU_POSTGRES_URL all 9
postgres azure both of the above all 9

How to select one. Two variables, each defaulting to disk, so an existing deployment that sets neither keeps today's behavior exactly:

# Postgres records, Azure Blob bytes
HUABU_STRUCTURED_BACKEND=postgres          # disk | sqlite | postgres
HUABU_BLOB_BACKEND=azure                   # disk | azure
HUABU_POSTGRES_URL=postgres://user:pw@host:5432/huabu
HUABU_AZURE_STORAGE_CONNECTION_STRING=...  # required for azure
HUABU_AZURE_BLOB_CONTAINER=huabu           # required for azure
HUABU_AZURE_BLOB_PREFIX=huabu              # optional, single path segment

.env.example documents every one of these, each with what breaks without it, and records two operational constraints that previously lived only in code comments: one Server per Postgres database, because the locking is not distributed admission; and that the Azure container is the operator's to create and keep private, since deleting a Space sweeps only that Space's own keys.

Optional per-backend overrides: HUABU_SQLITE_PATH (default <HUABU_DATA_DIR>/storage/sqlite/huabu.sqlite) and HUABU_BLOB_ROOT (default <HUABU_DATA_DIR>/storage/disk/blobs, and never built at all when Disk keeps the records, because the bytes then live inside the Space folder the user can see).

An unknown value on either axis fails startup by name rather than falling back, and a profile that names a backend without its configuration fails the same way before any connection opens — Postgres requires HUABU_POSTGRES_URL, Azure blobs require HUABU_AZURE_STORAGE_CONNECTION_STRING and HUABU_AZURE_BLOB_CONTAINER.

What a non-Disk profile gives up. Nine product features need a Space or Workspace to be a real directory on this machine; four of them additionally need the Space's bytes to be in that directory. They are declared in capabilities.ts, reported at startup, and refused at their own call sites with a sentence rather than a stack trace — unavailable, not emulated (proposal §6.4.2, disposition A). Structured-axis features: reveal a Space's folder, adopt Markdown dropped in from outside, choose/create/reveal a Workspace folder, the cross-Space user memory document, and user-authored Workspace skills. The four that also need Disk bytes: bundle export, bundle import, the built-in agent file tools, and reaching a Space as files over RFS — the plane external agents mount.

This PR is the implementation half. The live-service and browser test campaign that came out of this work is now a separate, stacked PR — see below. What remains here is the change under review plus the unit and integration coverage for it: 67 files, +5,115/−1,145, of which ~1,620 added lines are source and only two source files exceed 100 lines (postgres-stores.ts, new, and instance.ts).

Reconciled with main since the original branch. main moved under this work — the Agent Team runtime was dropped (#215), and the merged foundation added host metadata, namespace-scoped notifications, SQLite turn paging, and an invocation-lifecycle restructure. Three things follow from that:

  • updateHostMetadata and historyPage are asynchronous. The host-metadata merge chains onto the thread's up-report queue, so a read-modify-write that now spans awaits still observes every earlier write; a refused patch rejects to its caller alone and is never queued, so it cannot fail a later record.
  • PostgresTurnStore.page() is implemented, with an agenetes_turn_generations table mirroring the SQLite ordinal/generation scheme. A wholesale replacement bumps the generation, so a history cursor issued against the previous arrangement is refused the same way on both SQL backends.
  • The async migration is extended to main's newer callers: binding confirmation, Agent Node lifecycle, the conversation title service, canvas search, and the agent routes.

The backend suite was running fewer profiles than it claimed. PRODUCT_STORAGE_PROFILES gated the Postgres and Azure pairings on HUABU_TEST_POSTGRES_URL, which the retired scripts/test-storage-backends.mjs used to set; the foundation's Testcontainers harness provides its services through inject() instead, so nothing set it and only disk/disk and sqlite/disk ran. The list now asks the harness, which takes the suite from 183 to 298 checks.

Two reads that answered with more than they were asked about (from the Copilot review, both reproduced with a failing test before fixing):

  • records(namespace) awaited every thread's queued state write, whatever namespace it belonged to. The durable thread table is partitioned per namespace, so a write parked for a thread elsewhere could not change the answer but could hold the enumeration open. Queue entries now carry their namespace, and an enumeration waits for its own partition alone. The queue stays keyed by the globally unique threadId, like every other per-thread table.
  • historyPage read the fold boundary, the tail events, and the persisted page as three separate reads of a log a running turn is still writing. A turn that folded in between came back twice — as the newest persisted turn and as the active tail. The boundary is now re-read after paging and the page rebuilt against the settled layout; a boundary that keeps moving answers from the persisted page alone, a turn behind rather than self-contradictory. history({ withTail }) never had this, because it reads the persisted side first.

Also from that review: four agenetes.create/close calls the async migration left fire-and-forget, three of them immediately before a storage reopen or teardown; and a mount test asserting not.toThrow() on an async call, which observed nothing.

Review comments from #184 that belonged to this half. The port and adapter headers no longer describe Azure and Postgres as an unselectable foundation, and sqlite-stores.ts no longer claims the Agenetes ports are synchronous. canvas-storage.md keeps §2c's detailed record of shipped behavior and updates it for activation, adding an "Agenetes conversation persistence" subsection covering the tables, their cascade, write ordering, and cursor invalidation, plus a code-entry-points row. Every Markdown paragraph this change adds or edits is one physical line.

Dependency direction. storage/ no longer imports from the agent module in either the new code or the Postgres integration suite, which had been borrowing the agent's substrate helpers to seed a row under a Space's extension; it brings its own owner table, and those helpers keep their proof beside themselves. A module-boundaries.test.ts case holds the direction, since the agent module is the side that reaches into storage.

Subtree and Huabu changes are in separate commits, per .github/copilot-instructions.md.

Validation — pnpm check passes in full on this branch:

  • pnpm typecheck, pnpm lint (0 errors), pnpm format:check, check:i18n, check:agent-team-skills, and scripts/check-headers.mjs.
  • Tests: 1,968 server, 1,682 web, 540 shared, 179 Agenetes, plus 176 across the remaining packages.
  • pnpm test:storage-backends: 298 checks against disposable PostgreSQL 18 and Azurite — six product profiles, and conversation coverage (round-trip, host metadata, paging, rehome compensation, restart, deletion) on all four SQL pairings.

Live-account Azure validation, the browser suite, and multi-Server coordination are outside this PR.

🤖 Generated with Claude Code

Every storage port method may now answer with a promise, so a host can
back a conversation with a remote database instead of the local file
stores. The instance awaits each write before it reports: an up-report
persists through a per-thread queue that a `record`, `run` or `close`
drains, and a run awaits its turn boundary before the first frame, so
persist-then-notify survives a store that takes time to answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster
Yuge Zhang (ultmaster) force-pushed the feat/multi-backend-storage-phase-6 branch from f0e8a13 to d2b4c51 Compare September 20, 2026 01:57
@ultmaster
Yuge Zhang (ultmaster) changed the base branch from feat/multi-backend-storage-phase-6-foundation to main September 20, 2026 01:57
Conversation stores now dispatch on the active structured backend, with
native Postgres thread, event and turn tables beside the SQLite ones, so
a Space that is rows keeps its conversation where its records live. The
application awaits persistence throughout, and profile validation admits
every Postgres and Azure Blob pairing the adapters implement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Postgres integration suite borrowed the agent's substrate helpers to
put a row under a Space's extension, which pointed storage at one of its
consumers. It brings its own owner table instead, and the helpers keep
their own proof beside them, where the concurrent-pool case belongs. A
module-boundary case holds the direction: the agent module reaches into
storage, and nothing goes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fold the activated behavior into `canvas-storage.md`: what each profile is
configured with, where a Space's conversation now lives and how its writes
are ordered, and which limits remain. The proposal records Phase 6 as
implemented, and the docs index follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster
Yuge Zhang (ultmaster) force-pushed the feat/multi-backend-storage-phase-6 branch from d2b4c51 to 58ed0a7 Compare September 20, 2026 11:45
Reconciles the async Agenetes persistence with two upstream changes.

microsoft#218 made `close()` tear the runtime down before detaching persistence and
notification wiring, so a failed driver teardown can be retried. The async
close keeps that order and drains the thread's queued state writes before
either notification scope ends: a snapshot reported during teardown is the
driver's last word, and the scoped stream now closes with the listener
rather than ahead of the drain.

Its new suites — Agent close retry, close-before-rehome, and the Move
lifecycle cases — await the persistence surfaces they assert on, and the
Move service awaits `close()` and `rehome()` inside the phase boundaries
that map failures onto redacted responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Async snapshot races, cross-namespace queue coupling, stale-cursor handling, and incomplete caller migration must be corrected.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 4 Medium severity

Open (5)
What changed in this PR

Activates all Postgres/Azure storage profiles and migrates Agenetes persistence and application callers to asynchronous operations.

Changes:

  • Adds asynchronous conversation persistence and Postgres-backed stores.
  • Enables six structured/blob profile combinations with expanded integration coverage.
  • Updates application callers, tests, and storage architecture documentation.
File Description
external/​agenetes/​README.md Documents async persistence guarantees.
external/​agenetes/​packages/​agenetes/​src/​turn-store.ts Makes turn-store operations async-capable.
external/​agenetes/​packages/​agenetes/​src/​thread-store.ts Makes thread-store operations async-capable.
external/​agenetes/​packages/​agenetes/​src/​notifications.test.ts Tests asynchronous notification persistence.
external/​agenetes/​packages/​agenetes/​src/​mount.test.ts Adapts mounting test to async creation.
external/​agenetes/​packages/​agenetes/​src/​instance.ts Implements async lifecycle, reads, and ordering.
external/​agenetes/​packages/​agenetes/​src/​instance.test.ts Migrates instance tests to async APIs.
external/​agenetes/​packages/​agenetes/​src/​instance.rehome.test.ts Tests asynchronous rehome and compensation.
external/​agenetes/​packages/​agenetes/​src/​instance-log.test.ts Tests async history, paging, and tails.
external/​agenetes/​packages/​agenetes/​src/​event-log.ts Serializes asynchronous event persistence.
external/​agenetes/​packages/​agenetes/​src/​event-log.test.ts Covers async event ordering and failures.
docs/​README.md Updates Phase 6 documentation status.
docs/​proposals/​multi-backend-storage.md Records completed backend activation design.
docs/​architecture/​canvas-storage.md Documents active Postgres/Azure architecture.
apps/​server/​vitest.storage.config.ts Includes all remote storage tests.
apps/​server/​src/​modules/​storage/​testing.ts Provisions all product storage profiles.
apps/​server/​src/​modules/​storage/​storage.ts Activates Postgres and Azure composition.
apps/​server/​src/​modules/​storage/​profile.ts Marks Postgres and Azure selectable.
apps/​server/​src/​modules/​storage/​profile.test.ts Tests newly selectable profiles.
apps/​server/​src/​modules/​storage/​ports/​blob.ts Updates blob-backend contract documentation.
apps/​server/​src/​modules/​storage/​module-boundaries.test.ts Enforces storage dependency direction.
apps/​server/​src/​modules/​storage/​backends/​postgres/​structured-store.ts Updates Postgres adapter status.
apps/​server/​src/​modules/​storage/​backends/​postgres/​integration.remote.test.ts Removes agent dependency from storage tests.
apps/​server/​src/​modules/​storage/​backends/​azure/​product.remote.test.ts Runs product tests with Azure profiles.
apps/​server/​src/​modules/​remote_fs/​interactive-view.rfs.test.ts Awaits Agenetes lifecycle operations.
apps/​server/​src/​modules/​canvas/​space-move.service.ts Awaits conversation migration operations.
apps/​server/​src/​modules/​canvas/​space-move.agents.test.ts Tests moves across storage profiles.
apps/​server/​src/​modules/​canvas/​canvas-search.ts Makes conversation search asynchronous.
apps/​server/​src/​modules/​canvas/​agent-node-ownership.test.ts Updates async record mocks.
apps/​server/​src/​modules/​canvas/​agent-node-edit.ts Awaits persisted agent records.
apps/​server/​src/​modules/​agent/​substrate-store.remote.test.ts Tests Postgres extension helpers.
apps/​server/​src/​modules/​agent/​conversation-title.service.ts Migrates title persistence dependencies.
apps/​server/​src/​modules/​agent/​conversation-title.conversion.test.ts Tests async title conversion.
apps/​server/​src/​modules/​agent/​agent.service.ts Awaits agent persistence operations.
apps/​server/​src/​modules/​agent/​agent.service.test.ts Updates async agent-service tests.
apps/​server/​src/​modules/​agent/​agent.route.ts Awaits conversation route operations.
apps/​server/​src/​modules/​agent/​agent-thread.titles.test.ts Updates async Agenetes mocks.
apps/​server/​src/​modules/​agent/​agent-thread.service.ts Migrates thread orchestration to async.
apps/​server/​src/​modules/​agent/​agent-thread.service.test.ts Tests async binding resolution.
apps/​server/​src/​modules/​agent/​agent-node-lifecycle.ts Supports async lifecycle transitions.
apps/​server/​src/​modules/​agent/​agent-node-lifecycle.test.ts Awaits asynchronous transition callbacks.
apps/​server/​src/​modules/​agent/​agent-node-fsm.integration.test.ts Updates canonical execution setup.
apps/​server/​src/​modules/​agent/​agent-node-binding.ts Awaits record and history checks.
apps/​server/​src/​modules/​agent/​agenetes/​sqlite-stores.ts Clarifies synchronous SQLite implementation.
apps/​server/​src/​modules/​agent/​agenetes/​postgres-stores.ts Adds Postgres conversation stores and paging.
apps/​server/​src/​modules/​agent/​agenetes/​conversations.remote.test.ts Runs conversation tests on SQL profiles.
apps/​server/​src/​modules/​agent/​agenetes/​conversation-stores.ts Dispatches persistence by active backend.
apps/​server/​src/​modules/​agent/​acp/​threads.route.ts Awaits persisted ACP thread lookup.
apps/​server/​src/​modules/​agent/​acp/​service.ts Awaits log metadata before execution.
apps/​server/​src/​modules/​agent/​acp/​external-agent-realization.ts Makes external realization async.
apps/​server/​src/​modules/​agent/​acp/​external-agent-realization.test.ts Adjusts async realization synchronization.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread external/agenetes/packages/agenetes/src/instance.ts Outdated
Comment thread apps/server/src/modules/agent/agenetes/postgres-stores.ts Outdated
Comment thread external/agenetes/packages/agenetes/src/instance.ts
Comment thread external/agenetes/packages/agenetes/src/instance.ts Outdated
Comment thread external/agenetes/packages/agenetes/src/mount.test.ts Outdated
@ultmaster

Copy link
Copy Markdown
Collaborator Author

Test campaign against this PR — 16 findings, 3 of them high

I built an independent test suite for this PR and ran it against real services: PostgreSQL 18 in Docker, a live Azure Blob account, and real model turns through the configured provider. Six storage pairings were exercised end to end, including a full server-process restart mid-test.

Everything below is reproduced, not inferred. Each defect has a runnable reproduction; each is left in the tree as an it.skip asserting the correct behaviour, so it turns green when fixed.

Verification baseline

Suite Before After
@huabu/server unit 179 files / 1958 tests 188 / 2019
Remote suites, live Postgres + live Azure 8 / 298 14 / 341
Same, repo Testcontainers path 8 / 298 14 / 341
@agenetes/agenetes 11 / 140 17 / 171
Browser E2E — all 6 storage pairings, real model turn each

High

1. A Postgres connection that dies mid-transaction takes the server process down

agent/agenetes/postgres-stores.ts — mutate() and tables(); same shape in storage/backends/postgres/database.ts (PostgresStoreContext.init / transaction) and agent/substrate-store.ts (ensurePostgresTables).

Each borrows a pool client and never attaches an error listener. pg-pool removes its own while a client is checked out (pg-pool/index.js:344), and pg emits error on unexpected disconnection. PostgresStoreContext does install pool.on('error', …), but a pool-level handler only ever sees idle clients. There is no process.on('uncaughtException') anywhere in apps/server/src, so the process exits.

Reproduced directly — with a pg_terminate_backend landing inside the transaction:

[store] statement rejected: terminating connection due to administrator command
[process] UNCAUGHT: Connection terminated unexpectedly

The error path is correct: the client is released as broken and the pool recovers (pinned by a 12-consecutive-failure test). It is the unhandled event that kills the process. Realistic triggers: a Postgres restart or failover, pg_terminate_backend, idle_in_transaction_session_timeout, a dropped TCP connection during an agent write.

2. PostgresTurnStore.page() is five unsynchronised reads, so a concurrent replace() tears a history page

Every other multi-statement operation in postgres-stores.ts goes through mutate(), which opens a transaction and takes pg_advisory_xact_lock(hashtextextended('agenetes:<ext>:<thread>')). page() does not — it issues its five statements straight on the Pool, so they may be served by five different connections, with no transaction spanning them.

Two outcomes, both observed (180 rounds, 4 torn):

  • a page whose content comes from the post-replace arrangement but whose next / before / group.id encode the pre-replace generation — those cursors are then rejected as StaleTurnCursorError on the very next request, while the client is paging the arrangement it was just handed;
  • groups: [] with hasMore: true and a before cursor — a history pane rendering "nothing here" for a thread that has turns.

SqliteTurnStore.page() has the identical five-query shape and is immune only because node:sqlite is synchronous: the whole method runs in one JavaScript turn on one connection. So this is a Postgres-only divergence from a backend the new parity suite otherwise shows to be identical, cursors included.

Note for whoever fixes it: an isolation level alone is not enough, because an isolation level applies to a transaction and there is no transaction here. The statements have to be brought onto one client first.

3. One failed driver up-report permanently wedges a thread's persistence

agenetes/src/instance.ts, wireUpReport. The chain links with a bare .then(...):

const pending = (pendingReports.get(spec.threadId) ?? Promise.resolve())
  .then(async () => { ...upsert... });

.then(onFulfilled) on a rejected predecessor forwards the rejection without running the callback. So after one failure:

  1. every later up-report's threadStore.upsert is never attempted — newer driver snapshots (session/resume tokens, mode, metadata) are dropped with no trace and no notification;
  2. record() / records() / run() / close() on that thread keep rejecting with the original, stale error, however long ago it happened;
  3. only close() clears it.

EventLog.#serialize neutralises its predecessor with .catch(() => {}) for exactly this reason (event-log.ts L392-404), and self-clears via .then(clear, clear). wireUpReport does neither. The interface comment promises failures are "retained for record/run/close to surface" — surfacing works; retention never ends.

This matters because of this PR: the ThreadStore can now be a remote Postgres, and remote writes fail transiently. Today that costs one write. After this change the first blip on a thread stops driver-state checkpointing for its whole life (a later restart recovers from a stale snapshot), makes the thread unreadable, and kills the user's next message with an error about a write from minutes ago.

Probe trace — three reports, upsert #1 forced to fail:

durable state after report 1 (failed)   {"driverState":{}}
durable state after report 2 (good)     {"driverState":{}}   <- never attempted
record()                                Error: durability unavailable
durable state after report 3 (good)     {"driverState":{}}   <- never attempted
close()                                 Error: durability unavailable
run() on the same thread                Error: durability unavailable

Suggested shape: mirror #serialize — .catch(() => {}).then(...) — and keep the most recent failure for record/run/close to surface, or surface it once and clear.


Medium

4. EventLog.replace() / delete() skip the write queue and can destroy an acknowledged append

append and beginTurn queue behind their predecessor via #serialize; reads await #pending. replace() and delete() join neither side — they call the store directly and never register in #pending. An append() issued while a wholesale write is in flight therefore runs concurrently, resolves with a durable seq, and is then erased.

Reachable through rehome(): its "no live handle" guard reads the live-handle table, which a threaded Job never enters even though its run() is logged. A Job mid-turn passes the guard, and the instance's lifecycle queue covers create/fork/rehome/close but never run()'s streaming appends.

source before rehome   [1,2]
pulled                 {"type":"text_delta","data":{"content":"b"}}  <- acknowledged durable
source mid-rehome      [1,2,3]
source after           []       (eventLog.delete(source))
target after           [1,2]    (replace wrote the pre-pull snapshot)

The 'b' frame the user watched stream in exists in neither namespace, with no error anywhere.

5. Every conversation call, including pure reads, takes the database-wide structured write lock

substrate() resolves the Space on every store call via space(name).extension(...), which runs inside PostgresStoreContext.transaction() → the shared keyed mutex plus pg_advisory_xact_lock(184202606). So maxSeq / list / count / fence / page all queue behind the deployment's single write order, and mutate()'s carefully scoped per-thread lock sits behind a global one. Measured 7.0× (0.40 ms vs 0.06 ms per maxSeq) on an idle single-node database with no contention.

Two corollaries: the lookup also runs assertMutationAllowed, so a conversation read during a Space-delete admission throws instead of answering empty; and the three delete() methods resolve the substrate twice, i.e. two global-lock transactions per delete.

6. A lease-holding realization inherits another flight's agent_draft_busy

acp/external-agent-realization.ts. realize() single-flights per thread, but the key carries no notion of admission, and realizeOnce decides admission after the awaited record read. A caller passing turnLeaseHeld: true — i.e. agentThreadService.invoke, which is the turn holding the lease — joins a flight started by a caller that does not hold it (e.g. POST /api/acp/threads/:id/mode). That flight fails admission because the prompt coordinator holds the lease, and the coordinator then receives the rejection as its own: the turn 409s with "Thread … is preparing or running", naming itself.

Before this PR readRecord was synchronous, so the doomed flight existed for a microtask. With the read awaited behind a Postgres round trip it sits in inFlight for the whole duration of a database read — which is exactly when a second request lands.


Lower / latent

  1. ensure() memoizes a connection before it refuses to use one (storage.ts). It calls createStorage(profile) — and therefore postgresConnection() — before checking requiresExplicitInit, then throws "Call initStorage() during startup". A caller who follows that advice after fixing HUABU_POSTGRES_URL gets the stale context; only closeStorage() releases it. The same ordering lets a missing-Azure-env Error preempt the StorageProfileError that would have explained the real problem.

  2. AzureBlobStore.init() can mark a closed store open again. The closed check is before the await and this.#state = 'open' is after it, so an init() still in flight when close() lands resolves afterwards and resurrects the store. initStorage's Promise.allSettled is the only thing preventing it today — the invariant lives entirely in the caller. Mutating it to Promise.all reproduces the resurrection.

  3. The deleteSpace sweep narrowing drops legacy bytes. && blobs.kind === 'disk' is behaviour-neutral forward — reverting it passes all six pairings, because with remote blobs the byte root is never created. It differs in exactly one case: a deployment that ran sqlite|postgres + disk bytes, wrote Spaces, then switched HUABU_BLOB_BACKEND to azure. Those pre-migration local bytes now survive the Space forever — the husk the code exists to prevent, on the other side of a migration.

  4. turnStartSeq rests entirely on admission. Both runAcpAgent and runAgent compute the boundary from an awaited logMetadata after handle.run marked the start, so two overlapping turns on one thread report the same later boundary. Nothing at the call site re-checks; the only guard is the per-thread lease a layer up, and that coupling is undocumented where the seq is computed. Suggest reading the boundary before run, or at minimum a comment naming the dependency.

  5. Admission for a first realization is decided a round trip late. acquireAgentTurn now runs after await readRecord instead of in the calling tick, so anything admitting during that window converts a would-have-succeeded realization into a 409 whose likelihood scales with backend latency. Moving the lease before the read would also close finding 6.

  6. A read can fail because someone else's write failed. read / readRecords / maxSeq await this.#pending.get(threadId) unguarded, so a read rejects with a concurrent, unrelated write's error without ever reaching a perfectly readable store. Blast radius includes history, historyPage, logMetadata, and tail()'s backfill — whose pending next() then rejects, killing a live stream. Transient (the queue clears on settle), unlike finding 3. Possibly deliberate fail-fast; recorded so the choice is explicit.

  7. .env.example still tells operators these backends do not exist. The architecture docs were updated thoroughly, but the one file that is instructions rather than description was not:

    # Structured records: disk (default) or sqlite. Postgres is not implemented.
    # disk is the only implemented blob backend; Azure is not available yet.
    

    It also documents neither HUABU_POSTGRES_URL nor the three HUABU_AZURE_* variables. An operator who configures from this file cannot reach the feature, and one who tries gets a StorageProfileError naming a variable the file never mentions.


Informational

  1. The Canvas mutex now spans a remote agent-persistence read. To answer the question directly: the lock does cover the new await in AgentNodeLifecycle.start and in initializeAgentNodeCreationAlreadyLocked — correctness holds, proved by two tests. Worth knowing anyway: on the postgres profile every Agent Node start and attachment now blocks all other writers to that Space for a database round trip. Previously the same read was synchronous — a worse shape in one way, but a much shorter one.

  2. "Not implemented yet" is now unreachable from configuration. STRUCTURED_KINDS/BLOB_KINDS are identical to the AVAILABLE lists, so the branch whose doc comment explains the two-vocabulary design can no longer be reached by any environment. The guard is still the right shape; it is now covered programmatically so the sentence stays correct for the next unwritten backend.

  3. backends/azure/integration.remote.test.ts is contention-sensitive (pre-existing; this file is not in the PR diff). "handles hasMany batches…" passes in ~34 s against the live account when run alone, and trips the 120 s testTimeout when several Vitest processes or a Playwright run hit the same account. Under Azurite it is ~13 s. A failure there is only a regression if it reproduces with nothing else touching the account.


What the suites cover

  • Agenetes runtime — port asynchrony driven through a store that resolves out of call order; per-thread serialization and cross-thread independence; the live-tail backfill/live merge with the fence read held open; run() generator lifecycle on early abandon and mid-stream throw; realize() ordering; fork/rehome guards under slow reads.
  • Storage composition — init/close failure cleanup with failures injected by configuration (unreachable Postgres, absent container, garbage Azure env), pool drain asserted against pg_stat_activity; Postgres workspace creation, reopen and switching; a deleteSpace leak test over all six pairings asserting nothing is left in the container, in the local home, or anywhere under the mount.
  • Postgres conversation stores — schema bootstrap from 18 concurrent first-touch operations and from two independent contexts racing; 16 concurrent appends → seq exactly 1..16; parallelism proved by holding the exact advisory key from an outside connection; cascade over all four tables including agenetes_turn_generations; and observable parity with SQLite, cursors and generations included, each run also asserted against fixed expectations so parity cannot pass by both adapters agreeing on the wrong answer.
  • agent / ACP / canvas — realization single-flight with every step held open by hand rather than by tick counting; lifecycle races against the real Canvas mutex and real storage; ACP routes through a real Fastify instance; and one end-to-end run through the service layer on the postgres profile, across reopen(), reading history back through the real route.
  • Browser — a profile-parameterised Playwright suite that creates a Space, drives a real model turn, attaches a file, then stops and restarts the server process and checks everything came back. It asserts decoded image width, not just that a request was made, so the blob backend really served the bytes.

Caveats

  • The page() race (finding 2) has no committed test: the window is a few hundred microseconds and only opens with a deliberate stagger, so a probabilistic test would be flaky in CI. The reproduction is runnable.
  • The process-crash repro (finding 1) is it.skip in the tree because running it aborts the Vitest worker.
  • The realization race suites mount only the Disk profile — the in-flight map and the Canvas mutex are in-process, so per-profile runs would double cost for no new information.
  • Single-Server throughout. Nothing here exercises two Servers against one database.

The suites are in a local working tree and are not part of this PR. Happy to push them as a follow-up branch if that's useful.

🤖 Generated with Claude Code

@ultmaster

Copy link
Copy Markdown
Collaborator Author

Follow-up: a seventeenth finding, and a correction to the one above

I went back to check something I had claimed but not actually verified — whether agent turns are stored properly in Postgres. Row counts and end-to-end recovery were all I had looked at. Opening the rows turned up one more defect and one error in my own testing.

Turns that complete are stored correctly

Verified at row level, not by count:

 ordinal | seq_start | seq_end | json_len
       1 |         1 |      16 |    14625
       2 |        17 |      30 |    12660

Contiguous ordinals, seq_start/seq_end bracketing their Tier-1 events exactly, no gaps or overlap. The JSON carries request (the full submission), transcript, and meta with token usage and stopReason. The parity suite separately shows this is byte-identical to SQLite, cursors and generations included.

17. A turn interrupted before its fold is recoverable exactly once, then disappears

external/agenetes/packages/agenetes/src/instance.ts, readHistory:

const persisted = await turnStore.list(namespace, threadId);
if (!withTail) return persisted.map(({ turn }) => turn);
const fence = persisted[persisted.length - 1]?.seqEnd ?? 0;
return materializeHistory(persisted, await eventLog.readRecords(namespace, threadId, fence));

A turn commits its Tier-2 record only when its generator returns, so a process that dies mid-turn leaves Tier-1 events with no folded turn. That is by design — materializeHistory exists to replay such a suffix as an isIncomplete turn.

But the fence is the last persisted turn's seqEnd, and materializeHistory only looks at records after it. So an uncommitted turn is visible only while it is the newest thing in the log:

  1. Turn A starts. turn_start at seq 1, tool calls at 2–5. The tool call lands, so the Note it creates is already durable on the canvas.
  2. The process dies before A's generator returns. No Tier-2 row.
  3. Restart. persisted is empty, fence is 0, readRecords(0) returns 1–5, and they materialize as an incomplete turn. The agent sees turn A — recovery genuinely works here, which is what hides the problem.
  4. Turn B runs and folds as ordinal 1, seq_start 6, seq_end 21.
  5. Fence is now 21. readRecords(21) is empty. History is turn B alone. Turn A is gone.

Reproduced against real Postgres, through the HTTP API rather than by inference. The database left by a browser run on postgres/azure:

 ordinal | seq_start | seq_end
       1 |         6 |      21        <- turn B only

 seq | kind       | event
   1 | turn_start |                   <- turn A: "Create a single Note ..."
   2 |            | tool_call         <- the call that created the Note
   3 |            | tool_call_update
   4 |            | tool_call
   5 |            | tool_call_update
   6 | turn_start |                   <- turn B
   7+ |            | text_delta ... done

No done for turn A, and no Tier-2 row. With a server booted on that same database:

GET /api/agent/history/<threadId>       → only turn B
GET /api/agent/history/<threadId>/page  → {"turns":[…one…],"hasMore":false}
select count(*) from agenetes_events where seq <= 5;  →  5

The events are still there. Nothing will ever read them again.

Why it matters. The conversation opens with the model answering a question about "the Note you created earlier" — and there is no earlier. The Note sits on the canvas with no record of what created it. No error is raised anywhere; the thread simply has a hole at the front. It is worse than an ordinary "the crash lost the last turn", because the turn is not lost at first — it comes back on recovery, the user sees it, and it vanishes silently as soon as they send their next message.

Is it this PR's fault? The mechanism is pre-existing: readHistory's fence and materializeHistory are unchanged here apart from the added await, and the same shape holds on Disk and SQLite. But the PR widens the window it depends on. The fold is now an awaited remote write — on the postgres profile a round trip behind a per-thread advisory lock, which itself sits behind the database-wide structured lock (finding 5). The interval between "last event streamed" and "turn committed" is materially longer exactly where this PR makes it remote, and that interval is the one a crash has to land in.

Worth noting separately: isIncomplete never reaches the wire (it appears only in a test fixture), and /history/:threadId/page merges the tail into the newest page. So a client cannot tell a durable turn from a replay that is about to disappear.

The correction

My browser test was restarting the server as soon as the Note appeared on the canvas — which is mid-turn, because the tool call lands while the turn is still streaming. So it never exercised a committed Tier-2 turn surviving a restart, and the comment in it claiming "Tier 2 came back" was wrong. The statement in my comment above that the suite proves a folded turn survives a restart did not hold as written.

Fixed: it now waits for the model's own reply before restarting, and ends by asserting two turns with the original prompt still first — the one assertion an uncommitted tail cannot satisfy, since materializeHistory appends at most one tail turn. Verified in both directions:

  • before the fix — ✘ recovers an Agent conversation from the backend after a restart (2.8m)
  • after — ✓ (26.8s), with ordinal 1, seq_start 1, seq_end 15 holding "Create a single Note on this Space whose text is exactly: …"

Also re-checked on disk/disk (2 passed, 29.1s), so the fix is not Postgres-specific.

Suggested shape of a fix (for 17)

Either make the fence per-turn coverage rather than a high-water mark — materialize any Tier-1 run not covered by some folded turn's [seqStart, seqEnd], which materializeHistory already has both inputs to do — or fold a terminated turn on recovery, committing it as an incomplete Tier-2 record so the next fold cannot step over it. The first keeps the write path untouched; the second keeps reads simple.

I'm preparing fixes for the findings in both comments as a separate series of commits.

🤖 Generated with Claude Code

…edge cases

Five defects found by exercising these backends against real services. Each
only exists because the profile is now genuinely remote.

A dying connection took the process with it. Every place that borrows a pool
client ran its transaction without an `error` listener, and pg-pool removes
its own while a client is checked out, so a backend that went away
mid-transaction raised an unhandled 'error' event — an uncaught exception,
with no handler registered anywhere in the server. The pool-level handler
`PostgresStoreContext` installs only ever sees idle clients. Each borrowed
client now carries a listener for its checkout; the statement's own rejection
is still what the code acts on, and the pool still recovers as before.

A history page could be torn. `PostgresTurnStore.page()` issued its five
statements straight on the pool while every other multi-statement operation
in that file runs under the thread's advisory lock. Read committed gives each
statement its own snapshot, and statements on a pool need not even reach the
same connection, so a `replace()` landing between any two left the page built
from two arrangements: content from after it, cursors stamped with the
generation from before — rejected as stale on the very next request — or no
groups at all with `hasMore: true`. The transaction body is now shared by
`mutate` and a new `readLocked`. An isolation level would not have helped,
there being no transaction for one to apply to.

`ensure()` built the storage before refusing the profiles that need an
awaited init, so a pool was already memoized from the environment by the time
the caller was told to call `initStorage()` — and following that advice after
fixing the URL reused the context built from the old one. It also let a
missing Azure environment answer instead of the message explaining the real
problem. The refusal now comes first.

`AzureBlobStore.init()` checked for `closed` before its await and set `open`
after, so a `close()` landing in between was undone. Re-read on the far side,
through a method call so the check is a fresh read rather than the narrowing
the first one leaves behind.

`deleteSpace`'s local sweep had been narrowed to profiles with local blobs.
That reads as a saving and is not one — with remote bytes the root was never
created and `rm` with `force` is already a no-op. The one deployment the two
forms differ on is records-in-a-database with bytes moved to Azure, where the
pre-migration files are still on disk and would outlive the Space.

The `pg` doubles in two suites gain the EventEmitter surface a real
checked-out client has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… turn dies

The storage ports became asynchronous in this phase, and every existing suite
backs them with synchronous in-memory stores, so nothing could tell an
ordering claim from a store that happens to answer immediately. The harness
added here makes each method genuinely asynchronous and releasable out of
call order, which is what turned three defects up.

A failed up-report wedged a thread for good. The chain linked with a bare
`.then(...)`, and `.then` on a rejected promise forwards it without running
the callback, so the first failed state write stopped every later snapshot
being attempted — session and resume tokens dropped in silence — and left a
rejection in the queue that answered every later `record`, `run` and `close`
with that same stale error. `EventLog.#serialize` neutralises its predecessor
for exactly this reason. The queue is now always-settled the same way and the
failure moves to a map the query surface drains: reported once, to whoever
next asks, then cleared. One transient blip used to cost a thread its
checkpointing for the rest of its life.

`replace()` and `delete()` sat beside the per-thread write queue rather than
in it, so an append issued while a wholesale write was in flight ran
concurrently: it resolved, handing its caller a durable `seq`, and the
wholesale write then erased the entry that seq named. Both now queue. That
alone does not close the path it was reachable by — `rehome` reads the source
log and writes the target from that snapshot — so `rehome` also refuses a
thread whose turn is still streaming, which is what its "no live handle"
precondition was always asking; a threaded Job never enters the live table
even though its `run()` is logged.

An interrupted turn survived exactly one recovery. A turn commits its Tier-2
record only when its generator returns, and replaying the uncommitted records
is how a recovered conversation still holds the turn whose tool call already
changed the Space. But the fence was the last folded turn's `seqEnd`, so the
moment a later turn folded past it those records became unreachable — the
Note stayed on the canvas and the exchange that created it vanished, with no
error. Coverage is now per turn: `firstUncoveredSeq` answers from the folded
ranges alone, so a thread with everything committed reads exactly the suffix
it read before, and one with a gap reaches back far enough to find it.

Not fixed, and now said so in the code: reads inherit a queued write's
failure. It looks like over-reach and is load bearing — turn acceptance rests
on it, and neutralising it made `runAgent` announce a turn whose Tier-1 start
was never written. Removing it means giving acceptance a signal of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`realize()` single-flights per thread, but the key is the thread alone and
carries no notion of admission, while `realizeOnce` decides admission after
the record read. So a caller passing `turnLeaseHeld: true` — that is,
`agentThreadService.invoke`, which *is* the turn holding the lease — could
join a flight started by a caller without it, for instance a mode change on
the same thread. That flight failed admission against this very lease and
handed its rejection to everyone waiting: the turn 409'd with "Thread … is
preparing or running", naming itself.

A caller holding the lease is now retried once on its own terms, which skips
admission as it always would have alone. Sharing is unchanged for everyone
else.

This became reachable in this phase. While `readRecord` was synchronous the
doomed flight existed for a microtask; awaited behind a Postgres round trip
it sits in the map for the length of a database read, which is exactly when a
second request arrives.

The suite alongside holds each step open by hand rather than counting ticks —
the PR's own edit to the existing realization test had to stop doing that,
which is what pointed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster

Copy link
Copy Markdown
Collaborator Author

Pushed: fixes for ten of the seventeen findings, plus the suites

Four commits on top of the merge, 16534cb1..7ea251cb. The campaign's suites and the fixes they turned up are now on the branch.

Commit What it carries
a4b380ba fix(storage) — five Postgres/Azure defects: the process crash, the torn history page, the memoized-before-refused connection, the resurrected Azure store, the narrowed delete sweep
516af5ce fix(agenetes) — the wedged up-report chain, wholesale writes racing appends, and an interrupted turn disappearing once a later one folds. Carries the out-of-order store harness that found them
aaf79249 fix(acp) — a lease-holding realization inheriting another flight's agent_draft_busy
7ea251cb test — the rest of the campaign, plus .env.example and the turnStartSeq note

Each of the first three passes its own area's suite on its own commit, so the series bisects.

Fixed

Finding Change
1 — process crash Every borrowed pg client carries an error listener for its checkout
2 — torn history page page() runs in one transaction on one client, under the thread's lock
3 — wedged up-report Queue kept always-settled; the failure is held aside and surfaced once
4 — acknowledged append erased replace/delete join the per-thread queue; rehome refuses a streaming turn
6 — inherited refusal A lease-holding caller retries once on its own terms
7 — stale connection memoized The profile is refused before anything is built
8 — resurrected Azure store State re-read on the far side of the await
9 — delete sweep Narrowing reverted; one stat syscall buys back the migrated case
13 — stale .env.example Both axes documented, every required variable named
17 — interrupted turn lost Coverage computed per turn instead of from a high-water mark

Deliberately not fixed

Three, each now saying so in the code rather than silently:

  • 5 — conversation reads take the database-wide write lock. Needs substrate() to resolve a Space without the global transaction. That is a design change in the storage adapter, not a patch, and it is yours to make.
  • 12 — a read inheriting a failed write. I fixed this, and an existing test caught that it is load bearing: turn acceptance rests on that propagation, and neutralising it made runAgent announce a turn whose Tier-1 start was never written. Reverted, and the coupling is now documented at both ends. Removing it properly means giving acceptance a signal of its own first.
  • 11 — admission decided a round trip late. Mitigated by finding 6's fix. Moving the lease ahead of the record read would change behaviour for threads that already exist.

Verification

Suite Result
@huabu/server unit 2020 passed
@agenetes/agenetes 177 passed
Remote, live PostgreSQL 18 + live Azure 342 passed
Remote, repo Testcontainers path 342 passed
Browser, postgres/azure 2 passed, real model turn each

All with zero skips — every defect reproduction now runs rather than sitting skipped. Prettier clean, ESLint 0 errors.

Finding 17's fix was checked against the database that had actually lost a turn: same rows, same query, and the first turn with its tool calls now reads back in the place it happened. The browser run ends with two folded turns (seq 1–15, 16–31) where it previously ended with one.

Two things worth saying plainly

The browser suite was weaker than its own comment claimed. It restarted the server as soon as the Note appeared on the canvas — which is mid-turn, since the tool call lands while the turn is still streaming — so it never exercised a committed Tier-2 turn surviving a restart. That is what finding 17 came out of. It now waits for the model's reply before restarting and ends by asserting two turns with the original prompt still first, which an uncommitted tail cannot satisfy.

And one full remote run showed 24 failures that were not a regression: live-account contention between parallel suites, which is finding 16. The same file passes in isolation. Worth running the live-account suites one at a time.

🤖 Generated with Claude Code

@ultmaster
Yuge Zhang (ultmaster) marked this pull request as ready for review September 21, 2026 01:18
Two reads that answered with more than they were asked about.

`records(namespace)` awaited every thread's queued state write, whatever
namespace it belonged to. The durable thread table is partitioned per
namespace (I4.1), so a write queued for a thread elsewhere cannot change the
answer — but it could hold the enumeration open for as long as it stayed
parked. The queue stays keyed by the globally unique `threadId` (I4.2), like
every other per-thread table here, and now carries the namespace it was
queued for, so an enumeration waits for its own partition and nothing else.
The rejection half of this was never reachable: the queue is deliberately
kept always-settled, with the failure moved aside to `reportFailures`.

`historyPage` read the fold boundary, the tail events and the persisted page
as three separate reads of a log a running turn is still writing. A turn that
folded in between was returned twice — as the newest persisted turn and as
the active tail — so a caller saw one turn in two states at once. The
boundary is read again after paging, and a page assembled across a fold is
rebuilt against the settled layout; a boundary that keeps moving answers from
the persisted page alone, a turn behind rather than self-contradictory.
`history` never had this: it reads the persisted side first, so a later fold
can only appear in the tail.

Both are regression-tested on the deferred store harness, and the mount test
now awaits `create` rather than asserting `not.toThrow()` on an async call,
which observed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four `agenetes.create` / `close` calls stayed fire-and-forget after the
lifecycle became asynchronous. Three of them sit immediately before a
`mounted.reopen()` or an `afterEach` teardown, so the test reopened storage
while the close was still in flight, and any rejection surfaced as an
unhandled one attributed to whichever test happened to be running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster
Yuge Zhang (ultmaster) force-pushed the feat/multi-backend-storage-phase-6 branch from 1bf7583 to 8f68ec3 Compare September 21, 2026 02:12
@ultmaster Yuge Zhang (ultmaster) changed the title feat(storage): activate Postgres profiles with async agent persistence feat(storage): activate Postgres and Azure profiles with async agent persistence Sep 21, 2026
@ultmaster
Yuge Zhang (ultmaster) requested a balanced review from Copilot September 21, 2026 02:38
…t them

`.env.example` is where an operator configures storage, and it still said
Postgres was not implemented and Azure not available — in the change that
activates both. It named none of the variables the new profiles require, so
the only documented way to reach them was to read the source.

It now states that the two axes are independent and every pairing of an
implemented record backend with an implemented blob backend is a deployment,
and documents `HUABU_POSTGRES_URL`, the two required Azure variables and the
optional key prefix, each with what breaks without it. Two existing entries
were SQLite-specific and are now written for any record backend that keeps no
Workspace folder: the unset-`HUABU_WORKSPACE` requirement, and managed mode
being disk-only.

Also records two operational constraints that only existed in code comments:
one Server per Postgres database, because the locking is not distributed
admission; and that the Azure container is the operator's to create and keep
private, since deleting a Space sweeps only that Space's own keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment thread apps/server/src/modules/canvas/space-move.agents.test.ts Outdated
Comment thread external/agenetes/packages/agenetes/src/instance.ts
Comment thread external/agenetes/packages/agenetes/src/materialize-history.ts Outdated
Comment thread external/agenetes/packages/agenetes/src/thread-store.ts
Comment thread apps/server/src/modules/canvas/canvas-search.ts
@cxxxxxn
cxxxxxn (cxxxxxn) merged commit 8f98a6d into microsoft:main Sep 21, 2026
2 checks passed
Yuge Zhang (ultmaster) added a commit to ultmaster/Huabu that referenced this pull request Sep 21, 2026
…form holes

Copilot's review of the storage-profile acceptance suite found one assertion
that could pass without proving anything and three ways the harness or its
prose was wrong about the code underneath.

The recovery test asked the restarted Agent to quote the Note's text back.
That text is also on the Space, so an Agent whose history did not survive
could answer by reading the canvas in front of it. The first turn now plants a
per-run codeword that is never written to the Space — asserted against both
the structure the server holds and every node body, since those are served by
different routes — and the question after the restart asks for it. The Note's
text stays in the assertion as the weaker claim, so a regression there cannot
hide behind the codeword.

`stageLlmCredentials` reported readiness from two filenames. Staging
`encrypted-secrets.json` without `HUABU_SECRET_KEY` makes `initializeSecretStore`
fail the server's start outright, inside globalSetup and before any `test.skip`
could be reached, so the common half-configured machine lost the
credential-free storage test too. The key is now checked before either file is
copied.

Stopping the backend signalled a negative pid, which is POSIX-only; on Windows
both calls threw, were swallowed, and left the backend holding the port until
`restartBackend` gave up. Termination now goes through the process tree,
mirroring `killTree` in `scripts/dev-child-supervisor.mjs`.

The Postgres client-death case carried a comment saying it was skipped because
no error listener existed, citing an untracked scratchpad note. `locked` has
installed that listener and released the broken client for some time, and the
test is enabled; the comment now describes the regression it holds.

The acceptance note hard-wrapped its prose against the repository's one-line-
per-paragraph rule, and called the Postgres/Azure pairing one this PR adds —
microsoft#185 added it, this suite validates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants