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
|
This prevents a rejected stale revision from starving a subsequent Web or
|
||||||
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
|
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
|
||||||
and transport contracts are unchanged.
|
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
|
## 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
|
is unchanged; sibling revisions and resurrection after deletion are rejected
|
||||||
in the generation consumption transaction.
|
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
|
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
||||||
ownership.
|
ownership.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,48 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "positive"):
|
with self.assertRaisesRegex(ValueError, "positive"):
|
||||||
exchange.validate(self.document(invalid))
|
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):
|
def test_draft_is_durable_but_not_exported_until_validated(self):
|
||||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
item = entry(revision)
|
item = entry(revision)
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,12 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
||||||
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
sync_orchestrator.failure_code(RuntimeError("revision conflict")),
|
||||||
"data_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):
|
def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
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):
|
def test_drive_adapter_uses_remaining_conversation_deadline(self):
|
||||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
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"
|
return "device_unavailable"
|
||||||
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic:
|
||||||
return "capacity_exhausted"
|
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 "data_conflict"
|
||||||
return "internal_error"
|
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)
|
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:
|
def bounded(path: Path, limit: int = 65536) -> bytes:
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
raw = stream.read(limit + 1)
|
raw = stream.read(limit + 1)
|
||||||
|
|
@ -805,7 +814,7 @@ def main() -> int:
|
||||||
deadline,
|
deadline,
|
||||||
)
|
)
|
||||||
if ack["result"] != "consumed":
|
if ack["result"] != "consumed":
|
||||||
raise RuntimeError("desktop rejected Android generation")
|
raise rejected_android_generation_error(ack)
|
||||||
emit(
|
emit(
|
||||||
args.run_id,
|
args.run_id,
|
||||||
"local_import_committed",
|
"local_import_committed",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue