Skip to content

Commit 0e97b19

Browse files
jpheinclaude
andcommitted
feat(hooks): add daemon-strict mode to prevent concurrent local writes
When PALACE_DAEMON_URL is set, hooks now skip all local palace writes (both auto-ingest mining and silent-save fallback) instead of writing to a local SQLite. The daemon at the URL is treated as the single source of truth. Without this, hooks fired from any session could create a second writer on the SQLite file — under Syncthing replication this produces sync conflicts and disk-image corruption (see incident 2026-04-24). Set PALACE_DAEMON_STRICT=0 to opt back into the old fall-through-on- daemon-failure behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent c09582c commit 0e97b19

2 files changed

Lines changed: 27 additions & 2 deletions

File tree

.claude-plugin/hooks/hooks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"hooks": [
1717
{
1818
"type": "command",
19-
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
19+
"command": "PALACE_DAEMON_URL=http://disks.jphe.in:8085 PALACE_API_KEY=adb43b99effac3aced2c82de54827850c6da1fbf4a5e473e1938529b27c08ab3 bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
2020
}
2121
]
2222
}

mempalace/hooks_cli.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,19 @@ def _spawn_mine(cmd: list) -> None:
263263
_MINE_PID_FILE.write_text(str(proc.pid))
264264

265265

266+
def _daemon_strict() -> bool:
267+
"""When PALACE_DAEMON_URL is set and STRICT mode is on, skip all local writes."""
268+
return (
269+
os.environ.get("PALACE_DAEMON_URL", "").strip() != ""
270+
and os.environ.get("PALACE_DAEMON_STRICT", "1") != "0"
271+
)
272+
273+
266274
def _maybe_auto_ingest(transcript_path: str = ""):
267275
"""Run mempalace mine in background if a mine directory is available."""
276+
if _daemon_strict():
277+
_log("Skipping auto-ingest: PALACE_DAEMON_URL set, daemon owns writes")
278+
return
268279
mine_dir = _get_mine_dir(transcript_path)
269280
if not mine_dir:
270281
return
@@ -279,6 +290,9 @@ def _maybe_auto_ingest(transcript_path: str = ""):
279290

280291
def _mine_sync(transcript_path: str = ""):
281292
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
293+
if _daemon_strict():
294+
_log("Skipping sync mine: PALACE_DAEMON_URL set, daemon owns writes")
295+
return
282296
mine_dir = _get_mine_dir(transcript_path)
283297
if not mine_dir:
284298
return
@@ -454,7 +468,18 @@ def _save_diary_direct(
454468
"queued": daemon_result.get("queued", False),
455469
}
456470
except Exception as e:
457-
_log(f"Daemon silent-save failed ({e}); falling through to direct write")
471+
_log(f"Daemon silent-save failed ({e})")
472+
# Strict mode: when PALACE_DAEMON_URL is set, NEVER fall through to direct write.
473+
# Daemon is single source of truth; concurrent local writes corrupt SQLite under
474+
# Syncthing replication. Drop the save and surface the error instead.
475+
if os.environ.get("PALACE_DAEMON_STRICT", "1") != "0":
476+
return {
477+
"count": 0,
478+
"themes": themes,
479+
"systemMessage": f"⚠ Save dropped — daemon at {daemon_url} unreachable: {e}",
480+
"queued": False,
481+
}
482+
_log("PALACE_DAEMON_STRICT=0 — falling through to direct write")
458483

459484
try:
460485
from .mcp_server import tool_diary_write

0 commit comments

Comments
 (0)