feat: M2 composite locator + idle reconcile

Refs #1

- observations: hcl backfilled from local rowid; (node_id, hcl) UNIQUE
  locator index becomes the gossip merge key
- threads: cluster_key column + partial UNIQUE index; CreateThreadCluster
  is idempotent, threader folds into exact cluster_key before heuristic
- AutoLinkThreadObservations dedup re-expressed on hcl DESC
- new `knox reconcile [--dry-run]` rebuilds entries from the observation
  log and re-links threads idempotently (entry count, thread cluster_key
  verified bit-identical from log-only DB)
This commit is contained in:
2026-08-29 04:47:15 -07:00
parent 2c0f8fa257
commit aa0dec68c1
6 changed files with 190 additions and 22 deletions
+35 -5
View File
@@ -1,10 +1,12 @@
package watch
import (
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
@@ -80,8 +82,14 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
continue
}
// Fold into an existing active thread if one already covers these terms.
if existing := t.findExisting(c); existing > 0 {
// Fold into the exact thread this cluster keyed to (idempotent across
// nodes/reconciles); otherwise fall back to the keyword heuristic which
// also matches human-edited threads that keep their auto cluster key.
existing := t.DB.ThreadByClusterKey(clusterKey(c))
if existing == 0 {
existing = t.findExisting(c)
}
if existing > 0 {
if t.DryRun {
fmt.Printf("[threader] (dry-run) would link %d obs into existing thread #%d: %s\n", len(fps), existing, c.Name)
continue
@@ -118,7 +126,7 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
continue
}
id, err := t.DB.CreateThread(title, motivation, priority, tags, string(provJSON))
id, isNew, err := t.DB.CreateThreadCluster(title, motivation, priority, tags, string(provJSON), clusterKey(c))
if err != nil {
log.Printf("[knox] threader create err: %v", err)
continue
@@ -126,12 +134,34 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
if n, err := t.DB.AutoLinkThreadObservations(id, fps); err == nil {
linked += n
}
created++
log.Printf("[knox] auto-thread #%d: %s (%d obs, %s)", id, title, len(fps), priority)
if isNew {
created++
}
log.Printf("[knox] auto-thread %s #%d: %s (%d obs, %s)", statusWord(isNew), id, title, len(fps), priority)
}
return created, linked, nil
}
func statusWord(isNew bool) string {
if isNew {
return "created"
}
return "extended"
}
// clusterKey returns a deterministic content hash keying a thread to the topic
// cluster that generated it. Sorted keywords make it machine-independent, so
// two nodes auto-creating the same cluster converge on the same key (idempotent
// thread creation via CreateThreadCluster).
func clusterKey(c index.TopicCluster) string {
kws := make([]string, len(c.Keywords))
copy(kws, c.Keywords)
sort.Strings(kws)
joined := "thread:cluster:" + strings.Join(kws, "\x00")
sum := sha256.Sum256([]byte(joined))
return fmt.Sprintf("cluster:%x", sum[:16])
}
// crossesBar decides whether a cluster reflects real intent.
func (t *AutoThreader) crossesBar(c index.TopicCluster, fps []string, now time.Time) bool {
if len(c.Entries) < t.MinEntries {