Skip to content

fix(sync): never sync per-machine debris (.lock and .conflict artifacts) - #78

Closed
sjalife wants to merge 1 commit into
tawanorg:mainfrom
sjalife:upstream-pr/debris-excludes
Closed

sjalife wants to merge 1 commit into
tawanorg:mainfrom
sjalife:upstream-pr/debris-excludes

Conversation

@sjalife

@sjalife sjalife commented Aug 2, 2026

Copy link
Copy Markdown

Fixes #71. Independent of #77 — different function, no shared symbols, either can merge first.

The problem

Two classes of local-only file are uploaded and replicated to every machine.

.lock — Claude Code writes a zero-byte lock per task directory (tasks/<id>/.lock). A lock represents a process holding a resource on one machine. Replicated elsewhere it carries no pid, no hostname, nothing: it is indistinguishable from a lock genuinely held there, and it outlives the process that created it by weeks. Across three synced machines we had 12 of them in the bucket, the oldest about four weeks old.

.conflict.<timestamp> — handleConflict writes the remote copy next to the original:

conflictPath := relativePath + ".conflict." + time.Now().Format("20060102-150405")

That path is inside a synced directory, so the recovery artifact is itself uploaded. Each replica can then be re-detected on another machine and produce further artifacts. We accumulated 92 in about a week, including five copies of the same snapshot for one file, minted by successive pulls.

The fix

func (s *Syncer) isExcluded(relPath string) bool {
	// Per-machine debris never syncs, regardless of user excludes.
	base := filepath.Base(relPath)
	if base == ".lock" || conflictArtifactRe.MatchString(base) {
		return true
	}
	return s.cfg.IsExcluded(relPath)
}

Unconditional and ahead of the user's patterns, because neither file has meaning on another machine under any configuration. This also sidesteps the globstar matching in #43 — no pattern needs to be written or matched.

Why the regex is anchored

var conflictArtifactRe = regexp.MustCompile(`\.conflict\.\d{8}-\d{6}$`)

It matches only the <yyyymmdd>-<hhmmss> format handleConflict writes, not any name containing .conflict..

This is the load-bearing detail. Excluding a path that is already tracked in state makes the next push report it as a deletion and prune it from the bucket. A loose *.conflict.* would therefore remotely delete a user file called notes.conflict.md. The tests pin both directions: notes.conflict.md, my.conflict.2026.txt, a truncated timestamp, package.lock, and .lockfile all stay; the real artifacts go.

Migration note

The first push after upgrading prunes already-uploaded locks and conflict artifacts from the bucket. Local copies are untouched — this is cleanup, not data loss, but it is worth a line in release notes since the remote objects do disappear. In our fleet it removed 12 lock files and the stale conflict artifacts in one push.

Scope and tests

1 file changed in internal/sync/, +18 lines, plus a new test file. go vet ./... and go test ./... green on Linux/ext4.

  • TestIsExcludedSkipsPerMachineDebris — both classes excluded; 8 near-miss names explicitly kept
  • TestIsExcludedStillHonorsUserPatterns — user exclude config still applies
  • TestPushSkipsDebrisFiles — end-to-end: only the transcript reaches the bucket, and the local .lock is still on disk afterwards

Happy to adjust the naming or fold the two classes into separate predicates if you prefer.

Two classes of local-only file are currently uploaded and replicated to every
machine.

.lock: Claude Code writes a zero-byte lock per task directory
(tasks/<id>/.lock). A lock represents a process holding a resource on one
machine; replicated elsewhere it is indistinguishable from a lock genuinely
held there, and it long outlives the process that made it. Observed: 12 of
them, the oldest about four weeks old, syncing across three machines.

.conflict.<timestamp>: handleConflict writes the remote copy next to the
original, which is inside a synced directory, so the recovery artifact is
itself uploaded. Each replica can then be re-detected on another machine and
spawn further artifacts. Observed: 92 accumulated in about a week, including
five copies of one snapshot minted by successive pulls.

Both are now excluded unconditionally, ahead of the user's exclude patterns,
because neither has any meaning on another machine under any configuration.

The .conflict pattern is anchored on the exact <yyyymmdd>-<hhmmss> format this
tool generates rather than matching .conflict. loosely. That matters: an
already-tracked file becoming excluded is reported as a deletion and pruned
from the bucket on the next push, so a loose pattern would silently delete a
user file named e.g. notes.conflict.md from remote storage. Tests pin both
directions.

Migration note for existing users: the first push after upgrading prunes any
already-uploaded locks and conflict artifacts from the bucket. Local copies
are untouched.

Fixes tawanorg#71

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sjalife

sjalife commented Aug 2, 2026

Copy link
Copy Markdown
Author

Folding this into #77 rather than keeping it separate.

The remaining fixes turned out not to be independent — the diverged-transcript merge builds directly on #77's resolver, and both it and the desktop-records feature need the same fetchDecoded helper — so splitting them meant stacking branches for no reviewer benefit. #77 now carries all of it as seven self-contained commits, one per concern, and this change is the second of them (unchanged apart from its commit position).

Closing in favour of #77. Happy to re-split any subset if you would rather review them separately.

@sjalife sjalife closed this Aug 2, 2026
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.

Zero-byte .lock files under tasks/ are synced across machines

1 participant