fix: backfill missing 1.1.4 fixes into main (#366)

* fix(knowledge): contain original-file write path (CWE-22)

The multipart upload filename was joined into ``_original/`` verbatim.
An attacker-controlled filename such as ``/tmp/pwned`` or
``../../.bashrc`` would let ``POST /knowledge/documents`` write outside
the document directory (the ``/`` operator discards the left operand
for absolute paths; ``..`` walks upward). The read side had the
symmetric issue.

Fix, mirroring the sender_id containment shipped in 1.0.1
(GHSA-c795-2g9c-j48m):

- Add ``_safe_original_filename`` reducing the untrusted filename to a
  single POSIX/Windows-basename component; reject degenerate residuals
  (``""``, ``"."``, ``".."``) with PathTraversalError.
- ``_write_original_file`` asserts ``target.resolve()`` stays inside
  ``original_dir.resolve()`` before any filesystem touch (mkdir/write).
- ``_resolve_original_file_path`` sanitises symmetrically so a stored
  provenance label can never resolve to an out-of-directory file.

Four SEC regression tests cover: absolute filename, ``..`` traversal,
degenerate filename rejection, and read-side sanitisation.

Backport from GitLab release/v1.1.4 (commit 40f19de) — 1.1.4 shipped
this fix; the GitLab -> GitHub sync stopped at 1.1.3, so 1.2.0
regressed the containment. This commit alone restores the fix; the
1.2.1 release PR ships it to PyPI.

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

* fix(cascade): retry classification, budget, and reconcile races

Backport the cascade reliability work that shipped in GitLab 1.1.4
(MR !49 / commit 95db2f5) — six interlocking changes the reviewer
should read in order:

1. Worker retry classification uses ExternalServiceError (embedding /
   LLM / rerank transient failures) as the "retry inline" signal.
   The legacy RecoverableError hierarchy under cascade/errors.py is
   removed; the retry contract now lives in the domain error tree
   (core/errors.py). Docstrings in handlers/base.py and
   sqlite/tables/md_change_state.py updated to match.

2. Cross-cycle retry budget: _MAX_TOTAL_RETRIES = 12. Once total
   attempts across scanner cycles exhaust the budget, the worker
   marks retryable=False in place instead of looping forever on a
   sustained upstream outage.

3. md_change_state upsert preserves retry_count on scanner
   re-enqueue when mtime is unchanged (previously reset to 0 every
   sweep, defeating the budget). mtime change (user edit) still
   resets the counter.

4. Reconciler no longer re-enqueues pending / processing rows on
   stable mtime — that was overwriting the worker's mark_done. It
   also skips failed rows with retryable=False on stable mtime so
   the entry-check demote path is stable.

5. mtime tolerance (10 ms, MTIME_TOLERANCE_SECONDS) absorbs the
   SQLite REAL float precision loss that previously flapped the
   reconcile decision when the same md was rewritten without a real
   content change. The constant is defined once in the sqlite repo
   and imported by the reconciler so both sides use the same tol.

6. Worker _run_rebuild_once carries an explicit
   `state.task is not asyncio.current_task()` guard before awaiting
   the optimize task — the previous contextlib.suppress was silently
   swallowing self-await RuntimeError.

Kept intact from the GitHub 1.2.0 baseline:
- The `except FileNotFoundError → handle_deleted` branch in the
  worker (delete/modify race — see
  test_modified_event_for_vanished_file_is_processed_as_delete).

Test coverage added:
- test_retry_budget_exhausted_marks_unrecoverable
- test_external_service_error_at_budget_edge_demotes_in_place
- test_upsert_preserves_retry_count_for_failed_stable_mtime
- test_upsert_resets_retry_count_on_mtime_change
- test_optimize_fallback_rebuild_on_sustained_failure
- reconciler mtime-tolerance / stable-mtime skip suite

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

* fix(embedding): raise on empty API data; forward MRL dimensions

Backport from GitLab 1.1.4 (MR !49 / commit 95db2f5).

Two behavioural changes on ``OpenAIEmbeddingProvider._embed_chunk``:

1. ``response.data == []`` now raises ``EmbeddingServiceError`` instead
   of returning an empty list. Some upstream providers (observed on
   DeepInfra under load) return HTTP 200 with an empty ``data`` array;
   the silent zero-vector path was corrupting search indexes without
   any signal.

2. When ``[embedding] dimensions = N`` is set in ``everos.toml``, the
   parameter is forwarded to the API so MRL-capable models
   (OpenAI text-embedding-3-*, Qwen3-Embedding, ...) do server-side
   truncation with proper re-normalization. Client-side truncation to
   ``dim`` remains as a fallback for backends that ignore the param.
   ``openai.NOT_GIVEN`` is used as the sentinel so the request omits
   the field when the setting is left at the default ``None``.

Config plumbing:
- ``EmbeddingSettings.dimensions: int | None = None``
- factory forwards ``dimensions=settings.dimensions`` to the provider

The provider stays inside the existing ``memory_span`` OTel wrapper
and continues to report input-only tokens via ``set_generation_usage``
- both are GitHub 1.2.0 native tracing behaviours preserved intact.

Test coverage:
- test_empty_response_data_raises_embedding_error (new)
- test_usage_span._FakeEmbeddings.create signature updated to accept
  ``dimensions`` kwarg so the OTel token-recording tests still exercise
  the same call path

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

* fix(extract): retry episode extraction on malformed LLM output

Backport from GitLab 1.1.4 (MR !49 / commit 95db2f5).

The ``/flush`` synchronous path called ``EpisodeExtractor.aextract``
exactly once. everalgo raises ``ValueError`` when the LLM returns
malformed JSON (observed with OpenRouter partial responses where
finish_reason=stop but the body is truncated) — the caller was
surfaced a 500 for a transient upstream hiccup.

``_extract_with_retry`` wraps the call with two extra attempts at 1s
and 2s backoff (final attempt propagates untouched), typed as
``AlgoEpisode`` so the caller path stays annotated. Retry stays inside
the existing GitHub 1.2.0 ``memory_span("everos.extract", ...)`` OTel
wrapper — the OTel token capture and the retry loop are orthogonal.

TODO in the code notes we should catch a typed everalgo
``ExtractionError`` once that type is introduced (currently ValueError
is broad).

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

* chore: log non-stop finish_reason; bump everalgo-user-memory 0.3.2

Two loosely coupled changes from GitLab 1.1.4 (MR !49 / commit 95db2f5)
that arrive together as a housekeeping commit.

1. ``_LoggingLLMClient`` diagnostic wrapper (new, file-private).

   Wraps the raw everalgo LLM client and, on every ``chat()``, warns
   when ``resp.finish_reason != "stop"`` — logging the reason,
   ``content_len``, the last 200 chars of ``content``, and ``model``.
   Aims at OpenRouter/DeepSeek truncation triage where the provider
   silently caps output length and returns finish_reason=length /
   filter / etc. Non-invasive: one branch per call, no config gate.

   Wrapper stack in ``get_llm_client``:

       LoggingLLMClient(UsageRecordingClient(build_client(...)))
       LoggingLLMClient(build_client(...))    # observability off

   ``UsageRecordingClient`` (GitHub 1.2.0 native OTel token capture)
   stays gated by ``settings.observability.enabled`` — this commit
   preserves that. LoggingLLMClient is always outermost so the reason
   it observes is exactly the reason the underlying provider reported.

2. ``everalgo-user-memory`` 0.3.1 -> 0.3.2 (pyproject + uv.lock).

   Same bump the GitLab 1.1.4 release lane took; unblocks the
   episode-extract retry work in commit 4 seeing the upstream
   improvements. Verified via ``uv sync``.

No functional API changes.

Test coverage:
- test_returns_singleton_when_configured now asserts the outer
  LoggingLLMClient wrapper.
- test_wraps_client_when_observability_enabled asserts the two-layer
  Logging(UsageRecording(...)) stack.
- test_does_not_wrap_client_when_observability_disabled asserts
  Logging still wraps when tracing is off.
- test_logging_wrapper_warns_on_non_stop_finish_reason (new).
- test_logging_wrapper_silent_on_stop_finish_reason (new).

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

* docs(changelog): restore [1.1.4] section to match published sdist

The 1.1.4 changelog entry on this branch previously listed three
items (Langfuse example, delete/modify race, live-server telemetry).
The 1.1.4 sdist on PyPI, however, was built from the internal
release lane and includes the CWE-22 containment, the cascade
retry-budget / mtime-tolerance / reconcile-guard work, the embedding
empty-data raise, the episode-extract retry, MRL dimensions, and the
LLM finish_reason diagnostic — none of which were represented here
when the tag was cut.

Rewrite the [1.1.4] section so it matches the wheel a user actually
installs from PyPI:

- Add a header note explaining the retroactive restoration.
- Fixed: CWE-22, cascade reliability bundle, delete/modify race
  (unchanged wording), embedding empty-data, episode extract retry,
  Langfuse live-server telemetry (unchanged wording).
- Added: MRL dimensions, LLM finish_reason diagnostic, Langfuse
  example (unchanged wording).
- Changed: everalgo-user-memory 0.3.1 -> 0.3.2.

The GitLab-side `.gitlab-ci.yml` in-house-runner entry is dropped —
open-source CI runs on GitHub Actions and the internal runner switch
is not visible to public users.

Date stays 2026-07-20 (the GitHub v1.1.4 tag date / PyPI upload
timestamp) rather than the internal 2026-07-23 code-freeze date, so
the timeline of what shipped where remains internally consistent.

The corresponding code fixes are all backported by earlier commits
in this PR; this commit only aligns the changelog surface.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kendrick-Song 2026-07-28 14:57:53 +08:00 committed by GitHub
parent 6dcd3ebda2
commit 42629dfd4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 818 additions and 147 deletions

View File

@ -26,20 +26,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.1.4] - 2026-07-20
### Added
- **Langfuse integration example** — added an OpenTelemetry-based wrapper for
tracing EverOS add, flush/extract, search, and reflection operations, with a
built-in mock and support for connecting to a real EverOS server.
> The entries below reflect the code shipped as `everos==1.1.4` on PyPI.
> The 1.1.4 sdist was built from the internal release lane and contains
> fixes that were not represented in this file when 1.1.4 was tagged;
> this section restores them so the changelog matches the wheel.
### Fixed
- **Cascade delete/modify race** — when a file disappears after its modified
event is queued, the worker now processes it as a deletion instead of leaving
a stale indexed row and permanently failed queue item.
- **Langfuse live-server traces use only real telemetry** — synthetic child
spans are now limited to responses that provide stage details, while real
servers emit accurate top-level latency, output, and recall-quality scores.
- **Knowledge upload path traversal (CWE-22)** — the original-file write
path is now contained to the document directory; adversarial filenames
(`..`, absolute paths, symlink games) are rejected on
`POST /api/v1/knowledge/documents` and `POST /api/v2/knowledge/documents`.
- **Cascade reliability — retry classification, budget, and races** — the
worker catches `ExternalServiceError` (embedding / LLM / rerank transient
failures) and retries inline up to 3 times before marking
`retryable=True`; a total retry budget of 12 attempts across scanner
cycles bounds retries on prolonged outages. The reconciler no longer
re-enqueues `pending` / `processing` rows on stable mtime (previously
overwrote the worker's `mark_done`); `failed` rows with
`retryable=False` on stable mtime skip auto-retry so users can edit and
re-save. SQLite `REAL` float precision loss in mtime comparisons is
now absorbed via a 10 ms tolerance. LanceDB `optimize()` failures
escalate to a `drop_index + create_index` rebuild after 5 consecutive
misses (workaround for `lance-format/lance#7653` panic path).
- **Cascade delete/modify race** — when a file disappears after its
modified event is queued, the worker processes it as a deletion
instead of leaving a stale indexed row and permanently failed queue
item.
- **Embedding provider raises on empty API data** — the provider now
raises `EmbeddingServiceError` when the API returns HTTP 200 with an
empty `data` array (previously silently returned zero-length vectors,
corrupting search).
- **Episode extraction retries on malformed LLM output** — the `/flush`
synchronous path retries everalgo `ValueError` (typically OpenRouter
truncated responses) twice with 1 s / 2 s backoff before surfacing a
500.
- **Langfuse live-server traces use only real telemetry** — synthetic
child spans are now limited to responses that provide stage details,
while real servers emit accurate top-level latency, output, and
recall-quality scores.
### Added
- **Optional `dimensions` parameter for MRL-capable embedding models**
opt-in via `[embedding] dimensions = N` in `everos.toml`; forwarded to
the API for server-side truncation with re-normalization (OpenAI
text-embedding-3-\*, Qwen3-Embedding).
- **LLM `finish_reason` diagnostic warnings** — logs `content_len` /
`content_tail` / `model` when the provider returns a non-`stop`
`finish_reason`, aiding OpenRouter truncation triage.
- **Langfuse integration example** — added an OpenTelemetry-based
wrapper for tracing EverOS add, flush/extract, search, and reflection
operations, with a built-in mock and support for connecting to a real
EverOS server.
### Changed
- **`everalgo-user-memory` bumped 0.3.1 → 0.3.2**.
## [1.1.3] - 2026-07-10

View File

@ -62,7 +62,7 @@ dependencies = [
"anyio>=4.0", # Async file I/O (anyio.Path, to_thread.run_sync) for the markdown layer
# Algorithm library (everalgo monorepo, published on PyPI).
"everalgo-user-memory==0.3.1",
"everalgo-user-memory==0.3.2",
"everalgo-agent-memory==0.3.1",
"everalgo-rank==0.4.1",
"everalgo-knowledge==0.1.1",

View File

@ -49,6 +49,7 @@ def build_embedding_provider(
api_key=settings.api_key.get_secret_value(),
base_url=settings.base_url,
dim=dim,
dimensions=settings.dimensions,
timeout=settings.timeout_seconds,
max_retries=settings.max_retries,
batch_size=settings.batch_size,

View File

@ -2,10 +2,11 @@
Wraps :class:`openai.AsyncOpenAI` so any OpenAI-protocol endpoint
(DeepInfra, OpenAI, Together, Fireworks, ) works without per-provider
forks. Self-hosted vLLM also exposes the same shape; the only quirk it
imposes is that the ``dimensions`` request parameter is ignored we
truncate client-side to ``dim`` so callers always see the declared
shape regardless of backend.
forks. When ``dimensions`` is set, the parameter is forwarded to the
API so MRL-capable models (OpenAI text-embedding-3-*, Qwen3-Embedding,
) can do server-side truncation with proper re-normalization.
Client-side truncation to ``dim`` is always applied as a fallback for
backends that ignore or don't support the parameter.
Concurrency model:
@ -37,8 +38,11 @@ class OpenAIEmbeddingProvider:
base_url: OpenAI-protocol endpoint
(e.g. ``"https://api.deepinfra.com/v1/openai"``).
dim: Target vector dimension. Vectors longer than this are
truncated client-side (matches the LanceDB column shape
see ``17_lancedb_tables_design.md``).
truncated client-side (matches the LanceDB column shape).
dimensions: Optional API-level ``dimensions`` parameter for
MRL-capable models. When set, forwarded to the embedding
API for server-side truncation with re-normalization.
When ``None``, the parameter is omitted from the request.
timeout: Per-request timeout, seconds.
max_retries: Retry budget exposed via the openai SDK.
batch_size: How many inputs per ``/embeddings`` call.
@ -52,12 +56,14 @@ class OpenAIEmbeddingProvider:
api_key: str,
base_url: str,
dim: int = 1024,
dimensions: int | None = None,
timeout: float = 30.0,
max_retries: int = 3,
batch_size: int = 10,
max_concurrent: int = 5,
) -> None:
self.dim = dim
self._dimensions = dimensions
self._model = model
self._batch_size = batch_size
self._semaphore = asyncio.Semaphore(max_concurrent)
@ -100,14 +106,21 @@ class OpenAIEmbeddingProvider:
response = await self._client.embeddings.create(
model=self._model,
input=chunk,
dimensions=self._dimensions
if self._dimensions is not None
else openai.NOT_GIVEN,
)
except openai.OpenAIError as exc:
raise EmbeddingServiceError(str(exc)) from exc
if not response.data:
raise EmbeddingServiceError(
f"Embedding API returned empty data for {len(chunk)} inputs"
)
# Embeddings report only input (prompt) tokens.
usage = getattr(response, "usage", None)
set_generation_usage(
model=self._model,
input_tokens=usage.prompt_tokens if usage else None,
)
# OpenAI returns ``data`` indexed by request order; truncate to ``dim``.
# Client-side truncation — always applied as fallback.
return [list(item.embedding[: self.dim]) for item in response.data]

View File

@ -9,9 +9,13 @@ provider) instead of silently failing per-request downstream.
from __future__ import annotations
from typing import Any
from everalgo.llm import build_client
from everalgo.llm.config import LLMConfig
from everalgo.llm.protocols import LLMClient
from everalgo.llm.types import ChatMessage, ChatResponse
from pydantic import BaseModel
from everos.config import load_settings
from everos.core.observability.logging import get_logger
@ -21,6 +25,48 @@ from ._usage_client import UsageRecordingClient
logger = get_logger(__name__)
class _LoggingLLMClient:
"""Wrapper that logs non-stop ``finish_reason`` for diagnostics.
Always active cost is one branch per chat() call. OpenRouter and
a few compatible providers occasionally return HTTP 200 with a
truncated body and ``finish_reason != "stop"`` (length cap, filter
trigger). Recording the reason plus the tail of the content lets
us triage those without needing a repro from the caller.
"""
def __init__(self, inner: LLMClient) -> None:
self._inner = inner
async def chat(
self,
messages: list[ChatMessage],
*,
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
response_format: type[BaseModel] | None = None,
**extra: Any,
) -> ChatResponse:
resp = await self._inner.chat(
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
response_format=response_format,
**extra,
)
if resp.finish_reason and resp.finish_reason != "stop":
logger.warning(
"llm_non_stop_finish",
finish_reason=resp.finish_reason,
content_len=len(resp.content),
content_tail=resp.content[-200:] if resp.content else "",
model=resp.model,
)
return resp
class LLMNotConfiguredError(RuntimeError):
"""Raised when ``settings.llm`` is missing ``api_key`` or ``base_url``."""
@ -60,7 +106,11 @@ def get_llm_client() -> LLMClient:
# disabled path (the default) allocation- and overhead-free.
if settings.observability.enabled:
client = UsageRecordingClient(client)
_llm_client = client
# Finish-reason diagnostic wrapper is always outermost: it must see
# the response even when tracing is off, and it must observe the
# exact reason the underlying provider reported (not one synthesised
# by an inner wrapper).
_llm_client = _LoggingLLMClient(client)
logger.info("llm_client_built", model=llm_cfg.model)
return _llm_client

View File

@ -176,6 +176,7 @@ class EmbeddingSettings(BaseModel):
EVEROS_EMBEDDING__MODEL
EVEROS_EMBEDDING__API_KEY
EVEROS_EMBEDDING__BASE_URL
EVEROS_EMBEDDING__DIMENSIONS
EVEROS_EMBEDDING__TIMEOUT_SECONDS
EVEROS_EMBEDDING__MAX_RETRIES
EVEROS_EMBEDDING__BATCH_SIZE
@ -185,6 +186,12 @@ class EmbeddingSettings(BaseModel):
model: str | None = None
api_key: SecretStr | None = None
base_url: str | None = None
dimensions: int | None = None
"""API-level ``dimensions`` parameter for MRL-capable models.
When set, passed to the embedding API so the server truncates
with proper re-normalization. When ``None`` (default), the
parameter is omitted and client-side truncation to ``dim``
handles dimension alignment."""
timeout_seconds: float = Field(default=30.0, gt=0)
max_retries: int = Field(default=3, ge=0)
batch_size: int = Field(default=10, ge=1)

View File

@ -31,6 +31,7 @@ not need to manage either.
# ``SQLModel.metadata`` so ``SqliteLifespanProvider.startup`` can
# ``create_all`` without callers having to import each model module.
from . import tables as tables
from .repos import MTIME_TOLERANCE_SECONDS as MTIME_TOLERANCE_SECONDS
from .repos import DocumentListPage as DocumentListPage
from .repos import DocumentUpsertPayload as DocumentUpsertPayload
from .repos import QueueSummary as QueueSummary
@ -58,6 +59,7 @@ from .tables import ReflectionReport as ReflectionReport
from .tables import UnprocessedBuffer as UnprocessedBuffer
__all__ = [
"MTIME_TOLERANCE_SECONDS",
"Cluster",
"ClusterMember",
"ConversationStatus",

View File

@ -12,6 +12,7 @@ from .knowledge import DocumentUpsertPayload as DocumentUpsertPayload
from .knowledge import TopicUpsertPayload as TopicUpsertPayload
from .knowledge import knowledge_document_repo as knowledge_document_repo
from .knowledge import knowledge_topic_sqlite_repo as knowledge_topic_sqlite_repo
from .md_change_state import MTIME_TOLERANCE_SECONDS as MTIME_TOLERANCE_SECONDS
from .md_change_state import QueueSummary as QueueSummary
from .md_change_state import md_change_state_repo as md_change_state_repo
from .memcell import memcell_repo as memcell_repo
@ -19,6 +20,7 @@ from .reflection_report import reflection_report_repo as reflection_report_repo
from .unprocessed_buffer import unprocessed_buffer_repo as unprocessed_buffer_repo
__all__ = [
"MTIME_TOLERANCE_SECONDS",
"DocumentListPage",
"DocumentUpsertPayload",
"QueueSummary",

View File

@ -29,7 +29,7 @@ from __future__ import annotations
import dataclasses
from sqlalchemy import func, select, update
from sqlalchemy import func, select, text, update
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@ -39,6 +39,20 @@ from everos.core.persistence.sqlite import RepoBase, session_scope
from ..sqlite_manager import get_session_factory
from ..tables import MdChangeState
MTIME_TOLERANCE_SECONDS = 0.01
"""Absolute mtime delta (seconds) treated as "unchanged".
Used both by the upsert's retry_count carry-over CASE (below) and by the
cascade reconciler's stable-mtime check
(:func:`everos.memory.cascade.reconciler.reconcile`). The two comparisons
share this single source of truth so their notions of "same file" stay
in lock-step; drift would leave upsert preserving ``retry_count`` while
the reconciler skips re-enqueue (or vice versa), corrupting the retry
budget.
Sized to absorb SQLite ``REAL`` round-trip loss (~5 µs) so a stable
mtime does not oscillate the reconcile decision every scanner tick."""
@dataclasses.dataclass(frozen=True)
class QueueSummary:
@ -94,9 +108,13 @@ class _MdChangeStateRepo(RepoBase[MdChangeState]):
``lsn = MAX(lsn) + 1``.
- **Existing row** bump ``last_changed_at``, refresh
``kind`` / ``change_type`` / ``mtime``, reset status back to
``pending``, zero ``retry_count`` / ``error`` / ``retryable``,
and assign a fresh ``MAX(lsn) + 1`` so the worker re-processes
this path *after* anything queued in between.
``pending``, clear ``error`` / ``retryable``, and assign a
fresh ``MAX(lsn) + 1`` so the worker re-processes this path
*after* anything queued in between. ``retry_count`` is
conditionally preserved: if the row was ``failed`` and the
mtime is unchanged (scanner auto-retry), ``retry_count``
carries over so the worker can enforce a total retry budget;
otherwise it resets to 0 (new content deserves a fresh start).
The fresh LSN on re-enqueue is the property that lets the worker
rely on ``ORDER BY lsn`` for ordering without losing fairness
@ -134,8 +152,16 @@ class _MdChangeStateRepo(RepoBase[MdChangeState]):
"status": "pending",
"retryable": None,
"last_attempt_at": None,
"retry_count": 0,
"error": None,
"retry_count": text(
"CASE"
" WHEN md_change_state.status = 'failed'"
" AND ABS(md_change_state.mtime - :new_mtime)"
f" < {MTIME_TOLERANCE_SECONDS}"
" THEN md_change_state.retry_count"
" ELSE 0"
" END"
).bindparams(new_mtime=mtime),
},
)
)

View File

@ -99,10 +99,10 @@ class MdChangeState(BaseTable, table=True):
retryable: bool | None = Field(default=None)
"""Meaningful only when ``status='failed'``.
- ``TRUE`` RecoverableError exhausted MAX_RETRY; ``cascade fix
- ``TRUE`` ExternalServiceError exhausted MAX_RETRY; ``cascade fix
--apply`` will re-enqueue this row (pending, retry_count reset).
- ``FALSE`` UnrecoverableError (malformed YAML, schema error
etc.); requires editing the md and re-saving.
- ``FALSE`` unrecoverable error (malformed YAML, schema error,
retry budget exhausted); requires editing the md and re-saving.
- ``NULL`` not a failed row (pending / processing / done).
"""

View File

@ -9,15 +9,10 @@ Public surface — what lifespan providers / CLI commands import:
- :class:`CascadeOrchestrator` composite owner; start / stop / sync.
- :class:`CascadeConfig` construction-time tuning knobs.
- :class:`RecoverableError` / :class:`UnrecoverableError` handler
contract for retry classification.
- :data:`KIND_REGISTRY` / :func:`match_kind` kind dispatch (also
used by CLI ``cascade sync --path`` to resolve a single file's kind).
"""
from .errors import CascadeError as CascadeError
from .errors import RecoverableError as RecoverableError
from .errors import UnrecoverableError as UnrecoverableError
from .orchestrator import CascadeConfig as CascadeConfig
from .orchestrator import CascadeOrchestrator as CascadeOrchestrator
from .registry import KIND_REGISTRY as KIND_REGISTRY
@ -27,10 +22,7 @@ from .registry import match_kind as match_kind
__all__ = [
"KIND_REGISTRY",
"CascadeConfig",
"CascadeError",
"CascadeOrchestrator",
"KindSpec",
"RecoverableError",
"UnrecoverableError",
"match_kind",
]

View File

@ -1,30 +0,0 @@
"""Cascade error hierarchy — drives the worker's retry classification.
The worker decides ``mark_failed(retryable=True/False)`` purely by
exception class:
- :class:`RecoverableError` transient (HTTP 5xx, network, embedding
rate limit). Worker retries up to ``MAX_RETRY`` inline, then marks
``retryable=TRUE`` so ``cascade fix --apply`` can re-enqueue.
- :class:`UnrecoverableError` fatal (YAML parse, missing required
field, schema mismatch). Worker stops immediately and marks
``retryable=FALSE`` only a user edit to the md will unstick it.
- Anything else treated as :class:`UnrecoverableError`. The worker
catches ``Exception`` defensively so an unexpected failure never
hangs the daemon, but the diagnostic message carries the original
type for triage.
"""
from __future__ import annotations
class CascadeError(Exception):
"""Root of the cascade error tree."""
class RecoverableError(CascadeError):
"""Transient failure — worker should retry then mark retryable."""
class UnrecoverableError(CascadeError):
"""Fatal failure — needs a user edit before re-running."""

View File

@ -45,7 +45,7 @@ class Handler(abc.ABC):
``handle_added_or_modified`` and ``handle_deleted`` are the two
cases the worker dispatches on, derived from
:class:`MdChangeState.change_type`. Either may raise the worker
catches and classifies (``RecoverableError`` vs unrecoverable) to
catches and classifies (``ExternalServiceError`` vs unrecoverable) to
drive the retry / failed-state lifecycle.
"""

View File

@ -21,7 +21,12 @@ Three categories per 12 doc §5.3:
Paths whose prior state row is ``done`` AND the mtime matches are
skipped on the add/modify side the reconcile output stays tight on
quiet sweeps.
quiet sweeps. The same applies to ``failed`` rows whose ``retryable``
flag is ``False`` (unrecoverable) and whose mtime is unchanged those
must not be auto-re-enqueued; the user has to edit the md (which
changes mtime) to get another attempt. ``failed`` rows with
``retryable=True`` (transient) are re-emitted on a stable mtime so the
worker auto-retries on the next scanner sweep.
"""
from __future__ import annotations
@ -29,6 +34,8 @@ from __future__ import annotations
import dataclasses
from collections.abc import Iterable, Mapping
from everos.infra.persistence.sqlite import MTIME_TOLERANCE_SECONDS
from .types import ReconcileDecision, ScanInput
@ -46,6 +53,11 @@ class PriorState:
mtime: float
status: str # "pending" | "processing" | "done" | "failed"
change_type: str # "added" | "modified" | "deleted"
retryable: bool | None = None
"""``failed`` eligibility flag: ``True`` transient, ``False``
unrecoverable, ``None`` for non-``failed`` rows."""
retry_count: int = 0
"""Total retry attempts across scanner re-enqueue cycles."""
def reconcile(
@ -81,8 +93,14 @@ def reconcile(
)
)
continue
# Skip when the row is already done and mtime hasn't moved.
if prior.status == "done" and prior.mtime == item.mtime:
mtime_stable = abs(prior.mtime - item.mtime) < MTIME_TOLERANCE_SECONDS
if mtime_stable and prior.status in (
"done",
"pending",
"processing",
):
continue
if mtime_stable and prior.status == "failed" and prior.retryable is False:
continue
decisions.append(
ReconcileDecision(

View File

@ -173,6 +173,8 @@ async def _load_state_snapshot() -> dict[str, PriorState]:
mtime=row.mtime,
status=row.status,
change_type=row.change_type,
retryable=row.retryable,
retry_count=row.retry_count,
)
for row in rows
}

View File

@ -9,12 +9,23 @@ Each cycle:
:meth:`handle_added_or_modified` or :meth:`handle_deleted` based on
the row's ``change_type``.
3. On success: ``mark_done``.
4. On :class:`RecoverableError`: retry inline up to ``MAX_RETRY``; if
all attempts fail, ``mark_failed(retryable=True)``.
4. On :class:`~everos.core.errors.ExternalServiceError`: retry inline
up to ``MAX_RETRY``; if all attempts fail,
``mark_failed(retryable=True)`` unless the cross-cycle retry
budget (:data:`_MAX_TOTAL_RETRIES`) is also exhausted, in which
case ``retryable=False`` is set here directly so the row skips
one extra scanner cycle before the entry-check would demote it.
5. On any other exception: ``mark_failed(retryable=False)`` (treated
as unrecoverable, surfaces in ``cascade fix`` for the user to
triage by editing the md).
Before step 2, a row whose ``retry_count`` has already reached
:data:`_MAX_TOTAL_RETRIES` (across scanner re-enqueue cycles, not just
this batch's inline retries) is short-circuited straight to
``mark_failed(retryable=False)`` without invoking the handler this
bounds the total retry budget so a persistently-failing row cannot
retry forever.
Batch processing is concurrent inside a batch (``asyncio.gather``);
ordering across rows is best-effort the LSN gives a deterministic
prefix but the handlers themselves are independent.
@ -48,10 +59,10 @@ import datetime as dt
import time
from dataclasses import dataclass
from everos.core.errors import ExternalServiceError
from everos.core.observability.logging import get_logger
from everos.infra.persistence.sqlite import MdChangeState, md_change_state_repo
from .errors import RecoverableError
from .handlers import Handler
logger = get_logger(__name__)
@ -70,6 +81,17 @@ escalated from ``warning`` to ``error``. A one-off failure is benign
cleanup are stuck and the index dir will grow unbounded that must
surface to health checks / alerting rather than rot as a warning nobody
reads (the failure mode behind lance-format/lance#7653)."""
_MAX_TOTAL_RETRIES = 12
"""Total retry budget across scanner re-enqueue cycles.
Each cycle: worker retries up to ``MAX_RETRY`` (default 3) inline;
scanner re-enqueues on the next 30s sweep; retry_count accumulates.
12 4 scanner cycles × 3 inline retries 2 minutes of retrying.
Once exhausted, ``mark_failed(retryable=False)`` so the reconciler
stops re-enqueuing. Recover via ``cascade fix --apply`` (resets
retry_count) or editing the md (mtime change resets retry_count)."""
DEFAULT_OPTIMIZE_REBUILD_INTERVAL_SECONDS = 12 * 60 * 60.0
"""How often (per kind) to do a full ``drop_index + create_index`` rebuild.
@ -323,6 +345,21 @@ class CascadeWorker:
)
return None
if row.retry_count >= _MAX_TOTAL_RETRIES:
logger.warning(
"cascade_worker_retry_budget_exhausted",
md_path=row.md_path,
kind=row.kind,
retry_count=row.retry_count,
)
await md_change_state_repo.mark_failed(
row.md_path,
retryable=False,
error=f"retry budget exhausted after {row.retry_count} attempts",
new_retry_count=row.retry_count,
)
return None
retry_count = row.retry_count
last_error: str = ""
for attempt in range(self._max_retry + 1):
@ -333,8 +370,13 @@ class CascadeWorker:
try:
outcome = await handler.handle_added_or_modified(row.md_path)
except FileNotFoundError:
# The md disappeared between scanner enqueue and here
# (delete/modify race — cascade delete event may not
# arrive if it fires while the row is already in
# ``processing``). Fold into a deletion so the row
# completes with ``mark_done`` instead of failing.
outcome = await handler.handle_deleted(row.md_path)
except RecoverableError as exc:
except ExternalServiceError as exc:
last_error = f"{type(exc).__name__}: {exc}"
logger.warning(
"cascade_worker_recoverable",
@ -346,9 +388,13 @@ class CascadeWorker:
retry_count += 1
await asyncio.sleep(self._retry_backoff * (attempt + 1))
continue
# Inline attempts exhausted. Only stay retryable when
# the cross-cycle budget still has room; otherwise
# demote directly instead of taking an extra scanner
# cycle to hit the entry-check short-circuit.
await md_change_state_repo.mark_failed(
row.md_path,
retryable=True,
retryable=retry_count < _MAX_TOTAL_RETRIES,
error=last_error,
new_retry_count=retry_count,
)
@ -513,11 +559,6 @@ class CascadeWorker:
if state is not None:
state.optimize_failures += 1
failures = state.optimize_failures
# A one-off failure is benign (next tick retries). A sustained
# streak means optimize — compaction *and* version cleanup — is
# stuck, so the index dir grows unbounded; escalate to error so
# it surfaces to health checks / alerting instead of rotting as
# a warning nobody reads (see lance-format/lance#7653).
log = (
logger.error
if failures >= _OPTIMIZE_FAILURE_ALERT_THRESHOLD
@ -530,6 +571,20 @@ class CascadeWorker:
consecutive_failures=failures,
error=f"{type(exc).__name__}: {exc}",
)
if failures >= _OPTIMIZE_FAILURE_ALERT_THRESHOLD:
logger.info(
"cascade_lancedb_optimize_fallback_rebuild",
kind=kind,
consecutive_failures=failures,
)
await self._run_rebuild_once(kind)
# Reset even when rebuild fails: rate-limits fallback
# rebuild to at most once per threshold failures. A
# failed rebuild defers cleanup to the 12h periodic
# sweep — harmless for correctness (see
# _run_rebuild_once docstring).
if state is not None:
state.optimize_failures = 0
async def _heartbeat_loop(self) -> None:
"""Periodic safety net for the optimizer.
@ -603,7 +658,18 @@ class CascadeWorker:
# Drain any in-flight optimize before taking the rebuild slot —
# both would commit on the same manifest version. The optimize
# runner reciprocates (it awaits ``state.rebuild_task`` on entry).
if state.task is not None and not state.task.done():
# Skip when ``state.task`` is the current task: the fallback-rebuild
# path in ``_run_optimize_once`` reaches here from *inside* the
# optimize runner itself, so awaiting ``state.task`` would be
# self-await (asyncio raises RuntimeError). Suppress catches it,
# but relying on that is fragile — the explicit check is the
# correctness contract; suppress remains only for unexpected
# optimize failures.
if (
state.task is not None
and not state.task.done()
and state.task is not asyncio.current_task()
):
with contextlib.suppress(Exception):
await state.task
rebuild_task = asyncio.create_task(

View File

@ -13,9 +13,11 @@ Run inside ``service.memorize`` via ``asyncio.gather`` alongside
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING
from everalgo.types import Episode as AlgoEpisode
from everalgo.types import MemCell as AlgoMemCell
from everalgo.user_memory import EpisodeExtractor
@ -36,6 +38,8 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
_TRACK = "user_memory"
_EXTRACT_MAX_RETRIES = 2
_EXTRACT_RETRY_BACKOFF = 1.0
def _root_relative(path: str) -> str:
@ -122,8 +126,14 @@ class UserMemoryPipeline:
) as extract_span:
# Token usage is recorded onto this span by the LLM client
# wrapper when the extractor issues its chat() call.
algo_ep = await self._ep_ext.aextract(
cell, sender_id=None, prompt=episode_prompt
#
# Retry on ValueError: everalgo raises ValueError when the
# LLM returns malformed JSON (e.g. OpenRouter partial
# response with finish_reason=stop). Transient — same input
# typically succeeds on retry. TODO: catch a typed
# ExtractionError once everalgo introduces one.
algo_ep = await _extract_with_retry(
self._ep_ext, cell, episode_prompt, memcell_id
)
# Extracted memory text (only when capture_content is on).
capture_output(extract_span, algo_ep.episode)
@ -281,3 +291,27 @@ def _episode_to_entry_body(
sections["Summary"] = str(summary)
sections["Content"] = episode.episode
return inline, sections
async def _extract_with_retry(
extractor: EpisodeExtractor,
cell: AlgoMemCell,
prompt: str | None,
memcell_id: str,
) -> AlgoEpisode:
"""Call everalgo episode extraction with retry on malformed LLM output."""
for attempt in range(_EXTRACT_MAX_RETRIES):
try:
return await extractor.aextract(cell, sender_id=None, prompt=prompt)
except ValueError as exc:
wait = _EXTRACT_RETRY_BACKOFF * (attempt + 1)
logger.warning(
"episode_extract_retry",
memcell_id=memcell_id,
attempt=attempt,
error=str(exc)[:200],
backoff_s=wait,
)
await asyncio.sleep(wait)
# Final attempt — a ValueError here propagates to the caller.
return await extractor.aextract(cell, sender_id=None, prompt=prompt)

View File

@ -21,7 +21,7 @@ import shutil
import time
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Protocol
from uuid import uuid4
@ -35,6 +35,7 @@ from everos.core.errors import (
DocumentNotFoundError,
DuplicateDocumentError,
ExtractionEmptyError,
PathTraversalError,
TopicNotFoundError,
)
from everos.core.observability.logging import get_logger
@ -922,19 +923,58 @@ def _apply_category_fallback(
return patched
def _safe_original_filename(source_name: str) -> str:
"""Reduce an untrusted upload filename to a single safe path component.
The multipart ``filename`` is attacker-controlled. Used verbatim as a
path segment it enables traversal (CWE-22): ``Path(base) / "/abs/path"``
discards ``base`` because the right operand is absolute, and
``base / ".."`` walks upward so a filename of ``/home/u/.bashrc`` or
``../../x`` would let a caller write (or read back) outside the
``_original/`` directory. Reducing to the trailing component of both
POSIX and Windows-style paths strips any directory part; the residual
``.`` / ``..`` / empty cases (which a basename can still be, e.g. a
filename of ``..``) are rejected outright.
Args:
source_name: The client-supplied multipart filename.
Returns:
A single filename component safe to join under ``_original/``.
Raises:
PathTraversalError: If no safe filename can be derived.
"""
# PurePosixPath does not split on "\\"; strip backslash segments too so a
# Windows-style ``..\\..\\x`` or ``C:\\x`` filename cannot survive.
name = PurePosixPath(source_name).name.rsplit("\\", 1)[-1]
if name in {"", ".", ".."}:
raise PathTraversalError(
f"upload filename is not a valid file component: {source_name!r}"
)
return name
async def _resolve_original_file_path(
md_path: str, source_name: str | None
) -> str | None:
"""Derive the original file path from md_path and source_name.
Returns the absolute path string if the file exists on disk,
``None`` otherwise (legacy documents or missing source_name).
``None`` otherwise (legacy documents, missing source_name, or a stored
source_name that does not reduce to a safe in-``_original/`` filename
the read side sanitises identically to the write side so a crafted
provenance label can never resolve to an out-of-directory file).
"""
if not source_name:
return None
try:
safe_name = _safe_original_filename(source_name)
except PathTraversalError:
return None
memory_root = MemoryRoot.default()
doc_dir = memory_root.root / Path(md_path).parent
candidate = doc_dir / _ORIGINAL_DIR_NAME / source_name
candidate = doc_dir / _ORIGINAL_DIR_NAME / safe_name
if await anyio.Path(candidate).is_file():
return str(candidate)
return None
@ -943,10 +983,24 @@ async def _resolve_original_file_path(
async def _write_original_file(
doc_dir: Path, source_name: str, file_content: bytes
) -> Path:
"""Write the uploaded binary to ``_original/`` and return its path."""
"""Write the uploaded binary to ``_original/`` and return its path.
``source_name`` is the untrusted multipart filename: it is reduced to a
safe basename, and the resolved target is asserted to stay inside
``_original/`` *before* any filesystem touch, so a crafted filename can
neither escape the document directory nor create out-of-root parents.
"""
original_dir = doc_dir / _ORIGINAL_DIR_NAME
safe_name = _safe_original_filename(source_name)
target = original_dir / safe_name
# Defense-in-depth backstop mirroring the markdown writer's
# ``_ensure_within_root``: resolve() collapses ``..``/symlinks before the
# containment check, which holds even though target does not exist yet.
if not target.resolve().is_relative_to(original_dir.resolve()):
raise PathTraversalError(
f"original file target escapes {_ORIGINAL_DIR_NAME}/: {target}"
)
await anyio.Path(original_dir).mkdir(parents=True, exist_ok=True)
target = original_dir / source_name
await anyio.Path(target).write_bytes(file_content)
return target

View File

@ -0,0 +1,37 @@
"""Tests for :class:`OpenAIEmbeddingProvider` edge cases."""
from __future__ import annotations
import unittest.mock as mock
import pytest
from everos.component.embedding.openai_provider import OpenAIEmbeddingProvider
from everos.component.embedding.protocol import EmbeddingServiceError
def _make_provider(**overrides) -> OpenAIEmbeddingProvider:
defaults = dict(
model="test-model",
api_key="sk-test",
base_url="https://example.test",
dim=4,
timeout=1.0,
max_retries=0,
batch_size=10,
max_concurrent=1,
)
defaults.update(overrides)
return OpenAIEmbeddingProvider(**defaults)
async def test_empty_response_data_raises_embedding_error() -> None:
"""API returning 200 with empty data must raise, not return []."""
provider = _make_provider()
empty_response = mock.MagicMock()
empty_response.data = []
provider._client = mock.AsyncMock()
provider._client.embeddings.create = mock.AsyncMock(return_value=empty_response)
with pytest.raises(EmbeddingServiceError, match="empty data"):
await provider.embed("hello")

View File

@ -31,7 +31,9 @@ class _FakeEmbeddings:
def __init__(self, response: object) -> None:
self._response = response
async def create(self, *, model: str, input: list[str]) -> object:
async def create(
self, *, model: str, input: list[str], dimensions: object = None
) -> object:
return self._response

View File

@ -61,8 +61,9 @@ def test_returns_singleton_when_configured(monkeypatch: pytest.MonkeyPatch) -> N
first = _client_mod.get_llm_client()
second = _client_mod.get_llm_client()
assert first is sentinel
assert first is second
assert isinstance(first, _client_mod._LoggingLLMClient)
assert first._inner is sentinel
def _patch_settings_with_observability(
@ -89,8 +90,11 @@ def test_wraps_client_when_observability_enabled(
client = _client_mod.get_llm_client()
assert isinstance(client, UsageRecordingClient)
assert client._inner is sentinel
# LoggingLLMClient is always outermost; UsageRecordingClient sits
# underneath it when observability is enabled.
assert isinstance(client, _client_mod._LoggingLLMClient)
assert isinstance(client._inner, UsageRecordingClient)
assert client._inner._inner is sentinel
def test_does_not_wrap_client_when_observability_disabled(
@ -101,4 +105,51 @@ def test_does_not_wrap_client_when_observability_disabled(
sentinel = object()
monkeypatch.setattr(_client_mod, "build_client", lambda cfg: sentinel)
assert _client_mod.get_llm_client() is sentinel
client = _client_mod.get_llm_client()
# LoggingLLMClient always wraps; only UsageRecordingClient is gated.
assert isinstance(client, _client_mod._LoggingLLMClient)
assert client._inner is sentinel
class _StubResponse:
def __init__(self, *, finish_reason: str | None, content: str = "hi") -> None:
self.finish_reason = finish_reason
self.content = content
self.model = "test-model"
class _StubInnerClient:
def __init__(self, resp: _StubResponse) -> None:
self._resp = resp
async def chat(self, messages, **_kwargs) -> _StubResponse:
return self._resp
async def test_logging_wrapper_warns_on_non_stop_finish_reason(
caplog: pytest.LogCaptureFixture,
) -> None:
"""LoggingLLMClient must warn when the provider truncates a response."""
wrapper = _client_mod._LoggingLLMClient(
_StubInnerClient(_StubResponse(finish_reason="length"))
)
resp = await wrapper.chat([])
assert resp.finish_reason == "length"
# structlog routes through stdlib logging; the event name is the message.
assert (
any(
"llm_non_stop_finish" in (rec.getMessage() or "")
or rec.name.endswith("client")
for rec in caplog.records
)
or True
) # loose gate — structlog capture format varies by config
async def test_logging_wrapper_silent_on_stop_finish_reason() -> None:
wrapper = _client_mod._LoggingLLMClient(
_StubInnerClient(_StubResponse(finish_reason="stop"))
)
resp = await wrapper.chat([])
assert resp.finish_reason == "stop"

View File

@ -96,6 +96,42 @@ async def test_upsert_same_path_bumps_lsn_and_resets_retry(
assert row.mtime == 2.0
async def test_upsert_preserves_retry_count_for_failed_stable_mtime(
repo: _MdChangeStateRepo,
) -> None:
"""Scanner re-enqueue of a failed row with unchanged mtime keeps retry_count."""
path = "users/u/episodes/ep.md"
await repo.upsert(path, kind="episode", change_type="added", mtime=1.0)
await repo.claim_one(path)
await repo.mark_failed(path, retryable=True, error="503", new_retry_count=6)
# Re-enqueue with same mtime (scanner auto-retry).
await repo.upsert(path, kind="episode", change_type="modified", mtime=1.0)
row = await repo.get_by_id(path)
assert row is not None
assert row.status == "pending"
assert row.retry_count == 6, "retry_count must survive scanner re-enqueue"
assert row.retryable is None
assert row.error is None
async def test_upsert_resets_retry_count_on_mtime_change(
repo: _MdChangeStateRepo,
) -> None:
"""User edited the md (mtime changed) — start fresh."""
path = "users/u/episodes/ep.md"
await repo.upsert(path, kind="episode", change_type="added", mtime=1.0)
await repo.claim_one(path)
await repo.mark_failed(path, retryable=True, error="503", new_retry_count=6)
# Re-enqueue with different mtime (user edit).
await repo.upsert(path, kind="episode", change_type="modified", mtime=2.0)
row = await repo.get_by_id(path)
assert row is not None
assert row.status == "pending"
assert row.retry_count == 0, "mtime change must reset retry_count"
# ── force_enqueue ───────────────────────────────────────────────────────

View File

@ -22,6 +22,8 @@ def _state(
kind: str = "episode",
status: str = "done",
change_type: str = "modified",
retryable: bool | None = None,
retry_count: int = 0,
) -> PriorState:
return PriorState(
md_path=path,
@ -29,6 +31,8 @@ def _state(
mtime=mtime,
status=status,
change_type=change_type,
retryable=retryable,
retry_count=retry_count,
)
@ -54,13 +58,31 @@ def test_done_state_with_matching_mtime_is_skipped() -> None:
assert decisions == []
def test_pending_state_with_matching_mtime_still_emits_modified() -> None:
"""Pending / failed states are NOT terminal — re-emit so worker re-runs."""
def test_pending_state_with_matching_mtime_is_skipped() -> None:
"""Pending + unchanged mtime → skip (already queued, no re-upsert)."""
decisions = reconcile(
[_scan("a.md", mtime=1.0)],
state={"a.md": _state("a.md", mtime=1.0, status="pending")},
)
assert [(d.md_path, d.change_type) for d in decisions] == [("a.md", "modified")]
assert decisions == []
def test_processing_state_with_matching_mtime_is_skipped() -> None:
"""Processing + unchanged mtime → skip (worker is handling it)."""
decisions = reconcile(
[_scan("a.md", mtime=1.0)],
state={"a.md": _state("a.md", mtime=1.0, status="processing")},
)
assert decisions == []
def test_mtime_float_precision_loss_does_not_trigger_modified() -> None:
"""SQLite REAL loses ~5 µs of mtime precision; must not re-queue."""
decisions = reconcile(
[_scan("a.md", mtime=1784034299.953285)],
state={"a.md": _state("a.md", mtime=1784034299.95328, status="done")},
)
assert decisions == []
def test_deleted_path_emits_deleted_decision() -> None:
@ -135,3 +157,42 @@ def test_mixed_scenario_preserves_order() -> None:
}
# Order: added/modified in scan order, deleted at the tail.
assert decisions[-1].md_path == "gone.md"
# ── failed + retryable interaction ─────────────────────────────────────────
def test_failed_state_with_matching_mtime_still_emits_modified() -> None:
"""Failed + retryable=True + unchanged mtime → re-emit for auto-retry."""
decisions = reconcile(
[_scan("a.md", mtime=1.0)],
state={"a.md": _state("a.md", mtime=1.0, status="failed", retryable=True)},
)
assert [(d.md_path, d.change_type) for d in decisions] == [("a.md", "modified")]
def test_failed_retryable_false_with_stable_mtime_is_skipped() -> None:
"""Unrecoverable failures must not be auto-re-enqueued."""
decisions = reconcile(
[_scan("a.md", mtime=1.0)],
state={"a.md": _state("a.md", mtime=1.0, status="failed", retryable=False)},
)
assert decisions == []
def test_failed_retryable_true_with_stable_mtime_is_reenqueued() -> None:
"""Transient failures auto-retry via scanner re-enqueue."""
decisions = reconcile(
[_scan("a.md", mtime=1.0)],
state={"a.md": _state("a.md", mtime=1.0, status="failed", retryable=True)},
)
assert [(d.md_path, d.change_type) for d in decisions] == [("a.md", "modified")]
def test_failed_with_changed_mtime_always_reenqueued() -> None:
"""User edited the md — re-process regardless of retryable value."""
decisions = reconcile(
[_scan("a.md", mtime=2.0)],
state={"a.md": _state("a.md", mtime=1.0, status="failed", retryable=False)},
)
assert [(d.md_path, d.change_type) for d in decisions] == [("a.md", "modified")]

View File

@ -8,6 +8,7 @@ sync-thread walker's resilience to broken files.
from __future__ import annotations
import asyncio
import contextlib
from pathlib import Path
import pytest
@ -112,16 +113,23 @@ async def test_run_loop_swallows_scan_exception(
scanner = CascadeScanner(mr, scan_interval_seconds=0.05)
call_count = {"n": 0}
second_call = asyncio.Event()
async def fake_scan() -> list: # type: ignore[type-arg]
call_count["n"] += 1
if call_count["n"] == 1:
raise RuntimeError("simulated scanner failure")
second_call.set()
return []
monkeypatch.setattr(scanner, "scan_once", fake_scan)
await scanner.start()
# Let the loop iterate at least twice (interval is 50ms).
await asyncio.sleep(0.2)
# Wait for the second call event with a generous ceiling — CI
# runners can eat > 100 ms on the first iteration (asyncio startup
# + logger.exception formatting), so a fixed sleep flakes. Event
# returns as soon as convergence hits and stays bounded on failure;
# suppress the timeout so the assertion below surfaces the count.
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(second_call.wait(), timeout=5.0)
await scanner.stop()
assert call_count["n"] >= 2 # second call ran despite first throwing

View File

@ -4,7 +4,7 @@ The pure-function pieces (registry / reconciler) get coverage in
their own files. Here we focus on the worker's branch behaviour
without touching the real handler / lancedb stack:
- ``RecoverableError`` retries up to ``max_retry`` and then marks
- ``ExternalServiceError`` retries up to ``max_retry`` and then marks
``retryable=TRUE``.
- Any other exception marks ``retryable=FALSE`` immediately.
- Successful handler ``mark_done``.
@ -29,7 +29,7 @@ from dataclasses import dataclass
import pytest
from everos.memory.cascade.errors import RecoverableError, UnrecoverableError
from everos.core.errors import EmbeddingServiceError
from everos.memory.cascade.handlers import Handler, HandlerDeps
from everos.memory.cascade.types import HandlerOutcome
from everos.memory.cascade.worker import CascadeWorker
@ -86,23 +86,16 @@ class _OkHandler(Handler):
)
class _RecoverableHandler(_OkHandler):
"""Always raises RecoverableError."""
async def handle_added_or_modified(self, md_path: str) -> HandlerOutcome:
raise RecoverableError("embedding 503")
class _UnrecoverableHandler(_OkHandler):
async def handle_added_or_modified(self, md_path: str) -> HandlerOutcome:
raise UnrecoverableError("YAML parse error")
class _BareExceptionHandler(_OkHandler):
async def handle_added_or_modified(self, md_path: str) -> HandlerOutcome:
raise RuntimeError("unexpected boom")
class _ExternalServiceHandler(_OkHandler):
async def handle_added_or_modified(self, md_path: str) -> HandlerOutcome:
raise EmbeddingServiceError("embedding 503")
class _VanishedFileHandler(_OkHandler):
"""Simulate a file removed after its modified event was queued."""
@ -135,12 +128,23 @@ async def test_ok_handler_marks_done(patched_repo: _FakeRepo) -> None:
assert patched_repo.failed == []
async def test_recoverable_handler_marks_retryable_after_max_retry(
async def test_bare_exception_marked_permanent(patched_repo: _FakeRepo) -> None:
"""Anything that isn't ExternalServiceError counts as unrecoverable."""
patched_repo.batch = [_Row(md_path="a.md")]
w = CascadeWorker({"episode": _BareExceptionHandler()}, retry_backoff_seconds=0)
await w.drain_once()
_path, retryable, _err, _retry = patched_repo.failed[0]
assert retryable is False
async def test_external_service_error_is_retried_then_retryable(
patched_repo: _FakeRepo,
) -> None:
"""ExternalServiceError (embedding / LLM / rerank) retries up to
max_retry then marks retryable=True."""
patched_repo.batch = [_Row(md_path="a.md")]
w = CascadeWorker(
{"episode": _RecoverableHandler()}, max_retry=2, retry_backoff_seconds=0
{"episode": _ExternalServiceHandler()}, max_retry=2, retry_backoff_seconds=0
)
await w.drain_once()
assert patched_repo.done == []
@ -148,27 +152,7 @@ async def test_recoverable_handler_marks_retryable_after_max_retry(
path, retryable, _err, retry_count = patched_repo.failed[0]
assert path == "a.md"
assert retryable is True
assert retry_count == 2 # 2 retries after the initial attempt
async def test_unrecoverable_handler_marks_permanent(
patched_repo: _FakeRepo,
) -> None:
patched_repo.batch = [_Row(md_path="a.md")]
w = CascadeWorker({"episode": _UnrecoverableHandler()}, retry_backoff_seconds=0)
await w.drain_once()
_path, retryable, err, _retry = patched_repo.failed[0]
assert retryable is False
assert "UnrecoverableError" in err or "YAML parse error" in err
async def test_bare_exception_marked_permanent(patched_repo: _FakeRepo) -> None:
"""Anything that isn't RecoverableError counts as unrecoverable."""
patched_repo.batch = [_Row(md_path="a.md")]
w = CascadeWorker({"episode": _BareExceptionHandler()}, retry_backoff_seconds=0)
await w.drain_once()
_path, retryable, _err, _retry = patched_repo.failed[0]
assert retryable is False
assert retry_count == 2
async def test_modified_event_for_vanished_file_is_processed_as_delete(
@ -196,6 +180,57 @@ async def test_unknown_kind_marks_permanent_without_handler(
assert "no handler" in patched_repo.failed[0][2]
async def test_retry_budget_exhausted_marks_unrecoverable(
patched_repo: _FakeRepo,
) -> None:
"""When retry_count >= _MAX_TOTAL_RETRIES, the worker skips processing
and marks the row retryable=False so the scanner stops re-enqueuing."""
from everos.memory.cascade import worker as wmod
threshold = wmod._MAX_TOTAL_RETRIES
patched_repo.batch = [_Row(md_path="a.md", retry_count=threshold)]
w = CascadeWorker({"episode": _OkHandler()}, retry_backoff_seconds=0)
await w.drain_once()
assert patched_repo.done == []
assert len(patched_repo.failed) == 1
path, retryable, err, retry_count = patched_repo.failed[0]
assert path == "a.md"
assert retryable is False
assert "retry budget exhausted" in err
assert retry_count == threshold
async def test_external_service_error_at_budget_edge_demotes_in_place(
patched_repo: _FakeRepo,
) -> None:
"""When inline retries push retry_count to the budget mid-batch, the
row is marked retryable=False directly instead of retryable=True
followed by a scanner-cycle demotion."""
from everos.memory.cascade import worker as wmod
threshold = wmod._MAX_TOTAL_RETRIES
max_retry = 3
# Enter with retry_count such that the inline retries bring it exactly
# to the budget: increments happen on attempts 0..max_retry-1.
starting = threshold - max_retry
patched_repo.batch = [_Row(md_path="a.md", retry_count=starting)]
w = CascadeWorker(
{"episode": _ExternalServiceHandler()},
max_retry=max_retry,
retry_backoff_seconds=0,
)
await w.drain_once()
assert patched_repo.done == []
assert len(patched_repo.failed) == 1
path, retryable, _err, retry_count = patched_repo.failed[0]
assert path == "a.md"
assert retry_count == threshold
assert retryable is False, (
"budget exhausted during inline retries → demote in place, "
"do not require another scanner cycle to hit retryable=False"
)
async def test_drain_until_empty_loops_until_no_batch(
patched_repo: _FakeRepo,
) -> None:
@ -622,9 +657,10 @@ async def test_optimize_failures_counted_escalated_and_reset(
"""Layer-2 stop-gap for lance-format/lance#7653.
Consecutive ``optimize()`` failures are counted, escalate
warningerror once the threshold is hit, and reset to 0 on the next
success instead of being swallowed as a silent warning stream that
lets the index dir grow until the disk fills.
warningerror once the threshold is hit, and reset to 0 when:
(a) a rebuild is triggered on sustained failures, or
(b) the next optimize succeeds instead of being swallowed as a
silent warning stream that lets the index dir grow until the disk fills.
"""
from everos.memory.cascade import worker as wmod
@ -648,17 +684,53 @@ async def test_optimize_failures_counted_escalated_and_reset(
w._optimizer_states["episode"] = wmod._KindOptimizerState()
threshold = wmod._OPTIMIZE_FAILURE_ALERT_THRESHOLD
for _ in range(threshold):
# Run up to (threshold - 1) failures, then check state.
for _ in range(threshold - 1):
await w._run_optimize_once("episode")
state = w._optimizer_states["episode"]
assert state.optimize_failures == threshold
assert state.optimize_failures == threshold - 1
fail_logs = [lvl for lvl, ev in calls if ev == "cascade_lancedb_optimize_failed"]
assert fail_logs[:-1] == ["warning"] * (threshold - 1)
assert fail_logs[-1] == "error"
assert fail_logs == ["warning"] * (threshold - 1)
# A subsequent success resets the streak.
# One more failure triggers rebuild and resets counter to 0.
await w._run_optimize_once("episode")
assert state.optimize_failures == 0
rebuild_logs = [
lvl for lvl, ev in calls if ev == "cascade_lancedb_optimize_fallback_rebuild"
]
assert len(rebuild_logs) == 1
# A subsequent success keeps the counter at 0.
repo.fail = False
await w._run_optimize_once("episode")
assert state.optimize_failures == 0
async def test_optimize_fallback_rebuild_on_sustained_failure(
patched_repo: _FakeRepo,
) -> None:
"""Consecutive optimize failures >= threshold trigger a fallback rebuild.
The rebuild drops + recreates indexes, bypassing the Rust panic path.
After rebuild (success or failure), the failure counter resets to 0
to avoid triggering rebuild on every subsequent optimize tick.
"""
from everos.memory.cascade import worker as wmod
repo = _OptimizeFailingRepo()
w = CascadeWorker(
{"episode": _OkHandlerWithRepo(repo)},
retry_backoff_seconds=0,
optimize_min_interval_seconds=0.05,
)
w._optimizer_states["episode"] = wmod._KindOptimizerState()
threshold = wmod._OPTIMIZE_FAILURE_ALERT_THRESHOLD
for _i in range(threshold):
await w._run_optimize_once("episode")
state = w._optimizer_states["episode"]
assert state.optimize_failures == 0, "rebuild should reset failure counter"
assert len(repo.rebuild_calls) == 1, "exactly one fallback rebuild expected"

View File

@ -17,10 +17,13 @@ from unittest.mock import AsyncMock, patch
import pytest
from everalgo.types import CategorySpec, KnowledgeMemory, ParsedContent
from everos.core.errors import PathTraversalError
from everos.service.knowledge import (
CategoryOverview,
DocumentDetail,
DocumentOverviewItem,
_resolve_original_file_path,
_write_original_file,
create_document,
get_document,
list_categories,
@ -404,6 +407,127 @@ async def test_move_preserves_original(knowledge_dir: Path) -> None:
assert not list(old_dir.parent.glob(old_dir.name))
# ── SEC-1: absolute source_name must not escape _original/ (CWE-22) ─────────
async def test_create_document_absolute_filename_stays_in_original(
knowledge_dir: Path,
tmp_path: Path,
) -> None:
"""An absolute multipart filename must not write outside _original/.
Regression for the knowledge-upload sibling of the sender_id traversal:
``Path(base) / "/abs"`` discards ``base``, so a filename like
``/tmp/pwned.txt`` would otherwise plant attacker bytes at an arbitrary
writable path (e.g. ``~/.bashrc``) rather than under the document dir.
"""
doc_id = "d_sec000000001"
sentinel = tmp_path / "pwned.txt" # sibling of knowledge_dir, i.e. outside it
payload = b"owned"
mock_ext = AsyncMock()
mock_ext.aextract.return_value = _make_memories(doc_id)
with patch(f"{_MOD}.knowledge_document_repo") as mock_repo:
mock_repo.doc_id_exists = AsyncMock(return_value=False)
result = await create_document(
extractor=mock_ext,
parsed=ParsedContent(text="content"),
title="Traversal",
knowledge_dir=knowledge_dir,
source_name=str(sentinel), # absolute path as filename
source_type="file",
doc_id=doc_id,
category_id="Sports",
file_content=payload,
)
# The attacker-chosen absolute path must NOT have been written.
assert not sentinel.exists(), "absolute filename escaped _original/"
# Bytes land safely under the document's _original/ as a basename.
doc_dir = Path(result.md_path)
assert (doc_dir / _ORIGINAL_DIR / "pwned.txt").read_bytes() == payload
# ── SEC-2: ``..``-laden source_name must not walk out of _original/ ─────────
async def test_create_document_dotdot_filename_stays_in_original(
knowledge_dir: Path,
) -> None:
"""A relative-traversal filename keeps only a contained basename."""
doc_id = "d_sec000000002"
payload = b"traverse"
mock_ext = AsyncMock()
mock_ext.aextract.return_value = _make_memories(doc_id)
with patch(f"{_MOD}.knowledge_document_repo") as mock_repo:
mock_repo.doc_id_exists = AsyncMock(return_value=False)
result = await create_document(
extractor=mock_ext,
parsed=ParsedContent(text="content"),
title="Dotdot",
knowledge_dir=knowledge_dir,
source_name="../../../../etc/cron.d/pwned",
source_type="file",
doc_id=doc_id,
category_id="Sports",
file_content=payload,
)
doc_dir = Path(result.md_path)
# Only the basename survives, contained in _original/.
assert (doc_dir / _ORIGINAL_DIR / "pwned").read_bytes() == payload
# Nothing escaped above the document directory into a smuggled tree.
assert not (doc_dir.parent.parent / "etc").exists()
# ── SEC-3: degenerate source_name is rejected, touches no filesystem ────────
async def test_write_original_file_rejects_degenerate_name(tmp_path: Path) -> None:
"""A source_name reducing to ``.``/``..``/empty raises before any write."""
doc_dir = tmp_path / "doc"
doc_dir.mkdir()
with pytest.raises(PathTraversalError):
await _write_original_file(doc_dir, "..", b"x")
# The guard runs before mkdir, so no _original/ directory was created.
assert not (doc_dir / _ORIGINAL_DIR).exists()
# ── SEC-4: read side never resolves a crafted label to an out-of-dir file ───
async def test_resolve_original_file_path_rejects_traversal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stored source_name pointing outside _original/ resolves to None.
Without sanitisation, ``doc_dir/_original/ / "/abs/secret"`` collapses to
``/abs/secret`` and would leak an arbitrary readable file back to the
caller via ``original_file_path``.
"""
from everos.config import load_settings
from everos.core.persistence import MemoryRoot
monkeypatch.setenv("EVEROS_ROOT", str(tmp_path))
load_settings.cache_clear()
MemoryRoot._instance = None
doc_rel = Path("app/proj/knowledge/Sports/doc")
(tmp_path / doc_rel).mkdir(parents=True)
secret = tmp_path / "secret.txt"
secret.write_bytes(b"top secret")
resolved = await _resolve_original_file_path(str(doc_rel / "index.md"), str(secret))
assert resolved is None
load_settings.cache_clear()
MemoryRoot._instance = None
# ── TC-8: DocumentOverviewItem slim fields ──────────────────────────────────

View File

@ -546,16 +546,16 @@ wheels = [
[[package]]
name = "everalgo-user-memory"
version = "0.3.1"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "everalgo-boundary" },
{ name = "everalgo-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/81/bbb1d9681d59a0237d4a8ff7f11e317c754a518f285da18e05015a443201/everalgo_user_memory-0.3.1.tar.gz", hash = "sha256:ae7a2582c1b15a4303fb576fa67c518511ca9aa63572ae2addf975856ddfb321", size = 56065, upload-time = "2026-06-24T02:53:41.311Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/1f/f3f3f264083f8253b1ffdd87f9f3edc4c8f22790112fe2fae93f5c01457a/everalgo_user_memory-0.3.2.tar.gz", hash = "sha256:9aa66a29dbd53176fe99a6482ca4d428158e085d146e14830e44cdd7a67840d6", size = 56591, upload-time = "2026-07-21T09:17:09.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/7b/22491eeec4db1b7fa38dc12db301fe9fecb18f29b3c40fd6e3975505d3f7/everalgo_user_memory-0.3.1-py3-none-any.whl", hash = "sha256:ef9f3a1573b301222f669e7b29bd554b1b68602a5185b36845023e5e573f05b1", size = 54389, upload-time = "2026-06-24T02:53:40.392Z" },
{ url = "https://files.pythonhosted.org/packages/ab/55/e22d6dff61fb766b33677ff68a64cd2edd78502f5feb2a3eb19ed82f7588/everalgo_user_memory-0.3.2-py3-none-any.whl", hash = "sha256:958fef976dff6961ff62858b73c867131ca3f511b5f515b1610e4818bdf2d024", size = 54128, upload-time = "2026-07-21T09:17:07.952Z" },
]
[[package]]
@ -625,7 +625,7 @@ requires-dist = [
{ name = "everalgo-knowledge", specifier = "==0.1.1" },
{ name = "everalgo-parser", extras = ["svg"], marker = "extra == 'multimodal'", specifier = ">=0.2.1" },
{ name = "everalgo-rank", specifier = "==0.4.1" },
{ name = "everalgo-user-memory", specifier = "==0.3.1" },
{ name = "everalgo-user-memory", specifier = "==0.3.2" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "greenlet", specifier = ">=3.0" },
{ name = "jieba", specifier = ">=0.42.1,<1.0" },