The bug every two-way sync has to solve

Push a task's due date to Google Calendar. Later, pull changes back from Google. If your pull logic can't tell "the user actually moved this event in Google" from "this is the exact event I just pushed a second ago," you've built a system that updates itself, sees its own update as a change, writes it back, sees that write as a change, and so on. It doesn't loop instantly — it loops on whatever interval your sync job runs — which makes it worse, not better: it looks like normal activity in the logs until someone notices a task's due date silently drifting or the API quota getting eaten by a task nobody touched.

The naive fix, and why it's not enough

The obvious guard is "only push if the task changed." But that doesn't stop the pull side from mistaking your own push for an external edit — Google's changes feed doesn't distinguish "an API call from this same integration changed this" from "a human clicked this in their calendar." You need the pull to be skeptical of a specific class of "changes": the ones that are just your own writes reflected back.

What WKFGo actually does: compare content, not existence

Every task-to-event link stores a hash of the fields that were last pushed:

func contentHash(t TaskDue) string {
    sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d",
        t.Title, t.ProjectName, t.Due.UTC().Unix(), t.Progress)))
    return hex.EncodeToString(sum[:])
}

On push, WKFGo recomputes the hash for the task's current state and compares it against the hash stored on the link. If they match, nothing calls UpdateEvent — there's nothing new to say, so no API call happens, and no downstream "change" gets created for a future pull to misread as external:

if link.Hash != h {
    client.UpdateEvent(link.EventID, ev)
    link.Hash = h
}

On pull, the safeguard is even more direct — an incoming Google change only writes back to the task if the event's start time is actually different from what the task already has:

if !c.Start.IsZero() && !c.Start.Equal(due) {
    st.UpdateTaskDue(link.TaskID, c.Start)
}

An event that reflects exactly what WKFGo just pushed has a start time that matches the task's current due date — so this check is false, nothing gets written, and the loop has nowhere to continue. The comment in the code says it plainly: "Pull only rewrites a task's due date when the event's start actually differs from the task's current due, which is what stops our own pushes from bouncing back as changes."

Order matters: pull before push

Sync runs pull first, then push, every cycle — deliberately. If push ran first, a change made in Google moments earlier could get silently overwritten by the task's stale value before pull ever got a chance to see it. Pulling first means external edits land on the task before WKFGo decides whether anything needs pushing back out — so a real edit from the Google side and a real edit from the WKFGo side don't stomp on each other in the wrong order.

What happens when a link goes stale

If a user deletes the event directly in Google, the next pull sees it as Deleted and drops the local link — the task itself stays intact, so a future push simply recreates the event rather than the sync erroring out. If a task is reassigned or its due date is cleared entirely, the next push prunes any event whose task is no longer in the "due and assigned" set, deleting the orphaned calendar entry instead of leaving a stale event behind.

Common mistakes

Comparing timestamps instead of content. "Only sync if updated_at changed" breaks the moment your own write updates updated_at — you're back to the same loop, just gated on a different field.

Trusting the provider's changes feed to distinguish self vs. external edits. It usually can't — Google Calendar's sync token returns any change since the last token, including ones your own integration just made.

Pulling and pushing in the same pass without ordering guarantees. If both directions can run concurrently against the same link, you can get a push mid-flight racing a pull that just landed — sequential, pull-then-push, avoids the ambiguity entirely.

FAQ

What if Google's sync token expires?

ListChanges reinitializes and returns a fresh token with no changes reported for that call — pre-existing events aren't mistaken for new edits just because the token reset.

Does a pull failure block the push?

No — a pull error is logged and sync continues to push, so a temporary read failure doesn't stop task due-date changes from reaching the calendar.

Can I connect the same Google account to two WKFGo users?

Each connection and its sync token are per-user; two people syncing the same underlying calendar is a valid but separate connection each, not a shared one.

Summary

Two-way sync bugs are almost always the same shape: system A can't tell its own reflection in system B from a genuine external change. The fix isn't cleverer scheduling — it's making "did anything actually change" a content comparison, not an existence or timestamp check, on both the push side and the pull side.