Skip to content

fix(aw-sync): reconcile owner-originated event deletions - #855

Open
TimeToBuildBob wants to merge 13 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-owner-deletions
Open

TimeToBuildBob wants to merge 13 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-owner-deletions

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What

aw-sync never propagates an event deletion. aw-sync/README.md says so, but the destination copy is not just unsynced — it is permanent: any later replica push copies it back.

Repro

aw-sync/tests/owner_deletion.rs (first commit, no production change). Source bucket with three events inside the 7-day window, sync, delete the middle one on the source, sync again:

left:  [(18:40:29Z, "keep-me"), (19:00:29Z, "delete-me"), (19:20:29Z, "keep-me-too")]
right: [(18:40:29Z, "keep-me"),                        (19:20:29Z, "keep-me-too")]

Three propagation tests fail on 10b4b7f2dd: the middle deletion, the newest-event deletion (so it is not just an interior row), and the deletion through the staging hop (phone → staging → desktop, where the desktop pulls from staging rather than the phone).

Cause: reconcile_updated_events iterates src_by_identity, so it only visits identities that still exist on the source. A deleted event has no identity to visit, and the incremental copy resumes at the destination's newest event end.

Approach: absence in the bounded window, not tombstones

aw-sync is single-writer per bucket, and the edit path already treats the source as authoritative for its own bucket ("a same-identity row with different data is an owner-originated edit, not a conflict"). A deletion is the same authority argument with zero surviving rows, so no tombstone record is needed. In-band markers would have to be filtered by the bucket API, WebUI, exports and the Android store; a separate store is a new mechanism with no consumer.

After the edit pass, any remaining destination-only identity is removed unless the source still backs it. Two guards keep that from destroying data:

  • Clip-invariant match. A row whose timestamp still exists on the source is an edit the incremental path owns (e.g. a shrunken latest-event duration moves the identity off the resume boundary). A row whose (end, data) fingerprint still exists on the source is a start-clipped copy of a live event — the incremental copy can insert those with a different timestamp and zeroed duration. That fingerprint is the same scheme the resume-boundary dedup already uses; this PR factors it into boundary_fingerprint() and reuses it at all three sites.
  • An empty source window never prunes. It is indistinguishable from a reset datastore, so it must not read as "the owner deleted everything".

reconcile_updated_events now returns the number of removed rows so sync_one can keep the #682 "event count must not shrink" invariant while tolerating a shrink it can attribute to reconcile. report.rs renders that net delta with its sign, so a deletion-only bucket no longer prints +-1 events.

Retained limitations (now documented)

  • Deletions older than the 7-day window still do not propagate, same as edits older than it. Remove the destination bucket and re-sync to recover.
  • A destination row the source does not back is now pruned inside the window, including a hand-seeded one. test_pre697_origin_scan_resumes_legacy_bucket seeded a synthetic destination event the source never had; the fixture now seeds the older source event, which is what "previously-imported history" means. Its cursor intent is unchanged (a re-import would produce 3 events, not 2).

Not covered

The reverse case in the issue — a delete that lands on the destination device while its insert fails, leaving the event missing there — needs a repair pass for rows missing below the cursor. That is a different mechanism and is not claimed here.

Verification

  • cargo test -p aw-sync: 91 + 94 + 9 + 5 + 24 + 3 passed, 0 failed.
  • cargo fmt -p aw-sync -- --check and cargo clippy -p aw-sync --all-targets clean.
  • The three propagation tests fail against origin/master's aw-sync/src/sync.rs with the final test file; the two contract tests (7-day limitation, empty-window guard) pass before and after.

A source-side delete inside the 7-day reconcile window leaves the
destination row in place forever: reconcile_updated_events only walks
identities that still exist on the source, and the incremental copy
resumes at the destination's newest event end.

Both propagation tests fail on 10b4b7f. The two contract tests in the
same file (7-day limitation, empty-source-window guard) pass before and
after.

Git-Session-Id: d15e1888-bd37-4cc5-b7a1-8faf30bf1162
`reconcile_updated_events` only walks identities that still exist on the
source, so a deleted event had no representation and its destination row
stayed forever. Under the single-writer model the source is authoritative
for its own bucket — the same argument that makes a differing payload an
edit rather than a conflict — so absence inside the same bounded window is
enough: no tombstone record is needed.

After the edit pass, any remaining destination-only identity is removed
unless the source still backs it. Two guards keep that from destroying
data:

- a row whose timestamp still exists on the source is an edit the
  incremental path owns (e.g. a shrunken latest-event duration moves the
  identity off the resume boundary), and a row whose clip-invariant
  (end, data) fingerprint — the resume-boundary dedup scheme — still
  exists on the source is a start-clipped copy of a live event;
- an empty source window is indistinguishable from a reset datastore, so
  it never prunes.

`reconcile_updated_events` now returns the number of rows it removed, so
`sync_one` can keep the ActivityWatch#682 "count must not shrink" invariant while
allowing a shrink it can attribute to reconcile.

Legacy fixture: `test_pre697_origin_scan_resumes_legacy_bucket` seeded a
destination event the source never had. Previously-imported history is an
event the source still has, so the fixture now seeds the older source
event; the cursor intent (a re-import would yield 3) is unchanged.

README documents the shipped contract and both retained limitations.

Git-Session-Id: d15e1888-bd37-4cc5-b7a1-8faf30bf1162
@greptile-apps

greptile-apps Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; all three previous findings are addressed.

Summary

This PR propagates owner-originated deletions within the seven-day reconciliation window.

  • Sync removes source-deleted events from recent destination copies.
  • Sync reports show event additions and removals as net changes.

All three previous findings are addressed. No new actionable issues were found in the changes since the previous review.

Reviews (2) · Last reviewed commit: "fix(aw-sync): reconcile boundary deletio..." · Reviewed by Greptile

Comment thread aw-sync/src/sync.rs
Comment on lines +1114 to +1115
for (identity, dsts) in dest_by_identity {
let ts = identity.0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Deleted newest event stays

Deleting the newest zero-duration event still leaves its destination copy behind. Its timestamp equals resume, but dest_by_identity only includes rows with timestamps strictly before resume. This loop never sees the deleted row, even when other source events remain inside the window. Repeated syncs keep the same cursor and stale copy until newer data arrives.

Include boundary rows in deletion reconciliation without changing which rows the edit pass owns, and add a zero-duration latest-event test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 073e273. Destination deletion indexing now includes the resume boundary; source boundary rows still back their live copies, while the edit pass remains strictly before the cursor. The new zero-duration latest-event regression failed before the fix and passes afterward, including live-row preservation and repeated-sync checks.

Comment thread aw-sync/src/sync.rs
// Owner-originated deletions reconciled above are an expected shrink. The
// invariant this guards is that the *incremental copy* never loses events
// (#682); a shrink larger than the reconcile reported still fails.
if new_events_count + (reconciled_deletions as i64) < 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Reports show negative new events

Allowing the count to shrink now sends negative values through BucketReport.events_new. Deleting one event with no additions produces “Pulled -1 new events” in summary_message and “imported +-1 events” in the status output. Additions and deletions also cancel each other, hiding work the pass completed.

Track removals separately and keep events_new as the number added, or update the report fields and messages to clearly describe a net change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 073e273 using the net-change option: the persisted events_new field retains its wire name, but its contract and all summary/status counts now explicitly say net change rather than new events. Pull, push, and both-mode regression checks failed on the old wording and now pass; signed formatting remains intact.

Comment thread aw-sync/tests/sync.rs
Comment on lines +324 to +328
state
.ds_dest
.insert_events(legacy_id, &[before_t0])
.unwrap();
state.ds_dest.force_commit().unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Dedup masks an ignored cursor

Seeding the destination with before_t0 weakens this test's cursor check. The source now contains the exact same row, so boundary_dedup can skip it even if the source fetch starts from the beginning. The final count still reaches two, contrary to the assertion's claim that a full re-import would produce three.

Assert the returned resumed_at value, or check the source fetch's start bound, so this test still catches an ignored cursor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 073e273. The fixture now asserts the returned resumed_at equals the seeded legacy event end, independently of the final event count. Removed the incorrect claim that dedup necessarily allows a third row when the cursor is ignored. The targeted legacy-resume test and the full aw-sync suite pass.

The reconcile pass can remove rows the owner deleted, so a bucket's
events_new is a net delta that can go negative. The summary lines
hardcoded a '+' prefix and would print '+-1 events'.

Git-Session-Id: d15e1888-bd37-4cc5-b7a1-8faf30bf1162
The desktop pulls from the staging directory, not from the phone, so a
deletion that stops at the first hop leaves the event on the third device
and a later push can hand the stale copy back. Fails on 10b4b7f.

Git-Session-Id: d15e1888-bd37-4cc5-b7a1-8faf30bf1162
@TimeToBuildBob

TimeToBuildBob commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

The PR extends reconcile_updated_events to remove destination-only events and recover missing historical source rows within the bounded reconciliation window. It adds unclipped event lookup through AccessMethod::get_event, creation-timestamp checks in sync_one, and a shared boundary_fingerprint helper. Reports describe event counts as net changes, and documentation and tests describe deletion propagation, historical repair, and clipping limitations.

Safe to merge — no P0/P1 findings

Confidence 5/5

✅ No findings. The diff looks correct to me on this pass.

Files changed (6) — the diff as I read it
  • aw-sync/README.md — Documents creation provenance checks, bounded deletion reconciliation, clipping exemptions, and historical recovery after an edit's delete/insert gap.
  • aw-sync/src/accessmethod.rs — Adds get_event to AccessMethod and implements unclipped single-event lookup for Datastore and AwClient.
  • aw-sync/src/report.rs — Labels event counts as net changes, adds format_event_delta for signed rendering, and tests negative-count reporting.
  • aw-sync/src/sync.rs — Adds deletion reconciliation, historical repair, cursor-row hydration, creation provenance checks, shared boundary fingerprints, and deletion accounting for the event-count invariant.
  • aw-sync/tests/owner_deletion.rs — Adds integration tests for owner deletions, timestamp siblings, clipping, provenance collisions, historical repair, and propagation through staging.
  • aw-sync/tests/sync.rs — Updates destination fixtures with source creation timestamps and changes the legacy-resume test to seed a source-backed event and assert resumed_at.
Previous review passes
commit score findings engine when
2c1d7e44ac44 3/5 2 subscription 2026-11-04 21:03 UTC
073e273eb301 3/5 1 subscription 2026-11-04 21:42 UTC
ce38e861d2df 2/5 2 panel 2026-11-04 22:28 UTC
bc42255c9b5e 3/5 2 subscription 2026-11-04 22:43 UTC
9e954b583679 3/5 1 panel 2026-11-04 23:01 UTC
a8f20c239fb6 3/5 1 subscription 2026-11-04 23:22 UTC
d65964314c46 2/5 2 subscription 2026-11-05 01:02 UTC

Reviewed bb00cf23adbe · panel: codex:gpt-6.1-sol · subscription engine · 113s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

Comment thread aw-sync/src/sync.rs Resolved
Comment thread aw-sync/src/sync.rs Resolved
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.77898% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.00%. Comparing base (656f3c9) to head (bb00cf2).
⚠️ Report is 235 commits behind head on master.

Files with missing lines Patch % Lines
aw-sync/tests/owner_deletion.rs 91.31% 54 Missing ⚠️
aw-sync/src/accessmethod.rs 33.33% 4 Missing ⚠️
aw-sync/src/sync.rs 97.89% 2 Missing ⚠️
aw-sync/src/report.rs 85.71% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #855       +/-   ##
===========================================
+ Coverage   70.81%   86.00%   +15.19%     
===========================================
  Files          51       90       +39     
  Lines        2916    13578    +10662     
===========================================
+ Hits         2065    11678     +9613     
- Misses        851     1900     +1049     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Git-Session-Id: 5f2041dd-74f0-5713-afce-8d815508fd2f
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

Git-Session-Id: 5e8799cc-f71e-5db5-bf84-12512843f86d
Git-Session-Id: 5e8799cc-f71e-5db5-bf84-12512843f86d
Comment thread aw-sync/src/sync.rs Resolved
Git-Session-Id: 5e8799cc-f71e-5db5-bf84-12512843f86d
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Pushed the review fixes through bc42255c9b5e via the in-band review wrapper:

  • Surviving timestamp siblings no longer protect deleted durations; query-clipped boundary groups apply fingerprint protection per row.
  • Start-clipped live intervals survive a source title edit instead of being deleted. Without clip provenance, their historical title can remain stale; that conservative limitation is documented.
  • Same-identity sibling removals now contribute to the shrink allowance, so successful deletion does not return a failed-bucket report.
  • Clarified that the seven-day window includes overlapping events, not only events starting inside it.

Both timestamp-tie regressions and both final-review regressions were observed failing before their fixes. Final cargo test -p aw-sync: 234 passed, including 11 owner-deletion tests; Clippy and formatting checks pass. Fix replies are on the two AI-review threads. The wrapper's two local review iterations are spent; the current-head automatic review will verify the fixes and reconcile those threads. CI is still running, so this is not a merge-ready claim. No Greptile re-trigger or manual thread resolution.

Comment thread aw-sync/src/sync.rs Resolved
Git-Session-Id: 172fcbff-bfec-5486-a51e-4248bebcb802
Git-Session-Id: 172fcbff-bfec-5486-a51e-4248bebcb802
Comment thread aw-sync/src/sync.rs Resolved
Git-Session-Id: 172fcbff-bfec-5486-a51e-4248bebcb802
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Pushed fixes through a8f20c239fb6 via the in-band review wrapper.

  • A shortened live source interval now preserves its potentially start-clipped destination copy. This deliberately preserves possibly stale title/duration data rather than deleting live activity; the README names that limitation.
  • Cursor-start rows are fetched by ID before reconciliation, so end-bound clipping cannot make identical-payload timestamp siblings hide a deleted duration.
  • The local review exposed a separate hostname-collision risk. Nonempty destinations with missing/different bucket creation provenance now refuse sync before writes. Merely skipping reconciliation was insufficient: the next pull from the original source could delete the other source's appended history. The regression now alternates sources and verifies refusal leaves the destination unchanged.

Both original findings were reproduced as failing tests before their fixes. The alternating-source regression also failed before the final refusal. Final cargo test -p aw-sync: 237 passed, including 14 owner-deletion tests; Clippy, formatting and diff checks pass. Two prepopulated-destination fixtures now preserve the source creation timestamp, as a real prior import does.

The wrapper's two local reviews are spent; the final commit pushed without another verdict. Current-head CI/review remain required, and this is not a merge-ready claim. No manual thread resolution or extra Greptile trigger: Greptile's older findings were already addressed; these fixes answer the newer AI review. Creation timestamps are a conservative guard, not unique device identity or repair for histories already mixed by older versions.

Git-Session-Id: 59e2ca4e-813f-565f-be8f-10be5d902275
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Pushed d65964314c46 via the in-band review wrapper to address the latest P1: sync observing the delete half of an owner edit no longer leaves a complete historical replacement permanently missing below the cursor.

  • Reproduced the loss before the fix with historical_edit_recovers_after_sync_between_delete_and_insert; it now passes over repeated syncs without duplicates.
  • Added staging-hop recovery and configured-start/clipped-copy preservation regressions.
  • Repair uses the existing seven-day window and respects the configured import floor. Ambiguous clipped copies and unmatched durations at an existing destination start remain conservative skips, documented in the README; this is not an unbounded historical backfill.

Final cargo test -p aw-sync: 240 passed, including 17 owner-deletion tests. Clippy, formatting, diff checks and commit hooks passed. The wrapper reported its existing 2/2 iteration cap and verified the push without another local review, so current-head CI and automated review are still required; this is not a merge-ready claim. No manual thread resolution or Greptile re-trigger: its older findings were already addressed, and this commit answers the newer AI-review finding.

Comment thread aw-sync/src/sync.rs Resolved
Comment thread aw-sync/src/sync.rs Resolved
Git-Session-Id: 3ae9de99-8ef0-52eb-888e-fe78b935bb87
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Pushed bb00cf23adbe through the in-band review wrapper to address both current P1 findings.

  • Deleted positive-duration events now propagate when their start touches the preceding live interval's end.
  • Historical replacements are recovered through staging when the next positive-duration event starts exactly at the replacement's end.
  • Zero-duration endpoint copies remain protected as potentially live clipped suffixes; a separate regression checks preservation without duplication after a source title edit.

Both reported failures were reproduced before the fix. Final cargo test -p aw-sync: 243 passed, including 20 owner-deletion tests. Formatting, Clippy, diff checks and enabled commit hooks passed. Each finding has one fix reply on its own review thread.

The wrapper reported its existing 2/2 iteration cap and verified the push without another local verdict. Current-head CI has format passing and six checks running; automatic review still needs to verify the fixes and resolve the two threads. This is not a merge-ready claim. No manual thread resolution or Greptile re-trigger; the bounded repair and conservative clipping limitations remain documented.

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