agrep 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. {agrep-0.4.0 → agrep-0.4.2}/Cargo.lock +2 -2
  2. {agrep-0.4.0 → agrep-0.4.2}/Cargo.toml +1 -1
  3. {agrep-0.4.0 → agrep-0.4.2}/PKG-INFO +1 -1
  4. {agrep-0.4.0 → agrep-0.4.2}/agrep/__init__.py +1 -1
  5. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-cli/src/index_lock.rs +6 -0
  6. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-cli/src/main.rs +341 -87
  7. {agrep-0.4.0 → agrep-0.4.2}/py/audit.py +6 -5
  8. {agrep-0.4.0 → agrep-0.4.2}/py/doctor.py +7 -4
  9. {agrep-0.4.0 → agrep-0.4.2}/py/embed.py +10 -4
  10. {agrep-0.4.0 → agrep-0.4.2}/py/indexd_runtime.py +8 -4
  11. {agrep-0.4.0 → agrep-0.4.2}/py/semantic.py +4 -0
  12. {agrep-0.4.0 → agrep-0.4.2}/LICENSE +0 -0
  13. {agrep-0.4.0 → agrep-0.4.2}/README.md +0 -0
  14. {agrep-0.4.0 → agrep-0.4.2}/THIRD_PARTY_LICENSES.txt +0 -0
  15. {agrep-0.4.0 → agrep-0.4.2}/agrep/__main__.py +0 -0
  16. {agrep-0.4.0 → agrep-0.4.2}/cli.py +0 -0
  17. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-cli/Cargo.toml +0 -0
  18. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/Cargo.toml +0 -0
  19. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/boundary_rank.rs +0 -0
  20. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/boundary_stats.rs +0 -0
  21. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/cache.rs +0 -0
  22. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/emit.rs +0 -0
  23. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/fallback_scan.rs +0 -0
  24. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/antigravity.rs +0 -0
  25. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/claude.rs +0 -0
  26. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/cline.rs +0 -0
  27. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/codex.rs +0 -0
  28. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/crush.rs +0 -0
  29. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/cursor.rs +0 -0
  30. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/gemini.rs +0 -0
  31. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/kimi.rs +0 -0
  32. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/mod.rs +0 -0
  33. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/opencode.rs +0 -0
  34. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/parse_guard.rs +0 -0
  35. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/parse_timestamp.rs +0 -0
  36. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/pi.rs +0 -0
  37. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest/registry.rs +0 -0
  38. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/ingest_cache.rs +0 -0
  39. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/intake.rs +0 -0
  40. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/lib.rs +0 -0
  41. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/mapped_file.rs +0 -0
  42. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/model.rs +0 -0
  43. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/row_class.rs +0 -0
  44. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/semantic_q8.rs +0 -0
  45. {agrep-0.4.0 → agrep-0.4.2}/crates/agrep-core/src/unicode_v16.rs +0 -0
  46. {agrep-0.4.0 → agrep-0.4.2}/hatch_build.py +0 -0
  47. {agrep-0.4.0 → agrep-0.4.2}/hatch_sdist_build.py +0 -0
  48. {agrep-0.4.0 → agrep-0.4.2}/py/README.md +0 -0
  49. {agrep-0.4.0 → agrep-0.4.2}/py/archive.py +0 -0
  50. {agrep-0.4.0 → agrep-0.4.2}/py/around.py +0 -0
  51. {agrep-0.4.0 → agrep-0.4.2}/py/ask.py +0 -0
  52. {agrep-0.4.0 → agrep-0.4.2}/py/boundary_rank.py +0 -0
  53. {agrep-0.4.0 → agrep-0.4.2}/py/common.py +0 -0
  54. {agrep-0.4.0 → agrep-0.4.2}/py/compact.py +0 -0
  55. {agrep-0.4.0 → agrep-0.4.2}/py/conceptpair.py +0 -0
  56. {agrep-0.4.0 → agrep-0.4.2}/py/console.py +0 -0
  57. {agrep-0.4.0 → agrep-0.4.2}/py/corpusdb.py +0 -0
  58. {agrep-0.4.0 → agrep-0.4.2}/py/display_policy.py +0 -0
  59. {agrep-0.4.0 → agrep-0.4.2}/py/dist.py +0 -0
  60. {agrep-0.4.0 → agrep-0.4.2}/py/embedder.py +0 -0
  61. {agrep-0.4.0 → agrep-0.4.2}/py/embedding_segments.py +0 -0
  62. {agrep-0.4.0 → agrep-0.4.2}/py/embedding_store.py +0 -0
  63. {agrep-0.4.0 → agrep-0.4.2}/py/events.py +0 -0
  64. {agrep-0.4.0 → agrep-0.4.2}/py/explore.py +0 -0
  65. {agrep-0.4.0 → agrep-0.4.2}/py/fileops.py +0 -0
  66. {agrep-0.4.0 → agrep-0.4.2}/py/hookinstall.py +0 -0
  67. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/__init__.py +0 -0
  68. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/_log.py +0 -0
  69. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/agent_registry.json +0 -0
  70. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/capture.py +0 -0
  71. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/fswatch.py +0 -0
  72. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/live.py +0 -0
  73. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/locators.py +0 -0
  74. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/native.py +0 -0
  75. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/proc.py +0 -0
  76. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/procscan.py +0 -0
  77. {agrep-0.4.0 → agrep-0.4.2}/py/hookless/registry.py +0 -0
  78. {agrep-0.4.0 → agrep-0.4.2}/py/hooks/codex-postcompact.md +0 -0
  79. {agrep-0.4.0 → agrep-0.4.2}/py/hooks/codex_compact_payload.py +0 -0
  80. {agrep-0.4.0 → agrep-0.4.2}/py/hooks/compact-contract.md +0 -0
  81. {agrep-0.4.0 → agrep-0.4.2}/py/hooks/pi_postcompact.ts +0 -0
  82. {agrep-0.4.0 → agrep-0.4.2}/py/index_lock.py +0 -0
  83. {agrep-0.4.0 → agrep-0.4.2}/py/indexd.py +0 -0
  84. {agrep-0.4.0 → agrep-0.4.2}/py/indexer.py +0 -0
  85. {agrep-0.4.0 → agrep-0.4.2}/py/install_lag.py +0 -0
  86. {agrep-0.4.0 → agrep-0.4.2}/py/legacy_cleanup.py +0 -0
  87. {agrep-0.4.0 → agrep-0.4.2}/py/lifetime.py +0 -0
  88. {agrep-0.4.0 → agrep-0.4.2}/py/livetui.py +0 -0
  89. {agrep-0.4.0 → agrep-0.4.2}/py/mlx_embed.py +0 -0
  90. {agrep-0.4.0 → agrep-0.4.2}/py/mlx_modernbert.py +0 -0
  91. {agrep-0.4.0 → agrep-0.4.2}/py/nudge_codex.md +0 -0
  92. {agrep-0.4.0 → agrep-0.4.2}/py/nudge_default.md +0 -0
  93. {agrep-0.4.0 → agrep-0.4.2}/py/ownerfile.py +0 -0
  94. {agrep-0.4.0 → agrep-0.4.2}/py/postcompact.py +0 -0
  95. {agrep-0.4.0 → agrep-0.4.2}/py/proc.py +0 -0
  96. {agrep-0.4.0 → agrep-0.4.2}/py/recall.py +0 -0
  97. {agrep-0.4.0 → agrep-0.4.2}/py/regex_guard.py +0 -0
  98. {agrep-0.4.0 → agrep-0.4.2}/py/removal_fence.py +0 -0
  99. {agrep-0.4.0 → agrep-0.4.2}/py/resident.py +0 -0
  100. {agrep-0.4.0 → agrep-0.4.2}/py/resources.py +0 -0
  101. {agrep-0.4.0 → agrep-0.4.2}/py/resume.py +0 -0
  102. {agrep-0.4.0 → agrep-0.4.2}/py/runtime_manifest.json +0 -0
  103. {agrep-0.4.0 → agrep-0.4.2}/py/search.py +0 -0
  104. {agrep-0.4.0 → agrep-0.4.2}/py/segment_query.py +0 -0
  105. {agrep-0.4.0 → agrep-0.4.2}/py/semantic_q8.py +0 -0
  106. {agrep-0.4.0 → agrep-0.4.2}/py/semantic_segment_build.py +0 -0
  107. {agrep-0.4.0 → agrep-0.4.2}/py/semantic_segment_compact.py +0 -0
  108. {agrep-0.4.0 → agrep-0.4.2}/py/semworker.py +0 -0
  109. {agrep-0.4.0 → agrep-0.4.2}/py/server.py +0 -0
  110. {agrep-0.4.0 → agrep-0.4.2}/py/session_context.py +0 -0
  111. {agrep-0.4.0 → agrep-0.4.2}/py/settings.py +0 -0
  112. {agrep-0.4.0 → agrep-0.4.2}/py/summary.py +0 -0
  113. {agrep-0.4.0 → agrep-0.4.2}/py/surface_policy.py +0 -0
  114. {agrep-0.4.0 → agrep-0.4.2}/py/tail.py +0 -0
  115. {agrep-0.4.0 → agrep-0.4.2}/py/teach.py +0 -0
  116. {agrep-0.4.0 → agrep-0.4.2}/py/why.py +0 -0
  117. {agrep-0.4.0 → agrep-0.4.2}/py/winjob.py +0 -0
  118. {agrep-0.4.0 → agrep-0.4.2}/pyproject.toml +0 -0
  119. {agrep-0.4.0 → agrep-0.4.2}/reindex.py +0 -0
  120. {agrep-0.4.0 → agrep-0.4.2}/rust-toolchain.toml +0 -0
  121. {agrep-0.4.0 → agrep-0.4.2}/sdist_sources.py +0 -0
  122. {agrep-0.4.0 → agrep-0.4.2}/web/app.html +0 -0
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
10
 
11
11
  [[package]]
12
12
  name = "agrep-core"
13
- version = "0.4.0"
13
+ version = "0.4.2"
14
14
  dependencies = [
15
15
  "anyhow",
16
16
  "bincode",
@@ -30,7 +30,7 @@ dependencies = [
30
30
 
31
31
  [[package]]
32
32
  name = "agrep-ingest"
33
- version = "0.4.0"
33
+ version = "0.4.2"
34
34
  dependencies = [
35
35
  "agrep-core",
36
36
  "anyhow",
@@ -4,7 +4,7 @@ resolver = "2"
4
4
 
5
5
  [workspace.package]
6
6
  edition = "2021"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  license = "MIT"
9
9
 
10
10
  [workspace.dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agrep
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Find, open, resume and watch your AI agents' work across Claude, Codex, opencode, Antigravity, Kimi, Cline, Gemini, Crush, Cursor, pi and oh-my-pi - one local index.
5
5
  Project-URL: Homepage, https://github.com/dannyisbad/agrep
6
6
  Project-URL: Repository, https://github.com/dannyisbad/agrep
@@ -5,4 +5,4 @@ binary, so `uvx agrep` / `pipx run agrep` / `pip install agrep` need no clone or
5
5
  The real logic lives in the bundled modules; see __main__.py.
6
6
  """
7
7
 
8
- __version__ = "0.4.0"
8
+ __version__ = "0.4.2"
@@ -77,6 +77,12 @@ pub(crate) fn current_process_start_identity() -> Option<String> {
77
77
  process_start_identity(std::process::id())
78
78
  }
79
79
 
80
+ pub(crate) fn process_is_exact_live(pid: u32, start: &str) -> bool {
81
+ !["", "None", "unknown"].contains(&start)
82
+ && process_liveness(pid) == Liveness::Alive
83
+ && process_start_identity(pid).as_deref() == Some(start)
84
+ }
85
+
80
86
  pub(crate) fn observe_live_holder(path: &Path, label: &str) -> io::Result<Option<LiveHolder>> {
81
87
  let observed = match snapshot(path) {
82
88
  Ok(observed) => observed,
@@ -228,6 +228,7 @@ const ROOT_STAGING_ARTIFACTS: &[&str] = &[
228
228
  ".source_absence_pending",
229
229
  ".source-health.json",
230
230
  ".source_snapshot.bin",
231
+ ".source_snapshot.seal",
231
232
  ".token_material.json",
232
233
  "boundary_stats.json",
233
234
  "corpus.db",
@@ -256,6 +257,7 @@ const CORPUS_DB_TRIGGER_NAMES: [&str; 7] = [
256
257
  ];
257
258
  const DERIVED_ADOPTION_OWNER_TOKEN_ENV: &str = "AGREP_DERIVED_ADOPTION_OWNER_TOKEN";
258
259
  const DERIVED_ADOPTION_CLAIM_TOKEN_ENV: &str = "AGREP_DERIVED_ADOPTION_CLAIM_TOKEN";
260
+ const DERIVED_ADOPTION_WAIT_MS_ENV: &str = "AGREP_DERIVED_ADOPTION_WAIT_MS";
259
261
  const DERIVED_WRITER_IDENTITY_BLOCKED_ENV: &str = "AGREP_DERIVED_WRITER_IDENTITY_BLOCKED";
260
262
 
261
263
  #[derive(serde::Deserialize, serde::Serialize)]
@@ -891,7 +893,13 @@ fn read_daemon_owner_fields(path: &Path, name: &str) -> Result<HashMap<String, S
891
893
  }
892
894
  };
893
895
  let text = match std::str::from_utf8(&body) {
894
- Ok(text) if text.ends_with('\n') => text,
896
+ Ok(text)
897
+ if text.ends_with('\n')
898
+ && text.bytes().filter(|byte| *byte == b'\n').count() == 1
899
+ && !text.contains('\0') =>
900
+ {
901
+ text
902
+ }
895
903
  _ => return Err(format!("freshness-daemon owner {name} is malformed")),
896
904
  };
897
905
  let mut fields = HashMap::new();
@@ -908,15 +916,85 @@ fn read_daemon_owner_fields(path: &Path, name: &str) -> Result<HashMap<String, S
908
916
  Ok(fields)
909
917
  }
910
918
 
911
- fn adoption_daemon_fence(data: &Path) -> Option<String> {
919
+ #[derive(Debug, PartialEq, Eq)]
920
+ enum AdoptionFence {
921
+ LiveSameWriter(u32),
922
+ Refused(String),
923
+ }
924
+
925
+ impl From<String> for AdoptionFence {
926
+ fn from(reason: String) -> Self {
927
+ Self::Refused(reason)
928
+ }
929
+ }
930
+
931
+ impl std::fmt::Display for AdoptionFence {
932
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
933
+ match self {
934
+ Self::LiveSameWriter(pid) => {
935
+ write!(f, "this build is already adopting the index (pid {pid})")
936
+ }
937
+ Self::Refused(reason) => f.write_str(reason),
938
+ }
939
+ }
940
+ }
941
+
942
+ fn live_same_writer_adoption(owners: &[(String, PathBuf)], writer: &str) -> Option<u32> {
943
+ let mut claim = None;
944
+ for (name, path) in owners {
945
+ if !matches!(name.as_str(), ".indexd.lock" | ".indexd.v2.lock") {
946
+ return None;
947
+ }
948
+ let fields = read_daemon_owner_fields(path, name).ok()?;
949
+ let pid_text = fields.get("pid")?;
950
+ if !pid_text.bytes().all(|byte| byte.is_ascii_digit()) {
951
+ return None;
952
+ }
953
+ let pid = pid_text.parse::<u32>().ok()?;
954
+ let start = fields.get("start")?;
955
+ let token = fields.get("token")?;
956
+ if pid == 0
957
+ || pid > index_lock::MAX_PID
958
+ || !index_lock::process_is_exact_live(pid, start)
959
+ || fields.get("writer").map(String::as_str) != Some(writer)
960
+ || token.len() != 32
961
+ || !token
962
+ .bytes()
963
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
964
+ {
965
+ return None;
966
+ }
967
+ if fields.get("state").map(String::as_str) == Some("derived-adoption") {
968
+ if fields.len() != 5 {
969
+ return None;
970
+ }
971
+ let identity = (pid, token.clone());
972
+ if claim.as_ref().is_some_and(|prior| prior != &identity) {
973
+ return None;
974
+ }
975
+ claim = Some(identity);
976
+ } else if name != ".indexd.v2.lock"
977
+ || fields.get("protocol").map(String::as_str) != Some("2")
978
+ || std::env::var(DERIVED_ADOPTION_OWNER_TOKEN_ENV).as_deref() != Ok(token.as_str())
979
+ {
980
+ return None;
981
+ }
982
+ }
983
+ claim.map(|(pid, _)| pid)
984
+ }
985
+
986
+ fn adoption_daemon_fence(data: &Path) -> Option<AdoptionFence> {
912
987
  let entries = match fs::read_dir(data) {
913
988
  Ok(entries) => entries,
914
989
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
915
990
  Err(error) => {
916
- return Some(format!(
917
- "cannot inspect freshness-daemon ownership in {}: {error}",
918
- data.display()
919
- ));
991
+ return Some(
992
+ format!(
993
+ "cannot inspect freshness-daemon ownership in {}: {error}",
994
+ data.display()
995
+ )
996
+ .into(),
997
+ );
920
998
  }
921
999
  };
922
1000
  let mut owners = Vec::new();
@@ -924,10 +1002,13 @@ fn adoption_daemon_fence(data: &Path) -> Option<String> {
924
1002
  let entry = match entry {
925
1003
  Ok(entry) => entry,
926
1004
  Err(error) => {
927
- return Some(format!(
928
- "cannot inspect freshness-daemon ownership in {}: {error}",
929
- data.display()
930
- ));
1005
+ return Some(
1006
+ format!(
1007
+ "cannot inspect freshness-daemon ownership in {}: {error}",
1008
+ data.display()
1009
+ )
1010
+ .into(),
1011
+ );
931
1012
  }
932
1013
  };
933
1014
  let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
@@ -940,10 +1021,13 @@ fn adoption_daemon_fence(data: &Path) -> Option<String> {
940
1021
  Ok(false) => owners.push((name, path)),
941
1022
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
942
1023
  Err(error) => {
943
- return Some(format!(
944
- "cannot inspect freshness-daemon owner {}: {error}",
945
- path.display()
946
- ));
1024
+ return Some(
1025
+ format!(
1026
+ "cannot inspect freshness-daemon owner {}: {error}",
1027
+ path.display()
1028
+ )
1029
+ .into(),
1030
+ );
947
1031
  }
948
1032
  }
949
1033
  }
@@ -978,25 +1062,34 @@ fn adoption_daemon_fence(data: &Path) -> Option<String> {
978
1062
  if external.is_empty() {
979
1063
  return None;
980
1064
  }
1065
+ if let Some(pid) = live_same_writer_adoption(&external, &current) {
1066
+ return Some(AdoptionFence::LiveSameWriter(pid));
1067
+ }
981
1068
  if external.len() != 1 || external[0].0 == ".indexd.lock" {
982
- return Some(format!(
983
- "legacy or ambiguous freshness-daemon ownership is present ({})",
984
- external
985
- .iter()
986
- .map(|(name, _)| name.as_str())
987
- .collect::<Vec<_>>()
988
- .join(", ")
989
- ));
1069
+ return Some(
1070
+ format!(
1071
+ "legacy or ambiguous freshness-daemon ownership is present ({})",
1072
+ external
1073
+ .iter()
1074
+ .map(|(name, _)| name.as_str())
1075
+ .collect::<Vec<_>>()
1076
+ .join(", ")
1077
+ )
1078
+ .into(),
1079
+ );
990
1080
  }
991
1081
  let fields = match read_daemon_owner_fields(&external[0].1, &external[0].0) {
992
1082
  Ok(fields) => fields,
993
- Err(reason) => return Some(reason),
1083
+ Err(reason) => return Some(reason.into()),
994
1084
  };
995
1085
  if fields.get("writer").map(String::as_str) != Some(current.as_str()) {
996
- return Some(format!(
997
- "freshness-daemon owner {} belongs to a different writing build",
998
- external[0].0
999
- ));
1086
+ return Some(
1087
+ format!(
1088
+ "freshness-daemon owner {} belongs to a different writing build",
1089
+ external[0].0
1090
+ )
1091
+ .into(),
1092
+ );
1000
1093
  }
1001
1094
  let expected_token = match std::env::var(DERIVED_ADOPTION_OWNER_TOKEN_ENV) {
1002
1095
  Ok(value)
@@ -1008,17 +1101,23 @@ fn adoption_daemon_fence(data: &Path) -> Option<String> {
1008
1101
  value
1009
1102
  }
1010
1103
  _ => {
1011
- return Some(format!(
1012
- "freshness-daemon owner {} is not authorized for this derived write",
1013
- external[0].0
1014
- ));
1104
+ return Some(
1105
+ format!(
1106
+ "freshness-daemon owner {} is not authorized for this derived write",
1107
+ external[0].0
1108
+ )
1109
+ .into(),
1110
+ );
1015
1111
  }
1016
1112
  };
1017
1113
  if fields.get("token").map(String::as_str) != Some(expected_token.as_str()) {
1018
- return Some(format!(
1019
- "freshness-daemon owner {} belongs to a different writing generation",
1020
- external[0].0
1021
- ));
1114
+ return Some(
1115
+ format!(
1116
+ "freshness-daemon owner {} belongs to a different writing generation",
1117
+ external[0].0
1118
+ )
1119
+ .into(),
1120
+ );
1022
1121
  }
1023
1122
  None
1024
1123
  }
@@ -1032,7 +1131,7 @@ struct AdoptionClaim {
1032
1131
  }
1033
1132
 
1034
1133
  impl AdoptionClaim {
1035
- fn acquire(data: &Path) -> Result<Self, String> {
1134
+ fn acquire(data: &Path) -> Result<Self, AdoptionFence> {
1036
1135
  if let Some(reason) = adoption_daemon_fence(data) {
1037
1136
  return Err(reason);
1038
1137
  }
@@ -1072,7 +1171,8 @@ impl AdoptionClaim {
1072
1171
  return Err(format!(
1073
1172
  "cannot publish derived-adoption claim {}: {error}",
1074
1173
  path.display()
1075
- ));
1174
+ )
1175
+ .into());
1076
1176
  }
1077
1177
  }
1078
1178
  }
@@ -4258,7 +4358,51 @@ fn published_token_inventory(
4258
4358
  .then(|| cache.token_namespaces())
4259
4359
  }
4260
4360
 
4361
+ /// Whether the published source snapshot is sealed to the published messages.jsonl, by the identity
4362
+ /// the released-row record uses. A pass that replaced the rows and died before its snapshot, or
4363
+ /// held it, leaves them unsealed.
4364
+ fn source_snapshot_sealed(data: &Path, generation: Option<&cache::GenerationSeal>) -> bool {
4365
+ let Some(generation) = generation else {
4366
+ return false;
4367
+ };
4368
+ let seal_path = data.join(SOURCE_SNAPSHOT_SEAL_FILE);
4369
+ read_optional_bytes(&seal_path, SOURCE_SNAPSHOT_SEAL_MAX_BYTES)
4370
+ .ok()
4371
+ .flatten()
4372
+ .is_some_and(|recorded| {
4373
+ String::from_utf8_lossy(&recorded).trim() == generation_seal_hex(generation)
4374
+ })
4375
+ }
4376
+
4377
+ /// Bind the published source snapshot to the published messages.jsonl. Written after the snapshot,
4378
+ /// so a kill between the two leaves a seal no later pass matches; a seal already in place costs a read.
4379
+ fn seal_source_snapshot(data: &Path) -> anyhow::Result<()> {
4380
+ let seal_path = data.join(SOURCE_SNAPSHOT_SEAL_FILE);
4381
+ match cache::published_generation_seal(data) {
4382
+ Some(seal) => {
4383
+ let hex = generation_seal_hex(&seal);
4384
+ if regular_file_has_bytes(&seal_path, hex.as_bytes())? {
4385
+ return Ok(());
4386
+ }
4387
+ cache::write_bytes_atomic(&seal_path, hex.as_bytes())
4388
+ }
4389
+ None => cache::remove_if_exists(&seal_path),
4390
+ }
4391
+ }
4392
+
4393
+ fn generation_seal_hex(seal: &cache::GenerationSeal) -> String {
4394
+ use std::fmt::Write as _;
4395
+
4396
+ let mut hex = String::with_capacity(32);
4397
+ for byte in seal {
4398
+ let _ = write!(hex, "{byte:02x}");
4399
+ }
4400
+ hex
4401
+ }
4402
+
4261
4403
  const SOURCE_SNAPSHOT_FILE: &str = ".source_snapshot.bin";
4404
+ const SOURCE_SNAPSHOT_SEAL_FILE: &str = ".source_snapshot.seal";
4405
+ const SOURCE_SNAPSHOT_SEAL_MAX_BYTES: u64 = 4096;
4262
4406
  const INGEST_PENDING_FILE: &str = ".ingest_pending.bin";
4263
4407
  const SOURCE_ABSENCE_FILE: &str = ".source_absence_pending";
4264
4408
  const HARNESS_POLICY_SNAPSHOT_FILE: &str = ".harness_prefixes.snapshot";
@@ -5091,54 +5235,85 @@ fn index_cmd(agent: &str, full: bool) -> anyhow::Result<()> {
5091
5235
  disclose_read_only_ownership(&format!("derived writer identity is unavailable: {reason}"));
5092
5236
  return Ok(());
5093
5237
  }
5094
- // A rollback journal blocks the ownership probe itself and nothing on the
5095
- // box would ever clear it. Reclaim it first, under the index lock the
5096
- // writer takes anyway, then read ownership for real.
5097
- let corpus = data.join("corpus.db");
5098
- let corpus_missing = matches!(
5099
- agrep_core::ingest::registry::regular_file_edge_snapshot(&corpus, 0),
5100
- Ok(None)
5101
- );
5102
- if !corpus_missing && hot_rollback_journal(&corpus).map_or(true, |journal| journal.is_some()) {
5103
- let mut journal_lock = acquire_index_lock()?;
5104
- let reclaimed = reclaim_cold_rollback_journal(&corpus);
5105
- journal_lock.release()?;
5106
- if let Err(detail) = reclaimed {
5107
- disclose_read_only_ownership(&detail);
5108
- return Ok(());
5109
- }
5110
- }
5111
- let initial_ownership = derived_write_ownership(&data);
5112
- let ownership_was_current = initial_ownership == DerivedWriteOwnership::Current;
5113
- let adoption_claim = match initial_ownership {
5114
- DerivedWriteOwnership::Refused(reason)
5115
- | DerivedWriteOwnership::PostAdoptionClobber(reason) => {
5116
- disclose_read_only_ownership(&reason);
5117
- return Ok(());
5238
+ let wait_budget = std::env::var(DERIVED_ADOPTION_WAIT_MS_ENV)
5239
+ .ok()
5240
+ .and_then(|value| value.parse::<u64>().ok())
5241
+ .map(|millis| std::time::Duration::from_millis(millis.min(180_000)));
5242
+ let mut waiting_since = None;
5243
+ let mut wait_on_adoption = |fence: &AdoptionFence| -> anyhow::Result<bool> {
5244
+ let (AdoptionFence::LiveSameWriter(pid), Some(budget)) = (fence, wait_budget) else {
5245
+ return Ok(false);
5246
+ };
5247
+ let started = waiting_since.get_or_insert_with(|| {
5248
+ eprintln!(
5249
+ "waiting for the freshness daemon to finish adopting the index (pid {pid}) ..."
5250
+ );
5251
+ Instant::now()
5252
+ });
5253
+ let remaining = budget.saturating_sub(started.elapsed());
5254
+ anyhow::ensure!(
5255
+ !remaining.is_zero(),
5256
+ "freshness daemon is still adopting the index (pid {pid}); \
5257
+ the wait budget expired, but this adoption finishes on its own; \
5258
+ run `agrep index` again after it completes"
5259
+ );
5260
+ std::thread::sleep(remaining.min(std::time::Duration::from_millis(100)));
5261
+ Ok(true)
5262
+ };
5263
+ let (adoption_claim, ownership_was_current) = loop {
5264
+ // The incumbent may be replacing ownership artifacts or holding a SQLite
5265
+ // journal; wait before probing that family or taking its index lock.
5266
+ if wait_budget.is_some() {
5267
+ if let Some(fence) = adoption_daemon_fence(&data) {
5268
+ if wait_on_adoption(&fence)? {
5269
+ continue;
5270
+ }
5271
+ }
5118
5272
  }
5119
- // A foreign family may only converge under the index lock; the claim proves no
5120
- // live daemon holds it, and the locked section performs the successor takeover.
5121
- DerivedWriteOwnership::Foreign(reason) => match AdoptionClaim::acquire(&data) {
5122
- Ok(claim) => Some(claim),
5123
- Err(fence) => {
5124
- disclose_read_only_ownership(&format!("{reason}; {fence}"));
5273
+ // A rollback journal blocks the ownership probe itself and nothing on the
5274
+ // box would ever clear it. Reclaim it first, under the index lock the
5275
+ // writer takes anyway, then read ownership for real.
5276
+ let corpus = data.join("corpus.db");
5277
+ let corpus_missing = matches!(
5278
+ agrep_core::ingest::registry::regular_file_edge_snapshot(&corpus, 0),
5279
+ Ok(None)
5280
+ );
5281
+ if !corpus_missing
5282
+ && hot_rollback_journal(&corpus).map_or(true, |journal| journal.is_some())
5283
+ {
5284
+ let mut journal_lock = acquire_index_lock()?;
5285
+ let reclaimed = reclaim_cold_rollback_journal(&corpus);
5286
+ journal_lock.release()?;
5287
+ if let Err(detail) = reclaimed {
5288
+ disclose_read_only_ownership(&detail);
5125
5289
  return Ok(());
5126
5290
  }
5127
- },
5128
- DerivedWriteOwnership::Adoption => match AdoptionClaim::acquire(&data) {
5129
- Ok(claim) => Some(claim),
5130
- Err(reason) => {
5131
- disclose_read_only_ownership(&reason);
5132
- return Ok(());
5291
+ }
5292
+ let ownership = derived_write_ownership(&data);
5293
+ if let DerivedWriteOwnership::Refused(reason)
5294
+ | DerivedWriteOwnership::PostAdoptionClobber(reason) = &ownership
5295
+ {
5296
+ disclose_read_only_ownership(reason);
5297
+ return Ok(());
5298
+ }
5299
+ // Retry the exclusive claim before taking .index.lock; waiting with either
5300
+ // lock held would prevent the incumbent from completing its publication.
5301
+ match AdoptionClaim::acquire(&data) {
5302
+ Ok(claim) => {
5303
+ break (Some(claim), ownership == DerivedWriteOwnership::Current);
5133
5304
  }
5134
- },
5135
- DerivedWriteOwnership::Current => match AdoptionClaim::acquire(&data) {
5136
- Ok(claim) => Some(claim),
5137
- Err(reason) => {
5305
+ Err(fence) => {
5306
+ if wait_on_adoption(&fence)? {
5307
+ continue;
5308
+ }
5309
+ let reason = match ownership {
5310
+ DerivedWriteOwnership::Foreign(reason) => format!("{reason}; {fence}"),
5311
+ _ => fence.to_string(),
5312
+ };
5138
5313
  disclose_read_only_ownership(&reason);
5139
5314
  return Ok(());
5140
5315
  }
5141
- },
5316
+ }
5142
5317
  };
5143
5318
  let lock_path = data.join(".index.lock");
5144
5319
  if ownership_was_current && !full && !agrep_core::emit::on() {
@@ -5207,7 +5382,7 @@ fn index_cmd_locked(
5207
5382
  return Ok(());
5208
5383
  }
5209
5384
  if let Some(reason) = adoption_daemon_fence(&data) {
5210
- disclose_read_only_ownership(&reason);
5385
+ disclose_read_only_ownership(&reason.to_string());
5211
5386
  return Ok(());
5212
5387
  }
5213
5388
  consume_retained_corpus_owner(&data)?;
@@ -5221,7 +5396,7 @@ fn index_cmd_locked(
5221
5396
  return Ok(());
5222
5397
  }
5223
5398
  if let Some(reason) = adoption_daemon_fence(&data) {
5224
- disclose_read_only_ownership(&reason);
5399
+ disclose_read_only_ownership(&reason.to_string());
5225
5400
  return Ok(());
5226
5401
  }
5227
5402
  false
@@ -5381,6 +5556,8 @@ fn index_cmd_locked(
5381
5556
  }
5382
5557
  // The published generation stands as the pass that published it disclosed it; no
5383
5558
  // refused attempt intervened, since one leaves the pending marker that bars this lane.
5559
+ // Its snapshot is current, whatever outran its seal (a kill before it, an older build).
5560
+ seal_source_snapshot(&data)?;
5384
5561
  lap!("source-check");
5385
5562
  println!(
5386
5563
  " unchanged since last index ({} messages); skipped ingest + writes ({:.0}ms)",
@@ -5578,10 +5755,14 @@ fn index_cmd_locked(
5578
5755
  expected_paths.extend(paths);
5579
5756
  }
5580
5757
  pcache.set_repair_expectations(expected_agents, expected_paths);
5581
- // The publication guard's own inventory, independent of the deletion machinery above so
5582
- // --full still converges real deletions: what the last published generation contained,
5583
- // or a proven-empty set when nothing was ever published.
5584
- if let Some(published) = published_source.as_deref() {
5758
+ // The publication guard's own inventory, independent of the deletion machinery above so --full
5759
+ // still converges real deletions: what the published generation contained, or a proven-empty set
5760
+ // when nothing was published. A snapshot an unfinished publication outran inventories older rows.
5761
+ let generation = cache::published_generation_seal(&data);
5762
+ let published_inventory = published_source
5763
+ .as_deref()
5764
+ .filter(|_| pending_source.is_none() || source_snapshot_sealed(&data, generation.as_ref()));
5765
+ if let Some(published) = published_inventory {
5585
5766
  let (_, paths) = ingest::registry::source_snapshot_expectations(published);
5586
5767
  pcache.set_published_material(paths);
5587
5768
  // That inventory lists only what it could read. Name the scopes it could not, so a
@@ -5595,8 +5776,8 @@ fn index_cmd_locked(
5595
5776
  } else if !prior_generation {
5596
5777
  pcache.set_published_material(HashSet::new());
5597
5778
  } else if pcache.decoded_last_good_base() {
5598
- // Publication withheld every snapshot (0.3.2 held it beside any source issue, and an
5599
- // `--emit-rows` pass takes none). Every Stat and token row it published was cached
5779
+ // Publication withheld every snapshot (0.3.2 held it beside any source issue, `--emit-rows`
5780
+ // takes none) or sealed it beside older rows. Every Stat and token row published was cached
5600
5781
  // first, as `published_token_inventory` relies on; whole-store rows need not have been.
5601
5782
  pcache.set_published_material_from_cache();
5602
5783
  }
@@ -5660,7 +5841,7 @@ fn index_cmd_locked(
5660
5841
  source_issues
5661
5842
  .iter()
5662
5843
  .map(|issue| (issue.agent(), Path::new(issue.path()))),
5663
- cache::published_generation_seal(&data),
5844
+ generation,
5664
5845
  );
5665
5846
  }
5666
5847
  lap!("load-cache");
@@ -5995,6 +6176,7 @@ fn index_cmd_locked(
5995
6176
  &pending_path,
5996
6177
  )?;
5997
6178
  if source_published {
6179
+ seal_source_snapshot(&data)?;
5998
6180
  cache::remove_if_exists(&absence_path)?;
5999
6181
  }
6000
6182
  lap!("source-publish");
@@ -6150,6 +6332,7 @@ fn index_cmd_locked(
6150
6332
  &pending_path,
6151
6333
  )?;
6152
6334
  if source_published {
6335
+ seal_source_snapshot(&data)?;
6153
6336
  cache::remove_if_exists(&absence_path)?;
6154
6337
  }
6155
6338
  lap!("source-publish");
@@ -7801,6 +7984,77 @@ mod tests {
7801
7984
  let _ = std::fs::remove_dir_all(&data);
7802
7985
  }
7803
7986
 
7987
+ #[test]
7988
+ fn same_writer_adoption_remains_fenced_with_an_exact_busy_diagnosis() {
7989
+ let pid = std::process::id();
7990
+ let start = super::index_lock::current_process_start_identity().unwrap();
7991
+ let writer = agrep_core::ingest_cache::current_cache_writer_build_id();
7992
+ let token = "c".repeat(32);
7993
+ let data = std::env::temp_dir().join(format!(
7994
+ "agrep-exact-adoption-{pid}-{}",
7995
+ std::time::SystemTime::now()
7996
+ .duration_since(std::time::UNIX_EPOCH)
7997
+ .unwrap()
7998
+ .as_nanos()
7999
+ ));
8000
+ std::fs::create_dir_all(&data).unwrap();
8001
+ let legacy = data.join(".indexd.lock");
8002
+ let current = data.join(".indexd.v2.lock");
8003
+ let raw = format!(
8004
+ "state=derived-adoption pid={pid} start={start} writer={writer} token={token}\n"
8005
+ );
8006
+ std::fs::write(&legacy, &raw).unwrap();
8007
+ std::fs::write(&current, &raw).unwrap();
8008
+ assert_eq!(
8009
+ super::adoption_daemon_fence(&data),
8010
+ Some(super::AdoptionFence::LiveSameWriter(pid))
8011
+ );
8012
+ assert_eq!(std::fs::read_to_string(&legacy).unwrap(), raw);
8013
+ assert_eq!(std::fs::read_to_string(&current).unwrap(), raw);
8014
+ std::fs::write(&current, raw.replace(&token, &"d".repeat(32))).unwrap();
8015
+ assert!(matches!(
8016
+ super::adoption_daemon_fence(&data),
8017
+ Some(super::AdoptionFence::Refused(_))
8018
+ ));
8019
+
8020
+ std::fs::remove_file(&current).unwrap();
8021
+ for untrusted in [
8022
+ raw.replace(&format!("start={start}"), "start=unknown"),
8023
+ raw.replace(&format!("pid={pid}"), &format!("pid=+{pid}")),
8024
+ raw.replace("state=derived-adoption", "state=legacy"),
8025
+ raw.replace(&format!("token={token}"), "token=invalid"),
8026
+ raw.replace('\n', &format!(" pid={pid}\n")),
8027
+ raw.replace('\n', " extra=untrusted\n"),
8028
+ raw.replace('\n', "\n\n"),
8029
+ raw.replace('\n', "\0\n"),
8030
+ ] {
8031
+ std::fs::write(&legacy, &untrusted).unwrap();
8032
+ let reason = super::adoption_daemon_fence(&data).unwrap();
8033
+ assert!(
8034
+ matches!(reason, super::AdoptionFence::Refused(_)),
8035
+ "{untrusted}: {reason}"
8036
+ );
8037
+ assert_eq!(std::fs::read_to_string(&legacy).unwrap(), untrusted);
8038
+ }
8039
+ std::fs::write(&legacy, &raw).unwrap();
8040
+ let unknown = data.join(".indexd.v3.lock");
8041
+ std::fs::write(&unknown, &raw).unwrap();
8042
+ assert!(super::adoption_daemon_fence(&data)
8043
+ .unwrap()
8044
+ .to_string()
8045
+ .contains("legacy or ambiguous"));
8046
+ std::fs::remove_file(unknown).unwrap();
8047
+ #[cfg(unix)]
8048
+ {
8049
+ std::os::unix::fs::symlink(&legacy, &current).unwrap();
8050
+ assert!(matches!(
8051
+ super::adoption_daemon_fence(&data),
8052
+ Some(super::AdoptionFence::Refused(_))
8053
+ ));
8054
+ }
8055
+ std::fs::remove_dir_all(data).unwrap();
8056
+ }
8057
+
7804
8058
  #[test]
7805
8059
  fn live_adoption_claim_from_different_build_blocks_fencing() {
7806
8060
  let data = std::env::temp_dir().join(format!(
@@ -1660,16 +1660,17 @@ def main(argv: list[str] | None = None) -> int:
1660
1660
  if not work_available("orphaned-tally join"):
1661
1661
  validation_complete = False
1662
1662
  break
1663
- for _key, entry in keyed_entries:
1664
- agent = str(entry.get("agent") or "")
1665
- if agent:
1666
- book_agents.add(agent)
1663
+ agents_here = {str(entry.get("agent") or "") for _key, entry in keyed_entries} - {""}
1667
1664
  if path in seen_paths:
1665
+ book_agents.update(agents_here)
1668
1666
  continue
1669
1667
  state, detail = _path_state(path)
1670
1668
  if state == "missing":
1669
+ # A deleted store's tallies stay behind; they count here, not as that agent's evidence.
1671
1670
  orphan_count += 1
1672
- elif state == "unreadable":
1671
+ continue
1672
+ book_agents.update(agents_here)
1673
+ if state == "unreadable":
1673
1674
  problems.append(f"cannot stat tallied source {path}: {detail}")
1674
1675
  else:
1675
1676
  agents = sorted({str(entry.get("agent") or "")