Skip to content

fix(memory): guard fetchone() against None in MemoryStore.add_fact and update_fact#22660

Open
wesleysimplicio wants to merge 1 commit into
NousResearch:mainfrom
wesleysimplicio:fix/ag10-holographic-store-fetchone-null
Open

fix(memory): guard fetchone() against None in MemoryStore.add_fact and update_fact#22660
wesleysimplicio wants to merge 1 commit into
NousResearch:mainfrom
wesleysimplicio:fix/ag10-holographic-store-fetchone-null

Conversation

@wesleysimplicio

@wesleysimplicio wesleysimplicio commented May 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Two methods in plugins/memory/holographic/store.py call .fetchone()["field"] without first checking if fetchone() returned None, causing TypeError: 'NoneType' object is not subscriptable in concurrent-delete edge cases.

Root cause

Two methods in plugins/memory/holographic/store.py call .fetchone()["field"] without first checking if fetchone() returned None, causing TypeError: 'NoneType' object is not subscriptable in concurrent-delete edge cases.

add_fact() (line 178)

After a sqlite3.IntegrityError on a duplicate INSERT, the code does a SELECT to retrieve the existing fact_id. If a concurrent process deleted that row in the tiny window between the failed INSERT and the SELECT, fetchone() returns None and the subscript crashes.

update_fact() (line 299)

After successfully updating a fact, the method re-queries category when the caller didn't supply one. Since this second…

Fix

  • add_fact(): add if row is None: raise RuntimeError(...) before accessing row["fact_id"].
  • update_fact(): replace fetchone()["category"] with an explicit check, falling back to "general" when the row is gone.

Why this shape

This shape mirrors #29640 so reviewers can quickly compare scope, root cause, fix, tests, and related context without having to decode a custom PR description.

Tests

  • Veja a descrição original preservada abaixo para detalhes de validação, testes e notas de verificação.
Original body

Related PRs / issues

N/A

Original body

Summary

Two methods in plugins/memory/holographic/store.py call .fetchone()["field"] without first checking if fetchone() returned None, causing TypeError: 'NoneType' object is not subscriptable in concurrent-delete edge cases.

What Changed

  • Standardized this PR body to the current Hermes Turbo template.
  • Preserved the original detailed description below for reference.

Fluxo

A mudança continua seguindo o fluxo original descrito na seção preservada abaixo, sem ampliar o escopo funcional deste PR.

Visão

A padronização melhora a revisão, reduz ruído e evita deriva de formatação entre PRs abertos.

Test Plan

  • Veja a descrição original preservada abaixo para detalhes de validação, testes e notas de verificação.
Original body

What does this PR do?

Problem

Two methods in plugins/memory/holographic/store.py call .fetchone()["field"] without first checking if fetchone() returned None, causing TypeError: 'NoneType' object is not subscriptable in concurrent-delete edge cases.

add_fact() (line 178)

After a sqlite3.IntegrityError on a duplicate INSERT, the code does a SELECT to retrieve the existing fact_id. If a concurrent process deleted that row in the tiny window between the failed INSERT and the SELECT, fetchone() returns None and the subscript crashes.

update_fact() (line 299)

After successfully updating a fact, the method re-queries category when the caller didn't supply one. Since this second SELECT runs after the COMMIT, a concurrent process could delete the row, causing the same TypeError.

Fix

  • add_fact(): add if row is None: raise RuntimeError(...) before accessing row["fact_id"].
  • update_fact(): replace fetchone()["category"] with an explicit check, falling back to "general" when the row is gone.

Tests

tests/plugins/memory/test_holographic_store_null_guards.py (5 tests):

  • test_add_fact_duplicate_returns_existing_id — normal dedup path still works
  • test_add_fact_concurrent_delete_raises_runtime_error — confirms RuntimeError (not TypeError) on stale SELECT
  • test_update_fact_nonexistent_returns_false — non-existent fact_id returns False
  • test_update_fact_with_category_skips_refetch — supplied category avoids the re-fetch
  • test_update_fact_concurrent_delete_uses_general_fallback — concurrent delete → "general" fallback, no crash

Stash-verified: both failing tests reproduce TypeError at the exact lines (store.py:178 and store.py:299) without the fix.

Checklist

  • Regression tests added and stash-verified
  • pytest tests/plugins/memory/test_holographic_store_null_guards.py — 5 passed
  • No behaviour change for normal (non-concurrent) paths

Solution Sketch

  • fix the root cause in the touched subsystem instead of layering a broad workaround around the symptom
  • keep surrounding behavior stable and avoid unrelated refactors while the area is under review
  • prove the change with focused checks on the exact path that regressed

Related Issue

N/A

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • preserved the existing technical rationale and validation notes inside the template body
  • scoped this PR description to the implementation already present on the branch
  • aligned the delivery format with .github/PULL_REQUEST_TEMPLATE.md

How to Test

  1. Review the existing validation notes preserved in this PR body.
  2. Run the focused checks for the touched area.
  3. Confirm the scoped change still behaves as described above.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform:

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

  • N/A.

Generated by Hermes Turbo


Generated by Hermes Turbo

Copilot AI review requested due to automatic review settings May 9, 2026 15:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes rare concurrent-delete edge cases in the holographic SQLite MemoryStore where .fetchone() can return None and previously caused TypeError, and adds targeted regression tests to cover those scenarios.

Changes:

  • Add a None guard in add_fact()’s duplicate-handling path (after IntegrityError) and raise a clearer RuntimeError.
  • Add a None guard in update_fact()’s category re-fetch path to avoid subscripting None.
  • Add regression tests exercising both the normal dedupe path and the concurrent-delete edge cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
plugins/memory/holographic/store.py Adds fetchone()-None guards in add_fact() and update_fact() to prevent TypeError under concurrent deletions.
tests/plugins/memory/test_holographic_store_null_guards.py New regression tests simulating fetchone() -> None after a duplicate insert and after an update.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 173 to +180
except sqlite3.IntegrityError:
# Duplicate content — return existing id
# Duplicate content — return existing id.
# Guard against the (rare) cross-process concurrent-delete case
# where the row is gone by the time we SELECT it.
row = self._conn.execute(
"SELECT fact_id FROM facts WHERE content = ?", (content,)
).fetchone()
if row is None:
Comment on lines +311 to 315
_cat_row = self._conn.execute(
"SELECT category FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()
cat = _cat_row["category"] if _cat_row is not None else "general"
self._rebuild_bank(cat)

@pytest.fixture
def store(tmp_path):
return MemoryStore(db_path=str(tmp_path / "test.db"))
"""Regression tests for None-guard fixes in holographic MemoryStore.

Bug: add_fact() and update_fact() could crash with TypeError on fetchone()
when a concurrent process deletes the row between an UNIQUE violation and
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins tool/memory Memory tool and memory providers labels May 9, 2026
@wesleysimplicio wesleysimplicio force-pushed the fix/ag10-holographic-store-fetchone-null branch from 9118264 to 220739e Compare June 12, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants