From 74ce4bc6a985ca29a4bd98c616b4e72a8b384ab0 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 15:36:01 +0000 Subject: [PATCH 1/4] fix(graph-db): keep a serving code-shard head through the replay sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The superseded-generation replay sweep treated an installed verified head as disposable whenever the code index had deleted its generation file and no snapshot reader pinned it. That holds only for per-generation namespaces, where each generation is the permanent head of a one-member projection. Under the canonical code-shard namespace every generation of one scope publishes into a single shared projection, so the installed head is the serving authority until a successor publish supersedes it. When maintenance deleted a superseded generation before its successor's graph had published (the normal mid-refresh window, since the durable pointer moves at seal while the graph CAS lands later), the sweep retired the projection's live head. The next daemon restart found no verified head, logged code_index_graph_head_recovery_degraded, and replayed the entire sealed generation to repair a projection it had made headless itself — the full-file rebuild issue #1226 reports. Gate the head bypass to non-code-shard namespaces so a serving shared head stays retained like any other installed head: the release answers Retained, retries on the next tick, and reclaims the replay through the ordinary path once the successor's publish supersedes it. Fixes #1226 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/registry/publication.rs | 11 +- .../code_graph_layout.rs | 131 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-graph-db/src/registry/publication.rs b/crates/tracedecay-graph-db/src/registry/publication.rs index 5d7d114080..5cf559ee6c 100644 --- a/crates/tracedecay-graph-db/src/registry/publication.rs +++ b/crates/tracedecay-graph-db/src/registry/publication.rs @@ -15,6 +15,7 @@ use tracedecay_store::runtime::{ MAX_GRAPH_PUBLICATION_PROJECTION_PAGE_RECORDS_V1, MAX_GRAPH_REPLAY_PAGE_RECORDS_V1, }; +use super::code_graph_namespace::is_code_graph_shard_namespace; use super::path::canonical_graph_database_file; use super::publication_support::{ RegisteredGraphDbOperationV1, check_all, clear_retiring_fence, collect_closure, @@ -436,11 +437,19 @@ impl GraphDbRegistry { let selected = { let mut state = database.wait_verified_generations_write()?; for head in state.heads.values() { - if candidate_locators.contains(&head.locator) && Arc::strong_count(head) == 1 { + if candidate_locators.contains(&head.locator) + && !is_code_graph_shard_namespace(&head.locator.projection.namespace) + && Arc::strong_count(head) == 1 + { // Once the code index deletes a per-generation head, the // registry's installed pointer is not reader liveness. // Its dependency closure remains protected; any snapshot // clone raises the count and retains the whole lease. + // A code-shard head is different: one projection serves + // every generation of the scope, so the installed head is + // still the serving authority until a successor publish + // supersedes it, and it stays retained like any other + // installed head. for dependency in head.dependencies.values() { retain_lease_closure(dependency, &mut retained); } diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs index f672560b27..25e7e7e56c 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs @@ -233,6 +233,137 @@ fn second_generation_supersedes_the_head_and_the_first_retires_without_head_reti ); } +/// The mid-publish window: the code index deletes a superseded generation +/// while its replay is still the projection's verified head — the durable +/// pointer already names a successor whose graph publication has not +/// landed. The sweep must answer Retained and leave the shared head +/// serving; CAS-deleting it here is what left restarts a headless +/// projection that replayed the whole sealed generation to repair itself. +/// Once the successor publishes, the same sweep reclaims the superseded +/// generation through the ordinary non-head path. +#[test] +fn sweep_keeps_the_shared_head_while_the_deleted_generation_still_serves() { + let temp = TempDir::new().unwrap(); + let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + let mut authority = RelationalAuthority::default(); + let identity = canonical_projection("worktree.serving"); + let sealed_digest = + SealedGraphStateDigest::try_from(format!("sha256:{}", "7".repeat(64))).unwrap(); + let alpha = CodeGenerationId::new("code-generation.alpha").unwrap(); + let beta = CodeGenerationId::new("code-generation.beta").unwrap(); + + let g1 = manifest(identity.clone(), "serving-g1", "g1", vec![], vec![]); + let g1_record = stage_manifest( + &mut authority, + ®istered.binding, + &g1, + "publish:serving-g1", + None, + '1', + ); + let (control, probe) = control_and_probe(); + let g1_commit = registered + .registry + .publish_verified( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &fresh_context(&control, &probe), + &g1_record.publication.key, + None, + ) + .unwrap(); + let g1_head = g1_commit.head.clone(); + drop(g1_commit); + bind_sealed_source( + &mut authority, + ®istered.binding, + &g1, + &g1_record, + "publish:serving-g1", + None, + '1', + &alpha, + &sealed_digest, + ); + + let (control, probe) = control_and_probe(); + assert_eq!( + registered.registry.retire_one_code_generation_replay( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &fresh_context(&control, &probe), + &alpha, + &sealed_digest, + ), + Ok(GraphReplayCollectionOutcome::Retained), + "the still-serving shared head must survive the superseded-generation sweep", + ); + assert_eq!( + authority.heads.get(&g1_record.publication.key.projection), + Some(&g1_head), + "the shared head keeps serving until a successor publish supersedes it", + ); + assert_eq!(authority.head_retirement_calls, 0); + + let g2 = manifest(identity.clone(), "serving-g2", "g2", vec![], vec![]); + let g2_record = stage_manifest( + &mut authority, + ®istered.binding, + &g2, + "publish:serving-g2", + Some(g1_head.clone()), + '2', + ); + let (control, probe) = control_and_probe(); + let g2_commit = registered + .registry + .publish_verified( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &fresh_context(&control, &probe), + &g2_record.publication.key, + None, + ) + .unwrap(); + let g2_head = g2_commit.head.clone(); + drop(g2_commit); + bind_sealed_source( + &mut authority, + ®istered.binding, + &g2, + &g2_record, + "publish:serving-g2", + Some(g1_head), + '2', + &beta, + &sealed_digest, + ); + + let (control, probe) = control_and_probe(); + assert_eq!( + registered.registry.retire_one_code_generation_replay( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &fresh_context(&control, &probe), + &alpha, + &sealed_digest, + ), + Ok(GraphReplayCollectionOutcome::Retired(Box::new( + tracedecay_graph_db::GraphGenerationReplaySource::SealedCodeGeneration(sealed_source( + &alpha, + &sealed_digest, + )) + ))), + "the superseded generation retires once a successor holds the head", + ); + assert_eq!(authority.head_retirement_calls, 0); + assert_eq!( + authority.heads.get(&g2_record.publication.key.projection), + Some(&g2_head), + "the successor head still serves after the sweep", + ); +} + /// Number of sealed generation artifacts currently on disk under the store. fn sealed_generation_count(root: &std::path::Path) -> usize { std::fs::read_dir(support::graph_path(root).with_extension("sealed")) From 892bd7535f3a93b2ab621690be06f533f428c548 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 15:42:40 +0000 Subject: [PATCH 2/4] fix(graph-db): retain code-shard heads without an installed lease The serving-head retention must not depend on in-memory lease state: after a restart the registry mounts with no installed lease, and the relational verified head is the only authority left. Retain every relational code-shard head outright instead of gating on the lease census, and drop the now-subsumed namespace check in the lease loop. The regression test remounts before the second sweep to pin it. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/registry/publication.rs | 20 +++++++++---------- .../code_graph_layout.rs | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-graph-db/src/registry/publication.rs b/crates/tracedecay-graph-db/src/registry/publication.rs index 5cf559ee6c..9b6d738a54 100644 --- a/crates/tracedecay-graph-db/src/registry/publication.rs +++ b/crates/tracedecay-graph-db/src/registry/publication.rs @@ -410,10 +410,18 @@ impl GraphDbRegistry { .iter() .map(|(locator, _, _)| locator.clone()) .collect::>(); + // Every verified head is serving authority. A code-shard head stays + // authority even while its generation is deleted, because one shared + // projection serves every generation of the scope until a successor + // publish supersedes it; only per-generation namespaces make a + // deleted generation's head disposable. retained.extend( heads .keys() - .filter(|locator| !candidate_locators.contains(*locator)) + .filter(|locator| { + !candidate_locators.contains(*locator) + || is_code_graph_shard_namespace(&locator.projection.namespace) + }) .cloned(), ); if candidates.is_empty() { @@ -437,19 +445,11 @@ impl GraphDbRegistry { let selected = { let mut state = database.wait_verified_generations_write()?; for head in state.heads.values() { - if candidate_locators.contains(&head.locator) - && !is_code_graph_shard_namespace(&head.locator.projection.namespace) - && Arc::strong_count(head) == 1 - { + if candidate_locators.contains(&head.locator) && Arc::strong_count(head) == 1 { // Once the code index deletes a per-generation head, the // registry's installed pointer is not reader liveness. // Its dependency closure remains protected; any snapshot // clone raises the count and retains the whole lease. - // A code-shard head is different: one projection serves - // every generation of the scope, so the installed head is - // still the serving authority until a successor publish - // supersedes it, and it stays retained like any other - // installed head. for dependency in head.dependencies.values() { retain_lease_closure(dependency, &mut retained); } diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs index 25e7e7e56c..fc720fd04c 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs @@ -305,6 +305,26 @@ fn sweep_keeps_the_shared_head_while_the_deleted_generation_still_serves() { ); assert_eq!(authority.head_retirement_calls, 0); + // A restart mounts the registry before any snapshot seats a lease, so + // the relational head is the only serving authority left. The sweep + // must keep it exactly the same. + assert!(registered.close().unwrap()); + drop(registered); + let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + let (control, probe) = control_and_probe(); + assert_eq!( + registered.registry.retire_one_code_generation_replay( + registration(registered.binding.clone(), temp.path()), + &mut authority, + &fresh_context(&control, &probe), + &alpha, + &sealed_digest, + ), + Ok(GraphReplayCollectionOutcome::Retained), + "the relational head must survive with no installed lease", + ); + assert_eq!(authority.head_retirement_calls, 0); + let g2 = manifest(identity.clone(), "serving-g2", "g2", vec![], vec![]); let g2_record = stage_manifest( &mut authority, From 627c49e43c367b095e1ee8ce56bf61ddb9170bef Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Mon, 5 Oct 2026 06:28:08 +0000 Subject: [PATCH 3/4] style: unslop prose comments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../verified_generation_contract/code_graph_layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs index fc720fd04c..3e0ad4a0b5 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/code_graph_layout.rs @@ -234,7 +234,7 @@ fn second_generation_supersedes_the_head_and_the_first_retires_without_head_reti } /// The mid-publish window: the code index deletes a superseded generation -/// while its replay is still the projection's verified head — the durable +/// while its replay is still the projection's verified head. The durable /// pointer already names a successor whose graph publication has not /// landed. The sweep must answer Retained and leave the shared head /// serving; CAS-deleting it here is what left restarts a headless From d8d58bbb044f622b98811b01c30960557f767d8f Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Mon, 5 Oct 2026 05:47:16 +0000 Subject: [PATCH 4/4] style(agent-hosts): inline context-scout outcome guards Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/agents/context_scout/owner.rs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs index e06de7ab96..7cd16aea08 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs @@ -1012,26 +1012,20 @@ impl ProjectContextScoutOwnerV1 { let retired = match (&mutation, &receipt.result) { ( ContextScoutPublicMutationV1::Cancel { work }, - ContextScoutMutationResultV1::Cancel(outcome), - ) if matches!( - outcome, - ContextScoutDurableStoreOutcomeV1::Stored - | ContextScoutDurableStoreOutcomeV1::Duplicate - ) => - { - Some((*work, None)) - } + ContextScoutMutationResultV1::Cancel( + ContextScoutDurableStoreOutcomeV1::Stored + | ContextScoutDurableStoreOutcomeV1::Duplicate, + ), + ) => Some((*work, None)), ( ContextScoutPublicMutationV1::Delivery { work, .. }, - ContextScoutMutationResultV1::Delivery { outcome, receipt }, - ) if matches!( - outcome, - ContextScoutDurableStoreOutcomeV1::Stored - | ContextScoutDurableStoreOutcomeV1::Duplicate - ) => - { - Some((*work, Some(receipt.outcome))) - } + ContextScoutMutationResultV1::Delivery { + outcome: + ContextScoutDurableStoreOutcomeV1::Stored + | ContextScoutDurableStoreOutcomeV1::Duplicate, + receipt, + }, + ) => Some((*work, Some(receipt.outcome))), _ => None, }; if let Some((work, delivery)) = retired