From 083b9d76adf2a7fe2a886ce5023baaaf67887ac4 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Wed, 11 Nov 2026 16:54:54 -0500 Subject: [PATCH 1/2] fix(topic-memory): stop head conflicts from spending window attempts A Topic window whose publication lost the head CAS to a concurrent replace or Dream returned HEAD_CONFLICT without recording a cause, yet still kept the attempt it had begun. Three such conflicts left attempts at 3 with no last error, and the next attempt blocked the Scope with window_attempt_limit. The Worker now records head_conflict as the attempt's last error. The attempt ceiling and the next attempt's counter treat that attempt as not spent, so the existing 0.1 s conflict deferral rereads the head and recomputes (RFC 1614 section 5.3). Provider reservations are never refunded: each recomputation spends from the frontier's 512-request and 64M-token ceilings, which stop a head that keeps moving with window_provider_budget_exceeded. The supervisor's attempt.completed log now names cursor_conflict or head_conflict in error_code. No schema, migration or row encoding changes; the persistence review is updated for the reinterpreted budget column. --- docs/en/development/processing-failures.md | 2 +- docs/en/docs/operate/configuration.md | 5 +- docs/en/docs/operate/observability.md | 6 +- docs/en/docs/workflows/topic-memory.md | 6 + docs/zh/development/processing-failures.md | 2 +- docs/zh/docs/operate/configuration.md | 5 +- docs/zh/docs/operate/observability.md | 4 +- docs/zh/docs/workflows/topic-memory.md | 5 + release/upgrade-support.json | 4 +- .../persistence/topic_memory_budget.py | 18 +- .../builtin/runtime/artifact_processing.py | 1 + .../runtime/topic_memory_processing.py | 10 +- .../test_topic_memory_head_conflict.py | 332 ++++++++++++++++++ tests/e2e/test_topic_memory_head_conflict.py | 185 ++++++++++ 14 files changed, 572 insertions(+), 13 deletions(-) create mode 100644 tests/builtin/runtime/test_topic_memory_head_conflict.py create mode 100644 tests/e2e/test_topic_memory_head_conflict.py diff --git a/docs/en/development/processing-failures.md b/docs/en/development/processing-failures.md index bf3b4ff2e5..01d372dcb2 100644 --- a/docs/en/development/processing-failures.md +++ b/docs/en/development/processing-failures.md @@ -83,7 +83,7 @@ Topic Memory operation-level coverage: | `topic-memory` stages | Output parsing | Partial | Provider schema failures use the shared mapping; semantic rejections are business validation. | | `topic-memory` stages | Business validation | Covered | Every `TopicMemoryGenerationError` code maps to bounded diagnostics: domain and model rejections carry a stage and, when evidence supports it, a reason; control-flow rejections carry only `failure_location`. Input-budget failures carry input counts, token estimates, estimator identity, and the effective budget; the window token ceiling carries the charged tokens and the ceiling, while attempt and request ceilings are reported through their block details. | | `topic-memory.index` and query embedding | Embedding | Covered | A missing embedding adapter and a wrong vector count are classified. | -| Publish / persistence | Persistence | Partial | Projection content and vector-completeness rules are business validation; storage-invariant failures map to `persistence` / `storage_failure`; unsupported capabilities report `configuration`; publisher conflicts keep their domain outcomes, and other storage failures keep unknown. | +| Publish / persistence | Persistence | Partial | Projection content and vector-completeness rules are business validation; storage-invariant failures map to `persistence` / `storage_failure`; unsupported capabilities report `configuration`; publisher conflicts keep their domain outcomes, and other storage failures keep unknown. A Topic head conflict records `head_conflict` as the attempt's last error and does not spend a window attempt. | | Usage accounting | Usage accounting | Missing | The window provider budget is reported as `generation` / `usage_limit`; accounting failures are not classified. | The first two rollout slices cover Memory / Atomic Memory and Topic Memory: provider failures, diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 68e099c75d..4cb2d81c58 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -291,7 +291,10 @@ Topic Workers enforce a durable allowance per unadvanced Scope Cursor: 3 attempt and 64,000,000 estimated token-capacity units across all retries. A Window admits at most 4,194,304 canonical evidence characters including metadata; nested input is also bounded. Exhaustion preserves Sources, Cursor, Pending, and the same-Scope tail, and stops further provider calls. Flush and restart do not reset it; inspect -`pc_topic_memory_work_budgets` and structured errors for operator remediation. +`pc_topic_memory_work_budgets` and structured errors for operator remediation. A head conflict, where a concurrent +replace or Dream moves a Topic head after the Worker read it, does not spend an attempt: the Worker records +`head_conflict` as that attempt's last error, rereads the head and recomputes. Its reserved requests and token capacity +stay charged, so a head that keeps moving stops at `window_provider_budget_exceeded` instead of retrying indefinitely. Topic generation accepts `max_tokens`, `temperature`, `top_p`, `top_k`, `seed`, `presence_penalty`, `frequency_penalty`, `timeout`, `openai_reasoning_effort`, `openai_text_verbosity`, `service_tier`, `openai_service_tier`, diff --git a/docs/en/docs/operate/observability.md b/docs/en/docs/operate/observability.md index 2f9bd0b159..d176f86104 100644 --- a/docs/en/docs/operate/observability.md +++ b/docs/en/docs/operate/observability.md @@ -128,6 +128,8 @@ the supervisor records interruption or failure instead. The supervisor emits `artifact_processing.queued` on local queue admission, `artifact_processing.dispatched` when it starts an attempt, and `artifact_processing.attempt.completed` when it finalizes that attempt. +An attempt that ends in a control conflict finalizes with outcome `conflict`; its `error_code` is `cursor_conflict` or +`head_conflict`. `queue_wait_ms` uses a monotonic clock and excludes earlier policy waits and retry backoff before queue admission. On completion, `duration_ms` measures the supervisor attempt separately from queue time and child execution time. These are execution diagnostics, not counts of newly published Artifacts. @@ -488,8 +490,8 @@ The dashboard labels a policy block as “被规则阻止” (“blocked by a ru another failure; it means the same Source frontier has reached its retry or resource allowance and the system stopped starting more model work. Common `error_code` values are: -- `window_attempt_limit`: the same frontier has used three attempts and has not completed. Request and token budgets - may still be available. +- `window_attempt_limit`: the same frontier has used three attempts and has not completed. Attempts that lost a + Topic head conflict to a concurrent replace or Dream are not counted. Request and token budgets may still be available. - `window_provider_budget_exceeded`: the same frontier reached its request or token budget. - `source_complexity_limit`: the Source window exceeds the processing limit. diff --git a/docs/en/docs/workflows/topic-memory.md b/docs/en/docs/workflows/topic-memory.md index 22e467f15a..58185f2dc9 100644 --- a/docs/en/docs/workflows/topic-memory.md +++ b/docs/en/docs/workflows/topic-memory.md @@ -103,6 +103,12 @@ that no longer owns the current frontier returns HTTP 409. Cursor, target and So and its retry timestamp remain readable after progress resumes. Normal flushes and restarts retain their existing budget behavior. Each explicitly accepted retry grants the same three-attempt and provider ceilings. +A manual replace or Dream can move a Topic head while a Worker is generating against it. The Worker's publication then +fails its head check and writes nothing; the Worker records `head_conflict` as the attempt's last error, and the next +attempt rereads the head and recomputes. Such an attempt does not count toward the three-attempt ceiling, but its +provider reservations do. A head that keeps moving therefore ends with `window_provider_budget_exceeded`, whose block +can show `last_error_code` `head_conflict`. + The equivalent Python Client SDK methods are `get_topic_memory_processing_status`, `get_topic_memory_processing_block`, and `retry_topic_memory_processing`. Their HTTP routes are `POST /v1/topic-memory/processing/status`, `POST /v1/topic-memory/processing/blocks/get`, and diff --git a/docs/zh/development/processing-failures.md b/docs/zh/development/processing-failures.md index ec9bccbdb3..447b12dfa7 100644 --- a/docs/zh/development/processing-failures.md +++ b/docs/zh/development/processing-failures.md @@ -77,7 +77,7 @@ Topic Memory 的 operation 级覆盖: | `topic-memory` 各阶段 | Output parsing | Partial | provider schema 失败走共享映射;语义拒绝归为 business validation。 | | `topic-memory` 各阶段 | Business validation | Covered | 每个 `TopicMemoryGenerationError` code 都有有界诊断:领域/模型拒绝携带 stage,并在有证据时给出 reason;控制流拒绝只携带 `failure_location`。输入预算失败携带输入计数、token 估算、estimator 标识和生效预算;window token 上限触发时携带已计费 token 与上限,attempt/request 上限通过 block details 报告。 | | `topic-memory.index` 与查询嵌入 | Embedding | Covered | 缺少 embedding adapter、向量数量错误均已分类。 | -| Publish / persistence | Persistence | Partial | 投影内容及向量完整性规则归业务校验;存储不变量失败归 `persistence` / `storage_failure`;不支持的能力归 `configuration`;publisher 冲突保留既有领域结果,其他存储失败保持 unknown。 | +| Publish / persistence | Persistence | Partial | 投影内容及向量完整性规则归业务校验;存储不变量失败归 `persistence` / `storage_failure`;不支持的能力归 `configuration`;publisher 冲突保留既有领域结果,其他存储失败保持 unknown。Topic head 冲突将 `head_conflict` 记为该次尝试的最后错误,不消耗 window 尝试次数。 | | Usage accounting | Usage accounting | Missing | window provider budget 归为 `generation` / `usage_limit`;账务失败未分类。 | 前两个落地切片覆盖 Memory / Atomic Memory 与 Topic Memory:provider 失败、预算耗尽、提取/对账/领域校验 diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 1f01844222..1d28adcca9 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -261,7 +261,10 @@ Scope、家族、条数限制和上下文预算,并复用已经生成的查询 Topic Worker 对尚未推进的 Scope Cursor 强制使用持久额度:跨全部重试最多 3 次尝试、512 次预留 provider 请求和 64,000,000 个估算 token 容量单位。Window 的 canonical evidence(包含 metadata)最多 4,194,304 个字符,并限制 嵌套复杂度。耗尽后保留 Source、Cursor、Pending 和同 Scope 尾部,停止后续 provider 调用;flush 和重启均不重置。 -运维可检查 `pc_topic_memory_work_budgets` 与结构化错误以明确修复。 +运维可检查 `pc_topic_memory_work_budgets` 与结构化错误以明确修复。Worker 读取 Topic head 后,如果并发的 replace +或 Dream 移动了该 head(head 冲突),这次尝试不计入尝试次数:Worker 将 `head_conflict` 记为该次尝试的最后错误, +重新读取 head 后重新计算。该次尝试预留的请求数和 token 容量仍然计入,因此 head 持续变化时会在 +`window_provider_budget_exceeded` 处停止,不会无限重试。 Topic generation 只允许有界标量设置:`max_tokens`、`temperature`、`top_p`、`top_k`、`seed`、`presence_penalty`、 `frequency_penalty`、`timeout`、`openai_reasoning_effort`、`openai_text_verbosity`、`service_tier`、 diff --git a/docs/zh/docs/operate/observability.md b/docs/zh/docs/operate/observability.md index ae973506dd..fe2ab9db31 100644 --- a/docs/zh/docs/operate/observability.md +++ b/docs/zh/docs/operate/observability.md @@ -123,6 +123,7 @@ Child 返回结果不能单独证明业务完成。强制终止进程可能来 Supervisor 在本地入队时输出 `artifact_processing.queued`,开始一次执行时输出 `artifact_processing.dispatched`,结束该次执行时输出 `artifact_processing.attempt.completed`。 +以控制冲突结束的执行,其 outcome 为 `conflict`,`error_code` 为 `cursor_conflict` 或 `head_conflict`。 `queue_wait_ms` 使用单调时钟,不包含入队前的策略等待和重试退避。 完成事件的 `duration_ms` 单独表示 supervisor 的执行耗时,与排队时间和 child 执行时间分开计算。 这些事件用于执行诊断,不能当作新发布 Artifact 的数量。 @@ -434,7 +435,8 @@ Scope 的后台处理结果尚未确认写入成功;长时间大于零可能 “策略阻塞”在页面中显示为“被规则阻止”。它不是 Worker 崩溃,也不是一次新的失败;它表示同一批 Source 已经达到允许的重试或资源预算,系统停止继续调用模型。常见 `error_code` 如下: -- `window_attempt_limit`:同一 Cursor 后的这批 Source 已尝试 3 次,仍未完成。请求数和 Token 预算不一定用完。 +- `window_attempt_limit`:同一 Cursor 后的这批 Source 已尝试 3 次,仍未完成。因并发 replace 或 Dream 导致 + Topic head 冲突的尝试不计入。请求数和 Token 预算不一定用完。 - `window_provider_budget_exceeded`:同一批 Source 已达到请求数或 Token 预算。 - `source_complexity_limit`:该窗口的 Source 结构超过处理上限。 diff --git a/docs/zh/docs/workflows/topic-memory.md b/docs/zh/docs/workflows/topic-memory.md index bb7f9e06ab..e60fb85c83 100644 --- a/docs/zh/docs/workflows/topic-memory.md +++ b/docs/zh/docs/workflows/topic-memory.md @@ -147,6 +147,11 @@ retry 需要 `scope.admin` 权限,查询还需要该角色未包含的 `scope. 恢复后仍可读取原阻塞记录和重试时间。普通 flush、重启的额度语义不变;每次明确接受的 retry 使用相同的三次尝试 和 provider 上限。 +Worker 生成期间,手动 replace 或 Dream 可能移动它所基于的 Topic head。此时 Worker 的发布无法通过 head 检查, +不写入任何内容;Worker 将 `head_conflict` 记为该次尝试的最后错误,下一次尝试重新读取 head 后重新计算。这类尝试 +不计入三次尝试上限,但其 provider 预留仍然计入。因此 head 持续变化时会以 `window_provider_budget_exceeded` +结束,对应阻塞记录的 `last_error_code` 可能为 `head_conflict`。 + Python Client SDK 对应方法为 `get_topic_memory_processing_status`、`get_topic_memory_processing_block` 和 `retry_topic_memory_processing`。HTTP 路径分别为 `POST /v1/topic-memory/processing/status`、 `POST /v1/topic-memory/processing/blocks/get` 和 `POST /v1/topic-memory/processing/retry`。这些管理操作不作为 MCP 工具提供。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 96cb07b587..4e131b5378 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "f0ffb8f5bf57d8180acabc56c5d52506ae2459805b291bd3e1bc1bcc51fbdbca", - "rationale": "Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged." + "persistence_sha256": "a9509189c17b639f98894d785d28140801d7228960ad5082480713960c4ea7c4", + "rationale": "Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/topic_memory_budget.py b/src/powercontext/builtin/persistence/topic_memory_budget.py index cc35ac1ac5..dc958eed07 100644 --- a/src/powercontext/builtin/persistence/topic_memory_budget.py +++ b/src/powercontext/builtin/persistence/topic_memory_budget.py @@ -63,6 +63,20 @@ }) +# The last error of an attempt whose publication lost the Topic head CAS to a concurrent +# replace or Dream. The next attempt rereads the head and recomputes (RFC 1614 section 5.3). +TOPIC_MEMORY_HEAD_CONFLICT = "head_conflict" + + +def _spent_attempts(row: Mapping[Any, Any]) -> int: + """Attempts charged to the ceiling; a head conflict hands its attempt to the recomputation. + + The conflicted attempt's provider reservations stay charged, so the request and token + ceilings still bound how often a moving head can make the window recompute. + """ + return int(row["attempts"]) - (row.get("last_error_code") == TOPIC_MEMORY_HEAD_CONFLICT) + + def exhausted_reason(row: Mapping[Any, Any]) -> str: """Why this frontier stops, or "" while another attempt may still be spent. @@ -73,7 +87,7 @@ def exhausted_reason(row: Mapping[Any, Any]) -> str: code = str(row["failure_code"]) if code and code not in RECOVERABLE_TOPIC_MEMORY_REJECTIONS: return code - if row["attempts"] >= MAX_TOPIC_MEMORY_WORK_ATTEMPTS: + if _spent_attempts(row) >= MAX_TOPIC_MEMORY_WORK_ATTEMPTS: # Name the rejection that kept recurring; the ceiling only says how many # attempts were spent on it. return code or "window_attempt_limit" @@ -276,7 +290,7 @@ async def begin(self) -> None: update(table) .where(*self._key()) .values( - attempts=row["attempts"] + 1, + attempts=_spent_attempts(row) + 1, attempt_id=self.attempt_id, source_through=max(self.source_through, row["source_through"]), ) diff --git a/src/powercontext/builtin/runtime/artifact_processing.py b/src/powercontext/builtin/runtime/artifact_processing.py index 59e491a286..12a64ecb03 100644 --- a/src/powercontext/builtin/runtime/artifact_processing.py +++ b/src/powercontext/builtin/runtime/artifact_processing.py @@ -1580,6 +1580,7 @@ async def _reap(self, state: _FamilyState) -> None: # noqa: C901 - distinct Wor ArtifactProcessingWorkerOutcome.HEAD_CONFLICT, }: observed_outcome = "conflict" + diagnostic_code = completion.outcome.value previous = state.retries.get(scope) self._defer( state, diff --git a/src/powercontext/builtin/runtime/topic_memory_processing.py b/src/powercontext/builtin/runtime/topic_memory_processing.py index 24a68f3ec6..40e3e08f3e 100644 --- a/src/powercontext/builtin/runtime/topic_memory_processing.py +++ b/src/powercontext/builtin/runtime/topic_memory_processing.py @@ -105,6 +105,7 @@ MAX_TOPIC_MEMORY_WORK_REQUESTS, MAX_TOPIC_MEMORY_WORK_TOKENS, RECOVERABLE_TOPIC_MEMORY_REJECTIONS, + TOPIC_MEMORY_HEAD_CONFLICT, TopicMemoryWorkBudget, require_topic_memory_work_available, ) @@ -586,12 +587,17 @@ async def process( else: await budget.record_failure(error.code) raise + except RevisionConflictError: + # A concurrent replace or Dream moved a Topic head after this attempt read it. + # Recording the cause hands the attempt back; its reservations stay charged. Only a + # generated proposal can target an existing head, and every stage reserves before its + # call, so each recomputation spends from the frontier's request and token ceilings. + await budget.record_failure(TOPIC_MEMORY_HEAD_CONFLICT) + return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.HEAD_CONFLICT) except ArtifactProcessingLeadershipLostError: return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.LEADERSHIP_LOST) except GenerationConflictError: return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.CURSOR_CONFLICT) - except RevisionConflictError: - return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.HEAD_CONFLICT) finally: self._document_vectors.reset(vector_token) self._work_budget.reset(token) diff --git a/tests/builtin/runtime/test_topic_memory_head_conflict.py b/tests/builtin/runtime/test_topic_memory_head_conflict.py new file mode 100644 index 0000000000..f7d725bda9 --- /dev/null +++ b/tests/builtin/runtime/test_topic_memory_head_conflict.py @@ -0,0 +1,332 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A Topic head moved by a concurrent writer is reread and recomputed, not counted as a failed attempt.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest +from sqlalchemy import select + +from powercontext.builtin.artifacts.topic_memory import ( + TOPIC_MEMORY_SOURCE_WINDOW_BINDING, + TopicMemoryDraft, + prepare_topic_memory_projection, +) +from powercontext.builtin.artifacts.topic_memory.generation import ( + TopicMemoryGenerationError, + TopicMemoryGlobalOutput, + TopicMemoryProbe, + TopicMemoryProbeOutput, + TopicMemoryProposal, +) +from powercontext.builtin.inference import GenerationResult, character_token_estimator +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.processing import ArtifactProcessingPendingRepository +from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository +from powercontext.builtin.persistence.tables import TOPIC_MEMORY_WORK_BLOCKS_TABLE, TOPIC_MEMORY_WORK_BUDGETS_TABLE +from powercontext.builtin.persistence.topic_memory_budget import ( + MAX_TOPIC_MEMORY_WORK_ATTEMPTS, + MAX_TOPIC_MEMORY_WORK_TOKENS, + exhausted_reason, +) +from powercontext.builtin.runtime.artifact_processing import ( + ArtifactProcessingBinding, + ArtifactProcessingSupervisor, + ArtifactProcessingWorkerOutcome, +) +from powercontext.builtin.runtime.topic_memory_processing import ( + TopicMemoryAtomicPublisher, + TopicMemoryProcessor, + TopicMemoryStageSet, + TopicMemoryWindowSelector, +) +from powercontext.builtin.runtime.topic_memory_scope import TopicMemoryScopeProcessor +from powercontext.sources import SourceMaterialization +from tests.builtin.persistence.contract import NoteSource +from tests.builtin.runtime.test_topic_memory_processing import ( + _assignment, + _content, + _ProcessorLauncher, + _QueueGenerator, + _RepeatingGenerator, + _repositories, +) + +_UPDATED = _content("updated", "zircon") + + +class _ConcurrentWriterStage: + """The global stage; each scripted step either returns, fails, or lets a concurrent writer move the head first.""" + + def __init__(self, database, topics, steps) -> None: + self.database = database + self.topics = topics + self.steps = list(steps) + self.calls = 0 + self.output = TopicMemoryGlobalOutput( + proposals=( + TopicMemoryProposal(candidate_id="candidate-0001", content=_UPDATED, evidence_ids=("evidence-0001",)), + ) + ) + + async def generate(self, _value, /): + self.calls += 1 + step = self.steps.pop(0) if self.steps else "publish" + if step == "fail": + raise RuntimeError("provider unavailable") # noqa: TRY003 + if step == "move-head": + # A manual replace or Dream lands while the Worker is generating. + async with self.database.transaction() as connection: + (current,) = await self.topics.browse_current(connection, "scope-a", limit=10) + head = await self.topics.get_exact(connection, "scope-a", current.artifact_ref) + content = _content(f"replaced {self.calls}", "zircon") + await self.topics.publish_revision( + connection, + "scope-a", + head.topic, + TopicMemoryDraft(content=content, artifacts=(head.topic.as_ref(),)), + prepare_topic_memory_projection(content), + ) + return GenerationResult(output=self.output) + + +async def _setup(steps, *, config=None, limit=100_000): + manager, profile, sources, topics = await _repositories(config) + async with profile.database.transaction() as connection: + await topics.publish_create( + connection, + "scope-a", + "topic-history", + TopicMemoryDraft(content=_content("legacy", "zircon")), + prepare_topic_memory_projection(_content("legacy", "zircon")), + ) + source = await sources.add( + connection, + "scope-a", + NoteSource(name="note-1", materialization=SourceMaterialization.CAPTURED, body="updated zircon"), + ) + stage = _ConcurrentWriterStage(profile.database, topics, steps) + processor = TopicMemoryProcessor( + database=profile.database, + sources=sources, + topics=topics, + stages=TopicMemoryStageSet( + probe=_RepeatingGenerator( + TopicMemoryProbeOutput(probes=(TopicMemoryProbe(query="zircon", evidence_ids=("evidence-0001",)),)) + ), + global_evolver=stage, + planner=_QueueGenerator(), + evolver=_QueueGenerator(), + temporary=_QueueGenerator(), + reconciler=_QueueGenerator(), + estimator=character_token_estimator(), + input_tokens_limit=limit, + ), + publisher=TopicMemoryAtomicPublisher(profile.database, sources, topics), + ) + return manager, profile, sources, topics, stage, processor, source + + +async def _budget(profile): + async with profile.database.transaction() as connection: + row = (await connection.execute(select(TOPIC_MEMORY_WORK_BUDGETS_TABLE))).mappings().one_or_none() + return None if row is None else dict(row) + + +async def _fence(profile): + async with profile.database.transaction() as connection: + term = await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "holder") + return term.fence("single-process") + + +async def _assert_published_on_moved_head(profile, topics, moves: int) -> None: + async with profile.database.transaction() as connection: + (current,) = await topics.browse_current(connection, "scope-a", limit=10) + published = await topics.get_exact(connection, "scope-a", current.artifact_ref) + cursor = await SourceCursorRepository().load(connection, "scope-a", TOPIC_MEMORY_SOURCE_WINDOW_BINDING) + # Revision 1 is the original, then one revision per concurrent write, then the Worker's. + assert current.artifact_ref.revision == moves + 2 + assert published.topic.content == _UPDATED + assert published.topic.lineage.artifacts == (current.artifact_ref.model_copy(update={"revision": moves + 1}),) + assert cursor is not None and cursor.cursor.sequence == 1 + + +def test_repeated_head_conflicts_recompute_on_the_new_head_without_spending_attempts() -> None: + moves = MAX_TOPIC_MEMORY_WORK_ATTEMPTS + 2 + + async def scenario() -> None: + manager, profile, _, topics, stage, processor, _ = await _setup(["move-head"] * moves) + try: + assignment = _assignment(await _fence(profile)) + charged = [] + for _ in range(moves): + completion = await processor.process(assignment) + assert completion.outcome is ArtifactProcessingWorkerOutcome.HEAD_CONFLICT + row = await _budget(profile) + assert row is not None + # The cause is kept on the frontier, and it does not count toward the attempt ceiling. + assert row["last_error_code"] == "head_conflict" + assert row["attempts"] == 1 + assert exhausted_reason(row) == "" + charged.append(row["requests"]) + # Every recomputation is charged to the durable provider ceiling; nothing is refunded. + assert charged == sorted(set(charged)) + + assert (await processor.process(assignment)).outcome is ArtifactProcessingWorkerOutcome.SUCCEEDED + assert await _budget(profile) is None + assert stage.calls == moves + 1 + await _assert_published_on_moved_head(profile, topics, moves) + finally: + await manager.__aexit__(None, None, None) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("steps", "exhausted"), + [ + # Three real failures exhaust the frontier, however many head conflicts separate them. + (["move-head", "fail", "move-head", "fail", "move-head", "fail"], True), + # A head conflict on the last remaining attempt hands that attempt back. + (["fail", "fail", "move-head"], False), + ], +) +def test_real_failures_still_spend_attempts_between_head_conflicts(steps, exhausted) -> None: + async def scenario() -> None: + manager, profile, _, topics, stage, processor, _ = await _setup(steps) + try: + assignment = _assignment(await _fence(profile)) + for step in steps: + if step == "fail": + with pytest.raises(RuntimeError, match="provider unavailable"): + await processor.process(assignment) + else: + completion = await processor.process(assignment) + assert completion.outcome is ArtifactProcessingWorkerOutcome.HEAD_CONFLICT + row = await _budget(profile) + assert row is not None + assert row["attempts"] == MAX_TOPIC_MEMORY_WORK_ATTEMPTS + calls = stage.calls + if exhausted: + with pytest.raises(TopicMemoryGenerationError, match="window_attempt_limit"): + await processor.process(assignment) + assert stage.calls == calls + else: + assert (await processor.process(assignment)).outcome is ArtifactProcessingWorkerOutcome.SUCCEEDED + await _assert_published_on_moved_head(profile, topics, steps.count("move-head")) + finally: + await manager.__aexit__(None, None, None) + + asyncio.run(scenario()) + + +def test_a_head_that_never_settles_is_stopped_by_the_frontier_provider_ceiling() -> None: + # Large stage reservations make the durable token ceiling reachable in a few recomputations. + limit = MAX_TOPIC_MEMORY_WORK_TOKENS // 10 + per_attempt = 2 * 2 * (limit + limit // 4) # probe + global, each reserving two requests of capacity + + async def scenario() -> None: + manager, profile, _, _, stage, processor, _ = await _setup(["move-head"] * 1_000, limit=limit) + try: + assignment = _assignment(await _fence(profile)) + conflicts = 0 + with pytest.raises(TopicMemoryGenerationError) as error: + while True: + completion = await processor.process(assignment) + assert completion.outcome is ArtifactProcessingWorkerOutcome.HEAD_CONFLICT + conflicts += 1 + assert error.value.code == "window_provider_budget_exceeded" + assert 0 < conflicts <= MAX_TOPIC_MEMORY_WORK_TOKENS // per_attempt + assert stage.calls == conflicts + row = await _budget(profile) + assert row is not None and row["attempts"] == 1 + assert exhausted_reason(row) == "window_provider_budget_exceeded" + # The ceiling stopped the next attempt before it began, so the conflict stays the visible cause. + assert row["last_error_code"] == "head_conflict" + finally: + await manager.__aexit__(None, None, None) + + asyncio.run(scenario()) + + +def test_supervised_scope_rereads_moved_heads_and_publishes_without_a_block(tmp_path, caplog) -> None: + moves = MAX_TOPIC_MEMORY_WORK_ATTEMPTS + 1 + + async def scenario() -> None: + manager, profile, sources, topics, _, processor, source = await _setup( + ["move-head"] * moves, config=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'topic.db'}") + ) + try: + pending = ArtifactProcessingPendingRepository() + async with profile.database.transaction() as connection: + await pending.raise_source( + connection, "scope-a", TOPIC_MEMORY_SOURCE_WINDOW_BINDING, source.journal_position + ) + await pending.request_flush(connection, "scope-a", TOPIC_MEMORY_SOURCE_WINDOW_BINDING) + await ArtifactProcessingIntentRepository().request( + connection, "scope-a", TOPIC_MEMORY_SOURCE_WINDOW_BINDING + ) + binding = ArtifactProcessingBinding( + binding_name=TOPIC_MEMORY_SOURCE_WINDOW_BINDING, + artifact_family="topic-memory", + max_workers=1, + worker_timeout_seconds=5, + launcher=_ProcessorLauncher( + TopicMemoryScopeProcessor( + profile.database, + processor, + TopicMemoryWindowSelector( + profile.database, sources, character_token_estimator(), context_window_tokens=10_000 + ), + ) + ), + ) + with caplog.at_level(logging.INFO, logger="powercontext.builtin.runtime.artifact_processing"): + async with ArtifactProcessingSupervisor( + database=profile.database, + bindings=(binding,), + lease_mode="single-process", + retry_base_seconds=0.01, + retry_cap_seconds=0.01, + retry_jitter=lambda: 1.0, + ): + async with asyncio.timeout(30): + while True: + async with profile.database.transaction() as connection: + row = await pending.load(connection, "scope-a", TOPIC_MEMORY_SOURCE_WINDOW_BINDING) + if row is None: + break + await asyncio.sleep(0.01) + await _assert_published_on_moved_head(profile, topics, moves) + async with profile.database.transaction() as connection: + assert (await connection.execute(select(TOPIC_MEMORY_WORK_BLOCKS_TABLE))).first() is None + attempts = [ + record + for record in caplog.records + if record.__dict__.get("event") == "artifact_processing.attempt.completed" + ] + conflicts = [record for record in attempts if record.__dict__.get("outcome") == "conflict"] + assert len(conflicts) == moves + assert {record.__dict__.get("error_code") for record in conflicts} == {"head_conflict"} + assert not [r for r in caplog.records if r.__dict__.get("event") == "artifact_processing.blocked"] + finally: + await manager.__aexit__(None, None, None) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_topic_memory_head_conflict.py b/tests/e2e/test_topic_memory_head_conflict.py new file mode 100644 index 0000000000..1421ecbd32 --- /dev/null +++ b/tests/e2e/test_topic_memory_head_conflict.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Real HTTP replaces race a spawned Topic Worker; the model endpoint is a local stub.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import threading +from collections.abc import Callable, Coroutine +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import httpx +from pydantic import AnyHttpUrl + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.topic_memory_budget import MAX_TOPIC_MEMORY_WORK_ATTEMPTS +from powercontext.builtin.runtime.config import InferenceConfig, RuntimeConfig +from powercontext.client import PowerContextClient +from powercontext.http import ( + CaptureContentSourceRequest, + FlushTopicMemoryRequest, + TopicMemoryProcessingStatusRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + +_REPLACES = MAX_TOPIC_MEMORY_WORK_ATTEMPTS + 1 +_WORKER_CONTENT = {"title": "zircon worker", "summary": "zircon worker summary", "detail": "# zircon\nWorker update."} + + +def _manual_content(number: int) -> dict[str, str]: + return {"title": f"zircon manual {number}", "summary": "zircon manual", "detail": f"# zircon\nManual {number}."} + + +class _Provider(ThreadingHTTPServer): + """Answers the probe and global stages; a manual replace lands during the first global answers.""" + + loop: asyncio.AbstractEventLoop + replace: Callable[[int], Coroutine[Any, Any, None]] + global_calls = 0 + + +class _Handler(BaseHTTPRequestHandler): + def do_POST(self): + provider = self.server + assert isinstance(provider, _Provider) + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + prompt = json.dumps(request["messages"]) + # Lineage-only system Sources from the manual writes share the journal, so cite what was supplied. + evidence = sorted(set(re.findall(r"evidence-\d{4}", prompt))) + if "Evolve the whole Window" in prompt: + provider.global_calls += 1 + if provider.global_calls <= _REPLACES: + # The Worker already read the head; this replace makes its publication stale. + asyncio.run_coroutine_threadsafe(provider.replace(provider.global_calls), provider.loop).result(30) + output = { + "ambiguous": False, + "proposals": [{"candidate_id": "candidate-0001", "content": _WORKER_CONTENT, "evidence_ids": evidence}], + } + else: + output = {"probes": [{"query": "zircon", "evidence_ids": evidence}]} + arguments = json.dumps(output) + message = {"role": "assistant", "content": arguments} + finish_reason = "stop" + if request.get("tools"): + name = request["tools"][0]["function"]["name"] + message = { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "result", "type": "function", "function": {"name": name, "arguments": arguments}} + ], + } + finish_reason = "tool_calls" + body = json.dumps({ + "id": "result", + "object": "chat.completion", + "created": 0, + "model": request["model"], + "choices": [{"index": 0, "finish_reason": finish_reason, "message": message}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib override + pass + + +def test_http_replaces_during_generation_never_block_the_spawned_topic_worker(tmp_path, monkeypatch, caplog): + caplog.set_level(logging.INFO, logger="powercontext.builtin.runtime.artifact_processing") + monkeypatch.setenv("OPENAI_API_KEY", "local-test-provider") + provider = _Provider(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=provider.serve_forever, daemon=True) + thread.start() + + async def scenario(): + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'head-conflict.db'}"), + inference=InferenceConfig( + generation_model="openai-chat:head-conflict", + generation_base_url=AnyHttpUrl(f"http://127.0.0.1:{provider.server_port}/v1"), + ), + runtime=RuntimeConfig(artifact_processing_families=("topic-memory",)), + mcp=McpConfig(enabled=False), + ), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as http, + ): + client = PowerContextClient("http://testserver", http_client=http, trust_transport_security=True) + scope = (await client.get_default_scope()).scope_id + created = await http.post( + f"/v1/scopes/{scope}/artifacts", json={"family": "topic-memory", "content": _manual_content(0)} + ) + assert created.status_code == 201, created.text + path = created.headers["Location"] + + async def replace(number): + current = await http.get(path) + replaced = await http.put( + path, headers={"If-Match": current.headers["ETag"]}, json={"content": _manual_content(number)} + ) + assert replaced.status_code == 200, replaced.text + + provider.loop = asyncio.get_running_loop() + provider.replace = replace + await client.capture_content_source( + CaptureContentSourceRequest(scope_id=scope, source_id="update", content="zircon changed") + ) + await client.flush_topic_memory(FlushTopicMemoryRequest(scope_id=scope)) + async with asyncio.timeout(120): + while (await http.get(path)).json()["content"] != _WORKER_CONTENT: + status = await client.get_topic_memory_processing_status( + TopicMemoryProcessingStatusRequest(scope_id=scope) + ) + assert status.block is None + await asyncio.sleep(0.05) + + # The Worker revised the head written by the last manual replace. + revisions = (await http.get(path + "/revisions")).json()["items"] + assert len(revisions) == _REPLACES + 2 + assert provider.global_calls == _REPLACES + 1 + status = await client.get_topic_memory_processing_status(TopicMemoryProcessingStatusRequest(scope_id=scope)) + assert status.block is None + # The captured Source is consumed; later Sources are the manual writes' lineage-only records. + assert status.source_cursor >= 2 + + conflicts = [ + record + for record in caplog.records + if getattr(record, "event", None) == "artifact_processing.attempt.completed" + and getattr(record, "outcome", None) == "conflict" + ] + assert [getattr(record, "error_code", None) for record in conflicts] == ["head_conflict"] * _REPLACES + assert not [r for r in caplog.records if getattr(r, "event", None) == "artifact_processing.blocked"] + + try: + asyncio.run(scenario()) + finally: + provider.shutdown() + provider.server_close() + thread.join(timeout=5) From 9626a4cb560d828f7c81635a037c62a10879ca2a Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Wed, 11 Nov 2026 17:24:55 -0500 Subject: [PATCH 2/2] fix(topic-memory): scope head-conflict handback to publication Only the publisher's head CAS hands an attempt back. A RevisionConflictError from any other step keeps the previous mapping: HEAD_CONFLICT without a recorded cause, so that attempt still counts. The catch sits outside the commit tracing stage, so the stage still observes the conflict. A head that never settles normally crosses the provider ceiling inside an attempt, so the block shows window_provider_budget_exceeded as both reason and last error, with attempts=1. Test that case instead of one where the charges align exactly with the ceiling, and correct the Topic Memory docs. --- docs/en/docs/workflows/topic-memory.md | 5 +-- docs/zh/docs/workflows/topic-memory.md | 3 +- .../runtime/topic_memory_processing.py | 34 +++++++++++-------- .../test_topic_memory_head_conflict.py | 14 +++++--- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/en/docs/workflows/topic-memory.md b/docs/en/docs/workflows/topic-memory.md index 58185f2dc9..ea1f0ef6c9 100644 --- a/docs/en/docs/workflows/topic-memory.md +++ b/docs/en/docs/workflows/topic-memory.md @@ -106,8 +106,9 @@ budget behavior. Each explicitly accepted retry grants the same three-attempt an A manual replace or Dream can move a Topic head while a Worker is generating against it. The Worker's publication then fails its head check and writes nothing; the Worker records `head_conflict` as the attempt's last error, and the next attempt rereads the head and recomputes. Such an attempt does not count toward the three-attempt ceiling, but its -provider reservations do. A head that keeps moving therefore ends with `window_provider_budget_exceeded`, whose block -can show `last_error_code` `head_conflict`. +provider reservations do. A head that keeps moving therefore ends with `window_provider_budget_exceeded`. That +reason is usually also the block's `last_error_code`, because the ceiling is crossed during an attempt; a low +`attempts` count shows that recomputations after head conflicts, not failed attempts, used the budget. The equivalent Python Client SDK methods are `get_topic_memory_processing_status`, `get_topic_memory_processing_block`, and `retry_topic_memory_processing`. Their HTTP routes are diff --git a/docs/zh/docs/workflows/topic-memory.md b/docs/zh/docs/workflows/topic-memory.md index e60fb85c83..19aaa45432 100644 --- a/docs/zh/docs/workflows/topic-memory.md +++ b/docs/zh/docs/workflows/topic-memory.md @@ -150,7 +150,8 @@ retry 需要 `scope.admin` 权限,查询还需要该角色未包含的 `scope. Worker 生成期间,手动 replace 或 Dream 可能移动它所基于的 Topic head。此时 Worker 的发布无法通过 head 检查, 不写入任何内容;Worker 将 `head_conflict` 记为该次尝试的最后错误,下一次尝试重新读取 head 后重新计算。这类尝试 不计入三次尝试上限,但其 provider 预留仍然计入。因此 head 持续变化时会以 `window_provider_budget_exceeded` -结束,对应阻塞记录的 `last_error_code` 可能为 `head_conflict`。 +结束。由于额度通常在某次尝试中途耗尽,阻塞记录的 `last_error_code` 一般也是该原因;`attempts` 很小说明预算主要 +消耗在 head 冲突后的重新计算上,而不是失败的尝试。 Python Client SDK 对应方法为 `get_topic_memory_processing_status`、`get_topic_memory_processing_block` 和 `retry_topic_memory_processing`。HTTP 路径分别为 `POST /v1/topic-memory/processing/status`、 diff --git a/src/powercontext/builtin/runtime/topic_memory_processing.py b/src/powercontext/builtin/runtime/topic_memory_processing.py index 40e3e08f3e..f8dee45d09 100644 --- a/src/powercontext/builtin/runtime/topic_memory_processing.py +++ b/src/powercontext/builtin/runtime/topic_memory_processing.py @@ -573,31 +573,35 @@ async def process( await self._generate(assignment.scope_id, projected) if projected else ((), {}) ) operations = await self._prepare_operations(assignment.scope_id, proposals, candidates, evidence) - with ( - nullcontext() - if self._tracing is None - else self._tracing.stage("topic_memory.commit", attributes={}) - ): - await self._publisher.publish( - assignment, evidence, operations, work_budget=budget, commit_hook=commit_hook - ) + try: + with ( + nullcontext() + if self._tracing is None + else self._tracing.stage("topic_memory.commit", attributes={}) + ): + await self._publisher.publish( + assignment, evidence, operations, work_budget=budget, commit_hook=commit_hook + ) + except RevisionConflictError: + # A concurrent replace or Dream moved a Topic head after this attempt read it. + # Recording the cause hands the attempt back; its reservations stay charged. Only a + # generated proposal can target an existing head, and every stage reserves before its + # call, so each recomputation spends from the frontier's request and token ceilings. + await budget.record_failure(TOPIC_MEMORY_HEAD_CONFLICT) + return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.HEAD_CONFLICT) except TopicMemoryGenerationError as error: if error.code in RECORDED_TOPIC_MEMORY_REJECTIONS: await budget.fail(error.code) else: await budget.record_failure(error.code) raise - except RevisionConflictError: - # A concurrent replace or Dream moved a Topic head after this attempt read it. - # Recording the cause hands the attempt back; its reservations stay charged. Only a - # generated proposal can target an existing head, and every stage reserves before its - # call, so each recomputation spends from the frontier's request and token ceilings. - await budget.record_failure(TOPIC_MEMORY_HEAD_CONFLICT) - return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.HEAD_CONFLICT) except ArtifactProcessingLeadershipLostError: return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.LEADERSHIP_LOST) except GenerationConflictError: return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.CURSOR_CONFLICT) + except RevisionConflictError: + # Not raised by publication, so this attempt keeps counting toward the ceiling. + return ArtifactProcessingWorkerCompletion(ArtifactProcessingWorkerOutcome.HEAD_CONFLICT) finally: self._document_vectors.reset(vector_token) self._work_budget.reset(token) diff --git a/tests/builtin/runtime/test_topic_memory_head_conflict.py b/tests/builtin/runtime/test_topic_memory_head_conflict.py index f7d725bda9..54c518a09f 100644 --- a/tests/builtin/runtime/test_topic_memory_head_conflict.py +++ b/tests/builtin/runtime/test_topic_memory_head_conflict.py @@ -238,9 +238,11 @@ async def scenario() -> None: def test_a_head_that_never_settles_is_stopped_by_the_frontier_provider_ceiling() -> None: - # Large stage reservations make the durable token ceiling reachable in a few recomputations. - limit = MAX_TOPIC_MEMORY_WORK_TOKENS // 10 + # Large stage reservations make the durable token ceiling reachable in a few recomputations. The + # ceiling is not a multiple of one attempt's charge, so it is crossed inside an attempt, as usual. + limit = MAX_TOPIC_MEMORY_WORK_TOKENS // 9 per_attempt = 2 * 2 * (limit + limit // 4) # probe + global, each reserving two requests of capacity + assert MAX_TOPIC_MEMORY_WORK_TOKENS % per_attempt async def scenario() -> None: manager, profile, _, _, stage, processor, _ = await _setup(["move-head"] * 1_000, limit=limit) @@ -256,10 +258,12 @@ async def scenario() -> None: assert 0 < conflicts <= MAX_TOPIC_MEMORY_WORK_TOKENS // per_attempt assert stage.calls == conflicts row = await _budget(profile) - assert row is not None and row["attempts"] == 1 + assert row is not None assert exhausted_reason(row) == "window_provider_budget_exceeded" - # The ceiling stopped the next attempt before it began, so the conflict stays the visible cause. - assert row["last_error_code"] == "head_conflict" + # The attempt that crossed the ceiling records its own cause. A single spent attempt is what + # shows that recomputations after head conflicts, not failed attempts, used the budget. + assert row["last_error_code"] == "window_provider_budget_exceeded" + assert row["attempts"] == 1 finally: await manager.__aexit__(None, None, None)