Skip to content

fix(brain-repo): mirror propagates source deletions, safely - #127

Open
mt-alarcon wants to merge 1 commit into
evolution-foundation:developfrom
mt-alarcon:feat/brain-repo-mirror-propagates-deletions
Open

fix(brain-repo): mirror propagates source deletions, safely#127
mt-alarcon wants to merge 1 commit into
evolution-foundation:developfrom
mt-alarcon:feat/brain-repo-mirror-propagates-deletions

Conversation

@mt-alarcon

@mt-alarcon mt-alarcon commented Jul 30, 2026

Copy link
Copy Markdown

Problem

The workspace mirror uses shutil.copytree(dirs_exist_ok=True), which only ever adds or overwrites. A file deleted from a watched path stays in the brain repo forever, so the backup drifts further from the workspace with every sync — and a restore resurrects files that were removed on purpose.

What this does

Adds a reconciliation pass that removes destination files whose source counterpart is gone. Everything else in the diff exists to keep that pass from becoming a data-loss engine, since it is the only code here whose job is to delete backup content.

The invariant: absence of evidence is never evidence of deletion. Ask the source directly, and keep the file on any uncertainty.

Guard Why it is there
Reconciliation runs outside the copytree try block copytree accumulates per-file errors and raises shutil.Error once at the end, so one unreadable file skipped deletion propagation for the entire watch path, logging only a warning
Deletion authorised by _source_may_still_exist, not by the "copied this round" set That set is a proxy with a silent hole — a subdirectory that fails to scan leaves its subtree unvisited, and reconciling on the proxy erases that subtree
lstat, not exists() exists() follows symlinks, so a broken symlink — a file that plainly exists — reports False. Only FileNotFoundError proves deletion
No Path.is_file() probe on a possibly unreadable path pathlib's _ignore_error does not cover EACCES, so it raised straight out of the mirror
Cancel mid-walk skips reconciliation The copied set is partial at that point
Mass-deletion circuit breaker A pass erasing ≥50% of a watch path's backup and ≥50 files is refused and logged — at that scale the likeliest cause is the source being unavailable (an unmounted volume usually looks like an empty directory), not a real deletion
Credential caches never copied, stale copies purged OAuth token stores written by integration skills must not reach the backup

Known limitation (deliberate)

Content already mirrored under a watch path whose source directory disappears entirely is left untouched, so it stays in the backup indefinitely.

Reconciling it under the breaker was tried and rejected: the breaker needs a file count to judge, and a small watch path falls under any sane threshold — so a momentarily unavailable mount would silently take its backup with it. There is no local signal separating "this path is unused" from "this path is unavailable right now", and for a backup the ambiguous answer has to be "keep". Purging that residue should stay a deliberate operator action.

Tests

18 tests, one per guard. The permission-based ones carry a positive control and call skipTest loudly rather than passing vacuously when run as root.

Each guard was verified to be load-bearing by neutralising it and confirming the corresponding test fails — not merely by observing a green suite.

$ python -m pytest dashboard/backend/brain_repo/tests/ -q
..................                                                       [100%]
18 passed

Summary by Sourcery

Propagate deletions from workspace watch paths to the brain repo mirror while protecting against unintended data loss and excluding credential caches.

New Features:

  • Introduce a post-copy reconciliation pass that removes brain repo files whose source counterparts have been deleted.
  • Add explicit handling to skip and purge skill credential cache files from the brain repo backup.

Bug Fixes:

  • Ensure the brain repo mirror no longer retains files indefinitely after they are deleted or reorganized in the workspace.
  • Prevent live symlinks, broken symlinks, unreadable files, and unreadable directories from being incorrectly treated as deleted and removed from the backup.
  • Guard against mass-deletion scenarios so transient source outages or empty mounts do not wipe large portions of the backup.
  • Avoid deleting files in policy-excluded locations (e.g., oversized files, nested VCS/node_modules directories, .gitignore) during reconciliation.

Enhancements:

  • Extend the mirror workflow to track which files were copied per watch path and use this information in deletion reconciliation.
  • Refine ignore and exclusion logic to operate on resolved directories, avoid over-broad patterns, and maintain safety for backup content.

Tests:

  • Add a comprehensive regression test suite for workspace mirroring and reconciliation, covering deletion propagation, exclusions, credential caches, symlink handling, unreadable paths, mass-deletion safeguards, and cancel behavior.

The workspace mirror used `shutil.copytree(dirs_exist_ok=True)`, which only
ever adds or overwrites. A file deleted from a watched path stayed in the
brain repo forever, so the backup drifted further from the workspace with
every sync and a "restore" would resurrect files that were removed on
purpose.

This adds a reconciliation pass that removes destination files whose source
counterpart is gone. Everything else here exists to keep that pass from
becoming a data-loss engine, since it is the only code in the project whose
job is to delete backup content.

The invariant: absence of evidence is never evidence of deletion. Ask the
source directly, and keep the file on any uncertainty.

- Reconciliation runs OUTSIDE the copytree try block. copytree accumulates
  per-file errors and raises `shutil.Error` once at the end, so a single
  unreadable file used to skip deletion propagation for the ENTIRE watch
  path while logging only a warning.
- Deletion is authorised by `_source_may_still_exist`, not by the "was it
  copied this round" set. That set is a proxy with a silent hole: a
  subdirectory that fails to scan leaves its whole subtree unvisited, and
  reconciling on the proxy would erase that subtree from the backup.
- `lstat`, not `exists()`: `exists()` follows symlinks, so a broken symlink
  (a file that plainly exists) reports False. Only FileNotFoundError counts
  as proof of deletion; any other OSError means "cannot tell".
- No `Path.is_file()` probe on a possibly unreadable path — pathlib's
  `_ignore_error` does not cover EACCES, so it raised straight out of the
  mirror.
- A cancel mid-walk leaves the copied set partial; reconciliation is skipped
  in that case.
- Mass-deletion circuit breaker: a pass that would erase at least half of a
  watch path's backup, and at least 50 files, is refused and logged. At that
  scale the likeliest cause is the source being unavailable (an unmounted
  volume usually looks like an empty directory) rather than a real deletion.
- Credential caches written by integration skills are never copied, and any
  stale copy already in the backup is purged.

Known limitation, accepted deliberately: content already mirrored under a
watch path whose source directory disappears entirely is left untouched and
therefore stays in the backup indefinitely. Reconciling it under the breaker
was tried and rejected — a small watch path falls under any sane threshold,
so a momentarily unavailable mount would take its backup with it. There is
no local signal separating "unused" from "unavailable right now", and for a
backup the ambiguous answer has to be "keep".

Tests: 18 covering each guard. The permission-based ones carry a positive
control and skip loudly rather than pass vacuously when run as root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a post-copy reconciliation pass to the brain repo workspace mirror so that source deletions are propagated safely to the backup, with multiple guards against unintended data loss and special handling for credential caches.

Sequence diagram for workspace mirror with post-copy deletion reconciliation

sequenceDiagram
    participant MirrorJobRunner as _mirror_workspace
    participant CopyTree as shutil_copytree
    participant Reconcile as _reconcile_deletions
    participant SourceCheck as _source_may_still_exist

    MirrorJobRunner->>CopyTree: copytree(src, dst, dirs_exist_ok=True, ignore=_ignore)
    alt walk_completed
        MirrorJobRunner->>Reconcile: _reconcile_deletions(workspace, brain_dir, watch, copied_this_watch)
        loop each_backup_file
            Reconcile->>SourceCheck: _source_may_still_exist(src_file)
            alt source_may_still_exist and not _is_credential_cache
                Reconcile-->>MirrorJobRunner: keep_backup_file
            else source_absent or is_credential_cache
                Reconcile-->>MirrorJobRunner: mark_file_doomed
            end
        end
        Reconcile-->>MirrorJobRunner: deleted_count
        MirrorJobRunner->>MirrorJobRunner: log reconciled deletions
    else walk_aborted_or_cancel_flag
        MirrorJobRunner-->>MirrorJobRunner: skip_reconciliation
    end
Loading

Flow diagram for deletion reconciliation and mass-deletion circuit breaker

flowchart TD
    A[start_reconcile_deletions] --> B[scan_dst_root_with_rglob]
    B --> C[for_each_backup_file]
    C --> D{rel_in_copied_set}
    D -->|yes| C
    D -->|no| E[compute_src_file_from_workspace]
    E --> F[_source_may_still_exist]
    F -->|true and not _is_credential_cache| C
    F -->|false or is_credential_cache| G[_is_policy_excluded]
    G -->|true| C
    G -->|false| H[add_to_doomed_list]
    H --> C
    C --> I{all_files_scanned}
    I -->|yes| J["_is_mass_deletion(len_doomed, backup_files)"]
    J -->|true| K[log_refuse_and_return_0]
    J -->|false| L[unlink_doomed_files_and_prune_empty_dirs]
    L --> M[return_deleted_count]
Loading

File-Level Changes

Change Details Files
Introduce a guarded deletion-reconciliation pass that removes destination files whose source counterparts are gone, including a mass-deletion circuit breaker and symlink/permission edge-case handling.
  • Add _is_mass_deletion helper and associated thresholds to detect and refuse large-scale deletions likely caused by unavailable sources rather than real removals.
  • Implement _source_may_still_exist using lstat and parent directory access checks to distinguish true deletions from permission or symlink issues, biasing toward keeping backup content on uncertainty.
  • Implement _reconcile_deletions to walk the brain repo subtree for each watch path, decide deletions based on absence from the copied set plus source checks, respect policy exclusions, and prune now-empty directories in a bottom-up pass.
  • Wire reconciliation into _mirror_workspace after copytree for each watch path, tracking files_deleted and logging when deletions are reconciled, while skipping reconciliation if the copy walk aborted early or cancel fired mid-walk.
dashboard/backend/brain_repo/job_runner.py
Refactor and extend the copytree ignore callback to share policy exclusion logic with reconciliation, track copied paths, support cancellation visibility, and ensure symlinks are handled correctly.
  • Introduce _is_policy_excluded to centralize rules for paths that are never mirrored (e.g., .gitignore, excluded relative paths, excluded ancestor directories, oversized files) and reuse it in both the ignore callback and reconciliation.
  • Modify build_ignore_callback to accept record_kept and cancel_state, record workspace-relative paths that were actually copied, and ensure cancellation state is propagated outside the callback.
  • Change ignore logic to resolve only the source directory (not leaf files) to preserve symlink basenames in the copied set, preventing symlinks from being misinterpreted as deleted targets during reconciliation.
dashboard/backend/brain_repo/job_runner.py
Add explicit handling for credential cache files so they are never copied into the brain repo and any stale copies are purged during reconciliation.
  • Define _CREDENTIAL_CACHE_BASENAME_PATTERNS for narrow basename globs targeting skill OAuth/token/credential caches without catching legitimate files like tokens.json.
  • Add _is_credential_cache helper and integrate it into the ignore callback to skip copying credential cache files while not treating them as policy-excluded, allowing reconciliation to delete stale destination copies.
  • Update reconciliation logic to allow deletion of credential cache files even when the source may still exist, ensuring sensitive caches do not remain in backups.
dashboard/backend/brain_repo/job_runner.py
Adjust _mirror_workspace control flow around copytree to distinguish partial-failure and early-abort scenarios, ensure reconciliation only runs when the walk completed, and preserve backups when source watch paths disappear entirely.
  • Introduce walk_complete tracking and cancel_state for each watch path; set walk_complete for shutil.Error (per-file errors) but not for generic exceptions that abort the walk early, and conditionally run reconciliation based on these states.
  • Add explicit handling for missing source watch paths: log a warning when the destination still has content but skip deletion, making retention of backups for vanished paths a deliberate choice rather than automatic purge.
  • Count files_copied via dst.rglob after successful or partially successful walks, and accumulate files_deleted from reconciliation to log a summary of reconciled deletions.
  • Improve logging messages for copy errors, reconciliation refusal due to mass deletion, and cancel scenarios, clarifying operational behavior for operators.
dashboard/backend/brain_repo/job_runner.py
Add a dedicated test suite to validate deletion reconciliation behavior, credential cache handling, symlink robustness, mass-deletion protection, and cancel/permission edge cases.
  • Create MirrorReconcileTest to cover core behaviors: propagation of source deletions, preservation of policy-excluded files and .gitignore, normal copying of new/modified files, isolation of files outside watch paths, and preservation of backups when an entire watch path disappears from source.
  • Add MirrorSymlinkTest to verify that symlinks and their targets survive reconciliation while genuinely removed symlinks are deleted, guarding against path resolution issues in the copied set.
  • Add MirrorCredentialCacheTest to verify that credential caches are never copied, that similarly named legitimate files are still mirrored, that stale caches in the destination are purged, and that ordinary reconciliation remains intact.
  • Add resilience tests around unreadable files/directories, broken symlinks, mass-deletion circuit breaker behavior, and cancel mid-walk semantics (including a controlled override of _source_may_still_exist) to ensure each guard is load-bearing and not dead code.
  • Configure test fixtures using temporary directories, stub Flask app and models.BrainRepoConfig for cancel probing, and stub secrets_scanner functions to isolate mirror behavior from external dependencies.
dashboard/backend/brain_repo/tests/test_job_runner_mirror_reconcile.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The reconciliation and guarding logic (_is_policy_excluded, _reconcile_deletions, _source_may_still_exist, mass‑deletion breaker, credential cache handling) has grown quite large inside job_runner.py; consider extracting it into a dedicated module or class to reduce cognitive load and make the mirroring pipeline easier to follow.
  • In _mirror_workspace, you traverse dst twice via dst.rglob('*') (once to count files and once inside _reconcile_deletions); if this starts to hurt on large trees, consider aggregating file counting and reconciliation in a single walk to avoid repeated filesystem traversal.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The reconciliation and guarding logic (_is_policy_excluded, _reconcile_deletions, _source_may_still_exist, mass‑deletion breaker, credential cache handling) has grown quite large inside job_runner.py; consider extracting it into a dedicated module or class to reduce cognitive load and make the mirroring pipeline easier to follow.
- In _mirror_workspace, you traverse dst twice via dst.rglob('*') (once to count files and once inside _reconcile_deletions); if this starts to hurt on large trees, consider aggregating file counting and reconciliation in a single walk to avoid repeated filesystem traversal.

## Individual Comments

### Comment 1
<location path="dashboard/backend/brain_repo/tests/test_job_runner_mirror_reconcile.py" line_range="139-148" />
<code_context>
+
+        self._mirror()
+
+        self.assertFalse(
+            (self.workspace / watch / ".gitignore").exists() and False,
+            "sanity: the source has no .gitignore",
+        )
+        self.assertTrue(
</code_context>
<issue_to_address>
**issue (testing):** Sanity assertion for source .gitignore is ineffective and will always pass

In `test_gitignore_never_copied_and_never_deletes_stale_copy`, this assertion:

```python
self.assertFalse(
    (self.workspace / watch / ".gitignore").exists() and False,
    "sanity: the source has no .gitignore",
)
```
will always pass, because `X and False` is always `False`. It never actually checks whether `.gitignore` exists, so it can mask accidental fixture changes.

Please change it to a real absence check, e.g.:

```python
self.assertFalse(
    (self.workspace / watch / ".gitignore").exists(),
    "sanity: the source has no .gitignore",
)
```

or remove the assertion entirely if this precondition isn’t required.
</issue_to_address>

### Comment 2
<location path="dashboard/backend/brain_repo/tests/test_job_runner_mirror_reconcile.py" line_range="166-175" />
<code_context>
+    def test_gitignore_never_copied_and_never_deletes_stale_copy(self):
</code_context>
<issue_to_address>
**suggestion (testing):** Add a case where a source .gitignore actually exists to prove it is not copied

The current test only covers a stale `.gitignore` already in the destination and ensures reconciliation doesn’t delete it. To fully exercise the guard, add coverage for a `.gitignore` that actually exists in the source watch path: create the file in the source, run `_mirror_workspace`, and assert that it does not appear under `brain_dir`. This will validate both that source `.gitignore` files are never copied and that existing destination `.gitignore` files are preserved.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +139 to +148
self.assertFalse(
dst_file.exists(),
"a file deleted from the source should have been removed from the destination (original bug)",
)

# ── 2. A policy-excluded file stays in the destination ─────────────────
def test_policy_excluded_file_is_never_deleted(self):
watch = _WATCH_PATHS[0]
(self.workspace / watch).mkdir()
small = self.workspace / watch / "big.bin"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Sanity assertion for source .gitignore is ineffective and will always pass

In test_gitignore_never_copied_and_never_deletes_stale_copy, this assertion:

self.assertFalse(
    (self.workspace / watch / ".gitignore").exists() and False,
    "sanity: the source has no .gitignore",
)

will always pass, because X and False is always False. It never actually checks whether .gitignore exists, so it can mask accidental fixture changes.

Please change it to a real absence check, e.g.:

self.assertFalse(
    (self.workspace / watch / ".gitignore").exists(),
    "sanity: the source has no .gitignore",
)

or remove the assertion entirely if this precondition isn’t required.

Comment on lines +166 to +175
def test_gitignore_never_copied_and_never_deletes_stale_copy(self):
watch = _WATCH_PATHS[0]
(self.workspace / watch).mkdir()
(self.workspace / watch / "keep.md").write_text("k")

# Simulate a residue from an old sync (before the .gitignore rule
# existed): a .gitignore already present in the destination.
dst_dir = self.brain_dir / watch
dst_dir.mkdir(parents=True)
stale_gitignore = dst_dir / ".gitignore"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a case where a source .gitignore actually exists to prove it is not copied

The current test only covers a stale .gitignore already in the destination and ensures reconciliation doesn’t delete it. To fully exercise the guard, add coverage for a .gitignore that actually exists in the source watch path: create the file in the source, run _mirror_workspace, and assert that it does not appear under brain_dir. This will validate both that source .gitignore files are never copied and that existing destination .gitignore files are preserved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant