From c93dcff0741d5cdc4b3bb0aa3c44e88203e6573e Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 24 Aug 2026 13:09:31 +0200 Subject: [PATCH 1/2] knowledge: reset local tree before pulling to avoid ff-only wedge --- hive-c0re/src/workers/knowledge.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index c2e36079..33b07d7e 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -254,6 +254,21 @@ pub async fn pull(coord: &Coordinator) -> Result<()> { .args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain]) .output() .await; + // Discard any local modifications before pulling. Nothing in this + // codebase writes to `LOCAL_DIR` after the initial clone (`seed_readme` + // runs once, at creation, before any pull) — this working tree exists + // to mirror `origin/main`, not to be edited in place. A tracked file + // left dirty by any other means (a stray manual edit on the host, an + // interrupted prior operation) would otherwise abort the `--ff-only` + // merge below with "local changes would be overwritten", wedging + // every future pull the same way until someone notices and resets it + // by hand. Best-effort: a reset failure surfaces through the pull's + // own error below rather than needing its own branch here. + let _ = crate::lifecycle::git_command() + .args(["-C", LOCAL_DIR, "reset", "--hard", "HEAD"]) + .output() + .await; + let before = head_sha().await; // The repo is public (`ensure_knowledge_repo` makes it so), so an // unauthenticated pull is enough — but authenticate when a token is around, From 30067cbcb2c75a440d6e1ede4257f87772e2727e Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 24 Aug 2026 14:30:54 +0200 Subject: [PATCH 2/2] knowledge: remove the redundant concurrent boot-time pull that races with reset --hard, and clean -fd untracked drift too --- hive-c0re/src/main.rs | 18 ++++++++++-------- hive-c0re/src/workers/knowledge.rs | 30 ++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index f0274ce5..f16d165c 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -350,17 +350,19 @@ async fn cmd_serve( } }); // Knowledge periodic pull: hourly fallback in case the webhook is - // missed (e.g. hive-c0re was down during a push). First fires at - // startup (immediate pull after the clone is already present). + // missed (e.g. hive-c0re was down during a push). Deliberately does + // NOT also fire an immediate pull at startup the way this task used + // to: `auto_update::run`'s `NodeKind::KnowledgePull` DAG node (spawned + // separately, a few lines up) already does that unconditionally on + // every boot. The two used to run concurrently with no lock between + // them, both `git pull --ff-only`-ing the same working tree — a real + // race, and the likely root cause of the "local changes would be + // overwritten" wedge this file's `pull()` now defends against + // (`reset --hard` before every pull). Removing the redundant caller + // fixes the race at its source instead of just self-healing after it. let mut knowledge_shutdown = coord.shutdown_rx(); let knowledge_coord = coord.clone(); tokio::spawn(async move { - // Initial pull — reconcile any commits that landed while c0re - // was offline. Not fed to the health tracker: a startup miss is - // expected (the clone may not exist yet) and is logged at debug. - if let Err(e) = knowledge::pull(&knowledge_coord).await { - tracing::debug!(error = ?e, "knowledge: startup pull skipped (no clone yet?)"); - } // Persistent-failure → banner. An hourly sweep that keeps failing for // several hours means the operator's `/knowledge` is drifting; raise a // warn banner after 3 consecutive misses so a one-off network blip diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index 33b07d7e..873b0d44 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -254,20 +254,30 @@ pub async fn pull(coord: &Coordinator) -> Result<()> { .args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain]) .output() .await; - // Discard any local modifications before pulling. Nothing in this - // codebase writes to `LOCAL_DIR` after the initial clone (`seed_readme` - // runs once, at creation, before any pull) — this working tree exists - // to mirror `origin/main`, not to be edited in place. A tracked file - // left dirty by any other means (a stray manual edit on the host, an - // interrupted prior operation) would otherwise abort the `--ff-only` - // merge below with "local changes would be overwritten", wedging - // every future pull the same way until someone notices and resets it - // by hand. Best-effort: a reset failure surfaces through the pull's - // own error below rather than needing its own branch here. + // Discard any local drift before pulling — both tracked (`reset --hard`) + // and untracked (`clean -fd`). Nothing in this codebase writes to + // `LOCAL_DIR` after the initial clone (`seed_readme` runs once, at + // creation, before any pull) — this working tree exists to mirror + // `origin/main`, not to be edited in place. A tracked file left dirty + // by any other means (a stray manual edit on the host, an interrupted + // prior operation, or — the actual root cause here — two unsynchronized + // boot-time pull callers racing on this same working tree, since fixed + // in `main.rs`) would otherwise abort the `--ff-only` merge below with + // "local changes would be overwritten"; an untracked file left behind + // the same ways aborts it with "untracked working tree files would be + // overwritten" instead — same wedge, just `clean`'s failure message + // rather than `reset`'s. Either way it wedges every future pull + // identically until someone notices and resets it by hand. Best-effort: + // a failure here surfaces through the pull's own error below rather + // than needing its own branch. let _ = crate::lifecycle::git_command() .args(["-C", LOCAL_DIR, "reset", "--hard", "HEAD"]) .output() .await; + let _ = crate::lifecycle::git_command() + .args(["-C", LOCAL_DIR, "clean", "-fd"]) + .output() + .await; let before = head_sha().await; // The repo is public (`ensure_knowledge_repo` makes it so), so an