Skip to content
30 changes: 28 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,43 @@

## [0.5.4] — 2026-06-01

Durability & dependency-hardening release. All changes are additive and
backward compatible; existing databases upgrade automatically on first open.
Durability, simulation, and hardening release. All changes are additive and
backward compatible; existing databases and identities upgrade automatically.

### Added
- **Derived mood model** — a `CircumplexMood` maps an agent's `happiness` and
`suffering` onto a valence/arousal circumplex and names the mood (content,
motivated, steady, restless, discouraged, anxious, overwhelmed). Pure/derived
(no persisted state), swappable via `MoodRegistry`, and surfaced in the
goal-pursuit prompt.
- **Chaptered narrative** — instead of FIFO-dropping its oldest lines, an
agent's narrative seals into compact `Chapter` summaries (date span + entry
count + goal theme) on overflow, and the preamble shows a "Story so far"
section. `AgentIdentity.full_narrative()` exposes the complete history.
- **Identity narrative in the runtime prompt** — the agent *pursuing* a goal
now sees its persistent self (name + accumulated narrative), not just the
goal-generation path.
- **Event-log fsync durability (C5)** — `EventLog(fsync=True)` flushes and
`os.fsync()`s every append so a power/OS crash cannot lose an acknowledged
event. Gated by the new `event_log_fsync` config option (env
`HIVE_EVENT_LOG_FSYNC`), default off to protect the hot heartbeat write path;
the daemon honors it. Reads now tolerate a torn/partial last line.
- **`HiveStore.delete_agent()`** — deletes an agent and, via the new cascade,
all of its child rows in one call.
- **Test coverage (F1)** — first tests for the CLI, the MCP server protocol,
and the structured logging writer/reader.

### Fixed
- **Event log no longer shreds records with Unicode line separators** —
`replay()`/`stream()` split on `\n` only (not `str.splitlines()`, which also
breaks on `\r`/`\f`/NEL/`U+2028`/`U+2029` — all legal unescaped in JSON), so
an event whose text carried one is no longer mis-parsed as corruption.
- **Nudge delivery race** — `get_pending_nudges` marks only the nudges it
actually read as delivered, so one inserted concurrently isn't lost.
- Dropped dead `HIVE_MAX_TURNS`/`HIVE_SESSION_TIMEOUT` env mappings (no backing
fields); checkpoints now snapshot the freshly-saved identity; the crisis
directive is no longer duplicated in the pursuit prompt; event-log fsync also
fsyncs the parent directory on file creation.

### Changed
- **FK cascades (C3)** — every child table's foreign key to `agents`
Expand Down
11 changes: 10 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@

## [0.5.4] -- 2026-06-01

Durability & dependency-hardening release. All changes are additive and backward compatible; existing databases upgrade automatically on first open.
Durability, simulation, and hardening release. All changes are additive and backward compatible; existing databases and identities upgrade automatically.

### Added
- **Derived mood model**: a `CircumplexMood` maps `happiness` + `suffering` onto a valence/arousal circumplex and names the mood (content/motivated/steady/restless/discouraged/anxious/overwhelmed). Pure/derived, swappable via `MoodRegistry`, surfaced in the goal-pursuit prompt.
- **Chaptered narrative**: the narrative seals into compact `Chapter` summaries (date span + entry count + goal theme) on overflow instead of FIFO-dropping; the preamble shows a "Story so far" section; `AgentIdentity.full_narrative()` exposes the full history.
- **Identity narrative in the runtime prompt**: the goal-*pursuing* agent now sees its persistent self (name + accumulated narrative), not just goal generation.
- **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.
- **`HiveStore.delete_agent()`**: deletes an agent and, via cascade, all of its child rows in one call.
- **Test coverage (F1)**: first tests for the CLI, the MCP server protocol, and the structured logging writer/reader.

### Fixed
- **Event log no longer shreds records with Unicode line separators**: `replay()`/`stream()` split on `\n` only (not `str.splitlines()`, which also breaks on `\r`/`\f`/NEL/`U+2028`/`U+2029`, all legal unescaped in JSON).
- **Nudge delivery race**: `get_pending_nudges` marks only the nudges it actually read as delivered, so one inserted concurrently isn't lost.
- Dropped dead `HIVE_MAX_TURNS`/`HIVE_SESSION_TIMEOUT` env mappings; checkpoints snapshot the freshly-saved identity; the crisis directive isn't duplicated in the pursuit prompt; event-log fsync also fsyncs the parent directory on file creation.

### Changed
- **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.
Expand Down
2 changes: 0 additions & 2 deletions src/hive/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,6 @@ def load(cls, hive_dir: Path | None = None) -> "HiveConfig":
"HIVE_HEARTBEAT": ("daemon", "heartbeat", int),
"HIVE_MAX_RETRIES": ("daemon", "max_retries", int),
"HIVE_DEFAULT_MODEL": ("model", "default_model", str),
"HIVE_MAX_TURNS": ("model", "max_turns", int),
"HIVE_SESSION_TIMEOUT": ("model", "session_timeout", int),
"HIVE_STARTING_BALANCE": ("economy", "starting_balance", float),
"HIVE_PROFILES_DIR": ("profiles_dir", None, str),
"HIVE_LOGS_DIR": ("logs_dir", None, str),
Expand Down
7 changes: 6 additions & 1 deletion src/hive/daemon/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,10 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
# narrative/opinions -- which the persona/profile system prompt alone
# doesn't carry. Same context channel the suffering fragment uses.
# A derived mood (from happiness + suffering) colours the framing.
# Skip it in a crisis: the suffering fragment already states the
# crisis directive, so the "overwhelmed" mood line would duplicate it.
mood_line = ""
if persona is not None:
if persona is not None and not suffering.in_crisis:
mood = MoodRegistry.default().derive(
persona.happiness, suffering.cumulative_load, suffering.in_crisis
)
Expand Down Expand Up @@ -510,6 +512,9 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
metadata={"type": "goal_completed", "goal_id": active_goal["goal_id"]},
)
goals_snap = await self._store.list_agent_goals(agent.agent_id, limit=10)
# Reload so the snapshot reflects the narrative entry/chapter that
# update_narrative just wrote (it persists its own reloaded copy).
identity = self._identity.load(agent.agent_id) or identity
self._checkpoint.save(
agent.agent_id,
"goal_completed",
Expand Down
27 changes: 24 additions & 3 deletions src/hive/memory/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,23 @@ async def append(self, event: HiveEvent) -> None:
await asyncio.to_thread(self._write_line, path, line)

def _write_line(self, path: Path, line: str) -> None:
is_new = not path.exists()
with open(path, "a") as f:
f.write(line)
if self._fsync:
f.flush()
os.fsync(f.fileno())
if self._fsync and is_new:
# fsync the parent dir so a freshly-created file's directory entry
# is durably linked (content fsync alone doesn't guarantee this).
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass # some platforms/filesystems don't support directory fsync

async def replay(self, agent_id: str, session_id: str) -> list[HiveEvent]:
path = self._session_path(agent_id, session_id)
Expand All @@ -91,9 +103,15 @@ async def replay(self, agent_id: str, session_id: str) -> list[HiveEvent]:
# final line on a newline-terminated file) must parse -- a failure there
# is real corruption and is surfaced, not silently dropped.
ends_clean = text.endswith("\n")
lines = text.splitlines()
# Split ONLY on "\n" -- str.splitlines() also breaks on \r, \v, \f, NEL
# and Unicode 
/
, which are legal (unescaped) inside JSON
# strings, so a single record carrying one in its text would be shredded.
lines = text.split("\n")
if ends_clean and lines and lines[-1] == "":
lines = lines[:-1] # drop the empty element after a trailing newline
events = []
for idx, line in enumerate(lines):
for idx, raw in enumerate(lines):
line = raw.rstrip("\r") # tolerate CRLF
if not line.strip():
continue
try:
Expand Down Expand Up @@ -126,7 +144,10 @@ async def stream(self, agent_id: str) -> AsyncIterator[HiveEvent]:
nl = chunk.rfind("\n")
if nl != -1:
complete = chunk[: nl + 1]
for line in complete.splitlines():
# Split only on "\n" (see replay): splitlines() would shred a
# record containing a Unicode line separator in its text.
for raw in complete.split("\n"):
line = raw.rstrip("\r")
if not line.strip():
continue
# These are complete (newline-terminated) lines, so a parse
Expand Down
22 changes: 14 additions & 8 deletions src/hive/memory/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,16 +593,22 @@ async def get_pending_nudges(self, agent_id: str) -> list[str]:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT message FROM nudges WHERE agent_id = ? AND delivered = 0",
"SELECT nudge_id, message FROM nudges WHERE agent_id = ? AND delivered = 0",
(agent_id,),
) as cursor:
messages = [row["message"] async for row in cursor]
await db.execute(
"UPDATE nudges SET delivered = 1 WHERE agent_id = ? AND delivered = 0",
(agent_id,),
)
await db.commit()
return messages
rows = [(row["nudge_id"], row["message"]) async for row in cursor]
if rows:
# Mark delivered only the rows just read, by id -- so a nudge
# inserted (e.g. by another process) between the SELECT and the
# UPDATE isn't marked delivered without ever being returned.
ids = [r[0] for r in rows]
placeholders = ",".join("?" * len(ids))
await db.execute(
f"UPDATE nudges SET delivered = 1 WHERE nudge_id IN ({placeholders})",
ids,
)
await db.commit()
return [r[1] for r in rows]

async def save_schedule(
self,
Expand Down
16 changes: 16 additions & 0 deletions tests/memory/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,22 @@ async def writer(i: int) -> None:
# No exception == no "database is locked". Spot-check a couple landed.
assert await store.get_pending_nudges("agent-0")

@pytest.mark.asyncio
async def test_get_pending_nudges_only_marks_returned(self, tmp_path: Path) -> None:
"""A nudge added after a read isn't marked delivered, so it isn't lost."""
store = HiveStore(tmp_path / "state.db")
await store.initialize()
await store.save_nudge("n1", "a1", "first")
await store.save_nudge("n2", "a1", "second")

first = await store.get_pending_nudges("a1")
assert sorted(first) == ["first", "second"]

# A nudge that arrives later must still be delivered exactly once.
await store.save_nudge("n3", "a1", "third")
assert await store.get_pending_nudges("a1") == ["third"]
assert await store.get_pending_nudges("a1") == []
Comment thread
greptile-apps[bot] marked this conversation as resolved.


class TestCascades:
@pytest.mark.asyncio
Expand Down
34 changes: 34 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,40 @@ async def _run():
asyncio.run(_run())


def test_replay_handles_unicode_line_separators(tmp_dir):
"""A record whose text contains U+2028/U+2029/NEL must not be shredded.

These are legal (unescaped) inside JSON strings and have no '\\n', so the
record is one physical line -- but str.splitlines() would break it apart.
"""
log = EventLog(tmp_dir)

async def _run():
tricky = "before
middle
after\x85end"
await log.append(
HiveEvent(
event_type=EventType.ASSISTANT_MESSAGE,
agent_id="agent-1",
session_id="sess-1",
data={"text": tricky},
)
)
await log.append(
HiveEvent(
event_type=EventType.TOOL_USED,
agent_id="agent-1",
session_id="sess-1",
data={"tool": "x"},
)
)
events = await log.replay("agent-1", "sess-1")
assert len(events) == 2 # not shredded into extra/broken lines
assert events[0].data["text"] == tricky
assert events[1].data["tool"] == "x"

asyncio.run(_run())


def test_replay_raises_on_mid_log_corruption(tmp_dir):
"""A malformed line that is NOT the torn final line is real corruption -- surface it."""
import pytest
Expand Down
Loading