eagle-mem 4.14.0 → 4.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/docs/agent-compatibility/claude-code.md +9 -0
- package/lib/common.sh +12 -6
- package/package.json +1 -1
- package/scripts/statusline-em.sh +7 -5
- package/scripts/test.sh +1 -0
- package/tests/test_busy_timeout_echo.sh +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to the **Eagle Mem** project are documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## v4.14.1 busy_timeout Echo Fix
|
|
8
|
+
|
|
9
|
+
A latent data-corruption and statusline-display bug, introduced when `busy_timeout` protection was added to three hot SQLite paths (the 2026-06-10 data-integrity hardening). No new feature; pure correctness.
|
|
10
|
+
|
|
11
|
+
- **Root cause.** Setting the busy timeout with an inline value-form `PRAGMA busy_timeout=10000;` run in the *same* `sqlite3` invocation as a `SELECT` makes SQLite echo the timeout value (`10000`) as the **first output row**. Any caller that reads the first line then mis-reads `10000` as query data.
|
|
12
|
+
- **Impact (all three fixed).**
|
|
13
|
+
- `scripts/statusline-em.sh`: the HUD parsed the echoed `10000` as the session count, rendering `Sessions: 10000`, `Memories: 0`, `Last: never`.
|
|
14
|
+
- `lib/common.sh` `eagle_get_session_project_light`: returned `10000` as the project, **mis-filing sessions under a phantom `10000` project key** (the data-corruption vector).
|
|
15
|
+
- `lib/common.sh` `eagle_project_has_table_row`: the echoed value failed the `= "1"` test, so it **always reported "no row"**, breaking ancestor-project repair.
|
|
16
|
+
- **Fix.** All three set the timeout via the silent `-cmd ".timeout 10000"` dot-command, which emits no row. No SQL behavior, hook contract, statusline schema, or output format changes — only the timeout-set mechanism. `db/migrate.sh` (uses `tail -n1`) and the `.output`-bracketed setups in `lib/db-core.sh` were already echo-safe and are unchanged.
|
|
17
|
+
- **No data migration needed for most installs.** The corruption only accrues while running unpatched code, and only affects session→project tags. The release ships the durable source fix so `eagle-mem update` can no longer reintroduce the bug.
|
|
18
|
+
|
|
19
|
+
New coverage: `tests/test_busy_timeout_echo.sh` (reproduces the failure — buggy form returns `10000`/always-false — plus a structural guard against re-introducing the inline value-PRAGMA-before-SELECT shape). Compatibility evidence: `docs/agent-compatibility/claude-code.md`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
7
23
|
## v4.14.0 Governance Parity & Curator Provenance
|
|
8
24
|
|
|
9
25
|
Two trust-surface features that close the remaining governance gaps from the v4.13.0 review.
|
|
@@ -84,3 +84,12 @@ Phase 4 token-economy hardening added a generous global size ceiling on the reca
|
|
|
84
84
|
|
|
85
85
|
Covered by `tests/test_context_budget.sh`.
|
|
86
86
|
|
|
87
|
+
### Evidence: busy_timeout echo fix (2026-06-13)
|
|
88
|
+
|
|
89
|
+
Corrects a regression introduced by the 2026-06-10 data-integrity note above. Setting the SQLite busy timeout with an inline value-form `PRAGMA busy_timeout=10000;` run in the *same* `sqlite3` invocation as a `SELECT` echoes the timeout value (`10000`) as the first output row. Any caller that reads the first row then misreads `10000` as data. The earlier note's claim that "statusline output rendering [is] unchanged" was therefore wrong — the statusline showed `Sessions: 10000`, `Memories: 0`. The fix sets the timeout via the silent `-cmd ".timeout 10000"` dot-command (no echoed row); no Claude Code contract changes (hook events, statusline stdin schema, and stdout/exit semantics are all unchanged). Specifically:
|
|
90
|
+
|
|
91
|
+
- `scripts/statusline-em.sh`: the stats query drops the inline `PRAGMA busy_timeout=10000;` prefix and passes `-cmd ".timeout 10000"` to `sqlite3` instead, so `IFS='|' read` parses the real `sessions|memories|last` row rather than the echoed `10000`.
|
|
92
|
+
- `lib/common.sh`: `eagle_get_session_project_light` (was returning `10000` as the project, mis-filing sessions under a phantom `10000` project key) and `eagle_project_has_table_row` (whose `= "1"` test always failed on the echoed value, so ancestor-project repair always saw "no row") use the same `-cmd ".timeout"` form.
|
|
93
|
+
|
|
94
|
+
Covered by `tests/test_busy_timeout_echo.sh` (reproduces the failure: the buggy form returns `10000`/always-false; also a structural guard against re-introducing the inline value-PRAGMA-before-SELECT shape).
|
|
95
|
+
|
package/lib/common.sh
CHANGED
|
@@ -456,9 +456,12 @@ eagle_get_session_project_light() {
|
|
|
456
456
|
|
|
457
457
|
local sid_sql project
|
|
458
458
|
sid_sql=$(eagle_sql_escape "$session_id")
|
|
459
|
-
# busy_timeout
|
|
460
|
-
#
|
|
461
|
-
|
|
459
|
+
# busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
|
|
460
|
+
# `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY waits for the lock
|
|
461
|
+
# instead of exiting non-zero and being misread as "session has no project"
|
|
462
|
+
# (fail-open). An inline value-setting PRAGMA echoes its value ("10000") as
|
|
463
|
+
# the first output row, which `awk 'NF{...}'` would then return as the project.
|
|
464
|
+
project=$("$sqlite_bin" -cmd ".timeout 10000" "$EAGLE_MEM_DB" "SELECT project FROM sessions WHERE id = '$sid_sql' AND project != '' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
|
|
462
465
|
[ -n "$project" ] || return 1
|
|
463
466
|
printf '%s\n' "$project"
|
|
464
467
|
}
|
|
@@ -481,9 +484,12 @@ eagle_project_has_table_row() {
|
|
|
481
484
|
|
|
482
485
|
local project_sql found
|
|
483
486
|
project_sql=$(eagle_sql_escape "$project")
|
|
484
|
-
# busy_timeout
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
+
# busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
|
|
488
|
+
# `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY waits for the lock
|
|
489
|
+
# instead of exiting non-zero and being misread as "row doesn't exist"
|
|
490
|
+
# (fail-open). An inline value-setting PRAGMA echoes its value ("10000") as
|
|
491
|
+
# the first output row, which would then fail the `= "1"` test below.
|
|
492
|
+
found=$("$sqlite_bin" -cmd ".timeout 10000" "$EAGLE_MEM_DB" "SELECT 1 FROM $table WHERE project = '$project_sql' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
|
|
487
493
|
[ "$found" = "1" ]
|
|
488
494
|
}
|
|
489
495
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eagle-mem",
|
|
3
|
-
"version": "4.14.
|
|
3
|
+
"version": "4.14.1",
|
|
4
4
|
"description": "Shared memory, release guardrails, RTK token protection, and worker lanes for Claude Code, Codex, OpenCode, Grok, and Google Antigravity",
|
|
5
5
|
"bin": {
|
|
6
6
|
"eagle-mem": "bin/eagle-mem"
|
package/scripts/statusline-em.sh
CHANGED
|
@@ -65,11 +65,13 @@ eagle_mem_statusline_stats() {
|
|
|
65
65
|
project_scope=$(eagle_recall_project_scope_from_cwd "${current_dir:-$project_dir}" "$project_key")
|
|
66
66
|
project_condition=$(eagle_sql_project_scope_condition "project" "$project_scope")
|
|
67
67
|
|
|
68
|
-
# busy_timeout
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
# busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
|
|
69
|
+
# `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY (this is the hottest
|
|
70
|
+
# standalone query during live sessions) waits for the lock instead of exiting
|
|
71
|
+
# non-zero, which would otherwise escalate to an integrity-status mislabel below.
|
|
72
|
+
# An inline value-setting PRAGMA echoes "10000" as the first output row, which
|
|
73
|
+
# the `IFS='|' read` below would parse as sessions=10000, memories=0, last=never.
|
|
74
|
+
stats=$("$sqlite_bin" -cmd ".timeout 10000" "$em_db" "SELECT
|
|
73
75
|
COUNT(*) || '|' ||
|
|
74
76
|
(SELECT COUNT(*) FROM agent_memories WHERE $project_condition) || '|' ||
|
|
75
77
|
COALESCE(MAX(COALESCE(last_activity_at, started_at)), 'never')
|
package/scripts/test.sh
CHANGED
|
@@ -96,6 +96,7 @@ run_check "Test Runner No-Abort (failing check does not kill the suite under set
|
|
|
96
96
|
run_check "Antigravity Hook (native Python SDK lifecycle, mocked)" "( command -v python3 >/dev/null 2>&1 || exit 2; python3 \"$SCRIPTS_DIR/../tests/test_antigravity_hook.py\" )"
|
|
97
97
|
run_check "Release Gate Parity (eagle-mem gate: blocks pending, fails open, pre-push install)" "bash \"$SCRIPTS_DIR/../tests/test_release_gate_prepush.sh\""
|
|
98
98
|
run_check "Command Rule Provenance (curator rules tagged + trust_learned_rules gate)" "bash \"$SCRIPTS_DIR/../tests/test_command_rule_provenance.sh\""
|
|
99
|
+
run_check "busy_timeout Echo (no PRAGMA value leaks into project/row/statusline reads)" "bash \"$SCRIPTS_DIR/../tests/test_busy_timeout_echo.sh\""
|
|
99
100
|
|
|
100
101
|
echo ""
|
|
101
102
|
if [ "$errors" -eq 0 ]; then
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ═══════════════════════════════════════════════════════════
|
|
3
|
+
# Eagle Mem — busy_timeout echo regression test
|
|
4
|
+
#
|
|
5
|
+
# Root cause guarded here: an inline value-setting `PRAGMA busy_timeout=N;`
|
|
6
|
+
# run in the SAME sqlite3 invocation as a SELECT echoes its value ("10000")
|
|
7
|
+
# as the FIRST output row. Any caller that reads the first line then mis-reads
|
|
8
|
+
# the timeout value as data:
|
|
9
|
+
# - eagle_get_session_project_light -> returns "10000" as the project,
|
|
10
|
+
# mis-filing every session under a phantom "10000" project.
|
|
11
|
+
# - eagle_project_has_table_row -> `[ "10000" = "1" ]` is false, so it
|
|
12
|
+
# ALWAYS reports "no row", breaking ancestor-project repair.
|
|
13
|
+
# - statusline stats -> `IFS='|' read` parses "10000" as sessions, 0 memories.
|
|
14
|
+
# The fix sets the timeout via the silent `-cmd ".timeout"` dot-command, which
|
|
15
|
+
# emits no output. This test reproduces the failure (buggy code returns 10000
|
|
16
|
+
# / always-false) and structurally guards against re-introducing the shape.
|
|
17
|
+
# ═══════════════════════════════════════════════════════════
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
21
|
+
tmp_dir=$(mktemp -d)
|
|
22
|
+
trap 'rm -rf "$tmp_dir"' EXIT
|
|
23
|
+
|
|
24
|
+
export EAGLE_MEM_DIR="$tmp_dir/em"
|
|
25
|
+
export EAGLE_AGENT_SOURCE="claude-code"
|
|
26
|
+
export EAGLE_MEM_DISABLE_HOOKS=1
|
|
27
|
+
mkdir -p "$EAGLE_MEM_DIR"
|
|
28
|
+
|
|
29
|
+
pass=0; fail=0
|
|
30
|
+
ok() { echo " ok: $1"; pass=$((pass+1)); }
|
|
31
|
+
bad() { echo " FAIL: $1" >&2; fail=$((fail+1)); }
|
|
32
|
+
|
|
33
|
+
bash "$ROOT_DIR/db/migrate.sh" >/dev/null 2>&1
|
|
34
|
+
|
|
35
|
+
. "$ROOT_DIR/lib/common.sh"
|
|
36
|
+
. "$ROOT_DIR/lib/db.sh"
|
|
37
|
+
|
|
38
|
+
# ── eagle_get_session_project_light returns the real project ────────────────
|
|
39
|
+
SID="sess-busy-timeout-001"
|
|
40
|
+
PROJ="personal_projects/eagle-mem"
|
|
41
|
+
eagle_upsert_session "$SID" "$PROJ" "$tmp_dir" "" "test" "claude-code"
|
|
42
|
+
|
|
43
|
+
got=$(eagle_get_session_project_light "$SID")
|
|
44
|
+
if [ "$got" = "10000" ]; then
|
|
45
|
+
bad "eagle_get_session_project_light returned the busy_timeout echo ('10000') as the project"
|
|
46
|
+
elif [ "$got" = "$PROJ" ]; then
|
|
47
|
+
ok "eagle_get_session_project_light returns the real project ('$got')"
|
|
48
|
+
else
|
|
49
|
+
bad "eagle_get_session_project_light returned unexpected value: '$got' (expected '$PROJ')"
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Unknown session must resolve to nothing (rc=1), never the echo value.
|
|
53
|
+
if unknown=$(eagle_get_session_project_light "sess-does-not-exist-999"); then
|
|
54
|
+
bad "eagle_get_session_project_light succeeded for unknown session, returned '$unknown'"
|
|
55
|
+
else
|
|
56
|
+
ok "eagle_get_session_project_light fails closed for unknown session"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# ── eagle_project_has_table_row: true for present, false for absent ──────────
|
|
60
|
+
if eagle_project_has_table_row "sessions" "$PROJ"; then
|
|
61
|
+
ok "eagle_project_has_table_row finds the existing project row"
|
|
62
|
+
else
|
|
63
|
+
bad "eagle_project_has_table_row reports 'no row' for a project that HAS a row (busy_timeout echo broke the '= 1' test)"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if eagle_project_has_table_row "sessions" "no/such/project-xyz"; then
|
|
67
|
+
bad "eagle_project_has_table_row reports a row for a project with none"
|
|
68
|
+
else
|
|
69
|
+
ok "eagle_project_has_table_row correctly reports no row for an absent project"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# ── Structural guard: no inline value-setting PRAGMA before a parsed SELECT ──
|
|
73
|
+
# (comment lines are stripped so the explanatory comments above don't match)
|
|
74
|
+
common_hit=$(grep -vE '^[[:space:]]*#' "$ROOT_DIR/lib/common.sh" | grep -nE 'busy_timeout=[0-9]+;[[:space:]]*SELECT' || true)
|
|
75
|
+
if [ -n "$common_hit" ]; then
|
|
76
|
+
bad "lib/common.sh re-introduced an inline 'PRAGMA busy_timeout=N; SELECT' (echoes the value): $common_hit"
|
|
77
|
+
else
|
|
78
|
+
ok "lib/common.sh has no inline value-setting PRAGMA before a SELECT"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
statusline_hit=$(grep -vE '^[[:space:]]*#' "$ROOT_DIR/scripts/statusline-em.sh" | grep -nE 'busy_timeout' || true)
|
|
82
|
+
if [ -n "$statusline_hit" ]; then
|
|
83
|
+
bad "scripts/statusline-em.sh re-introduced an inline PRAGMA busy_timeout (use -cmd \".timeout\"): $statusline_hit"
|
|
84
|
+
else
|
|
85
|
+
ok "scripts/statusline-em.sh sets the timeout via the silent -cmd \".timeout\" dot-command"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
echo
|
|
89
|
+
echo "busy_timeout echo regression: $pass passed, $fail failed"
|
|
90
|
+
[ "$fail" -eq 0 ]
|