Frontmatter
| title | fix(ai): harden Sandman REM failure handling (#11698) |
| author | neo-gpt |
| state | Merged |
| createdAt | May 20, 2026, 11:24 PM |
| updatedAt | May 21, 2026, 12:16 AM |
| closedAt | May 21, 2026, 12:16 AM |
| mergedAt | May 21, 2026, 12:16 AM |
| branches | dev ← codex/11698-sandman-null-apoptosis |
| url | https://github.com/neomjs/neo/pull/11699 |

PR Review Summary
Status: Approved
🪜 Strategic-Fit Decision
Per §9 Strategic-Fit Step-Back:
- Decision: Approve
- Rationale: Correct, well-tested regression fix; CI green; the error-contract change (
processUndigestedSessionsnever-rejects → rejects) is verified safe across all three callers. No blocking defects. §9.0 Premise Pre-Flight: premise verified TRUE (the swallow-without-rethrow atDreamService.mjsgenuinely masked failures as success) — no structural-invalidity trigger; Request Changes / Drop+Supersede would be wrong-shape.
Peer-Review Opening: Clean fix for a genuinely dangerous failure mode — a daemon printing ✅ Sandman cycle complete. over a swallowed exception. The three-layer hardening (id guards / error re-throw / testable runRemPipeline) is sound, and I verified the error-contract change does not surprise any downstream caller. Approving — notes below are non-blocking.
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #11698
- Related Graph Nodes:
DreamService.processUndigestedSessions;runSandman/runRemPipeline;OrchestratorDREAM task;GraphService/Database/SQLitegraph-write layer
🔬 Depth Floor
Documented search: I actively looked for (1) downstream callers of processUndigestedSessions that would be surprised by the new throw error, (2) transaction-rollback correctness of the new SQLite write guard, and (3) close-target validity. Findings: (1) three callers, all handle it — see Cross-Skill Integration Audit below; (2) the SQLite guard throws inside the addNodes db.transaction(...) body → better-sqlite3 rolls the batch back atomically, so a bad node never partially persists; (3) #11698 is a leaf bug/regression, valid close-target.
Challenge (follow-up concern — non-blocking): This PR hardens the failure path — correct and complete for #11698's "hides-as-success" scope — but it does not diagnose why apoptosis produces a null/invalid node id in the first place. With the new guards that case now fails loud (a clear invalid node id error) instead of a cryptic Cannot read properties of null, which is the right outcome. If the post-merge live Sandman run (Post-Merge Validation item 1) surfaces actual null ids in runGarbageCollection, a follow-up ticket should trace the id-origin. Flagged as a watch-item for the post-merge run, not a verified gap — the PR correctly converts silent corruption into a loud, attributable error.
Rhetorical-Drift Audit (§7.4): PR description ("Hardens the Sandman REM failure path", "re-throws ... after logging so runSandman can fail non-zero", "Extracts runRemPipeline() so the success-log contract is unit-testable") matches the diff exactly. New JSDoc on isValidGraphNodeId and runRemPipeline uses precise terminology. Evidence line accurately scopes L2→L3. Findings: Pass — no drift.
🧠 Graph Ingestion Notes
[RETROSPECTIVE]: The false-success masking had a second, latent surface beyondrunSandman: theOrchestratorDREAM task (Orchestrator.mjs:897) wrapsprocessUndigestedSessions()in atry/catchwhosecatchdoesmarkFailed— but pre-fix that catch was dead code (the function never rejected), so a failed REM cycle was recorded as acompletedtask in the orchestrator's task-state. This PR'sthrow errorrevives that catch, so the orchestrator now records REM failures correctly too. A swallowed exception silently corrupts every observer that was already written to handle the failure.
🔗 Cross-Skill Integration Audit
The PR changes the error contract of DreamService.processUndigestedSessions (previously swallowed all errors and always resolved; now re-throws after logging). Per §8.2, this requires enumerating downstream consumers. The PR body's Deltas do not enumerate them, so I verified all three call sites:
| Caller | Handling | Effect of the new throw |
|---|---|---|
DreamService.mjs:86 (startup, fire-and-forget) |
.catch(e => logger.error('[Startup] DreamService failed:', e)) |
Benign — startup REM failures now log instead of silently swallowing; not awaited, does not crash boot. |
Orchestrator.mjs:897 (DREAM maintenance task) |
try { ... markCompleted } catch (e) { ... markFailed } |
Improvement — the pre-existing catch now correctly fires; the DREAM task transitions to failed instead of false completed. |
runSandman.mjs runRemPipeline (this PR) |
runSandman's outer catch → non-zero process.exitCode |
The PR's intended fix. |
Findings: Pass — all three callers handle the new contract; no integration gap. Non-blocking: a one-line caller-audit note in the PR body's Deltas would have made this verification part of the graph-ingestion record.
🪜 Evidence Audit
- PR body carries an
Evidence:line:L2 (focused unit regressions + combined touched-suite run) → L3 required (operator CLI Sandman run). Residual: post-merge live Sandman validation [#11698]. - L2 verified locally (see Test-Execution Audit); L3 (live
npm run ai:run-sandmanexit-code + absence of false success log) is correctly declared as a residual with a## Post-Merge Validationchecklist. - Two-ceiling distinction explicit: the live CLI behavior is a host-effect the sandbox cannot reach; unit tests lock the contract.
Findings: Pass — evidence level matches the close-target; residual properly declared.
🎯 Close-Target Audit
- Close-target:
#11698— newline-isolatedResolves #11698, syntax-exact. - #11698 labels:
[bug, ai, regression, architecture]— notepic-labeled.
Findings: Pass.
🧪 Test-Execution & Location Audit
- Branch checked out locally (
gh pr checkout 11699). - Ran the four touched spec files:
npm run test-unit -- test/playwright/unit/ai/graph/Database.spec.mjs test/playwright/unit/ai/services/memory-core/GraphService.spec.mjs test/playwright/unit/ai/daemons/DreamService.spec.mjs test/playwright/unit/ai/buildScripts/runSandman.spec.mjs→ 59 passed (2.7s), matching the PR body's claim. The 5 new#11698cases (id-guard rejection ×3, REM-failure re-throw ×1, success-log-suppression ×1) all pass. - Locations canonical:
test/playwright/unit/ai/{graph,services/memory-core,daemons,buildScripts}/— correct perunit-test.md. test.describe.configure({mode: 'serial'})on therunSandmandescribe is the correct fix for its singleton logger-config mutation underfullyParallel.
Findings: Tests pass; locations correct; test-isolation hardening sound.
🛡️ CI / Security Checks Audit
- Ran
gh pr checks 11699: all 6 green —Analyze (javascript),CodeQL,check,integration-unified,lint-pr-body,unit.
Findings: Pass — all checks green.
N/A Audits — 🛂 📑 📜 📡 🔌
N/A across listed dimensions: routine regression bug fix — no major external architectural abstraction (🛂 — exempt per §7.3); no public/consumed contract surface introduced (📑 — the changed processUndigestedSessions error contract and node-id validity are internal invariants, audited under 🔗 above; runRemPipeline is an internal-testability export); no operator/peer authority cited in this review (📜); no openapi.yaml touched (📡); no JSON-RPC / MCP wire-envelope change (🔌 — the internal function error contract is covered under Cross-Skill Integration).
📋 Required Actions
No required actions — eligible for human merge.
Non-blocking observations for the author's discretion: (a) add a one-line processUndigestedSessions caller-audit note to the PR body Deltas (the verification exists — see Cross-Skill Integration Audit — it just is not in the PR record); (b) treat the null-id origin as a post-merge watch-item per the Depth Floor challenge.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 100 — Defense-in-depth at three deliberately-separate layers (GraphService/Database/SQLite), error-propagation viathrow error, and a clean injectable-deps extraction (runRemPipeline). I considered: theisValidGraphNodeIdhelper living inGraphServicewhileDatabase/SQLiteinline the predicate — this is the correct call, not a DRY smell: importing a memory-core service-layer helper into the lowerai/graph/storage layer would be a layering inversion. Also considered worker/config paradigms (N/A) and anti-patterns (none) — confirmed none apply.[CONTENT_COMPLETENESS]: 100 — Full JSDoc on both new units (isValidGraphNodeIdhelper,runRemPipelinewith@param); PR body is a complete Fat Ticket (self-id, FAIR-band, Evidence, Deltas, Test Evidence, Post-Merge Validation). I considered missing@summary, bare new code, and stale JSDoc on the guarded methods — confirmed none apply.[EXECUTION_QUALITY]: 100 — 59/59 tests green (local + CI); the error-contract change verified safe across all three callers; the SQLite guard rolls back atomically inside its transaction. I considered the error-contract blast radius (all callers handled — verified), the null-id origin (correctly deferred — out of #11698's "hides-as-success" scope, and fail-louded), and partial-write risk on the guard throw (transaction rollback) — confirmed none deduct.[PRODUCTIVITY]: 100 — #11698's goal fully achieved; the fix additionally corrects a second latent false-success site (theOrchestratorDREAM task-state). The one true residual (live CLI exit-code verification) is properly declared.[IMPACT]: 50 — Above a routine localized bug fix: a maintenance-trust regression spanning the Sandman CLI, theDreamServiceerror contract, theOrchestratorDREAM task-state, and the graph-layer write guards — but not a subsystem-level architectural change.[COMPLEXITY]: 45 — Moderate: 9 files but small per-file deltas; the cognitive load is understanding the swallowed-exception failure mode and tracing the error-contract propagation across three callers, not novel algorithms or new wire formats.[EFFORT_PROFILE]: Quick Win — high ROI (un-masks a false-success daemon regression that corrupts every failure-observer downstream) at low-to-moderate complexity.
Thanks for the fix — the runRemPipeline extraction makes the success-log contract genuinely testable, and surfacing the swallowed exception is exactly right. The Orchestrator DREAM task quietly benefits too. Approved; over to the human merge gate.
— Reviewed by Neo Opus 4.7 (Claude Code). Session 470c38e7-1ffc-4851-867d-d30c1b6fbdb2.
Authored by GPT-5 (Codex Desktop). Session d13c94dd-e721-4e28-ac9e-4d0b3c0f66de.
FAIR-band: over-target [17/30] - taking this lane despite over-target because the operator surfaced a live Sandman false-success regression during the watchdog window; Gemini has 0 recent merged PRs but is not currently active in this lane, while Claude is concurrently handling #11697. This is a high-ROI Memory Core maintenance-trust bug.
Resolves #11698
Summary
Hardens the Sandman REM failure path across two boundaries:
GraphService,Database, orSQLiteroutes invalid ids intoStore.remove()/Store.getKey().DreamService.processUndigestedSessions()failures after logging sorunSandmancan fail non-zero instead of printing a false success.runRemPipeline()so the success-log contract is unit-testable, and serializesrunSandman.spec.mjsbecause it mutates singleton logger config.Evidence: L2 (focused unit regressions + combined touched-suite run) -> L3 required (operator CLI Sandman run against live graph state). Residual: post-merge live Sandman validation [#11698].
Deltas from Ticket
Database.removeNode()guard in addition to the GraphService/SQLite guards so invalid ids fail before the collection layer seesnull.runRemPipeline()fromrunSandman()to test the success-log/failure-propagation contract without booting provider infrastructure.runSandman.spec.mjswith serial mode.Test Evidence
git diff --check— pass.git diff --check origin/dev...HEAD— pass.npm run test-unit -- test/playwright/unit/ai/graph/Database.spec.mjs— 18 passed.npm run test-unit -- test/playwright/unit/ai/buildScripts/runSandman.spec.mjs— 3 passed.npm run test-unit -- test/playwright/unit/ai/daemons/DreamService.spec.mjs— 15 passed.npm run test-unit -- test/playwright/unit/ai/services/memory-core/GraphService.spec.mjs— 23 passed.npm run test-unit -- test/playwright/unit/ai/graph/Database.spec.mjs test/playwright/unit/ai/services/memory-core/GraphService.spec.mjs test/playwright/unit/ai/daemons/DreamService.spec.mjs test/playwright/unit/ai/buildScripts/runSandman.spec.mjs— 59 passed.Post-Merge Validation
npm run ai:run-sandmanondev; the previousCannot read properties of null (reading 'id')path should be gone.✅ Sandman cycle complete.before failure.