fix(sync): expose durable Sleep conflict diagnostic

This commit is contained in:
fy59 2026-09-28 12:47:54 +02:00
parent 3d5dfa224a
commit 9568bb60d3
7 changed files with 84 additions and 2 deletions

View file

@ -21,6 +21,11 @@ new features, modules, protocols, or speculative refactors.
This prevents a rejected stale revision from starving a subsequent Web or
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
and transport contracts are unchanged.
- Preserved the desktop consumer's known Sleep revision rejection diagnostic
through the full-generation worker and classified it as `data_conflict` in
Web run state. A synthetic divergent-final-wake regression keeps the true
conflict rejected; this reporting fix does not reconcile real Sleep data or
turn a rejected generation into a successful exchange.
## 0.1.6 — 2026-09-25

View file

@ -35,6 +35,12 @@ V1 input omits `quantity` and is normalized to one unit. Exact revision replay
is unchanged; sibling revisions and resurrection after deletion are rejected
in the generation consumption transaction.
A durable Sleep rejection keeps its exact ACK and does not advance either
causal tip. The full-generation worker carries the consumer's bounded Sleep
diagnostic through to the Web run state, where a known concurrent revision is
reported as `data_conflict` rather than an opaque `internal_error`. This
classification does not retry, merge, or acknowledge the rejected generation.
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
ownership.

View file

@ -246,6 +246,48 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positive"):
exchange.validate(self.document(invalid))
def test_contradictory_final_get_up_siblings_remain_unresolved(self):
base = "slr_20000000-0000-4000-8000-000000000002"
desktop_tip = "slr_40000000-0000-4000-8000-000000000004"
android_parent = "slr_50000000-0000-4000-8000-000000000005"
android_tip = "slr_60000000-0000-4000-8000-000000000006"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(base))))
desktop = entry(desktop_tip, base)
desktop["events"].append({
"event_id": "sle_70000000-0000-4000-8000-000000000007",
"type": "final_get_up", "start_at": "2026-10-25T06:30:00+01:00",
"end_at": None,
})
self.assertEqual((1, 0), exchange.apply(self.db, self.document(desktop)))
before = self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
"ORDER BY revision_id"
).fetchall()
android = entry(android_tip, android_parent)
android["ancestry"] = [android_tip, android_parent, base]
android["events"][0]["end_at"] = "2026-10-25T06:50:00+01:00"
android["events"].append({
"event_id": "sle_80000000-0000-4000-8000-000000000008",
"type": "final_get_up", "start_at": "2026-10-25T06:50:00+01:00",
"end_at": None,
})
with self.assertRaisesRegex(ValueError, "concurrent sleep diary revision"):
exchange.apply(self.db, self.document(android))
self.assertEqual(before, self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
"ORDER BY revision_id"
).fetchall())
self.assertEqual(desktop_tip, self.db.execute(
"SELECT current_revision_id FROM sleep_diary_entries"
).fetchone()[0])
self.assertEqual(1, self.db.execute(
"SELECT COUNT(*) FROM sleep_diary_events WHERE revision_id=? "
"AND event_type='final_get_up'", (desktop_tip,)
).fetchone()[0])
def test_draft_is_durable_but_not_exported_until_validated(self):
revision = "slr_20000000-0000-4000-8000-000000000002"
item = entry(revision)

View file

@ -159,6 +159,12 @@ class SyncErrorClassificationTest(unittest.TestCase):
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
"data_conflict",
)
self.assertEqual(
sync_orchestrator.failure_code(RuntimeError(
"desktop rejected Android generation: concurrent sleep diary revision"
)),
"data_conflict",
)
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
with tempfile.TemporaryDirectory() as directory:

View file

@ -26,6 +26,19 @@ class SyncPeerWorkerTest(unittest.TestCase):
def tearDown(self):
self.temporary.cleanup()
def test_rejected_android_generation_retains_consumer_diagnostic(self):
error = worker.rejected_android_generation_error(
{"diagnostic": "concurrent sleep diary revision"}
)
self.assertEqual(
str(error),
"desktop rejected Android generation: concurrent sleep diary revision",
)
self.assertEqual(
str(worker.rejected_android_generation_error({"diagnostic": ""})),
"desktop rejected Android generation",
)
def test_drive_adapter_uses_remaining_conversation_deadline(self):
completed = subprocess.CompletedProcess([], 0, b"", b"")
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:

View file

@ -131,7 +131,8 @@ def failure_code(error: BaseException) -> str:
return "device_unavailable"
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
return "capacity_exhausted"
if "conflict" in diagnostic or "wrong consumer" in diagnostic:
if ("conflict" in diagnostic or "wrong consumer" in diagnostic or
"concurrent sleep diary revision" in diagnostic):
return "data_conflict"
return "internal_error"

View file

@ -72,6 +72,15 @@ def emit(run_id: str, phase: str, **values: object) -> None:
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
def rejected_android_generation_error(ack: dict) -> RuntimeError:
"""Keep the durable peer rejection visible without changing its ACK."""
# WHY: the browser cannot classify a known causal conflict when the worker
# drops the consumer's bounded diagnostic at this process boundary.
diagnostic = ack.get("diagnostic", "")
detail = f": {diagnostic}" if diagnostic else ""
return RuntimeError(f"desktop rejected Android generation{detail}")
def bounded(path: Path, limit: int = 65536) -> bytes:
with path.open("rb") as stream:
raw = stream.read(limit + 1)
@ -805,7 +814,7 @@ def main() -> int:
deadline,
)
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
raise rejected_android_generation_error(ack)
emit(
args.run_id,
"local_import_committed",