Skip to content

Commit ab95adb

Browse files
authored
Merge pull request #404 from ahrav/feature/wire-pg-git-persistence
Feature/wire pg git persistence
2 parents c9e4f66 + 3fee0dd commit ab95adb

16 files changed

Lines changed: 1105 additions & 281 deletions

File tree

.beads/issues.jsonl

Lines changed: 3 additions & 2 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
**NEVER auto-commit, auto-add, or auto-push code to git. Only perform git operations when explicitly asked by the user.**
22

3+
<!-- autoresearch-git-exception-v1 -->
4+
5+
### Exception: Autoresearch Commit/Revert Cycle
6+
7+
The `/autoresearch` family of commands (and their OpenCode `_` variants) are
8+
**exempt** from the no-auto-commit policy during an active autoresearch loop.
9+
Autoresearch requires git commits as a core mechanism — commit-before-verify
10+
enables clean rollback, and git history serves as the agent's memory of what
11+
was tried and what failed.
12+
13+
**Allowed git operations inside an autoresearch loop only:**
14+
15+
| Operation | Purpose |
16+
| --------- | ------- |
17+
| `git add` + `git commit -m "experiment: ..."` | Record each atomic change before verification |
18+
| `git revert HEAD --no-edit` | Roll back a failed experiment |
19+
| `git log` / `git diff` | Read own experiment history for learning |
20+
21+
**Still prohibited, even inside autoresearch:**
22+
23+
- `git push` (never push experiment commits automatically)
24+
- `git commit --amend` or `git rebase` (never rewrite experiment history)
25+
- `git reset --hard` (always use `git revert` to preserve history)
26+
- Committing to `main`/`master` directly (create a dedicated experiment branch first)
27+
28+
**Activation:** This exception applies only when the user explicitly invokes
29+
an autoresearch command (`/autoresearch`, `/autoresearch:debug`,
30+
`/autoresearch_fix`, etc.). Normal agent operations outside the autoresearch
31+
loop must still follow the no-auto-commit policy.
32+
33+
<!-- end-autoresearch-git-exception -->
34+
335
<!-- comment-policy-v2 -->
436

537
## Comment Policy — MANDATORY, HOOK-ENFORCED

CLAUDE.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
**NEVER auto-commit, auto-add, or auto-push code to git. Only perform git operations when explicitly asked by the user.**
22

3+
<!-- autoresearch-git-exception-v1 -->
4+
5+
### Exception: Autoresearch Commit/Revert Cycle
6+
7+
The `/autoresearch` family of commands (and their OpenCode `_` variants) are
8+
**exempt** from the no-auto-commit policy during an active autoresearch loop.
9+
Autoresearch requires git commits as a core mechanism — commit-before-verify
10+
enables clean rollback, and git history serves as the agent's memory of what
11+
was tried and what failed.
12+
13+
**Allowed git operations inside an autoresearch loop only:**
14+
15+
| Operation | Purpose |
16+
| --------- | ------- |
17+
| `git add` + `git commit -m "experiment: ..."` | Record each atomic change before verification |
18+
| `git revert HEAD --no-edit` | Roll back a failed experiment |
19+
| `git log` / `git diff` | Read own experiment history for learning |
20+
21+
**Still prohibited, even inside autoresearch:**
22+
23+
- `git push` (never push experiment commits automatically)
24+
- `git commit --amend` or `git rebase` (never rewrite experiment history)
25+
- `git reset --hard` (always use `git revert` to preserve history)
26+
- Committing to `main`/`master` directly (create a dedicated experiment branch first)
27+
28+
**Activation:** This exception applies only when the user explicitly invokes
29+
an autoresearch command (`/autoresearch`, `/autoresearch:debug`,
30+
`/autoresearch_fix`, etc.). Normal agent operations outside the autoresearch
31+
loop must still follow the no-auto-commit policy.
32+
33+
<!-- end-autoresearch-git-exception -->
34+
335
## Agent Model Inheritance — MANDATORY
436

537
When dispatching subagents via the Agent tool, **NEVER set the `model` parameter**.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/gossip-scanner-runtime/src/distributed/execution.rs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use gossip_contracts::{
2626
},
2727
};
2828
use gossip_coordination::{AcquireScratch, CoordinationFacade, CursorSemantics};
29-
use scanner_git::{FinalizeOutcome, GitEventOutput};
29+
use scanner_git::{FinalizeOutcome, GitEventOutput, PersistenceStore};
3030
use scanner_scheduler::events::EventOutput;
3131

3232
use super::commit_bridge::{
@@ -550,9 +550,10 @@ pub(super) struct GitRepoPersistenceInput<'a> {
550550
pub(super) tenant_secret_key: TenantSecretKey,
551551
pub(super) rule_fingerprint: &'a dyn Fn(u32) -> RuleFingerprint,
552552
pub(super) claim_time: LogicalTime,
553-
/// Wall-clock timestamp captured after scan execution *and* persistence
554-
/// finalize complete. The `(claim_time, complete_time)` interval therefore
555-
/// measures claim-to-durable-finalize, not claim-to-scan-completion alone.
553+
/// Wall-clock timestamp captured after scan execution completes but before
554+
/// persistence submissions. The `(claim_time, complete_time)` interval
555+
/// measures claim-to-scan-completion; it does not include persistence
556+
/// latency.
556557
pub(super) complete_time: LogicalTime,
557558
}
558559

@@ -912,11 +913,11 @@ where
912913
)));
913914
}
914915

915-
// Findings must be durable BEFORE shard checkpoint advances. The in-memory
916-
// GitPersistenceBackend makes post-execution batch write safe: a crash
917-
// clears in-memory watermarks, causing full re-scan on re-claim. A durable
918-
// GitPersistenceBackend would require splitting finalize into: seen-bitmaps
919-
// → findings persistence → watermark commit.
916+
// External findings and done-ledger state must land before a complete Git
917+
// finalize advances the repo's durable scan state. The scan phase buffers
918+
// complete finalizes via `DeferredCompleteFinalizeStore` so the git-kv
919+
// commit can run after these receipts succeed; partial finalizes remain
920+
// inline because they never advance watermarks.
920921
let captured_findings = capture_sink.take_captured_findings();
921922
let detected_count = capture_sink.detected_finding_count();
922923
if detected_count != captured_findings.len() as u64 {
@@ -966,6 +967,33 @@ where
966967
receipts: GIT_REPO_RECEIPT_FAMILIES,
967968
});
968969

970+
// At-least-once guarantee: findings and done-ledger records are already
971+
// durable at this point. If commit_finalize fails (connection drop,
972+
// constraint violation) or the process is killed before it completes,
973+
// watermarks remain at their pre-scan position. The next lease re-scans
974+
// the same blobs and re-emits findings. Done-ledger and findings
975+
// consumers must tolerate duplicate submissions.
976+
if matches!(execution.finalize_outcome, FinalizeOutcome::Complete)
977+
&& execution.deferred_finalize.is_none()
978+
{
979+
return Err(DistributedRuntimeError::Durability(anyhow!(
980+
"complete finalize for shard '{}' must produce a deferred finalize output; \
981+
watermarks would be silently dropped",
982+
stage_sink.redacted_shard_id()
983+
)));
984+
}
985+
if let Some(finalize) = execution.deferred_finalize.as_ref() {
986+
execution
987+
.persistence
988+
.commit_finalize(finalize)
989+
.map_err(|error| {
990+
DistributedRuntimeError::Durability(AnyError::new(error).context(format!(
991+
"git repo-frontier shard '{}' git state finalize commit failed",
992+
stage_sink.redacted_shard_id()
993+
)))
994+
})?;
995+
}
996+
969997
tracing::debug!(
970998
shard_id = %stage_sink.redacted_shard_id(),
971999
detected_findings = detected_count,

crates/gossip-scanner-runtime/src/distributed/integration_tests.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2019,6 +2019,58 @@ fn run_git_repo_worker_fails_cleanly_on_persistence_error() {
20192019
);
20202020
}
20212021

2022+
/// The post-receipt `commit_finalize` failure must surface as a `Durability`
2023+
/// error while leaving findings and done-ledger rows already persisted (the
2024+
/// at-least-once guarantee). Scan-phase persistence succeeds; only the
2025+
/// deferred finalize commit that advances watermarks and clears staging is
2026+
/// injected to fail.
2027+
#[test]
2028+
fn run_git_repo_worker_surfaces_durability_error_on_post_receipt_finalize_failure() {
2029+
let repo = create_clean_git_repo_fixture();
2030+
let mirror_root = tempdir().expect("mirror root");
2031+
let mut mirrors = LocalMirrorManager::new(mirror_root.path()).expect("mirror manager");
2032+
let backend = TestGitBackend::default();
2033+
backend.fail_on_finalize_commit();
2034+
let mut coordinator =
2035+
setup_coordinator_with_git_shard(repo.path(), CoordCursorUpdate::initial(), 30_000);
2036+
2037+
let findings = InMemoryFindingsSink::new();
2038+
let done_ledger = InMemoryDoneLedger::new();
2039+
let err = run_git_repo_worker(
2040+
&mut coordinator,
2041+
&mut mirrors,
2042+
git_worker_identity(repo.path()),
2043+
backend.clone(),
2044+
DistributedPersistence::new(findings.clone(), done_ledger.clone()),
2045+
DistributedRuntimeConfig::default(),
2046+
)
2047+
.expect_err("post-receipt finalize commit failure should propagate");
2048+
2049+
assert!(
2050+
matches!(err, DistributedRuntimeError::Durability(_)),
2051+
"expected Durability error variant, got: {err:?}"
2052+
);
2053+
assert!(
2054+
err.to_string().contains("finalize commit failed"),
2055+
"error message should identify the finalize commit failure: {err}"
2056+
);
2057+
2058+
// Scan-phase spill writes must have succeeded before the finalize failed.
2059+
assert!(
2060+
backend.batch_call_count() > 0,
2061+
"scan-phase persistence batches should have succeeded before the finalize failure"
2062+
);
2063+
2064+
// Done-ledger and findings are submitted BEFORE commit_finalize, so they
2065+
// must be durable despite the finalize failure (at-least-once guarantee).
2066+
let done_rows = done_ledger.snapshot().expect("done-ledger snapshot");
2067+
assert_eq!(
2068+
done_rows.len(),
2069+
1,
2070+
"done-ledger row must be durable even when finalize commit fails"
2071+
);
2072+
}
2073+
20222074
/// Git events remain observable when the repo-frontier worker persists
20232075
/// findings durably.
20242076
#[test]

crates/gossip-scanner-runtime/src/distributed/test_support.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ pub(super) struct TestGitBackendState {
104104
pub(super) kv: BTreeMap<Vec<u8>, Vec<u8>>,
105105
pub(super) batch_call_count: usize,
106106
pub(super) fail_after_n_batches: Option<usize>,
107+
/// Fail any `apply_batch` call that contains a `Delete` op AND has more
108+
/// than two operations. Complete-finalize commits include data ops,
109+
/// checkpoint deletes, staging delete, and watermark puts (4+ ops).
110+
/// Scan-phase checkpoint writes have at most 2 ops. This flag targets
111+
/// post-scan finalize failures without knowledge of batch counts.
112+
pub(super) fail_on_finalize_commit: bool,
107113
}
108114

109115
#[derive(Debug, Clone, Default)]
@@ -126,6 +132,13 @@ impl TestGitBackend {
126132
.fail_after_n_batches = Some(n);
127133
}
128134

135+
pub(super) fn fail_on_finalize_commit(&self) {
136+
self.state
137+
.lock()
138+
.expect("git backend state lock")
139+
.fail_on_finalize_commit = true;
140+
}
141+
129142
pub(super) fn stored_keys(&self) -> Vec<Vec<u8>> {
130143
self.state
131144
.lock()
@@ -159,6 +172,16 @@ impl GitPersistenceBackend for TestGitBackend {
159172
message: "injected persistence failure",
160173
});
161174
}
175+
if state.fail_on_finalize_commit
176+
&& ops.len() > 2
177+
&& ops
178+
.iter()
179+
.any(|op| matches!(op, GitPersistenceOp::Delete { .. }))
180+
{
181+
return Err(TestGitBackendError {
182+
message: "injected finalize commit failure",
183+
});
184+
}
162185
state.batch_call_count += 1;
163186
for op in ops {
164187
match op {

0 commit comments

Comments
 (0)