Skip to content

Commit 54fbd13

Browse files
authored
release: v0.5.4 -- durability & dependency hardening (C3/C4/C5/E3) (#33)
* feat(C5): config-gated event-log fsync + partial-line tolerance - EventLog(fsync=True) flushes + os.fsync()'s each append for crash durability; defaults off to protect the hot heartbeat write path. - Gated by HiveConfig.event_log_fsync (env HIVE_EVENT_LOG_FSYNC); daemon threads it into its EventLog. - replay() tolerates a torn/partial last line; stream() consumes only complete (newline-terminated) lines, leaving a partial tail for the next poll. Mirrors the TFIDFBackend partial-line pattern. - Document the append-only guarantee on EventLog. - Tests: fsync round-trip, partial-last-line replay, env-var bool parse. * feat(C3): ON DELETE CASCADE on agent child tables + delete_agent - Add ON DELETE CASCADE to every child-table FK to agents (sessions, goals, nudges, schedules, sub_agents, tasks, alarms) in _SCHEMA, and a user_version 1->2 table-rebuild migration to add it to existing DBs. - New HiveStore.delete_agent(): deletes an agent and cascades to all its child rows (no more orphans). It is the one place FK enforcement is turned on. - Centralize connections behind _connect(); foreign_keys stays OFF by default (preserves writing child rows for non-persisted agents, a valid standalone-toolkit pattern) and is opt-in via _connect(foreign_keys=True). - initialize() runs schema+migrations with FKs off so the table rebuild's DROP/RENAME can't trip referential checks. - Tests: cascade delete, missing-agent returns False, v1->v2 migration round-trip preserving data with cascade then live. * feat(C4): WAL journaling + explicit busy timeout for concurrency - initialize() switches the DB to WAL (persistent in the header), so readers and a single writer no longer lock each other out across the daemon's concurrent cycles and other processes (MCP server, CLI). - Every connection sets an explicit 5s busy_timeout so contention queues instead of erroring with 'database is locked'. - Deliberately NOT pooling/reusing a single connection: aiosqlite's worker thread is non-daemon, so a long-lived shared connection without teardown at all ~20 call sites would block interpreter exit. WAL delivers the concurrency win without that lifecycle hazard. - Tests: WAL is enabled persistently for fresh connections; 40 concurrent writers complete with no lock errors. Stress harness: 7/7, 1200 concurrent writes 0 db-locked. * build(E3): cap fast-moving deps at next major Add conservative upper bounds so a breaking major can't silently enter on a fresh install, while keeping minimums loose for downstream compat: - anthropic >=0.40,<1 (0.x pre-1.0) - httpx >=0.27,<1 - openai >=1.30,<3 (currently 2.x; block v3) - mcp >=1.27,<2 - pydantic >=2.5,<3 Stable/slow-moving deps (typer, rich, aiosqlite, pyyaml, etc.) keep minimum-only pins. Lockfile refreshed. * release: v0.5.4 -- durability & dependency hardening Bump 0.5.3 -> 0.5.4. Bundles C5 (event-log fsync), C3 (FK cascades + delete_agent), C4 (WAL + busy timeout), E3 (dep upper bounds). - CHANGELOG.md + docs/changelog.md: 0.5.4 entry. - docs/guide/architecture.md: WAL/cascade/fsync notes in Performance & Persistence. - docs/getting-started/cli-quickstart.md: event_log_fsync config option + HIVE_EVENT_LOG_FSYNC env var. - uv.lock synced to 0.5.4. * fix: address Greptile review on PR #33 (migration crash-safety + log corruption) P1 -- migration crash window (store.py): a crash after DROP TABLE {table} but before the RENAME left {table} gone and {table}_new holding the only data; a re-run's executescript(_SCHEMA) recreated {table} empty, masking the loss, then the rebuild dropped {table}_new -> data loss. Add _recover_interrupted_rebuilds() that finishes an interrupted swap (renames the orphaned {table}_new back) and runs BEFORE _SCHEMA, so the dropped table can't be silently recreated empty. _migration_2 reverts to the simple rebuild (recovery handled upstream). Regression test simulates the exact mid-swap on-disk state and asserts no data loss. P2 -- over-broad except in events.py: replay() caught Exception for every line, silently hiding mid-log corruption. Now it tolerates a parse error ONLY on a torn final line (file not newline-terminated) and re-raises otherwise; the catch is narrowed to ValueError (covers JSON + pydantic ValidationError). stream() only ever sees complete lines, so its broad catch is removed -- malformed complete lines now surface. Regression test asserts mid-log corruption raises. 963 tests pass; ruff/format/mypy clean. * docs: address review observations on PR #33 - Changelog (both copies): the '1200 concurrent writes' figure came from the local (gitignored) stress harness, not the committed test (40 writers). Reword to cite both so the claim is reproducible from the repo. - pyproject: clarify the anthropic<1 cap -- anthropic is still 0.x (breaks at minor bumps), so <1 mainly guards the eventual 1.0; note to revisit when 1.0 ships. No pin change (caps still include all locked versions). Doc/comment only -- no code or dependency-resolution change.
1 parent e2cef9d commit 54fbd13

14 files changed

Lines changed: 645 additions & 75 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
# Changelog
22

3+
## [0.5.4] — 2026-06-01
4+
5+
Durability & dependency-hardening release. All changes are additive and
6+
backward compatible; existing databases upgrade automatically on first open.
7+
8+
### Added
9+
- **Event-log fsync durability (C5)**`EventLog(fsync=True)` flushes and
10+
`os.fsync()`s every append so a power/OS crash cannot lose an acknowledged
11+
event. Gated by the new `event_log_fsync` config option (env
12+
`HIVE_EVENT_LOG_FSYNC`), default off to protect the hot heartbeat write path;
13+
the daemon honors it. Reads now tolerate a torn/partial last line.
14+
- **`HiveStore.delete_agent()`** — deletes an agent and, via the new cascade,
15+
all of its child rows in one call.
16+
17+
### Changed
18+
- **FK cascades (C3)** — every child table's foreign key to `agents`
19+
(`sessions`, `goals`, `nudges`, `schedules`, `sub_agents`, `tasks`, `alarms`)
20+
now declares `ON DELETE CASCADE`. A `user_version` 1→2 migration rebuilds
21+
existing tables to add it (data preserved). Foreign-key enforcement is opt-in
22+
per operation (on for `delete_agent`), so writing child rows for
23+
not-yet-persisted agents keeps working.
24+
- **WAL journaling (C4)** — the store now runs in WAL mode (set persistently on
25+
initialize) with an explicit 5s busy timeout, so readers and a writer no
26+
longer lock each other out across the daemon's concurrent cycles and other
27+
processes (MCP server, CLI). Verified with concurrent writers and no
28+
"database is locked" errors (40 in the test suite; 1200 in the local stress
29+
harness).
30+
- **Dependency upper bounds (E3)** — fast-moving deps are capped below their
31+
next major (`anthropic<1`, `httpx<1`, `openai<3`, `mcp<2`, `pydantic<3`) so a
32+
breaking release can't silently enter; minimums stay loose.
33+
334
## [0.5.3] — 2026-06-01
435

536
### Fixed

docs/changelog.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## [0.5.4] -- 2026-06-01
4+
5+
Durability & dependency-hardening release. All changes are additive and backward compatible; existing databases upgrade automatically on first open.
6+
7+
### Added
8+
- **Event-log fsync durability (C5)**: `EventLog(fsync=True)` flushes and `os.fsync()`s every append so a power/OS crash can't lose an acknowledged event. Gated by the `event_log_fsync` config option (env `HIVE_EVENT_LOG_FSYNC`), default off to protect the hot heartbeat write path; the daemon honors it. Reads tolerate a torn/partial last line.
9+
- **`HiveStore.delete_agent()`**: deletes an agent and, via cascade, all of its child rows in one call.
10+
11+
### Changed
12+
- **FK cascades (C3)**: every child table's foreign key to `agents` (`sessions`, `goals`, `nudges`, `schedules`, `sub_agents`, `tasks`, `alarms`) now declares `ON DELETE CASCADE`; a `user_version` 1->2 migration rebuilds existing tables to add it (data preserved). FK enforcement is opt-in per operation, so writing child rows for not-yet-persisted agents keeps working.
13+
- **WAL journaling (C4)**: the store runs in WAL mode (persistent) with a 5s busy timeout, so readers and a writer don't lock each other out across concurrent cycles and other processes. Verified with concurrent writers and no "database is locked" errors (40 in the test suite; 1200 in the local stress harness).
14+
- **Dependency upper bounds (E3)**: fast-moving deps capped below their next major (`anthropic<1`, `httpx<1`, `openai<3`, `mcp<2`, `pydantic<3`); minimums stay loose.
15+
316
## [0.5.3] -- 2026-06-01
417

518
### Fixed

docs/getting-started/cli-quickstart.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ economy:
111111
suffering:
112112
threshold_crisis: 0.90
113113
max_stressors: 5
114+
115+
event_log_fsync: false # fsync every event-log append (crash-durable, slower)
114116
```
115117
116-
Override with environment variables: `HIVE_HEARTBEAT`, `HIVE_DEFAULT_MODEL`, `HIVE_STARTING_BALANCE`.
118+
Override with environment variables: `HIVE_HEARTBEAT`, `HIVE_DEFAULT_MODEL`, `HIVE_STARTING_BALANCE`, `HIVE_EVENT_LOG_FSYNC`.

docs/guide/architecture.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ src/hive/
116116
SQLite's `PRAGMA user_version`. Schema changes are ordered migration steps applied in
117117
a single transaction on `initialize()`, so an older database upgrades in place without
118118
data loss.
119+
- **WAL journaling + cascades.** The store runs in WAL mode (set once on `initialize()`,
120+
persistent in the DB header) with a 5s busy timeout, so readers and a writer don't lock
121+
each other out across concurrent agent cycles or other processes (the MCP server, the
122+
CLI). Child tables (`sessions`, `goals`, `tasks`, ...) declare `ON DELETE CASCADE` on
123+
their foreign key to `agents`, so `HiveStore.delete_agent()` removes an agent and all
124+
of its rows together. FK enforcement is opt-in per operation, so writing child rows for
125+
a standalone agent that was never persisted to `agents` still works.
126+
- **Crash-durable event log.** The JSONL session log is append-only by construction.
127+
Setting `event_log_fsync: true` (env `HIVE_EVENT_LOG_FSYNC`) flushes and `fsync()`s
128+
every append so an acknowledged event survives a power/OS crash; it defaults to off
129+
because that is one `fsync` per event on the hot write path. Readers tolerate a torn
130+
last line from an interrupted append.
119131

120132
## Extension Points
121133

pyproject.toml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "hive-agent"
3-
version = "0.5.3"
3+
version = "0.5.4"
44
description = "Local-first agent OS. Spawn persistent AI agents that collaborate, write code, and use tools autonomously."
55
authors = [{ name = "chiruu12" }]
66
license = { text = "MIT" }
@@ -21,15 +21,19 @@ classifiers = [
2121
dependencies = [
2222
"typer>=0.12",
2323
"rich>=13.0",
24-
"httpx>=0.27",
24+
# Upper bounds on fast-moving deps cap the next major so a breaking release
25+
# can't silently enter; minimums stay loose for downstream compatibility.
26+
# anthropic is still 0.x (breaking changes land in minor bumps), so its <1
27+
# cap mainly guards the eventual 1.0 -- revisit/loosen when 1.0 ships.
28+
"httpx>=0.27,<1",
2529
"aiosqlite>=0.20",
2630
"pyyaml>=6.0",
27-
"pydantic>=2.5",
28-
"anthropic>=0.40",
29-
"openai>=1.30",
31+
"pydantic>=2.5,<3",
32+
"anthropic>=0.40,<1",
33+
"openai>=1.30,<3",
3034
"python-dotenv>=1.0",
3135
"python-dateutil>=2.8",
32-
"mcp>=1.27",
36+
"mcp>=1.27,<2",
3337
"beautifulsoup4>=4.14.3",
3438
"tqdm>=4.67.3",
3539
]

src/hive/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Hive - Autonomous agent OS."""
22

3-
__version__ = "0.5.3"
3+
__version__ = "0.5.4"
44

55
from hive.agents.existence import ExistenceLoop
66
from hive.agents.goal_strategy import Goal, GoalContext, GoalStrategy

src/hive/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Central configuration — all tunables in one place."""
22

33
import os
4+
from collections.abc import Callable
45
from pathlib import Path
56
from typing import Any
67

@@ -38,6 +39,11 @@ def get_env(key: str, default: str = "") -> str:
3839
return dot.get(key) or os.environ.get(key, default)
3940

4041

42+
def _parse_bool(value: str) -> bool:
43+
"""Parse a truthy env-var string (bool('false') is True, so we can't use it)."""
44+
return value.strip().lower() in {"1", "true", "yes", "on"}
45+
46+
4147
class SufferingConfig(BaseModel):
4248
threshold_prominent: float = 0.35
4349
threshold_constrained: float = 0.55
@@ -163,6 +169,8 @@ class HiveConfig(BaseModel):
163169
model: ModelConfig = ModelConfig()
164170
profiles_dir: str = ""
165171
logs_dir: str = "logs"
172+
# fsync every event-log append for crash durability (one fsync per event).
173+
event_log_fsync: bool = False
166174

167175
@classmethod
168176
def load(cls, hive_dir: Path | None = None) -> "HiveConfig":
@@ -176,7 +184,7 @@ def load(cls, hive_dir: Path | None = None) -> "HiveConfig":
176184
file_data = yaml.safe_load(f) or {}
177185
data.update(file_data)
178186

179-
env_map = {
187+
env_map: dict[str, tuple[str, str | None, Callable[[str], Any]]] = {
180188
"HIVE_HEARTBEAT": ("daemon", "heartbeat", int),
181189
"HIVE_MAX_RETRIES": ("daemon", "max_retries", int),
182190
"HIVE_DEFAULT_MODEL": ("model", "default_model", str),
@@ -185,6 +193,7 @@ def load(cls, hive_dir: Path | None = None) -> "HiveConfig":
185193
"HIVE_STARTING_BALANCE": ("economy", "starting_balance", float),
186194
"HIVE_PROFILES_DIR": ("profiles_dir", None, str),
187195
"HIVE_LOGS_DIR": ("logs_dir", None, str),
196+
"HIVE_EVENT_LOG_FSYNC": ("event_log_fsync", None, _parse_bool),
188197
}
189198

190199
for env_key, (section, field, cast) in env_map.items():

src/hive/daemon/loop.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def __init__(
6868
self._economy_enabled = cfg.economy.enabled
6969
self._running = False
7070
self._store = HiveStore(hive_dir / "hive.db")
71-
self._events = EventLog(hive_dir)
71+
self._events = EventLog(hive_dir, fsync=cfg.event_log_fsync)
7272

7373
world = None
7474
if self._economy_enabled:

src/hive/memory/events.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import json
5+
import os
56
from collections.abc import AsyncIterator
67
from datetime import UTC, datetime
78
from enum import StrEnum
@@ -45,11 +46,21 @@ def from_jsonl(cls, line: str) -> "HiveEvent":
4546

4647

4748
class EventLog:
48-
"""Append-only JSONL event stream for agent sessions."""
49+
"""Append-only JSONL event stream for agent sessions.
4950
50-
def __init__(self, hive_dir: Path):
51+
Each session is one file opened only in append mode ("a"); events are never
52+
rewritten or removed in place, so the log is append-only by construction.
53+
54+
With ``fsync=True`` every append is flushed and ``os.fsync``'d before
55+
returning, so a power/OS crash cannot lose an acknowledged event. This costs
56+
one fsync per event, so it defaults to off (it runs off the event loop via a
57+
worker thread, but the syscall is still on the per-event write path).
58+
"""
59+
60+
def __init__(self, hive_dir: Path, fsync: bool = False):
5161
self._sessions_dir = hive_dir / "sessions"
5262
self._sessions_dir.mkdir(parents=True, exist_ok=True)
63+
self._fsync = fsync
5364

5465
def _session_path(self, agent_id: str, session_id: str) -> Path:
5566
agent_dir = self._sessions_dir / agent_id
@@ -64,16 +75,34 @@ async def append(self, event: HiveEvent) -> None:
6475
def _write_line(self, path: Path, line: str) -> None:
6576
with open(path, "a") as f:
6677
f.write(line)
78+
if self._fsync:
79+
f.flush()
80+
os.fsync(f.fileno())
6781

6882
async def replay(self, agent_id: str, session_id: str) -> list[HiveEvent]:
6983
path = self._session_path(agent_id, session_id)
7084
if not path.exists():
7185
return []
7286
text = await asyncio.to_thread(path.read_text)
87+
if not text:
88+
return []
89+
# A complete append always ends in "\n"; if the file doesn't, the final
90+
# line is a torn/half-written record we tolerate. Earlier lines (and a
91+
# final line on a newline-terminated file) must parse -- a failure there
92+
# is real corruption and is surfaced, not silently dropped.
93+
ends_clean = text.endswith("\n")
94+
lines = text.splitlines()
7395
events = []
74-
for line in text.strip().splitlines():
75-
if line.strip():
96+
for idx, line in enumerate(lines):
97+
if not line.strip():
98+
continue
99+
try:
76100
events.append(HiveEvent.from_jsonl(line))
101+
except ValueError:
102+
# pydantic ValidationError / JSON errors subclass ValueError.
103+
if idx == len(lines) - 1 and not ends_clean:
104+
continue # torn final write -- expected, skip it
105+
raise
77106
return events
78107

79108
async def stream(self, agent_id: str) -> AsyncIterator[HiveEvent]:
@@ -91,11 +120,20 @@ async def stream(self, agent_id: str) -> AsyncIterator[HiveEvent]:
91120

92121
while True:
93122
text = await asyncio.to_thread(path.read_text)
94-
lines = text[offset:].strip().splitlines()
95-
for line in lines:
96-
if line.strip():
123+
chunk = text[offset:]
124+
# Only consume up to the last newline; a trailing partial line (an
125+
# in-progress append) is left for the next poll once fully flushed.
126+
nl = chunk.rfind("\n")
127+
if nl != -1:
128+
complete = chunk[: nl + 1]
129+
for line in complete.splitlines():
130+
if not line.strip():
131+
continue
132+
# These are complete (newline-terminated) lines, so a parse
133+
# error is real corruption -- let it surface rather than
134+
# silently dropping events.
97135
yield HiveEvent.from_jsonl(line)
98-
offset = len(text)
136+
offset += len(complete)
99137
await asyncio.sleep(0.3)
100138

101139
async def list_sessions(self, agent_id: str) -> list[str]:

0 commit comments

Comments
 (0)