Update version to 0.4.0.2 and improve tokenizer index handling (#290)

* fix(core): update version number to 0.4.0.1

- Incremented version from 0.4.0.0 to 0.4.0.1 in __init__.py

* fix(index): remove stopwords path from tokenizer config and add keyword index repair

- Remove stopwords_path from tokenizer config to prevent index forking by install path
- Add _sync_keyword_index_from_chunks method to repair keyword index when persisted state mismatches
- Implement test for keyword index repair from persisted chunks when missing
- Add test to verify tokenizer fingerprint ignores stopwords absolute path
- Update version from 0.4.0.1 to 0.4.0.2

* feat(dream): add scan_days parameter to dream extraction process

- Add scan_days configuration option to default.yaml with default value of 2
- Implement recent_dates utility function to calculate date ranges for scanning
- Modify DreamExtractStep to scan multiple days based on scan_days parameter
- Update dream extraction to process files across multiple dates instead of single day
- Extend DreamState schema to include dates and scan_days fields
- Update DreamTopicsStep to handle multi-day topic processing
- Modify finish step to checkpoint files from all scanned dates
- Add comprehensive tests for multi-day scanning functionality
- Update prompt templates to include scan dates information
- Refactor topics writing logic to target specific date rather than current date
This commit is contained in:
jinliyl 2026-06-24 15:01:07 +08:00 committed by GitHub
parent 8c1d348468
commit a3bd81bde2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 315 additions and 40 deletions

View file

@ -1,6 +1,6 @@
"""ReMe CLI package."""
__version__ = "0.4.0.1"
__version__ = "0.4.0.2"
from . import config
from . import constants

View file

@ -1,5 +1,7 @@
"""In-memory file store with compressed JSONL persistence on close."""
from contextlib import suppress
import numpy as np
from .base_file_store import BaseFileStore
@ -88,9 +90,34 @@ class LocalFileStore(BaseFileStore):
chunk = FileChunk.model_validate_json(line)
self.file_chunks[chunk.id] = chunk
self.logger.info(f"Loaded {len(self.file_chunks)} chunks from {self.chunks_path}")
await self._sync_keyword_index_from_chunks()
except Exception as e:
self.logger.exception(f"Failed to load {self.chunks_path}: {e}")
async def _sync_keyword_index_from_chunks(self) -> None:
"""Repair keyword index when its persisted state does not match chunks."""
if not self.keyword_index or not self.file_chunks:
return
docs = {cid: chunk.text for cid, chunk in self.file_chunks.items() if chunk.text}
if not docs:
return
expected_ids = set(docs)
live_ids = None
with suppress(Exception):
live_ids = set(getattr(self.keyword_index, "doc_meta", {}).keys())
if live_ids == expected_ids:
return
n_docs = getattr(self.keyword_index, "n_docs", None)
if live_ids is None and n_docs == len(expected_ids):
return
self.logger.warning(f"{self.name}: keyword index mismatch with chunks; rebuilding {len(docs)} docs")
await self.keyword_index.reset_index(docs)
async def dump(self) -> None:
"""Atomically rewrite the JSONL, then cascade dump into keyword_index and file_graph."""
assert self.file_graph is not None

View file

@ -82,7 +82,6 @@ class BM25Index(BaseKeywordIndex):
stopwords_path = getattr(self.tokenizer, "stopwords_path", None)
if stopwords_path is not None:
path = Path(stopwords_path)
config["stopwords_path"] = str(path)
if path.exists() and path.is_file():
config["stopwords_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
else:

View file

@ -59,6 +59,7 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
@ -80,6 +81,10 @@ jobs:
type: string
description: "caller guidance passed through to dream extract/integrate"
default: ""
scan_days:
type: integer
description: "number of recent daily directories to scan, ending at date"
default: 2
topic_count:
type: integer
description: "maximum number of final daily interest topics"
@ -92,6 +97,7 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3

View file

@ -12,6 +12,7 @@ from .utils import (
llm_available,
pack_paths,
parse_structured_reply,
recent_dates,
scan_day_files,
store_state,
today,
@ -25,34 +26,49 @@ _TOOLS = ("read",)
class DreamExtractStep(BaseStep):
"""Scan changed daily files and globally extract merged units/topics."""
def __init__(self, topic_session_id: str = "interests", **kwargs):
def __init__(self, topic_session_id: str = "interests", scan_days: int = 2, **kwargs):
super().__init__(**kwargs)
self.topic_session_id = topic_session_id
self.scan_days = scan_days
async def execute(self):
assert self.context is not None
day = today(self, str(self.context.get("date", "") or ""))
raw_scan_days = self.context.get("scan_days", self.scan_days)
scan_days = max(int(raw_scan_days or self.scan_days), 1)
dates = recent_dates(day, scan_days)
hint = str(self.context.get("hint", "") or "").strip()
daily, workspace = daily_dir(self), workspace_dir(self)
if self.file_catalog is None:
raise RuntimeError("dream_extract_step requires file_catalog")
await refresh_day_index(self.file_store, day, daily)
for scan_day in dates:
await refresh_day_index(self.file_store, scan_day, daily)
existing = self._existing(workspace, scan_day_files(workspace, day, daily, f"{self.topic_session_id}.yaml"))
interests_rel = f"{daily}/{day}/{self.topic_session_id}.yaml"
day_md, day_prefix = f"{daily}/{day}.md", f"{daily}/{day}/"
existing = self._existing(
workspace,
[
path
for scan_day in dates
for path in scan_day_files(workspace, scan_day, daily, f"{self.topic_session_id}.yaml")
],
)
interest_rels = {f"{daily}/{scan_day}/{self.topic_session_id}.yaml" for scan_day in dates}
day_mds = {f"{daily}/{scan_day}.md" for scan_day in dates}
day_prefixes = tuple(f"{daily}/{scan_day}/" for scan_day in dates)
nodes = await self.file_catalog.get_nodes()
indexed_all = {n.path: n.st_mtime for n in nodes if n.path == day_md or n.path.startswith(day_prefix)}
indexed = {path: mt for path, mt in indexed_all.items() if path != interests_rel}
indexed_all = {n.path: n.st_mtime for n in nodes if n.path in day_mds or n.path.startswith(day_prefixes)}
indexed = {path: mt for path, mt in indexed_all.items() if path not in interest_rels}
changed = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
unchanged = [rel for rel, mt in existing.items() if indexed.get(rel) == mt]
protected = set(existing) | ({interests_rel} if (workspace / interests_rel).is_file() else set())
protected = set(existing) | {rel for rel in interest_rels if (workspace / rel).is_file()}
deleted = sorted(indexed_all.keys() - protected)
if deleted:
await self.file_catalog.delete(deleted)
state = DreamState(
date=day,
dates=dates,
scan_days=scan_days,
hint=hint,
daily_dir=daily,
workspace=str(workspace),
@ -67,7 +83,7 @@ class DreamExtractStep(BaseStep):
indexed=indexed,
)
if not changed:
return self._finish(state, True, f"No changed dream input for {day}")
return self._finish(state, True, f"No changed dream input for {', '.join(dates)}")
if not llm_available(self):
state.errors.append("no llm configured; dream extract requires an LLM")
return self._finish(state, False, state.errors[-1])
@ -76,6 +92,7 @@ class DreamExtractStep(BaseStep):
self.prompt_format(
"extract_user_message",
date=day,
dates_json=json.dumps(dates, ensure_ascii=False, indent=2),
hint=hint or "(none)",
changed_paths_json=json.dumps(changed, ensure_ascii=False, indent=2),
material_blob=pack_paths(workspace, changed),
@ -91,7 +108,7 @@ class DreamExtractStep(BaseStep):
self._clean_output(state, meta)
state.extract_summary = str(result.get("result") or "").strip()
answer = f"Extracted {len(state.units)} unit(s), {len(state.topics)} topic(s)"
answer = f"{answer} from {len(changed)} changed file(s)"
answer = f"{answer} from {len(changed)} changed file(s) across {len(dates)} day(s)"
return self._finish(state, True, answer)
def _existing(self, workspace, files: list[str]) -> dict[str, float]:

View file

@ -152,6 +152,8 @@ extract_system_prompt_zh: |
extract_user_message: |
date: {date}
scan_dates:
{dates_json}
hint: {hint}
changed_paths:
@ -165,6 +167,8 @@ extract_user_message: |
extract_user_message_zh: |
日期:{date}
扫描日期:
{dates_json}
提示:{hint}
changed_paths:

View file

@ -25,7 +25,9 @@ class DreamFinishStep(BaseStep):
raise RuntimeError("dream_finish_step requires file_catalog")
checkpoint = [p for p in state.changed_paths if p not in set(state.failed_paths)]
upsert_paths = checkpoint + [p for p in [state.interests_path, f"{state.daily_dir}/{state.date}.md"] if p]
day_index_paths = [f"{state.daily_dir}/{day}.md" for day in (state.dates or [state.date]) if day]
interest_paths = state.interests_paths or ([state.interests_path] if state.interests_path else [])
upsert_paths = checkpoint + [p for p in [*interest_paths, *day_index_paths] if p]
upserts = self._nodes(workspace, upsert_paths)
if upserts:
await self.file_catalog.upsert(upserts)
@ -52,12 +54,14 @@ class DreamFinishStep(BaseStep):
def render_summary(state: DreamState) -> str:
"""Render summary."""
interest_paths = state.interests_paths or ([state.interests_path] if state.interests_path else [])
lines = [
f"[AutoDream] date={state.date} scanned={state.files_scanned} changed={state.files_changed} "
f"[AutoDream] date={state.date} dates={','.join(state.dates or [state.date])} "
f"scanned={state.files_scanned} changed={state.files_changed} "
f"unchanged={state.files_unchanged} deleted={state.files_deleted}",
f" - extract: {len(state.units)} unit(s), {len(state.topics)} topic(s)",
f" - integrate: {len(state.integrate_results)} ok, {len(state.failed_units)} failed",
f" - topics: {state.topics_written} written" + (f" to {state.interests_path}" if state.interests_path else ""),
f" - topics: {state.topics_written} written" + (f" to {', '.join(interest_paths)}" if interest_paths else ""),
f" - catalog: checkpointed {len(state.checkpoint_paths)} changed path(s)",
]
if state.failed_paths:

View file

@ -64,6 +64,8 @@ class DreamState(BaseModel):
"""Shared state passed across the four dream steps."""
date: str = ""
dates: list[str] = Field(default_factory=list)
scan_days: int = 2
hint: str = ""
daily_dir: str = ""
workspace: str = ""
@ -85,6 +87,7 @@ class DreamState(BaseModel):
failed_units: list[dict] = Field(default_factory=list)
failed_paths: list[str] = Field(default_factory=list)
interests_path: str = ""
interests_paths: list[str] = Field(default_factory=list)
topics_written: int = 0
topic_error: str = ""
checkpoint_paths: list[str] = Field(default_factory=list)

View file

@ -35,51 +35,87 @@ class DreamTopicsStep(BaseStep):
raw_days = self.context.get("topic_diversity_days", self.topic_diversity_days)
diversity_days = int(raw_days or self.topic_diversity_days)
workspace = Path(state.workspace).resolve() if state.workspace else workspace_dir(self)
rel_path = f"{state.daily_dir}/{state.date}/interests.yaml"
abs_path = workspace / state.daily_dir / state.date / "interests.yaml"
same_day = load_yaml_topics(abs_path)
target_day = state.date or ((state.dates or [""])[-1])
if not state.topics:
state.interests_path = rel_path if abs_path.is_file() else ""
state.topics_written = len(same_day)
answer = f"Kept {len(same_day)} existing interest topic(s) at {rel_path}"
answer = answer if abs_path.is_file() else "Skipped interests.yaml write: no new topic candidates"
existing_paths = []
if target_day and self._abs_path(workspace, state.daily_dir, target_day).is_file():
existing_paths = [self._rel_path(state.daily_dir, target_day)]
state.interests_paths = existing_paths
state.interests_path = existing_paths[-1] if existing_paths else ""
state.topics_written = (
len(load_yaml_topics(self._abs_path(workspace, state.daily_dir, target_day))) if target_day else 0
)
answer = (
f"Kept existing interest topic(s) at {', '.join(existing_paths)}"
if existing_paths
else "Skipped interests.yaml write: no new topic candidates"
)
return self._finish(state, True, answer)
recent = [
topic
for day in previous_dates(state.date, diversity_days)
for topic in load_yaml_topics(workspace / state.daily_dir / day / "interests.yaml")
]
try:
topics, _used_llm = await self._select_topics(state, same_day, recent, topic_count, diversity_days)
if not target_day:
state.interests_paths = []
state.interests_path = ""
state.topics_written = 0
return self._finish(state, True, "Skipped interests.yaml write: no target date")
rel_path = self._rel_path(state.daily_dir, target_day)
abs_path = self._abs_path(workspace, state.daily_dir, target_day)
same_day = load_yaml_topics(abs_path)
recent = [
topic
for previous_day in previous_dates(target_day, diversity_days)
for topic in load_yaml_topics(self._abs_path(workspace, state.daily_dir, previous_day))
]
topics, _used_llm = await self._select_topics(
state,
target_day,
state.topics,
same_day,
recent,
topic_count,
diversity_days,
)
payload = {
"date": state.date,
"date": target_day,
"topic_count": topic_count,
"diversity_days": diversity_days,
"topics": topics,
}
write_yaml(abs_path, payload)
await refresh_day_index(self.file_store, state.date, state.daily_dir)
state.interests_path, state.topics_written = rel_path, len(topics)
return self._finish(state, True, f"Wrote {len(topics)} interest topic(s) to {rel_path}")
await refresh_day_index(self.file_store, target_day, state.daily_dir)
state.interests_paths = [rel_path]
state.interests_path = rel_path
state.topics_written = len(topics)
answer = f"Wrote {len(topics)} interest topic(s) to {rel_path}"
return self._finish(state, True, answer)
except Exception as e: # noqa: BLE001
state.topic_error = f"{type(e).__name__}: {e}"
state.errors.append(state.topic_error)
return self._finish(state, False, f"Error: {state.topic_error}")
async def _select_topics(self, state, same_day: list[dict], recent: list[dict], count: int, days: int):
if not state.topics:
async def _select_topics(
self,
_state,
day: str,
candidates: list[dict],
same_day: list[dict],
recent: list[dict],
count: int,
days: int,
):
if not candidates:
return self._dedupe([], same_day, recent, count), False
if not llm_available(self):
return self._dedupe(state.topics, same_day, recent, count), False
return self._dedupe(candidates, same_day, recent, count), False
result = await self.agent_wrapper.reply(
self.prompt_format(
"topics_user_message",
date=state.date,
date=day,
topic_count=count,
diversity_days=days,
candidates_json=json.dumps(state.topics, ensure_ascii=False, indent=2),
candidates_json=json.dumps(candidates, ensure_ascii=False, indent=2),
same_day_json=json.dumps(same_day, ensure_ascii=False, indent=2),
recent_topics_json=json.dumps(recent, ensure_ascii=False, indent=2),
),
@ -88,9 +124,17 @@ class DreamTopicsStep(BaseStep):
meta = parse_structured_reply(str(result.get("result") or ""))
selected = [self._clean_topic(t) for t in meta.get("topics") or []]
if not any(selected):
selected = state.topics
selected = candidates
return self._dedupe(selected, same_day, recent, count), True
@staticmethod
def _rel_path(daily_dir: str, day: str) -> str:
return f"{daily_dir}/{day}/interests.yaml"
@staticmethod
def _abs_path(workspace: Path, daily_dir: str, day: str) -> Path:
return workspace / daily_dir / day / "interests.yaml"
@staticmethod
def _clean_topic(raw) -> dict:
if not isinstance(raw, dict):

View file

@ -48,6 +48,16 @@ def today(step: BaseStep, explicit: str = "") -> str:
return now(tz).strftime("%Y-%m-%d")
def recent_dates(day: str, n_days: int) -> list[str]:
"""Return the inclusive recent-date window ending at ``day``."""
try:
base = dt.date.fromisoformat(day)
except ValueError:
return [day] if day else []
n = max(int(n_days or 1), 1)
return [(base - dt.timedelta(days=i)).isoformat() for i in range(n - 1, -1, -1)]
def llm_available(step: BaseStep) -> bool:
"""Check if LLM is available."""
try:

View file

@ -4,11 +4,15 @@ import asyncio
import tempfile
from pathlib import Path
import yaml
from reme.components.file_catalog import BaseFileCatalog
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.steps.evolve.dream.finish import DreamFinishStep
from reme.steps.evolve.dream.schema import DreamState
from reme.steps.evolve.dream.utils import parse_structured_reply, scan_day_files
from reme.steps.evolve.dream.topics import DreamTopicsStep
from reme.steps.evolve.dream.utils import parse_structured_reply, recent_dates, scan_day_files
def _touch(path: Path, text: str = "x") -> Path:
@ -36,6 +40,40 @@ class _Catalog(BaseFileCatalog):
self.dumps += 1
class _FileStore(BaseFileStore):
def __init__(self, workspace: Path):
super().__init__()
self._workspace_path = workspace
@property
def workspace_path(self) -> Path:
return self._workspace_path
async def upsert(self, files):
return None
async def delete(self, path):
return None
async def clear(self):
return None
async def get_nodes(self, paths=None):
return []
async def get_outlinks(self, path, scope=None):
return []
async def get_inlinks(self, path, scope=None):
return []
async def vector_search(self, query, limit, search_filter):
return []
async def keyword_search(self, query, limit, search_filter):
return []
def test_scan_day_files_includes_nested_md_and_excludes_interests():
"""Scan day files."""
with tempfile.TemporaryDirectory() as tmp:
@ -52,6 +90,12 @@ def test_scan_day_files_includes_nested_md_and_excludes_interests():
]
def test_recent_dates_includes_anchor_and_previous_days():
"""Recent date window is inclusive and chronological."""
assert recent_dates("2026-05-28", 3) == ["2026-05-26", "2026-05-27", "2026-05-28"]
assert recent_dates("2026-05-28", 1) == ["2026-05-28"]
def test_parse_structured_reply_handles_fenced_yaml_and_scalar_fallback():
"""Parse a JSON/YAML object from an agent reply, including fenced blocks."""
data = parse_structured_reply(
@ -66,6 +110,48 @@ def test_parse_structured_reply_handles_fenced_yaml_and_scalar_fallback():
assert data["note"].startswith("Extended node")
def test_topics_step_writes_only_target_date_interests():
"""Topics are written only to ``state.date`` even when scan dates span multiple days."""
async def run():
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
_touch(workspace / "daily" / "2026-05-26" / "old.md")
_touch(workspace / "daily" / "2026-05-28" / "today.md")
old_interests = workspace / "daily" / "2026-05-26" / "interests.yaml"
_touch(old_interests, "date: 2026-05-26\ntopics: []\n")
state = DreamState(
date="2026-05-28",
dates=["2026-05-26", "2026-05-27", "2026-05-28"],
workspace=str(workspace),
daily_dir="daily",
topics=[
{
"title": "Old changed topic",
"reason": "Old daily material changed.",
"paths": ["daily/2026-05-26/old.md"],
},
{
"title": "Today changed topic",
"reason": "Today's daily material changed.",
"paths": ["daily/2026-05-28/today.md"],
},
],
)
step = DreamTopicsStep()
resp = await step(RuntimeContext(dream=state.model_dump(), file_store=_FileStore(workspace)))
target = workspace / "daily" / "2026-05-28" / "interests.yaml"
dream = resp.metadata["dream"]
assert resp.success is True
assert target.is_file()
assert old_interests.read_text(encoding="utf-8") == "date: 2026-05-26\ntopics: []\n"
assert dream["interests_paths"] == ["daily/2026-05-28/interests.yaml"]
assert yaml.safe_load(target.read_text(encoding="utf-8"))["date"] == "2026-05-28"
asyncio.run(run())
def test_finish_does_not_checkpoint_failed_changed_paths():
"""Finish does not checkpoint failed changed paths."""
@ -74,13 +160,16 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
workspace = Path(tmp)
ok = _touch(workspace / "daily" / "2026-05-28" / "ok.md")
failed = _touch(workspace / "daily" / "2026-05-28" / "failed.md")
day_index = _touch(workspace / "daily" / "2026-05-28.md")
interests = _touch(workspace / "daily" / "2026-05-28" / "interests.yaml")
state = DreamState(
date="2026-05-28",
dates=["2026-05-26", "2026-05-27", "2026-05-28"],
workspace=str(workspace),
daily_dir="daily",
changed_paths=[str(ok.relative_to(workspace)), str(failed.relative_to(workspace))],
failed_paths=[str(failed.relative_to(workspace))],
interests_path=str(interests.relative_to(workspace)),
interests_paths=[str(interests.relative_to(workspace))],
)
step, catalog = DreamFinishStep(), _Catalog()
resp = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
@ -90,6 +179,7 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
assert str(ok.relative_to(workspace)) in upserted
assert str(failed.relative_to(workspace)) not in upserted
assert str(interests.relative_to(workspace)) in upserted
assert str(day_index.relative_to(workspace)) in upserted
assert catalog.dumps == 1
asyncio.run(run())

View file

@ -90,6 +90,36 @@ def test_keyword_only_upsert_removes_old_chunks_and_docs():
run(go())
def test_load_rebuilds_keyword_index_from_persisted_chunks_when_missing():
"""Loading persisted chunks repairs a missing keyword index."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = LocalFileStore(name="t_keyword_repair", embedding_store="")
await store.start()
await store.upsert(
[
(node("a.md"), [chunk("a", "a.md", "uniquerepairword stock")]),
(node("b.md"), [chunk("b", "b.md", "work preference")]),
],
)
await store.dump()
await store.keyword_index.clear()
assert not store.keyword_index.index_file.exists()
assert await store.keyword_search("uniquerepairword", 5, {}) == []
store.file_chunks.clear()
await store.load()
assert store.keyword_index.index_file.exists()
assert [c.id for c in await store.keyword_search("uniquerepairword", 5, {})] == ["a"]
await store.close()
run(go())
def test_same_chunk_id_with_changed_text_gets_new_embedding():
"""Changing a chunk text refreshes its embedding."""

View file

@ -787,6 +787,47 @@ def test_index_file_isolated_by_tokenizer_config():
run(go())
def test_tokenizer_fingerprint_ignores_stopwords_absolute_path():
"""Same stopwords content should not fork indexes by install path."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
stopwords_a = os.path.join(tmp, "a", "stopwords")
stopwords_b = os.path.join(tmp, "b", "stopwords")
os.makedirs(os.path.dirname(stopwords_a))
os.makedirs(os.path.dirname(stopwords_b))
with open(stopwords_a, "w", encoding="utf-8") as f:
f.write("alpha\nbeta\n")
with open(stopwords_b, "w", encoding="utf-8") as f:
f.write("alpha\nbeta\n")
first = BM25Index()
first_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_a)
first.tokenizer = first_tokenizer
first._owned.append(first_tokenizer)
await first.start()
second = BM25Index()
second_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_b)
second.tokenizer = second_tokenizer
second._owned.append(second_tokenizer)
await second.start()
assert first._tokenizer_config()["stopwords_sha256"] == second._tokenizer_config()["stopwords_sha256"]
assert "stopwords_path" not in first._tokenizer_config()
assert first._tokenizer_fingerprint() == second._tokenizer_fingerprint()
assert first.index_file == second.index_file
with open(stopwords_b, "w", encoding="utf-8") as f:
f.write("alpha\ngamma\n")
assert first._tokenizer_fingerprint() != second._tokenizer_fingerprint()
await first.close()
await second.close()
run(go())
def test_dump_failure_is_not_silent():
"""A failed write must be observable by callers."""