fix(sync): expose durable Sleep conflict diagnostic
This commit is contained in:
parent
3d5dfa224a
commit
9568bb60d3
7 changed files with 84 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue