Skip to content

Version-conflict retry can never recover a card carrying a /description op — apply run aborts on HTTP 428 #105

Description

@thewrz

This was written agentically; verify its assertions and edit accordingly:

What happened

A real python sync.py --apply run aborted:

dep    [0F5b] +1 dependency(ies)
dep    [0F5c] +1 dependency(ies)
AgilePlace PATCH /card/2494062382 failed: HTTP 428 {"statusCode":428,...,"data":{"operations":[{"op":"test","path":"/version","value":"1","fromHeader":"x-lk-resource-version","error":"Test operation failed","actualValue":"2"}]}}

Root cause

patch_card already has a one-shot refetch-validate-retry for exactly this (409/428, issue #72). It could not fire.

_conflict_retry gates the retry on _changed_patch_paths, which resolves each queued op path through _card_value_for_patch_path. That function has no case for /description — it raises ValueError, which _changed_patch_paths deliberately counts as "changed". So the retry is refused and the original 428 re-raises out of sync.main(), aborting the run.

description_sync.sync_description queues /description on essentially every card whose text differs from its issue, so the conflict-recovery net is structurally dead for the ordinary sync flush path — any version bump on such a card aborts the whole run instead of retrying.

/externalLink has the same gap. It is already documented (intake._card_for_link_write, docs/API-VALIDATION.md "Reverse intake") and locally worked around with an explicit refetch — but the gap itself was never closed, so the main flush inherited it.

The version bump itself came from the run: step 4 (sync_dependencies -> POST /card/dependency) writes to cards before step 5 flushes their queued PATCH with the pre-POST snapshot version. Whether that POST is what bumps the version is unconfirmed against the live API — tracked separately; it does not change the fix here.

Fix

Teach _card_value_for_patch_path the two missing paths so the guard can actually compare them:

  • /description — the run snapshot already carries the observed value (board_reads.hydrate_run_reads hydrates card["description"] in place; created cards get it from _created_card_snapshot), so comparing snapshot vs. refetch is sound. A card whose description hydration failed carries no key and keeps failing closed, unchanged.
  • /externalLink — compare the singular field directly.

Unknown paths must keep failing closed. Add a test that enumerates every op-builder path in agilesync/ and asserts the guard understands it, so the next new op path cannot silently kill the retry net again.

Testing

  • Red test: patch_card with a /description op + a version bump retries with the fresh version (currently aborts)
  • Op-path coverage test over every op-builder in agilesync/
  • Genuine concurrent description edit still refuses the retry (guard authority intact)
  • Full suite green
  • Live: python -m agilesync.tools.smoke new step passes on the real board
  • Live: the failing python sync.py --apply run completes

🤖 Co-authored by Claude Opus 5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:agileplaceLeanKit io v2 clientbugSomething isn't workingpriority:highGate for first live --apply

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions