diff --git a/CHANGELOG.md b/CHANGELOG.md index 91eeb43..c7767ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 81626e1..17fc89b 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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. diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index c239574..73a1f85 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -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) diff --git a/tests/test_sync_error_classification.py b/tests/test_sync_error_classification.py index e42dcc9..b7db830 100644 --- a/tests/test_sync_error_classification.py +++ b/tests/test_sync_error_classification.py @@ -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: diff --git a/tests/test_sync_peer_worker.py b/tests/test_sync_peer_worker.py index bcbc4fd..e83e6cf 100644 --- a/tests/test_sync_peer_worker.py +++ b/tests/test_sync_peer_worker.py @@ -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: diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index 3669837..4638e95 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -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" diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index 3e031a3..3324c2b 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -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",