fix: derived-state determinism, error visibility, metrics naming, HTTP hardening

- rebuild: fold observations per fingerprint in a total order
  (hcl DESC, node_id DESC, id DESC) so two nodes with identical logs
  rebuild identical entries (was: arbitrary bare-column row, merge-order
  dependent)
- threads: CreateThreadCluster uses INSERT OR IGNORE + existing-id
  fallback — concurrent auto-threaders converge instead of hitting UNIQUE
- errors surfaced instead of swallowed: scanEntries returns rows.Err(),
  Stats() fails fast on query errors, AutoLinkThreadObservations /
  linkTemporalNeighbors / golden-thread linking propagate failures,
  AddThreadNote + LinkObservationToThread write under one tx,
  watch records session upsert failures
- gossip client: push checks HTTP status and reports errors (a broken
  push direction no longer looks like a silent success); gossip diff
  gets a 10s timeout so a dead peer cannot hang the CLI
- ingest: failed source sweeps (obsidian/browser/gitea) are logged,
  and -d's help text now states its file-only scope
- watch --quiet: fatal errors go to stderr instead of io.Discard
- main: cobra SilenceErrors/SilenceUsage (errors print once, usage is
  not dumped on runtime failures); knox mcp exits 0 on SIGINT/SIGTERM
- metrics: drop _total suffix from gauges (knox_observations,
  knox_entries, knox_projects, knox_sessions, knox_peers, knox_threads);
  _total stays on counters per Prometheus convention
- http: ReadHeaderTimeout + IdleTimeout on gossip, metrics, and web servers

tests: concurrent cluster-create idempotency, HCL-order rebuild fold
(both merge orders), push HTTP-error surfacing; full suite + -race pass,
gofmt clean
This commit is contained in:
2026-09-17 02:28:08 -07:00
parent bb852faa27
commit 52c4d1f1e6
12 changed files with 294 additions and 88 deletions
+86
View File
@@ -154,3 +154,89 @@ func TestOpenReopenHCLMonotonicAcrossRestart(t *testing.T) {
t.Errorf("unexpected row above max: %s", after[0].Fingerprint)
}
}
// TestCreateThreadClusterConcurrentIdempotent: two concurrent creators of the
// same cluster key must converge on one thread (one created=true, one false)
// with no UNIQUE constraint error — the INSERT OR IGNORE path.
func TestCreateThreadClusterConcurrentIdempotent(t *testing.T) {
k := tmpDB(t)
const key = "cluster:race"
var wg sync.WaitGroup
ids := make([]int64, 2)
created := make([]bool, 2)
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
ids[i], created[i], errs[i] = k.CreateThreadCluster("Race thread", "m", "medium", "[]", `{}`, key)
}(i)
}
wg.Wait()
for i := range errs {
if errs[i] != nil {
t.Fatalf("creator %d: %v", i, errs[i])
}
}
if ids[0] != ids[1] {
t.Errorf("concurrent creators got different ids: %d vs %d", ids[0], ids[1])
}
if created[0] == created[1] {
t.Errorf("exactly one creator should report created=true, got %v %v", created[0], created[1])
}
threads, err := k.ListThreads("")
if err != nil {
t.Fatal(err)
}
n := 0
for _, th := range threads {
if th.Title == "Race thread" {
n++
}
}
if n != 1 {
t.Errorf("want exactly 1 race thread, got %d", n)
}
}
// TestRebuildEntriesDeterministicFold: two nodes holding the same observations
// in different merge orders must rebuild identical derived state — content
// fields come from the highest-HCL observation per fingerprint (spec 6.2),
// never from an arbitrary (merge-order-dependent) row.
func TestRebuildEntriesDeterministicFold(t *testing.T) {
const foreign = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
rows := []GossipObservation{
{NodeID: foreign, HCL: 10, Fingerprint: "fp:fold", SourceID: "test", Title: "old title", Summary: "old", CollectedAt: "2026-08-29T00:00:00Z", Confidence: 0.5},
{NodeID: foreign, HCL: 20, Fingerprint: "fp:fold", SourceID: "test", Title: "new title", Summary: "new", CollectedAt: "2026-08-29T01:00:00Z", Confidence: 0.9},
}
results := make([]Entry, 2)
for i, order := range [][]GossipObservation{{rows[0], rows[1]}, {rows[1], rows[0]}} {
k := tmpDB(t)
if _, err := k.PushObservations(order); err != nil {
t.Fatalf("push: %v", err)
}
if _, _, err := k.RebuildEntriesFromObservations(); err != nil {
t.Fatalf("rebuild: %v", err)
}
e, err := k.FindEntry("fp:fold")
if err != nil || e == nil {
t.Fatalf("find entry: %v", err)
}
results[i] = *e
}
if results[0].Title != "new title" {
t.Errorf("order [old,new]: title = %q, want %q", results[0].Title, "new title")
}
if results[1].Title != "new title" {
t.Errorf("order [new,old]: title = %q, want %q", results[1].Title, "new title")
}
if results[0] != results[1] {
t.Errorf("derived state diverged between merge orders:\n%+v\n%+v", results[0], results[1])
}
if results[0].RefCount != 2 {
t.Errorf("ref_count = %d, want 2", results[0].RefCount)
}
if results[0].FirstSeen != "2026-08-29T00:00:00Z" || results[0].LastSeen != "2026-08-29T01:00:00Z" {
t.Errorf("first/last seen = %q/%q", results[0].FirstSeen, results[0].LastSeen)
}
}