eaaf9d935a
Second review pass on PR #68. All three findings real; the first two reproduced by injection before fixing. The candidate is validated by CONSULTING it, so the thing being checked got to influence the check. Three ways through, all demonstrated: - `:- halt(0).` as a delta -- fixed last round by refusing directives - REMOVING kb_consistent: it then throws existence_error, and tpl exits 0 when a goal throws before reaching halt, so the gate passed by not existing. The error was printed and the tool still reported success. - REPLACING it with the fact `kb_consistent.`, which passes forever after. CHECK_GOAL now catches, so a throw exits non-zero, and writes touching the gate predicates are refused unless KB_ALLOW_GATE makes it deliberate. That list is hand-maintained and a new gate predicate is unprotected until named -- stated rather than hidden. Writes were read/validate/mv with nothing in between, so two agents -- ordinary here, waves run in parallel -- would both read the same file, both validate, and the later mv would discard the earlier addition. The lost-update class this tool exists to remove, reintroduced in the tool. Closed by stamping content at read time and refusing to commit if it moved, plus an mkdir lock. The stamp is the part that matters; the lock only makes the collision rare. AGENTS.md's top-level instruction still said to save facts through the MCP. The wave section had been fixed; the authoritative line had not. kb-test.sh is 17 cases. The race is deterministic via KB_TEST_RACE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
163 lines
5.4 KiB
Bash
Executable File
163 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# The knowledge base's one maintenance tool. Replaces the Prolog MCP cycle:
|
|
# adding a clause costs the clause, not the whole predicate.
|
|
set -euo pipefail
|
|
|
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
repo="$(cd "$here/.." && pwd)"
|
|
KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}"
|
|
MERGE="$here/kb-merge.pl"
|
|
|
|
# trealla comes from the dev shell, which direnv puts on PATH. Reaching it
|
|
# through `nix shell` per invocation cost more than the work it did: this
|
|
# tool's first real use timed out past two minutes on a half-second write.
|
|
if ! command -v tpl >/dev/null 2>&1; then
|
|
echo "kb: tpl is not on PATH -- run inside the dev shell (direnv allow, or nix develop)" >&2
|
|
exit 127
|
|
fi
|
|
|
|
# stdin is closed on every call: an uncaught Prolog error falls back to the
|
|
# REPL, which hangs on a terminal and exits 0 without one -- a refusal that
|
|
# reads as acceptance. kb-merge.pl catches parse errors; this is the backstop.
|
|
tpl() { command tpl "$@" </dev/null; }
|
|
|
|
# The goal that runs kb-merge.pl. `-g main,halt` is NOT enough: measured on
|
|
# trealla 3.10.3 it exits 0 on a throw and on a failure alike, so a crash reads
|
|
# as success to `if ! tpl ...`. This propagates both.
|
|
MERGE_GOAL="(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))"
|
|
merge() { tpl -q "$MERGE" -g "$MERGE_GOAL" -- "$@"; }
|
|
|
|
# kb_consistent is the gate CI runs. Anything that writes runs it on the
|
|
# CANDIDATE, before the candidate becomes the knowledge base.
|
|
# The goal reaches a halt on EVERY path, for the same reason MERGE_GOAL does: a
|
|
# goal that throws never reaches the halt beside it and tpl exits 0. Without the
|
|
# catch, a write that REMOVED kb_consistent made the gate pass by not existing.
|
|
CHECK_GOAL="(catch(kb_consistent,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; (kb_report, halt(1)))"
|
|
check_file() {
|
|
tpl "$1" -g "$CHECK_GOAL" 2>/dev/null
|
|
}
|
|
|
|
# A content stamp of the knowledge base as it was READ. Two agents running this
|
|
# concurrently would otherwise both read the same file, both validate, and the
|
|
# later mv would discard the earlier addition -- the lost-update class this tool
|
|
# exists to avoid, and waves here run agents in parallel.
|
|
stamp_of() {
|
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
|
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
|
|
else cksum "$1" | tr -d ' '
|
|
fi
|
|
}
|
|
|
|
# mkdir is atomic everywhere; flock is absent on macOS. The lock makes a
|
|
# collision rare, the stamp makes one that happens anyway LOUD rather than silent.
|
|
acquire_lock() {
|
|
local tries=0
|
|
until mkdir "$KB.lock" 2>/dev/null; do
|
|
tries=$((tries + 1))
|
|
if [ "$tries" -ge 300 ]; then
|
|
echo "kb: could not acquire $KB.lock after 30s; remove it if stale" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
trap 'rmdir "$KB.lock" 2>/dev/null || true' EXIT
|
|
}
|
|
|
|
# Test seam: simulate another writer landing between the read and the commit.
|
|
race_hook() {
|
|
[ -n "${KB_TEST_RACE:-}" ] || return 0
|
|
cp -f "$KB_TEST_RACE" "$KB"
|
|
}
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage: kb <command>
|
|
|
|
check run kb_consistent (what CI runs)
|
|
count clauses per keyed predicate
|
|
query '<goal>' run a goal against the knowledge base
|
|
fmt rewrite in canonical form (idempotent)
|
|
add <delta.pl> add clauses; refuses if any key already exists
|
|
replace <delta.pl> add clauses, overwriting existing keys
|
|
rm <keys.pl> remove clauses by key; refuses if any key is absent
|
|
|
|
Every writing command verifies the result reads back as the clauses intended
|
|
and passes kb_consistent before it replaces the knowledge base.
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
commit() { # $1 = candidate file, $2 = stamp of the kb before it was read
|
|
local cand="$1" was="${2:-}"
|
|
race_hook
|
|
if [ -n "$was" ] && [ "$(stamp_of "$KB")" != "$was" ]; then
|
|
rm -f "$cand"
|
|
echo "kb: REFUSED -- $KB changed while this write was being prepared." >&2
|
|
echo "kb: another writer won; re-run and your delta applies on top." >&2
|
|
exit 1
|
|
fi
|
|
if ! check_file "$cand"; then
|
|
rm -f "$cand"
|
|
echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2
|
|
exit 1
|
|
fi
|
|
mv -f "$cand" "$KB"
|
|
}
|
|
|
|
[ $# -ge 1 ] || usage
|
|
cmd="$1"; shift
|
|
|
|
case "$cmd" in
|
|
check)
|
|
if check_file "$KB"; then echo "kb_consistent: TRUE"; else exit 1; fi
|
|
;;
|
|
count)
|
|
merge count "$KB" 2>/dev/null
|
|
;;
|
|
query)
|
|
[ $# -eq 1 ] || usage
|
|
tpl "$KB" -g "($1 -> halt(0) ; (write('false'), nl, halt(1)))" 2>/dev/null
|
|
;;
|
|
fmt)
|
|
acquire_lock
|
|
was="$(stamp_of "$KB")"
|
|
cand="$(mktemp)"
|
|
: > "${cand}.delta"
|
|
if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then
|
|
rm -f "$cand" "${cand}.delta"
|
|
echo "kb: REFUSED -- $KB is unchanged" >&2
|
|
exit 1
|
|
fi
|
|
rm -f "${cand}.delta"
|
|
commit "$cand" "$was"
|
|
echo "kb: canonicalized"
|
|
;;
|
|
rm)
|
|
[ $# -eq 1 ] || usage
|
|
acquire_lock
|
|
was="$(stamp_of "$KB")"
|
|
cand="$(mktemp)"
|
|
if ! merge remove "$KB" "$1" "$cand"; then
|
|
rm -f "$cand"
|
|
echo "kb: REFUSED -- $KB is unchanged" >&2
|
|
exit 1
|
|
fi
|
|
commit "$cand" "$was"
|
|
echo "kb: $KB updated"
|
|
;;
|
|
add|replace)
|
|
[ $# -eq 1 ] || usage
|
|
acquire_lock
|
|
was="$(stamp_of "$KB")"
|
|
cand="$(mktemp)"
|
|
if ! merge "$cmd" "$KB" "$1" "$cand"; then
|
|
rm -f "$cand"
|
|
echo "kb: REFUSED -- $KB is unchanged" >&2
|
|
exit 1
|
|
fi
|
|
commit "$cand" "$was"
|
|
echo "kb: $KB updated"
|
|
;;
|
|
*) usage ;;
|
|
esac
|